Skip to content
35 changes: 33 additions & 2 deletions astrbot/core/config/astrbot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@

ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD"
DASHBOARD_RESET_FLAG_FILE = os.path.join(
get_astrbot_data_path(), ".reset_dashboard_password"
)
logger = logging.getLogger("astrbot")


Expand Down Expand Up @@ -76,13 +80,21 @@ def __init__(
)
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.
dashboard_reset_requested = self._is_dashboard_password_reset_requested()
if (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
and (
dashboard_reset_requested
or (
not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
)
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.
)
):
self._reset_generated_dashboard_password(conf)
if dashboard_reset_requested:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "0"
has_new = True
elif (
"dashboard" in conf
Expand Down Expand Up @@ -118,6 +130,12 @@ def _reset_generated_dashboard_password(self, conf: dict) -> None:
"_generated_dashboard_password_change_required",
True,
)
# Consume and remove the flag file after successful reset
if os.path.exists(DASHBOARD_RESET_FLAG_FILE):
try:
os.remove(DASHBOARD_RESET_FLAG_FILE)
except OSError:
logger.warning("Failed to remove dashboard reset flag file.")

@staticmethod
def _resolve_initial_dashboard_password() -> str:
Expand All @@ -127,6 +145,19 @@ def _resolve_initial_dashboard_password() -> str:
validate_dashboard_password(env_password)
return env_password

@staticmethod
def _is_dashboard_password_reset_requested() -> bool:
env_requested = os.environ.get(
DASHBOARD_RESET_PASSWORD_ENV, ""
).strip().lower() in {
"1",
"true",
"yes",
"on",
}
flag_requested = os.path.exists(DASHBOARD_RESET_FLAG_FILE)
return env_requested or flag_requested

def _config_schema_to_default_config(self, schema: dict) -> dict:
"""将 Schema 转换成 Config"""
conf = {}
Expand Down
122 changes: 121 additions & 1 deletion astrbot/dashboard/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import asyncio
import datetime
import os
import secrets
import time

import jwt
from quart import current_app, g, jsonify, make_response, request

from astrbot import logger
from astrbot.core import DEMO_MODE
from astrbot.core.config.astrbot_config import DASHBOARD_RESET_FLAG_FILE
from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
is_legacy_dashboard_password,
Expand Down Expand Up @@ -48,19 +51,48 @@


class AuthRoute(Route):
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
def __init__(self, context: RouteContext, db) -> None:
def __init__(self, context: RouteContext, db, core_lifecycle=None) -> None:
super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
# Password reset confirmation code state
self._reset_code: str | None = None
self._reset_code_expiry: float = 0.0
self._reset_failed_attempts: int = 0
# Rate limiting: list of recent attempt timestamps
self._reset_attempts: list[float] = []
self.routes = {
"/auth/login": ("POST", self.login),
"/auth/logout": ("POST", self.logout),
"/auth/setup-status": ("GET", self.setup_status),
"/auth/setup": ("POST", self.setup),
"/auth/setup-authenticated": ("POST", self.setup_authenticated),
"/auth/account/edit": ("POST", self.edit_account),
"/auth/forgot-password": ("POST", self.forgot_password),
"/auth/forgot-password/init": ("POST", self.forgot_password_init),
}
self.register_routes()

def _generate_reset_code(self) -> str:
"""Generate a 6-digit alphanumeric confirmation code."""
charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(charset) for _ in range(6))

def _is_rate_limited(
self, max_attempts: int = 3, window_seconds: float = 300.0
) -> bool:
"""Check if the forgot-password endpoint is rate-limited."""
now = time.monotonic()
# Keep only attempts within the time window
self._reset_attempts = [
t for t in self._reset_attempts if now - t < window_seconds
]
return len(self._reset_attempts) >= max_attempts

def _record_attempt(self) -> None:
"""Record a forgot-password attempt timestamp."""
self._reset_attempts.append(time.monotonic())

async def setup_status(self):
return (
Response()
Expand Down Expand Up @@ -199,6 +231,94 @@ async def logout(self):
self._clear_dashboard_jwt_cookie(response)
return response

async def forgot_password_init(self):
"""Generate a confirmation code and print it to the terminal."""
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)

if self._is_rate_limited():
return Response().error("请求过于频繁,请 5 分钟后再试").__dict__

self._record_attempt()
code = self._generate_reset_code()
self._reset_code = code
self._reset_code_expiry = time.monotonic() + 300.0 # 5 minutes
self._reset_failed_attempts = 0

logger.info(
"Password reset requested. Confirmation code: %s "
"(valid for 5 minutes). Enter this code in the WebUI to proceed.",
code,
)
return (
Response().ok(None, "确认码已生成,请查看终端日志获取 6 位确认码").__dict__
)

async def forgot_password(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)

post_data = await request.json
if not isinstance(post_data, dict):
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.
return Response().error("Invalid request payload").__dict__

code = post_data.get("code", "")
if not isinstance(code, str):
return Response().error("确认码格式不正确").__dict__
code = code.strip()
if len(code) != 6:
return Response().error("确认码格式不正确").__dict__
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.

if self._reset_code is None:
return Response().error("请先点击忘记密码获取确认码").__dict__

if time.monotonic() > self._reset_code_expiry:
self._reset_code = None
return Response().error("确认码已过期,请重新获取").__dict__

