Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
90b79d5
fix(webui): enforce 12-char dashboard password policy with backend+fr…
Soulter Apr 3, 2026
a0159c8
fix(i18n): update password policy hints and validation rules for impr…
Soulter Apr 3, 2026
a3abb28
test: adapt dashboard auth fixtures for hashed default password
Soulter Apr 3, 2026
b749f62
fix(security): increase PBKDF2 iterations
Soulter Apr 4, 2026
a47e39d
feat(auth): implement secure login challenge and proof verification
Soulter Apr 4, 2026
5c27cf1
chore: ruff format
Soulter Apr 4, 2026
a64d4e7
fix(auth): update md5 import syntax for consistency
Soulter Apr 4, 2026
fb1cb4e
feat(dashboard): implement random password generation for empty dashb…
Soulter May 8, 2026
58da2f1
Merge remote-tracking branch 'origin/master' into fix/dashboard-passw…
Soulter May 8, 2026
2009901
feat(auth): enforce plaintext password requirement for legacy MD5 hashes
Soulter May 8, 2026
e041f0e
fix(i18n): update password hint texts to reflect auto-generated initi…
RC-CHN May 8, 2026
6744329
feat(dashboard): implement password change requirement and reset logic
Soulter May 11, 2026
eada50d
feat(auth): implement account setup flow and password change requirem…
Soulter May 11, 2026
0c2f796
feat: Implement legacy password support and upgrade mechanism
Soulter May 12, 2026
a18e2d4
fix(logo): update text color styles to use CSS variables for consistency
Soulter May 12, 2026
bd7fc50
feat(dashboard): upgrade password storage and enforce change requirement
Soulter May 12, 2026
7c02af8
fix(dashboard): update minimum password length from 12 to 10 characters
Soulter May 12, 2026
75f5f48
fix(dashboard): update minimum password length from 10 to 8 characters
Soulter May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions astrbot/cli/commands/cmd_conf.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import hashlib
import json
import zoneinfo
from collections.abc import Callable
from typing import Any

import click

from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_legacy_dashboard_password,
validate_dashboard_password,
)

from ..utils import check_astrbot_root, get_astrbot_root


Expand Down Expand Up @@ -39,9 +44,11 @@ def _validate_dashboard_username(value: str) -> str:

def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
if not value:
raise click.ClickException("Password cannot be empty")
return hashlib.md5(value.encode()).hexdigest()
try:
validate_dashboard_password(value)
except ValueError as e:
raise click.ClickException(str(e))
return value


def _validate_timezone(value: str) -> str:
Expand Down Expand Up @@ -163,7 +170,19 @@ def set_config(key: str, value: str) -> None:
try:
old_value = _get_nested_item(config, key)
validated_value = CONFIG_VALIDATORS[key](value)
_set_nested_item(config, key, validated_value)
if key == "dashboard.password":
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(validated_value),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(validated_value),
)
else:
_set_nested_item(config, key, validated_value)
_save_config(config)

click.echo(f"Config updated: {key}")
Expand Down
54 changes: 53 additions & 1 deletion astrbot/core/config/astrbot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import os

from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.auth_password import (
generate_dashboard_password,
hash_dashboard_password,
hash_legacy_dashboard_password,
)

from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP

