mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
fix(cli): use raise from e in exception clauses
This commit is contained in:
@@ -72,7 +72,7 @@ def _validate_dashboard_password(value: str) -> str:
|
||||
try:
|
||||
validate_dashboard_password(value)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
raise click.ClickException(str(e)) from e
|
||||
# Return the canonical stored representation.
|
||||
return hash_dashboard_password(value)
|
||||
|
||||
@@ -80,8 +80,8 @@ def _validate_dashboard_password(value: str) -> str:
|
||||
def _validate_timezone(value: str) -> str:
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception:
|
||||
raise click.ClickException(t("config_timezone_invalid", value=value))
|
||||
except Exception as e:
|
||||
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
|
||||
return value
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ def _load_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||
raise click.ClickException(f"Failed to parse config file: {e!s}") from e
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
@@ -225,7 +225,7 @@ def set_config(key: str, value: str) -> None:
|
||||
# Attempt to get old value (may raise KeyError)
|
||||
try:
|
||||
old_value = _get_nested_item(config, key)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
old_value = "<not set>"
|
||||
|
||||
validated_value = CONFIG_VALIDATORS[key](value)
|
||||
@@ -240,7 +240,7 @@ def set_config(key: str, value: str) -> None:
|
||||
except click.ClickException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||
raise click.UsageError(f"Failed to set config: {e!s}") from e
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@@ -258,7 +258,7 @@ def get_config(key: str | None = None) -> None:
|
||||
except KeyError:
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||
raise click.UsageError(f"Failed to get config: {e!s}") from e
|
||||
else:
|
||||
click.echo("Current config:")
|
||||
for k in CONFIG_VALIDATORS:
|
||||
|
||||
160
astrbot/core/utils/auth_password.py
Normal file
160
astrbot/core/utils/auth_password.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Utilities for dashboard password hashing and verification."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
|
||||
try:
|
||||
import argon2.exceptions as argon2_exceptions
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_PASSWORD_HASHER = PasswordHasher()
|
||||
except ImportError:
|
||||
_PASSWORD_HASHER = None
|
||||
|
||||
_PBKDF2_ITERATIONS = 600_000
|
||||
_PBKDF2_SALT_BYTES = 16
|
||||
_PBKDF2_ALGORITHM = "pbkdf2_sha256"
|
||||
_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$"
|
||||
_LEGACY_MD5_LENGTH = 32
|
||||
_DASHBOARD_PASSWORD_MIN_LENGTH = 12
|
||||
DEFAULT_DASHBOARD_PASSWORD = "astrbot"
|
||||
|
||||
|
||||
def hash_dashboard_password(raw_password: str) -> str:
|
||||
"""Return a salted hash for dashboard password using Argon2 (if available) or PBKDF2-HMAC-SHA256 fallback."""
|
||||
if not isinstance(raw_password, str) or raw_password == "":
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
if _PASSWORD_HASHER is not None:
|
||||
try:
|
||||
return _PASSWORD_HASHER.hash(raw_password)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to hash password securely (argon2): {e!s}") from e
|
||||
|
||||
salt = secrets.token_hex(_PBKDF2_SALT_BYTES)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_password.encode("utf-8"),
|
||||
bytes.fromhex(salt),
|
||||
_PBKDF2_ITERATIONS,
|
||||
).hex()
|
||||
return f"{_PBKDF2_FORMAT}{_PBKDF2_ITERATIONS}${salt}${digest}"
|
||||
|
||||
|
||||
def validate_dashboard_password(raw_password: str) -> None:
|
||||
"""Validate whether dashboard password meets the minimal complexity policy."""
|
||||
if not isinstance(raw_password, str) or raw_password == "":
|
||||
raise ValueError("Password cannot be empty")
|
||||
if len(raw_password) < _DASHBOARD_PASSWORD_MIN_LENGTH:
|
||||
raise ValueError(
|
||||
f"Password must be at least {_DASHBOARD_PASSWORD_MIN_LENGTH} characters long"
|
||||
)
|
||||
|
||||
if not re.search(r"[A-Z]", raw_password):
|
||||
raise ValueError("Password must include at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", raw_password):
|
||||
raise ValueError("Password must include at least one lowercase letter")
|
||||
if not re.search(r"\d", raw_password):
|
||||
raise ValueError("Password must include at least one digit")
|
||||
|
||||
|
||||
def normalize_dashboard_password_hash(stored_password: str) -> str:
|
||||
"""Ensure dashboard password has a value, fallback to default dashboard password hash."""
|
||||
if not stored_password:
|
||||
return hash_dashboard_password(DEFAULT_DASHBOARD_PASSWORD)
|
||||
return stored_password
|
||||
|
||||
|
||||
def _is_legacy_md5_hash(stored: str) -> bool:
|
||||
return (
|
||||
isinstance(stored, str)
|
||||
and len(stored) == _LEGACY_MD5_LENGTH
|
||||
and all(c in "0123456789abcdefABCDEF" for c in stored)
|
||||
)
|
||||
|
||||
|
||||
def _is_pbkdf2_hash(stored: str) -> bool:
|
||||
return isinstance(stored, str) and stored.startswith(_PBKDF2_FORMAT)
|
||||
|
||||
|
||||
def _is_argon2_hash(stored: str) -> bool:
|
||||
return isinstance(stored, str) and stored.startswith("$argon2")
|
||||
|
||||
|
||||
def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool:
|
||||
"""Verify password against legacy md5, new PBKDF2-SHA256 format, or Argon2."""
|
||||
if not isinstance(stored_hash, str) or not isinstance(candidate_password, str):
|
||||
return False
|
||||
|
||||
if _is_argon2_hash(stored_hash):
|
||||
if _PASSWORD_HASHER is None:
|
||||
return False
|
||||
try:
|
||||
return _PASSWORD_HASHER.verify(stored_hash, candidate_password)
|
||||
except argon2_exceptions.VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if _is_legacy_md5_hash(stored_hash):
|
||||
# Keep compatibility with existing md5-based deployments:
|
||||
# new clients send plain password, old clients may send md5 of it.
|
||||
candidate_md5 = hashlib.md5(candidate_password.encode("utf-8")).hexdigest()
|
||||
return hmac.compare_digest(
|
||||
stored_hash.lower(), candidate_md5.lower()
|
||||
) or hmac.compare_digest(
|
||||
stored_hash.lower(),
|
||||
candidate_password.lower(),
|
||||
)
|
||||
|
||||
if _is_pbkdf2_hash(stored_hash):
|
||||
parts: list[str] = stored_hash.split("$")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
_, iterations_s, salt, digest = parts
|
||||
try:
|
||||
iterations = int(iterations_s)
|
||||
stored_key = bytes.fromhex(digest)
|
||||
salt_bytes = bytes.fromhex(salt)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
candidate_key = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
candidate_password.encode("utf-8"),
|
||||
salt_bytes,
|
||||
iterations,
|
||||
)
|
||||
return hmac.compare_digest(stored_key, candidate_key)
|
||||
|
||||
# Legacy SHA-256 fallback compatibility
|
||||
if len(stored_hash) == 64 and all(
|
||||
c in "0123456789abcdefABCDEF" for c in stored_hash
|
||||
):
|
||||
candidate_sha256 = hashlib.sha256(
|
||||
candidate_password.encode("utf-8")
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(
|
||||
stored_hash.lower(), candidate_sha256.lower()
|
||||
) or hmac.compare_digest(stored_hash.lower(), candidate_password.lower())
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_default_dashboard_password(stored_hash: str) -> bool:
|
||||
"""Check whether the password still equals the built-in default value."""
|
||||
return verify_dashboard_password(stored_hash, DEFAULT_DASHBOARD_PASSWORD)
|
||||
|
||||
|
||||
def is_legacy_dashboard_password(stored_hash: str) -> bool:
|
||||
"""Check whether the password is still stored with legacy MD5 or plain SHA256."""
|
||||
if not isinstance(stored_hash, str) or not stored_hash:
|
||||
return False
|
||||
if _is_legacy_md5_hash(stored_hash):
|
||||
return True
|
||||
if len(stored_hash) == 64 and all(
|
||||
c in "0123456789abcdefABCDEF" for c in stored_hash
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -5,11 +5,13 @@ import jwt
|
||||
from quart import request
|
||||
|
||||
from astrbot.cli.commands.cmd_conf import (
|
||||
hash_dashboard_password_secure,
|
||||
is_dashboard_password_hash,
|
||||
verify_dashboard_password,
|
||||
)
|
||||
from astrbot.core import DEMO_MODE
|
||||
from astrbot.core.utils.auth_password import (
|
||||
hash_dashboard_password,
|
||||
verify_dashboard_password,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -99,7 +101,7 @@ class AuthRoute(Route):
|
||||
return Response().error("两次输入的新密码不一致").to_json()
|
||||
# Hash the new password before storing to ensure backend and CLI use the same format
|
||||
try:
|
||||
new_hash = hash_dashboard_password_secure(new_pwd)
|
||||
new_hash = hash_dashboard_password(new_pwd)
|
||||
except Exception as e:
|
||||
return Response().error(f"Failed to hash new password: {e}").to_json()
|
||||
self.config["dashboard"]["password"] = new_hash
|
||||
@@ -143,7 +145,7 @@ class AuthRoute(Route):
|
||||
|
||||
if pwd_plain:
|
||||
try:
|
||||
return verify_dashboard_password(pwd_plain, stored_password_hash)
|
||||
return verify_dashboard_password(stored_password_hash, pwd_plain)
|
||||
except Exception:
|
||||
# Do not crash authentication on unexpected verifier errors; treat as mismatch.
|
||||
return False
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest_asyncio
|
||||
from quart import Quart, g, request
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
|
||||
from astrbot.core.utils.auth_password import hash_dashboard_password
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
@@ -59,7 +59,7 @@ async def core_lifecycle_td(tmp_path_factory):
|
||||
await core_lifecycle.initialize()
|
||||
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
|
||||
core_lifecycle.astrbot_config["dashboard"]["password"] = (
|
||||
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
|
||||
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
|
||||
)
|
||||
try:
|
||||
yield core_lifecycle
|
||||
|
||||
@@ -16,7 +16,7 @@ import pytest_asyncio
|
||||
from quart import Quart
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
|
||||
from astrbot.core.utils.auth_password import hash_dashboard_password
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle, LifecycleState
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
@@ -115,7 +115,7 @@ async def core_lifecycle_td(tmp_path_factory):
|
||||
await core_lifecycle.initialize()
|
||||
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
|
||||
core_lifecycle.astrbot_config["dashboard"]["password"] = (
|
||||
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
|
||||
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
|
||||
)
|
||||
try:
|
||||
yield core_lifecycle
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart
|
||||
|
||||
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
|
||||
from astrbot.core.utils.auth_password import hash_dashboard_password
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
@@ -26,7 +26,7 @@ async def core_lifecycle_td(tmp_path_factory):
|
||||
await core_lifecycle.initialize()
|
||||
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
|
||||
core_lifecycle.astrbot_config["dashboard"]["password"] = (
|
||||
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
|
||||
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
|
||||
)
|
||||
|
||||
# Mock kb_manager and kb_helper
|
||||
|
||||
Reference in New Issue
Block a user