if code.upper() != self._reset_code.upper():
self._reset_failed_attempts += 1
if self._reset_failed_attempts >= 3:
self._reset_code = None
return Response().error("确认码错误次数过多,已失效,请重新获取").__dict__
remaining = 3 - self._reset_failed_attempts
return Response().error(f"确认码不正确,还可以尝试 {remaining} 次").__dict__

# Clear the code after successful validation
self._reset_code = None

try:
with open(DASHBOARD_RESET_FLAG_FILE, "w", encoding="utf-8") as f:
f.write("1")
except OSError as e:
logger.error(f"Failed to create password reset flag file: {e}")
return Response().error("创建重置标记失败,请检查文件权限").__dict__

# Trigger restart asynchronously so the HTTP response can be sent first
if self.core_lifecycle is not None:
self._restart_task = asyncio.create_task(self._delayed_restart())
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

return Response().ok(None, "密码重置请求已接受,AstrBot 即将重启").__dict__

async def _delayed_restart(self, delay: float = 1.0) -> None:
"""Delay briefly to let the HTTP response finish, then restart."""
await asyncio.sleep(delay)
if self.core_lifecycle is None:
logger.warning("core_lifecycle is not available, skipping auto-restart.")
return
try:
await self.core_lifecycle.restart()
except Exception as e:
logger.error(f"Auto-restart after password reset failed: {e}")

async def edit_account(self):
if DEMO_MODE:
return (
Expand Down
4 changes: 3 additions & 1 deletion astrbot/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def __init__(
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context, db)
self.ar = AuthRoute(self.context, db, core_lifecycle)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.open_api_route = OpenApiRoute(
Expand Down Expand Up @@ -251,6 +251,8 @@ async def auth_middleware(self):
"/api/auth/logout",
"/api/auth/setup-status",
"/api/auth/setup",
"/api/auth/forgot-password",
"/api/auth/forgot-password/init",
}
allowed_endpoint_prefixes = [
"/api/file",
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
"title": "AstrBot Dashboard",
"subtitle": "Welcome"
},
"forgotPassword": {
"label": "Forgot Password",
"title": "Reset Password",
"codeHint": "A confirmation code has been printed in the AstrBot terminal logs. Please enter the 6-digit code to continue.",
"codeLabel": "Confirmation Code",
"codeInvalid": "Please enter a 6-digit confirmation code",
"confirmTitle": "Confirm",
"warningTitle": "Warning",
"warningText": "AstrBot will restart automatically after reset. All existing logins will be invalidated. The new random password will be printed in the terminal logs.",
"restartHint": "Please note down the new password before logging in.",
"confirmReset": "Confirm Reset"
},
"cancel": "Cancel",
"next": "Next",
"restarting": "Restarting...",
"restartingTitle": "Restarting",
"restartingHint": "AstrBot is restarting, please wait...",
"theme": {
"switchToDark": "Switch to Dark Theme",
"switchToLight": "Switch to Light Theme"
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
"title": "Панель управления AstrBot",
"subtitle": "Добро пожаловать"
},
"forgotPassword": {
"label": "Забыли пароль?",
"title": "Сброс пароля",
"codeHint": "Код подтверждения выведен в терминал AstrBot. Введите 6-значный код для продолжения.",
"codeLabel": "Код подтверждения",
"codeInvalid": "Введите 6-значный код подтверждения",
"confirmTitle": "Подтвердить сброс",
"warningTitle": "Предупреждение",
"warningText": "После сброса AstrBot автоматически перезапустится. Все текущие сессии будут завершены. Новый случайный пароль будет выведен в терминале.",
"restartHint": "Запишите новый пароль перед входом.",
"confirmReset": "Подтвердить сброс"
},
"cancel": "Отмена",
"next": "Далее",
"restarting": "Перезапуск...",
"restartingTitle": "Перезапуск",
"restartingHint": "AstrBot перезапускается, пожалуйста, подождите...",
"theme": {
"switchToDark": "Перейти на темную тему",
"switchToLight": "Перейти на светлую тему"
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
"title": "AstrBot WebUI",
"subtitle": "欢迎使用"
},
"forgotPassword": {
"label": "忘记密码",
"title": "重置密码",
"codeHint": "确认码已输出到 AstrBot 终端日志,请输入 6 位确认码以继续",
"codeLabel": "确认码",
"codeInvalid": "请输入 6 位确认码",
"confirmTitle": "二次确认",
"warningTitle": "警告",
"warningText": "重置后 AstrBot 将自动重启,所有现有登录将失效。新的随机密码会输出到终端日志中。",
"restartHint": "请记下新密码后再登录。",
"confirmReset": "确认重置"
},
"cancel": "取消",
"next": "下一步",
"restarting": "正在重启...",
"restartingTitle": "正在重启",
"restartingHint": "AstrBot 正在重启中,请稍候...",
"theme": {
"switchToDark": "切换到深色主题",
"switchToLight": "切换到浅色主题"
Expand Down
24 changes: 24 additions & 0 deletions dashboard/src/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ export const useAuthStore = defineStore("auth", {
return false;
}
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.
},
async forgotPasswordInit(): Promise<void> {
try {
const res = await axios.post('/api/auth/forgot-password/init');
if (res.data.status === 'error') {
return Promise.reject(res.data.message);
}
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
async forgotPassword(code: string): Promise<void> {
try {
const res = await axios.post('/api/auth/forgot-password', {
code: code
});
if (res.data.status === 'error') {
return Promise.reject(res.data.message);
}
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
logout() {
this.username = '';
localStorage.removeItem('user');
Expand Down
Loading
Loading