Expand Down Expand Up @@ -56,15 +61,62 @@ def __init__(
if conf_str.startswith("\ufeff"):
conf_str = conf_str[1:]
conf = json.loads(conf_str)

dashboard_conf = conf.get("dashboard")
legacy_dashboard_password_change_required = bool(
isinstance(dashboard_conf, dict)
and dashboard_conf.get("password_change_required", False)
)
if legacy_dashboard_password_change_required:
object.__setattr__(
self,
"_dashboard_password_change_required_from_config",
True,
)
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
if (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
):
self._reset_generated_dashboard_password(conf)
has_new = True
elif (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and legacy_dashboard_password_change_required
and conf["dashboard"].get("pbkdf2_password")
):
self._reset_generated_dashboard_password(conf)
has_new = True
self.update(conf)
if has_new:
self.save_config()

self.update(conf)

def _reset_generated_dashboard_password(self, conf: dict) -> None:
generated_password = generate_dashboard_password()
conf["dashboard"]["pbkdf2_password"] = hash_dashboard_password(
generated_password
)
conf["dashboard"]["password"] = hash_legacy_dashboard_password(
generated_password
)
conf["dashboard"]["password_storage_upgraded"] = True
conf["dashboard"]["password_change_required"] = True
object.__setattr__(
self,
"_generated_dashboard_password",
generated_password,
)
object.__setattr__(
self,
"_generated_dashboard_password_change_required",
True,
)

def _config_schema_to_default_config(self, schema: dict) -> dict:
"""将 Schema 转换成 Config"""
conf = {}
Expand Down
5 changes: 4 additions & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,10 @@
"dashboard": {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"password": "",
"pbkdf2_password": "",
"password_storage_upgraded": False,
"password_change_required": False,
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
Expand Down
126 changes: 126 additions & 0 deletions astrbot/core/utils/auth_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Utilities for dashboard password hashing and verification."""

import hashlib
import hmac
import re
import secrets
import string

_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 = 8
_GENERATED_DASHBOARD_PASSWORD_LENGTH = 24
DEFAULT_DASHBOARD_PASSWORD = "astrbot"


def generate_dashboard_password() -> str:
"""Generate a strong dashboard password that satisfies the complexity policy."""
alphabet = string.ascii_letters + string.digits
password_chars = [
secrets.choice(string.ascii_uppercase),
secrets.choice(string.ascii_lowercase),
secrets.choice(string.digits),
*(
secrets.choice(alphabet)
for _ in range(_GENERATED_DASHBOARD_PASSWORD_LENGTH - 3)
),
]
secrets.SystemRandom().shuffle(password_chars)
return "".join(password_chars)


def hash_dashboard_password(raw_password: str) -> str:
"""Return a salted hash for dashboard password using PBKDF2-HMAC-SHA256."""
if not isinstance(raw_password, str) or raw_password == "":
raise ValueError("Password cannot be empty")

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 hash_legacy_dashboard_password(raw_password: str) -> str:
"""Return legacy MD5 hash for downgrade compatibility only."""
if not isinstance(raw_password, str) or raw_password == "":
raise ValueError("Password cannot be empty")
return hashlib.md5(raw_password.encode("utf-8")).hexdigest()
Comment thread
Soulter marked this conversation as resolved.
Dismissed


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")
Comment thread
Soulter marked this conversation as resolved.


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 verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring verify_dashboard_password into a small dispatcher plus per-scheme helpers so each hash type’s detection, parsing, and verification are clearly separated.

verify_dashboard_password is carrying multiple responsibilities (scheme detection, parsing, and verification) and mixing semantics in a single branch. You can keep all behavior but make it easier to read and test by introducing a small dispatcher + per-scheme helpers.

For example:

from enum import Enum, auto

class _HashScheme(Enum):
    LEGACY_MD5 = auto()
    PBKDF2 = auto()
    UNKNOWN = auto()


def _get_hash_scheme(stored_hash: str) -> _HashScheme:
    if _is_legacy_md5_hash(stored_hash):
        return _HashScheme.LEGACY_MD5
    if _is_pbkdf2_hash(stored_hash):
        return _HashScheme.PBKDF2
    return _HashScheme.UNKNOWN

Then split the verification logic into named functions so the compatibility behavior is explicit:

def _verify_legacy_md5_hash(stored_hash: str, candidate_password: str) -> bool:
    # 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()
    stored = stored_hash.lower()
    return (
        hmac.compare_digest(stored, candidate_md5.lower())
        or hmac.compare_digest(stored, candidate_password.lower())
    )


def _verify_pbkdf2_hash(stored_hash: str, candidate_password: str) -> bool:
    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)

verify_dashboard_password then becomes a straightforward dispatcher:

def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool:
    if not isinstance(stored_hash, str) or not isinstance(candidate_password, str):
        return False

    scheme = _get_hash_scheme(stored_hash)
    if scheme is _HashScheme.LEGACY_MD5:
        return _verify_legacy_md5_hash(stored_hash, candidate_password)
    if scheme is _HashScheme.PBKDF2:
        return _verify_pbkdf2_hash(stored_hash, candidate_password)
    return False

This keeps all existing behavior (including the legacy MD5 dual comparison and default-password checks) but makes each piece self-contained and easier to reason about and test individually.

"""Verify password against legacy md5 or new PBKDF2-SHA256 format."""
if not isinstance(stored_hash, str) or not isinstance(candidate_password, str):
return False

if _is_legacy_md5_hash(stored_hash):
# Keep compatibility with existing MD5-based deployments while requiring
# the real plaintext password, not the stored MD5 value itself.
candidate_md5 = hashlib.md5(candidate_password.encode("utf-8")).hexdigest()
return hmac.compare_digest(stored_hash.lower(), candidate_md5.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)

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."""
return _is_legacy_md5_hash(stored_hash)
94 changes: 94 additions & 0 deletions astrbot/dashboard/password_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_legacy_dashboard_password,
is_legacy_dashboard_password,
)

PASSWORD_STORAGE_UPGRADED_KEY = "password_storage_upgraded"
PASSWORD_CHANGE_REQUIRED_KEY = "password_change_required"


def _set_dashboard_flag(config: AstrBotConfig, key: str, value: bool) -> None:
if config["dashboard"].get(key) == bool(value):
return
config["dashboard"][key] = bool(value)
config.save_config()


def _has_usable_pbkdf2_password(config: AstrBotConfig) -> bool:
password = config["dashboard"].get("pbkdf2_password", "")
if not isinstance(password, str) or not password.startswith("pbkdf2_sha256$"):
return False

parts = password.split("$")
if len(parts) != 4:
return False

_, iterations, salt, digest = parts
try:
int(iterations)
bytes.fromhex(salt)
bytes.fromhex(digest)
except ValueError:
return False
return True


async def is_password_storage_upgraded(
db: BaseDatabase,
config: AstrBotConfig,
) -> bool:
config_upgraded = _has_usable_pbkdf2_password(config)
if config["dashboard"].get(PASSWORD_STORAGE_UPGRADED_KEY) != config_upgraded:
_set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, config_upgraded)
return config_upgraded


