Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(dashboard): implement random password generation for empty dashb…
…oard password
  • Loading branch information
Soulter committed May 8, 2026
commit fb1cb4e759fd6878877e4c016c0f7ec13c8e4574
11 changes: 9 additions & 2 deletions astrbot/core/config/astrbot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

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

from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
Expand Down Expand Up @@ -67,7 +68,13 @@ def __init__(
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("password")
):
conf["dashboard"]["password"] = normalize_dashboard_password_hash("")
generated_password = generate_dashboard_password()
conf["dashboard"]["password"] = hash_dashboard_password(generated_password)
object.__setattr__(
self,
"_generated_dashboard_password",
generated_password,
)
has_new = True
self.update(conf)
if has_new:
Expand Down
25 changes: 18 additions & 7 deletions astrbot/core/utils/auth_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import hmac
import re
import secrets
import string
from typing import Any

_PBKDF2_ITERATIONS = 600_000
Expand All @@ -12,9 +13,26 @@
_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$"
_LEGACY_MD5_LENGTH = 32
_DASHBOARD_PASSWORD_MIN_LENGTH = 12
_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 == "":
Expand Down Expand Up @@ -47,13 +65,6 @@ def validate_dashboard_password(raw_password: str) -> None:
raise ValueError("Password must include at least one digit")
Comment thread
Soulter marked this conversation as resolved.


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)
Expand Down
16 changes: 15 additions & 1 deletion astrbot/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ def _init_jwt_secret(self) -> None:
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = self.config["dashboard"]["jwt_secret"]

def _build_dashboard_credentials_display(self) -> str:
username = self.config["dashboard"].get("username", "astrbot")
generated_password = getattr(self.config, "_generated_dashboard_password", None)
if not generated_password:
return f" ➜ 用户名: {username}\n ✨✨✨\n"

credentials_display = (
f" ➜ 初始用户名: {username}\n"
f" ➜ 初始密码: {generated_password}\n"
" ➜ 可以在登录后修改密码\n ✨✨✨\n"
)
object.__setattr__(self.config, "_generated_dashboard_password", None)
return credentials_display

