Skip to content

Commit 90b79d5

Browse files
committed
fix(webui): enforce 12-char dashboard password policy with backend+frontend validation
1 parent 5886c43 commit 90b79d5

18 files changed

Lines changed: 276 additions & 62 deletions

File tree

astrbot/cli/commands/cmd_conf.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import hashlib
21
import json
32
import zoneinfo
43
from collections.abc import Callable
54
from typing import Any
65

76
import click
87

8+
from astrbot.core.utils.auth_password import (
9+
hash_dashboard_password,
10+
validate_dashboard_password,
11+
)
12+
913
from ..utils import check_astrbot_root, get_astrbot_root
1014

1115

@@ -39,9 +43,11 @@ def _validate_dashboard_username(value: str) -> str:
3943

4044
def _validate_dashboard_password(value: str) -> str:
4145
"""Validate Dashboard password"""
42-
if not value:
43-
raise click.ClickException("Password cannot be empty")
44-
return hashlib.md5(value.encode()).hexdigest()
46+
try:
47+
validate_dashboard_password(value)
48+
except ValueError as e:
49+
raise click.ClickException(str(e))
50+
return hash_dashboard_password(value)
4551

4652

4753
def _validate_timezone(value: str) -> str:

astrbot/core/config/astrbot_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import os
55

66
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
7+
from astrbot.core.utils.auth_password import (
8+
normalize_dashboard_password_hash,
9+
)
710

811
from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
912

