Skip to content

Commit 3d2ff93

Browse files
committed
fix(cli): use raise from e in exception clauses
1 parent 4308580 commit 3d2ff93

6 files changed

Lines changed: 179 additions & 17 deletions

File tree

astrbot/cli/commands/cmd_conf.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,16 @@ def _validate_dashboard_password(value: str) -> str:
7272
try:
7373
validate_dashboard_password(value)
7474
except ValueError as e:
75-
raise click.ClickException(str(e))
75+
raise click.ClickException(str(e)) from e
7676
# Return the canonical stored representation.
7777
return hash_dashboard_password(value)
7878

7979

8080
def _validate_timezone(value: str) -> str:
8181
try:
8282
zoneinfo.ZoneInfo(value)
83-
except Exception:
84-
raise click.ClickException(t("config_timezone_invalid", value=value))
83+
except Exception as e:
84+
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
8585
return value
8686

8787

@@ -126,7 +126,7 @@ def _load_config() -> dict[str, Any]:
126126
try:
127127
return json.loads(config_path.read_text(encoding="utf-8-sig"))
128128
except json.JSONDecodeError as e:
129-
raise click.ClickException(f"Failed to parse config file: {e!s}")
129+
raise click.ClickException(f"Failed to parse config file: {e!s}") from e
130130

131131

132132
def _save_config(config: dict[str, Any]) -> None:
@@ -225,7 +225,7 @@ def set_config(key: str, value: str) -> None:
225225
# Attempt to get old value (may raise KeyError)
226226
try:
227227
old_value = _get_nested_item(config, key)
228-
except Exception:
228+
except Exception as e:
229229
old_value = "<not set>"
230230

231231
validated_value = CONFIG_VALIDATORS[key](value)
@@ -240,7 +240,7 @@ def set_config(key: str, value: str) -> None:
240240
except click.ClickException:
241241
raise
242242
except Exception as e:
243-
raise click.UsageError(f"Failed to set config: {e!s}")
243+
raise click.UsageError(f"Failed to set config: {e!s}") from e
244244

245245