async def set_password_storage_upgraded(
db: BaseDatabase,
config: AstrBotConfig,
upgraded: bool,
) -> None:
_set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, upgraded)


async def is_password_change_required(
db: BaseDatabase,
config: AstrBotConfig,
) -> bool:
stored = config["dashboard"].get(PASSWORD_CHANGE_REQUIRED_KEY, None)
if stored is not None:
return bool(stored)

required = bool(
getattr(config, "_generated_dashboard_password_change_required", False)
or getattr(config, "_dashboard_password_change_required_from_config", False)
)
if required:
_set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, True)
return required


async def set_password_change_required(
db: BaseDatabase,
config: AstrBotConfig,
required: bool,
) -> None:
_set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, required)


def get_dashboard_password_hash(config: AstrBotConfig, *, upgraded: bool) -> str:
if upgraded and _has_usable_pbkdf2_password(config):
return config["dashboard"].get("pbkdf2_password", "")

legacy_password = config["dashboard"].get("password", "")
if upgraded and not is_legacy_dashboard_password(legacy_password):
return ""
return legacy_password


def set_dashboard_password_hashes(config: AstrBotConfig, raw_password: str) -> None:
config["dashboard"]["pbkdf2_password"] = hash_dashboard_password(raw_password)
config["dashboard"]["password"] = hash_legacy_dashboard_password(raw_password)
Loading
Loading