@staticmethod
def _resolve_dashboard_ssl_config(
ssl_config: dict,
Expand Down Expand Up @@ -419,7 +433,7 @@ def run(self):
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
for ip in ip_addr:
parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n")
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
parts.append(self._build_dashboard_credentials_display())
display = "".join(parts)

if not ip_addr:
Expand Down
21 changes: 21 additions & 0 deletions tests/test_api_key_open_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.utils.auth_password import hash_dashboard_password
from astrbot.dashboard.routes.route import Response
from astrbot.dashboard.server import AstrBotDashboard

_TEST_DASHBOARD_PASSWORD = "AstrbotTest123"


def _get_open_api_route(app: Quart):
rule = next(
Expand Down Expand Up @@ -54,6 +57,21 @@ async def core_lifecycle_td(tmp_path_factory):
log_broker = LogBroker()
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
await core_lifecycle.initialize()
generated_password = getattr(
core_lifecycle.astrbot_config,
"_generated_dashboard_password",
None,
)
dashboard_password = generated_password or _TEST_DASHBOARD_PASSWORD
if not generated_password:
core_lifecycle.astrbot_config["dashboard"]["password"] = (
hash_dashboard_password(dashboard_password)
)
object.__setattr__(
core_lifecycle,
"_dashboard_plain_password",
dashboard_password,
)
try:
yield core_lifecycle
finally:
Expand All @@ -73,6 +91,9 @@ def app(core_lifecycle_td: AstrBotCoreLifecycle):


def _resolve_dashboard_password(core_lifecycle_td: AstrBotCoreLifecycle) -> str:
generated_password = getattr(core_lifecycle_td, "_dashboard_plain_password", None)
if generated_password:
return generated_password
password = core_lifecycle_td.astrbot_config["dashboard"]["password"]
if isinstance(password, str) and password.startswith("pbkdf2_sha256$"):
return "astrbot"
Expand Down
21 changes: 21 additions & 0 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.star.star import star_registry
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.utils.auth_password import hash_dashboard_password
from astrbot.core.utils.pip_installer import PipInstallError
from astrbot.dashboard.routes.plugin import PluginRoute
from astrbot.dashboard.server import AstrBotDashboard
Expand All @@ -27,6 +28,8 @@
create_mock_updater_update,
)

_TEST_DASHBOARD_PASSWORD = "AstrbotTest123"


@pytest_asyncio.fixture(scope="module")
async def core_lifecycle_td(tmp_path_factory):
Expand All @@ -36,6 +39,21 @@ async def core_lifecycle_td(tmp_path_factory):
log_broker = LogBroker()
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
await core_lifecycle.initialize()
generated_password = getattr(
core_lifecycle.astrbot_config,
"_generated_dashboard_password",
None,
)
dashboard_password = generated_password or _TEST_DASHBOARD_PASSWORD
if not generated_password:
core_lifecycle.astrbot_config["dashboard"]["password"] = (
hash_dashboard_password(dashboard_password)
)
object.__setattr__(
core_lifecycle,
"_dashboard_plain_password",
dashboard_password,
)
try:
yield core_lifecycle
finally:
Expand All @@ -60,6 +78,9 @@ def app(core_lifecycle_td: AstrBotCoreLifecycle):

def _resolve_dashboard_password(core_lifecycle_td: AstrBotCoreLifecycle) -> str:
"""Return a login password compatible with both hashed and plain defaults."""
generated_password = getattr(core_lifecycle_td, "_dashboard_plain_password", None)
if generated_password:
return generated_password
password = core_lifecycle_td.astrbot_config["dashboard"]["password"]
if isinstance(password, str) and password.startswith("pbkdf2_sha256$"):
return "astrbot"
Expand Down
21 changes: 21 additions & 0 deletions tests/test_kb_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.models import KBDocument
from astrbot.core.utils.auth_password import hash_dashboard_password
from astrbot.dashboard.server import AstrBotDashboard

_TEST_DASHBOARD_PASSWORD = "AstrbotTest123"


@pytest_asyncio.fixture(scope="module")
async def core_lifecycle_td(tmp_path_factory):
Expand Down Expand Up @@ -44,6 +47,21 @@ async def core_lifecycle_td(tmp_path_factory):

# kb_manager.get_kb.return_value = kb_helper # Removed this line as it's handled above
core_lifecycle.kb_manager = kb_manager
generated_password = getattr(
core_lifecycle.astrbot_config,
"_generated_dashboard_password",
None,
)
dashboard_password = generated_password or _TEST_DASHBOARD_PASSWORD
if not generated_password:
core_lifecycle.astrbot_config["dashboard"]["password"] = (
hash_dashboard_password(dashboard_password)
)
object.__setattr__(
core_lifecycle,
"_dashboard_plain_password",
dashboard_password,
)

try:
yield core_lifecycle
Expand All @@ -65,6 +83,9 @@ def app(core_lifecycle_td: AstrBotCoreLifecycle):


def _resolve_dashboard_password(core_lifecycle_td: AstrBotCoreLifecycle) -> str:
generated_password = getattr(core_lifecycle_td, "_dashboard_plain_password", None)
if generated_password:
return generated_password
password = core_lifecycle_td.astrbot_config["dashboard"]["password"]
if isinstance(password, str) and password.startswith("pbkdf2_sha256$"):
return "astrbot"
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
from astrbot.core.config.astrbot_config import AstrBotConfig, RateLimitStrategy
from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.utils.auth_password import (
DEFAULT_DASHBOARD_PASSWORD,
validate_dashboard_password,
verify_dashboard_password,
)


@pytest.fixture
Expand Down Expand Up @@ -185,6 +190,32 @@ def test_check_exist(self, temp_config_path, minimal_default_config):
assert config2.check_exist() is True
assert os.path.exists(non_existent_path)

def test_empty_dashboard_password_generates_random_password(self, temp_config_path):
"""Test that an empty dashboard password is replaced with a random password."""
default_config = {
"dashboard": {
"username": "astrbot",
"password": "",
},
}

config = AstrBotConfig(
config_path=temp_config_path,
default_config=default_config,
)

generated_password = getattr(config, "_generated_dashboard_password", None)
assert isinstance(generated_password, str)
validate_dashboard_password(generated_password)
assert verify_dashboard_password(
config["dashboard"]["password"],
generated_password,
)
assert not verify_dashboard_password(
config["dashboard"]["password"],
DEFAULT_DASHBOARD_PASSWORD,
)


class TestConfigValidation:
"""Tests for config validation and integrity checking."""
Expand Down