fix(cli): use raise from e in exception clauses

This commit is contained in:
LIghtJUNction
2026-04-04 16:34:56 +08:00
parent 4308580039
commit 3d2ff938f5
6 changed files with 179 additions and 17 deletions

View File

@@ -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:

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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