diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index bcca0a36f..3e087e61c 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -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 = "" 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: diff --git a/astrbot/core/utils/auth_password.py b/astrbot/core/utils/auth_password.py new file mode 100644 index 000000000..b612dc7ee --- /dev/null +++ b/astrbot/core/utils/auth_password.py @@ -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 diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index eea036914..1e566e4c1 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -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 diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py index 5a825860a..83ba16372 100644 --- a/tests/test_api_key_open_api.py +++ b/tests/test_api_key_open_api.py @@ -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 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1e55fb8e9..83bba7643 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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 diff --git a/tests/test_kb_import.py b/tests/test_kb_import.py index c96ed7955..5ff9a8755 100644 --- a/tests/test_kb_import.py +++ b/tests/test_kb_import.py @@ -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