246246
@conf.command(name="get")
@@ -258,7 +258,7 @@ def get_config(key: str | None = None) -> None:
258258
except KeyError:
259259
raise click.ClickException(f"Unknown config key: {key}")
260260
except Exception as e:
261-
raise click.UsageError(f"Failed to get config: {e!s}")
261+
raise click.UsageError(f"Failed to get config: {e!s}") from e
262262
else:
263263
click.echo("Current config:")
264264
for k in CONFIG_VALIDATORS:
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Utilities for dashboard password hashing and verification."""
2+
3+
import hashlib
4+
import hmac
5+
import re
6+
import secrets
7+
8+
try:
9+
import argon2.exceptions as argon2_exceptions
10+
from argon2 import PasswordHasher
11+
12+
_PASSWORD_HASHER = PasswordHasher()
13+
except ImportError:
14+
_PASSWORD_HASHER = None
15+
16+
_PBKDF2_ITERATIONS = 600_000
17+
_PBKDF2_SALT_BYTES = 16
18+
_PBKDF2_ALGORITHM = "pbkdf2_sha256"
19+
_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$"
20+
_LEGACY_MD5_LENGTH = 32
21+
_DASHBOARD_PASSWORD_MIN_LENGTH = 12
22+
DEFAULT_DASHBOARD_PASSWORD = "astrbot"
23+
24+
25+
def hash_dashboard_password(raw_password: str) -> str:
26+
"""Return a salted hash for dashboard password using Argon2 (if available) or PBKDF2-HMAC-SHA256 fallback."""
27+
if not isinstance(raw_password, str) or raw_password == "":
28+
raise ValueError("Password cannot be empty")
29+
30+
if _PASSWORD_HASHER is not None:
31+
try:
32+
return _PASSWORD_HASHER.hash(raw_password)
33+
except Exception as e:
34+
raise ValueError(f"Failed to hash password securely (argon2): {e!s}") from e
35+
36+
salt = secrets.token_hex(_PBKDF2_SALT_BYTES)
37+
digest = hashlib.pbkdf2_hmac(
38+
"sha256",
39+
raw_password.encode("utf-8"),
40+
bytes.fromhex(salt),
41+
_PBKDF2_ITERATIONS,
42+
).hex()
43+
return f"{_PBKDF2_FORMAT}{_PBKDF2_ITERATIONS}${salt}${digest}"
44+
45+
46+
def validate_dashboard_password(raw_password: str) -> None:
47+
"""Validate whether dashboard password meets the minimal complexity policy."""
48+
if not isinstance(raw_password, str) or raw_password == "":
49+
raise ValueError("Password cannot be empty")
50+
if len(raw_password) < _DASHBOARD_PASSWORD_MIN_LENGTH:
51+
raise ValueError(
52+
f"Password must be at least {_DASHBOARD_PASSWORD_MIN_LENGTH} characters long"
53+
)
54+
55+
if not re.search(r"[A-Z]", raw_password):
56+
raise ValueError("Password must include at least one uppercase letter")
57+
if not re.search(r"[a-z]", raw_password):
58+
raise ValueError("Password must include at least one lowercase letter")
59+
if not re.search(r"\d", raw_password):
60+
raise ValueError("Password must include at least one digit")
61+
62+
63+
def normalize_dashboard_password_hash(stored_password: str) -> str:
64+
"""Ensure dashboard password has a value, fallback to default dashboard password hash."""
65+
if not stored_password:
66+
return hash_dashboard_password(DEFAULT_DASHBOARD_PASSWORD)
67+
return stored_password
68+
69+
70+
def _is_legacy_md5_hash(stored: str) -> bool:
71+
return (
72+
isinstance(stored, str)
73+
and len(stored) == _LEGACY_MD5_LENGTH
74+
and all(c in "0123456789abcdefABCDEF" for c in stored)
75+
)
76+
77+
78+
def _is_pbkdf2_hash(stored: str) -> bool:
79+
return isinstance(stored, str) and stored.startswith(_PBKDF2_FORMAT)
80+
81+
82+
def _is_argon2_hash(stored: str) -> bool:
83+
return isinstance(stored, str) and stored.startswith("$argon2")
84+
85+
86+
def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool:
87+
"""Verify password against legacy md5, new PBKDF2-SHA256 format, or Argon2."""
88+
if not isinstance(stored_hash, str) or not isinstance(candidate_password, str):
89+
return False
90+
91+
if _is_argon2_hash(stored_hash):
92+
if _PASSWORD_HASHER is None:
93+
return False
94+
try:
95+
return _PASSWORD_HASHER.verify(stored_hash, candidate_password)
96+
except argon2_exceptions.VerifyMismatchError:
97+
return False
98+
except Exception:
99+
return False
100+
101+
if _is_legacy_md5_hash(stored_hash):
102+
# Keep compatibility with existing md5-based deployments:
103+
# new clients send plain password, old clients may send md5 of it.
104+
candidate_md5 = hashlib.md5(candidate_password.encode("utf-8")).hexdigest()
105+
return hmac.compare_digest(
106+
stored_hash.lower(), candidate_md5.lower()
107+
) or hmac.compare_digest(
108+
stored_hash.lower(),
109+
candidate_password.lower(),
110+
)
111+
112+
if _is_pbkdf2_hash(stored_hash):
113+
parts: list[str] = stored_hash.split("$")
114+
if len(parts) != 4:
115+
return False
116+
_, iterations_s, salt, digest = parts
117+
try:
118+
iterations = int(iterations_s)
119+
stored_key = bytes.fromhex(digest)
120+
salt_bytes = bytes.fromhex(salt)
121+
except (TypeError, ValueError):
122+
return False
123+
candidate_key = hashlib.pbkdf2_hmac(
124+
"sha256",
125+
candidate_password.encode("utf-8"),
126+
salt_bytes,
127+
iterations,
128+
)
129+
return hmac.compare_digest(stored_key, candidate_key)
130+
131+
# Legacy SHA-256 fallback compatibility
132+
if len(stored_hash) == 64 and all(
133+
c in "0123456789abcdefABCDEF" for c in stored_hash
134+
):
135+
candidate_sha256 = hashlib.sha256(
136+
candidate_password.encode("utf-8")
137+
).hexdigest()
138+
return hmac.compare_digest(
139+
stored_hash.lower(), candidate_sha256.lower()
140+
) or hmac.compare_digest(stored_hash.lower(), candidate_password.lower())
141+
142+
return False
143+
144+
145+
def is_default_dashboard_password(stored_hash: str) -> bool:
146+
"""Check whether the password still equals the built-in default value."""
147+
return verify_dashboard_password(stored_hash, DEFAULT_DASHBOARD_PASSWORD)
148+
149+
150+
def is_legacy_dashboard_password(stored_hash: str) -> bool:
151+
"""Check whether the password is still stored with legacy MD5 or plain SHA256."""
152+
if not isinstance(stored_hash, str) or not stored_hash:
153+
return False
154+
if _is_legacy_md5_hash(stored_hash):
155+
return True
156+
if len(stored_hash) == 64 and all(
157+
c in "0123456789abcdefABCDEF" for c in stored_hash
158+
):
159+
return True
160+
return False

astrbot/dashboard/routes/auth.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
from quart import request
66

77
from astrbot.cli.commands.cmd_conf import (
8-
hash_dashboard_password_secure,
98
is_dashboard_password_hash,
10-
verify_dashboard_password,
119
)
1210
from astrbot.core import DEMO_MODE
11+
from astrbot.core.utils.auth_password import (
12+
hash_dashboard_password,
13+
verify_dashboard_password,
14+
)
1315

1416
from .route import Response, Route, RouteContext
1517

@@ -99,7 +101,7 @@ async def edit_account(self):
99101
return Response().error("两次输入的新密码不一致").to_json()
100102
# Hash the new password before storing to ensure backend and CLI use the same format
101103
try:
102-
new_hash = hash_dashboard_password_secure(new_pwd)
104+
new_hash = hash_dashboard_password(new_pwd)
103105
except Exception as e:
104106
return Response().error(f"Failed to hash new password: {e}").to_json()
105107
self.config["dashboard"]["password"] = new_hash
@@ -143,7 +145,7 @@ def _matches_dashboard_password(
143145

144146
if pwd_plain:
145147
try:
146-
return verify_dashboard_password(pwd_plain, stored_password_hash)
148+
return verify_dashboard_password(stored_password_hash, pwd_plain)
147149
except Exception:
148150
# Do not crash authentication on unexpected verifier errors; treat as mismatch.
149151
return False

tests/test_api_key_open_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from quart import Quart, g, request
99
from werkzeug.datastructures import FileStorage
1010

11-
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
11+
from astrbot.core.utils.auth_password import hash_dashboard_password
1212
from astrbot.core import LogBroker
1313
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
1414
from astrbot.core.db.sqlite import SQLiteDatabase
@@ -59,7 +59,7 @@ async def core_lifecycle_td(tmp_path_factory):
5959
await core_lifecycle.initialize()
6060
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
6161
core_lifecycle.astrbot_config["dashboard"]["password"] = (
62-
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
62+
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
6363
)
6464
try:
6565
yield core_lifecycle

tests/test_dashboard.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from quart import Quart
1717
from werkzeug.datastructures import FileStorage
1818

19-
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
19+
from astrbot.core.utils.auth_password import hash_dashboard_password
2020
from astrbot.core import LogBroker
2121
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle, LifecycleState
2222
from astrbot.core.db.sqlite import SQLiteDatabase
@@ -115,7 +115,7 @@ async def core_lifecycle_td(tmp_path_factory):
115115
await core_lifecycle.initialize()
116116
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
117117
core_lifecycle.astrbot_config["dashboard"]["password"] = (
118-
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
118+
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
119119
)
120120
try:
121121
yield core_lifecycle

tests/test_kb_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest_asyncio
66
from quart import Quart
77

8-
from astrbot.cli.commands.cmd_conf import hash_dashboard_password_secure
8+
from astrbot.core.utils.auth_password import hash_dashboard_password
99
from astrbot.core import LogBroker
1010
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
1111
from astrbot.core.db.sqlite import SQLiteDatabase
@@ -26,7 +26,7 @@ async def core_lifecycle_td(tmp_path_factory):
2626
await core_lifecycle.initialize()
2727
core_lifecycle.astrbot_config["dashboard"]["username"] = "astrbot"
2828
core_lifecycle.astrbot_config["dashboard"]["password"] = (
29-
hash_dashboard_password_secure(TEST_DASHBOARD_PASSWORD)
29+
hash_dashboard_password(TEST_DASHBOARD_PASSWORD)
3030
)
3131

3232
# Mock kb_manager and kb_helper

0 commit comments

Comments
 (0)