diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index 6d48ec28d..3dc0d0e41 100644 --- a/astrbot/cli/__main__.py +++ b/astrbot/cli/__main__.py @@ -5,7 +5,7 @@ import sys import click from . import __version__ -from .commands import conf, init, plug, run +from .commands import conf, init, password, plug, run logo_tmpl = r""" ___ _______.___________..______ .______ ______ .___________. @@ -54,6 +54,7 @@ cli.add_command(run) cli.add_command(help) cli.add_command(plug) cli.add_command(conf) +cli.add_command(password) if __name__ == "__main__": cli() diff --git a/astrbot/cli/commands/__init__.py b/astrbot/cli/commands/__init__.py index 1d3e0bca2..d1765e5c2 100644 --- a/astrbot/cli/commands/__init__.py +++ b/astrbot/cli/commands/__init__.py @@ -1,6 +1,7 @@ from .cmd_conf import conf from .cmd_init import init +from .cmd_password import password from .cmd_plug import plug from .cmd_run import run -__all__ = ["conf", "init", "plug", "run"] +__all__ = ["conf", "init", "password", "plug", "run"] diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index cf583b9d5..ac626e0d1 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -137,6 +137,22 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any: return obj +def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None: + """Set dashboard password hashes and clear password migration flags.""" + _set_nested_item( + config, + "dashboard.pbkdf2_password", + hash_dashboard_password(raw_password), + ) + _set_nested_item( + config, + "dashboard.password", + hash_legacy_dashboard_password(raw_password), + ) + _set_nested_item(config, "dashboard.password_storage_upgraded", True) + _set_nested_item(config, "dashboard.password_change_required", False) + + @click.group(name="conf") def conf() -> None: """Configuration management commands @@ -171,16 +187,7 @@ def set_config(key: str, value: str) -> None: old_value = _get_nested_item(config, key) validated_value = CONFIG_VALIDATORS[key](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), - ) + _set_dashboard_password(config, validated_value) else: _set_nested_item(config, key, validated_value) _save_config(config) diff --git a/astrbot/cli/commands/cmd_password.py b/astrbot/cli/commands/cmd_password.py new file mode 100644 index 000000000..ab0ced0c9 --- /dev/null +++ b/astrbot/cli/commands/cmd_password.py @@ -0,0 +1,38 @@ +import click + +from .cmd_conf import ( + _load_config, + _save_config, + _set_dashboard_password, + _set_nested_item, + _validate_dashboard_password, + _validate_dashboard_username, +) + + +@click.command(name="password") +@click.option( + "--username", + help="Optional dashboard username to set together with the new password.", +) +def password(username: str | None) -> None: + """Change the AstrBot dashboard password.""" + config = _load_config() + + new_password = click.prompt( + "New dashboard password", + hide_input=True, + confirmation_prompt=True, + ) + validated_password = _validate_dashboard_password(new_password) + + if username is not None: + validated_username = _validate_dashboard_username(username.strip()) + _set_nested_item(config, "dashboard.username", validated_username) + + _set_dashboard_password(config, validated_password) + _save_config(config) + + click.echo("Dashboard password updated.") + if username is not None: + click.echo(f"Dashboard username updated: {validated_username}") diff --git a/tests/test_cli_password.py b/tests/test_cli_password.py new file mode 100644 index 000000000..e934220eb --- /dev/null +++ b/tests/test_cli_password.py @@ -0,0 +1,90 @@ +import copy +import json + +from click.testing import CliRunner + +from astrbot.cli.commands.cmd_conf import conf +from astrbot.cli.commands.cmd_password import password +from astrbot.core.config.default import DEFAULT_CONFIG +from astrbot.core.utils.auth_password import verify_dashboard_password + + +def _write_config(root): + (root / ".astrbot").touch() + data_dir = root / "data" + data_dir.mkdir() + config = copy.deepcopy(DEFAULT_CONFIG) + config["dashboard"]["password_change_required"] = True + config["dashboard"]["password_storage_upgraded"] = False + config_path = data_dir / "cmd_config.json" + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + return config_path + + +def _read_config(config_path): + return json.loads(config_path.read_text(encoding="utf-8-sig")) + + +def test_password_command_changes_dashboard_password(monkeypatch, tmp_path): + config_path = _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + password, + input="AstrbotChanged123\nAstrbotChanged123\n", + ) + + assert result.exit_code == 0 + config = _read_config(config_path) + dashboard_config = config["dashboard"] + assert verify_dashboard_password( + dashboard_config["pbkdf2_password"], + "AstrbotChanged123", + ) + assert verify_dashboard_password( + dashboard_config["password"], + "AstrbotChanged123", + ) + assert dashboard_config["password_storage_upgraded"] is True + assert dashboard_config["password_change_required"] is False + + +def test_password_command_can_update_dashboard_username(monkeypatch, tmp_path): + config_path = _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + password, + ["--username", "astrbot-admin"], + input="AstrbotChanged123\nAstrbotChanged123\n", + ) + + assert result.exit_code == 0 + config = _read_config(config_path) + assert config["dashboard"]["username"] == "astrbot-admin" + + +def test_conf_set_dashboard_password_updates_password_state(monkeypatch, tmp_path): + config_path = _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + conf, + ["set", "dashboard.password", "AstrbotChanged123"], + ) + + assert result.exit_code == 0 + config = _read_config(config_path) + dashboard_config = config["dashboard"] + assert verify_dashboard_password( + dashboard_config["pbkdf2_password"], + "AstrbotChanged123", + ) + assert dashboard_config["password_storage_upgraded"] is True + assert dashboard_config["password_change_required"] is False