@@ -59,6 +62,13 @@ def __init__(
5962

6063
# 检查配置完整性,并插入
6164
has_new = self.check_config_integrity(default_config, conf)
65+
if (
66+
"dashboard" in conf
67+
and isinstance(conf["dashboard"], dict)
68+
and not conf["dashboard"].get("password")
69+
):
70+
conf["dashboard"]["password"] = normalize_dashboard_password_hash("")
71+
has_new = True
6272
self.update(conf)
6373
if has_new:
6474
self.save_config()

astrbot/core/config/default.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@
235235
"dashboard": {
236236
"enable": True,
237237
"username": "astrbot",
238-
"password": "77b90590a8945a7d36c963981a307dc9",
238+
"password": "",
239239
"jwt_secret": "",
240240
"host": "0.0.0.0",
241241
"port": 6185,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Utilities for dashboard password hashing and verification."""
2+
3+
import hashlib
4+
import hmac
5+
import re
6+
import secrets
7+
8+
_PBKDF2_ITERATIONS = 200_000
9+
_PBKDF2_SALT_BYTES = 16
10+
_PBKDF2_ALGORITHM = "pbkdf2_sha256"
11+
_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$"
12+
_LEGACY_MD5_LENGTH = 32
13+
_DASHBOARD_PASSWORD_MIN_LENGTH = 12
14+
DEFAULT_DASHBOARD_PASSWORD = "astrbot"
15+
16+
17+
def hash_dashboard_password(raw_password: str) -> str:
18+
"""Return a salted hash for dashboard password using PBKDF2-HMAC-SHA256."""
19+
if not isinstance(raw_password, str) or raw_password == "":
20+
raise ValueError("Password cannot be empty")
21+
22+
salt = secrets.token_hex(_PBKDF2_SALT_BYTES)
23+
digest = hashlib.pbkdf2_hmac(
24+
"sha256",
25+
raw_password.encode("utf-8"),
26+
bytes.fromhex(salt),
27+
_PBKDF2_ITERATIONS,
28+
).hex()
29+
return f"{_PBKDF2_FORMAT}{_PBKDF2_ITERATIONS}${salt}${digest}"
30+
31+
32+
def validate_dashboard_password(raw_password: str) -> None:
33+
"""Validate whether dashboard password meets the minimal complexity policy."""
34+
if not isinstance(raw_password, str) or raw_password == "":
35+
raise ValueError("Password cannot be empty")
36+
if len(raw_password) < _DASHBOARD_PASSWORD_MIN_LENGTH:
37+
raise ValueError(
38+
f"Password must be at least {_DASHBOARD_PASSWORD_MIN_LENGTH} characters long"
39+
)
40+
41+
if not re.search(r"[A-Z]", raw_password):
42+
raise ValueError("Password must include at least one uppercase letter")
43+
if not re.search(r"[a-z]", raw_password):
44+
raise ValueError("Password must include at least one lowercase letter")
45+
if not re.search(r"\d", raw_password):
46+
raise ValueError("Password must include at least one digit")
47+
48+
49+
def normalize_dashboard_password_hash(stored_password: str) -> str:
50+
"""Ensure dashboard password has a value, fallback to default dashboard password hash."""
51+
if not stored_password:
52+
return hash_dashboard_password(DEFAULT_DASHBOARD_PASSWORD)
53+
return stored_password
54+
55+
56+
def _is_legacy_md5_hash(stored: str) -> bool:
57+
return (
58+
isinstance(stored, str)
59+
and len(stored) == _LEGACY_MD5_LENGTH
60+
and all(c in "0123456789abcdefABCDEF" for c in stored)
61+
)
62+
63+
64+
def _is_pbkdf2_hash(stored: str) -> bool:
65+
return isinstance(stored, str) and stored.startswith(_PBKDF2_FORMAT)
66+
67+
68+
def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool:
69+
"""Verify password against legacy md5 or new PBKDF2-SHA256 format."""
70+
if not isinstance(stored_hash, str) or not isinstance(candidate_password, str):
71+
return False
72+
73+
if _is_legacy_md5_hash(stored_hash):
74+
# Keep compatibility with existing md5-based deployments:
75+
# new clients send plain password, old clients may send md5 of it.
76+
candidate_md5 = hashlib.md5(candidate_password.encode("utf-8")).hexdigest()
77+
return hmac.compare_digest(
78+
stored_hash.lower(), candidate_md5.lower()
79+
) or hmac.compare_digest(
80+
stored_hash.lower(),
81+
candidate_password.lower(),
82+
)
83+
84+
if _is_pbkdf2_hash(stored_hash):
85+
parts: list[str] = stored_hash.split("$")
86+
if len(parts) != 4:
87+
return False
88+
_, iterations_s, salt, digest = parts
89+
try:
90+
iterations = int(iterations_s)
91+
stored_key = bytes.fromhex(digest)
92+
salt_bytes = bytes.fromhex(salt)
93+
except (TypeError, ValueError):
94+
return False
95+
candidate_key = hashlib.pbkdf2_hmac(
96+
"sha256",
97+
candidate_password.encode("utf-8"),
98+
salt_bytes,
99+
iterations,
100+
)
101+
return hmac.compare_digest(stored_key, candidate_key)
102+
103+
return False
104+
105+
106+
def is_default_dashboard_password(stored_hash: str) -> bool:
107+
"""Check whether the password still equals the built-in default value."""
108+
return verify_dashboard_password(stored_hash, DEFAULT_DASHBOARD_PASSWORD)
109+
110+
111+
def is_legacy_dashboard_password(stored_hash: str) -> bool:
112+
"""Check whether the password is still stored with legacy MD5."""
113+
return _is_legacy_md5_hash(stored_hash)

astrbot/dashboard/routes/auth.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
from astrbot import logger
88
from astrbot.core import DEMO_MODE
9+
from astrbot.core.utils.auth_password import (
10+
hash_dashboard_password,
11+
is_default_dashboard_password,
12+
is_legacy_dashboard_password,
13+
validate_dashboard_password,
14+
verify_dashboard_password,
15+
)
916

1017
from .route import Response, Route, RouteContext
1118

@@ -23,15 +30,29 @@ async def login(self):
2330
username = self.config["dashboard"]["username"]
2431
password = self.config["dashboard"]["password"]
2532
post_data = await request.json
26-
if post_data["username"] == username and post_data["password"] == password:
33+
34+
req_username = (
35+
post_data.get("username") if isinstance(post_data, dict) else None
36+
)
37+
req_password = (
38+
post_data.get("password") if isinstance(post_data, dict) else None
39+
)
40+
if not isinstance(req_username, str) or not isinstance(req_password, str):
41+
return Response().error("Invalid request payload").__dict__
42+
43+
if req_username == username and verify_dashboard_password(
44+
password, req_password
45+
):
2746
change_pwd_hint = False
47+
legacy_pwd_hint = is_legacy_dashboard_password(password)
2848
if (
2949
username == "astrbot"
30-
and password == "77b90590a8945a7d36c963981a307dc9"
50+
and is_default_dashboard_password(password)
3151
and not DEMO_MODE
3252
):
3353
change_pwd_hint = True
34-
logger.warning("为了保证安全,请尽快修改默认密码。")
54+
logger.warning("检测到默认管理员凭据,请尽快修改密码。")
55+
legacy_pwd_hint = True
3556

3657
return (
3758
Response()
@@ -40,6 +61,7 @@ async def login(self):
4061
"token": self.generate_jwt(username),
4162
"username": username,
4263
"change_pwd_hint": change_pwd_hint,
64+
"legacy_pwd_hint": legacy_pwd_hint,
4365
},
4466
)
4567
.__dict__
@@ -57,8 +79,14 @@ async def edit_account(self):
5779

5880
password = self.config["dashboard"]["password"]
5981
post_data = await request.json
82+
if not isinstance(post_data, dict):
83+
return Response().error("Invalid request payload").__dict__
84+
85+
req_password = post_data.get("password")
86+
if not isinstance(req_password, str):
87+
return Response().error("Invalid request payload").__dict__
6088

61-
if post_data["password"] != password:
89+
if not verify_dashboard_password(password, req_password):
6290
return Response().error("原密码错误").__dict__
6391

6492
new_pwd = post_data.get("new_password", None)
@@ -68,10 +96,16 @@ async def edit_account(self):
6896

6997
# Verify password confirmation
7098
if new_pwd:
99+
if not isinstance(new_pwd, str):
100+
return Response().error("新密码无效").__dict__
71101
confirm_pwd = post_data.get("confirm_password", None)
72-
if confirm_pwd != new_pwd:
102+
if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd:
73103
return Response().error("两次输入的新密码不一致").__dict__
74-
self.config["dashboard"]["password"] = new_pwd
104+
try:
105+
validate_dashboard_password(new_pwd)
106+
except ValueError as e:
107+
return Response().error(str(e)).__dict__
108+
self.config["dashboard"]["password"] = hash_dashboard_password(new_pwd)
75109
if new_username:
76110
self.config["dashboard"]["username"] = new_username
77111

astrbot/dashboard/routes/stat.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import aiohttp
1313
import psutil
1414
from quart import request
15-
from sqlmodel import select
15+
from sqlmodel import col, select
1616

1717
from astrbot.core import DEMO_MODE, logger
1818
from astrbot.core.config import VERSION
@@ -21,6 +21,10 @@
2121
from astrbot.core.db.migration.helper import check_migration_needed_v4
2222
from astrbot.core.db.po import ProviderStat
2323
from astrbot.core.utils.astrbot_path import get_astrbot_path
24+
from astrbot.core.utils.auth_password import (
25+
is_default_dashboard_password,
26+
is_legacy_dashboard_password,
27+
)
2428
from astrbot.core.utils.io import get_dashboard_version
2529
from astrbot.core.utils.storage_cleaner import StorageCleaner
2630
from astrbot.core.utils.version_comparator import VersionComparator
@@ -76,7 +80,7 @@ def is_default_cred(self):
7680
password = self.config["dashboard"]["password"]
7781
return (
7882
username == "astrbot"
79-
and password == "77b90590a8945a7d36c963981a307dc9"
83+
and is_default_dashboard_password(password)
8084
and not DEMO_MODE
8185
)
8286

@@ -90,6 +94,9 @@ async def get_version(self):
9094
"version": VERSION,
9195
"dashboard_version": await get_dashboard_version(),
9296
"change_pwd_hint": self.is_default_cred(),
97+
"legacy_pwd_hint": is_legacy_dashboard_password(
98+
self.config["dashboard"]["password"]
99+
),
93100
"need_migration": need_migration,
94101
},
95102
)
@@ -226,7 +233,7 @@ async def get_provider_token_stats(self):
226233
ProviderStat.agent_type == "internal",
227234
ProviderStat.created_at >= query_start_utc,
228235
)
229-
.order_by(ProviderStat.created_at.asc())
236+
.order_by(col(ProviderStat.created_at).asc())
230237
)
231238
records = result.scalars().all()
232239

dashboard/src/i18n/locales/en-US/core/header.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,21 @@
8080
},
8181
"accountDialog": {
8282
"title": "Modify Account",
83-
"securityWarning": "Security Reminder: Please change the default password to ensure account security",
83+
"securityWarning": "Security Reminder: Please change your password to secure your account",
84+
"securityWarningLegacy": "The new AstrBot version has improved security. Please change your password.",
8485
"form": {
8586
"currentPassword": "Current Password",
8687
"newPassword": "New Password",
8788
"confirmPassword": "Confirm New Password",
8889
"newUsername": "New Username (Optional)",
89-
"passwordHint": "Password must be at least 8 characters",
90+
"passwordHint": "Password must be at least 12 characters",
9091
"confirmPasswordHint": "Please enter new password again to confirm",
9192
"usernameHint": "Leave blank to keep current username",
92-
"defaultCredentials": "Default username and password are both astrbot"
93+
"defaultCredentials": "The new AstrBot version has improved security. Please change your password."
9394
},
9495
"validation": {
9596
"passwordRequired": "Please enter password",
96-
"passwordMinLength": "Password must be at least 8 characters",
97+
"passwordMinLength": "Password must be at least 12 characters",
9798
"passwordMatch": "Passwords do not match",
9899
"usernameMinLength": "Username must be at least 3 characters"
99100
},

dashboard/src/i18n/locales/en-US/features/auth.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"login": "Login",
33
"username": "Username",
44
"password": "Password",
5-
"defaultHint": "Default username and password: astrbot",
5+
"defaultHint": "The new AstrBot version has improved security. Please change your password.",
66
"logo": {
77
"title": "AstrBot Dashboard",
88
"subtitle": "Welcome"
@@ -11,4 +11,4 @@
1111
"switchToDark": "Switch to Dark Theme",
1212
"switchToLight": "Switch to Light Theme"
1313
}
14-
}
14+
}

dashboard/src/i18n/locales/en-US/messages/validation.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
"fileType": "Unsupported file type",
1515
"required_field": "Please fill in the required field",
1616
"invalid_format": "Invalid format",
17-
"password_too_short": "Password must be at least 8 characters",
17+
"password_too_short": "Password must be at least 12 characters",
1818
"password_too_weak": "Password is too weak",
1919
"invalid_phone": "Please enter a valid phone number",
2020
"invalid_date": "Please enter a valid date",
2121
"date_range": "Invalid date range",
2222
"upload_failed": "File upload failed",
2323
"network_error": "Network connection error, please try again",
2424
"operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!"
25-
}
25+
}

0 commit comments

Comments
 (0)