diff --git a/astrbot/core/initial_loader.py b/astrbot/core/initial_loader.py index 3f836a4c42..35b971dbee 100644 --- a/astrbot/core/initial_loader.py +++ b/astrbot/core/initial_loader.py @@ -6,6 +6,7 @@ """ import asyncio +import hashlib import traceback from astrbot.core import LogBroker, logger @@ -17,11 +18,17 @@ class InitialLoader: """AstrBot 启动器,负责初始化和启动核心组件和仪表板服务器。""" - def __init__(self, db: BaseDatabase, log_broker: LogBroker) -> None: + def __init__( + self, + db: BaseDatabase, + log_broker: LogBroker, + dashboard_temporary_login_token: str | None = None, + ) -> None: self.db = db self.logger = logger self.log_broker = log_broker self.webui_dir: str | None = None + self.dashboard_temporary_login_token = dashboard_temporary_login_token async def start(self) -> None: core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db) @@ -33,6 +40,22 @@ async def start(self) -> None: logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!") return + if self.dashboard_temporary_login_token: + token_hash = hashlib.sha256( + self.dashboard_temporary_login_token.encode("utf-8") + ).hexdigest() + object.__setattr__( + core_lifecycle.astrbot_config, + "_dashboard_temporary_login_token_hash", + token_hash, + ) + object.__setattr__( + core_lifecycle.astrbot_config, + "_dashboard_temporary_login_token", + self.dashboard_temporary_login_token, + ) + self.dashboard_temporary_login_token = None + core_task = core_lifecycle.start() webui_dir = self.webui_dir diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 70747a8a29..8db47324df 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -1,6 +1,8 @@ import asyncio import datetime +import hashlib import os +import secrets import jwt import pyotp @@ -43,6 +45,7 @@ DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 +DASHBOARD_TEMPORARY_LOGIN_JWT_MAX_AGE = 3 * 24 * 60 * 60 SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"} @@ -64,6 +67,15 @@ ) +def verify_dashboard_temporary_login_token(config, token: str) -> bool: + token = token.strip() + token_hash = getattr(config, "_dashboard_temporary_login_token_hash", "") + if not token or not isinstance(token_hash, str) or not token_hash: + return False + candidate_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + return secrets.compare_digest(candidate_hash, token_hash) + + class AuthRoute(Route): def __init__(self, context: RouteContext, db) -> None: super().__init__(context) @@ -87,6 +99,7 @@ async def setup_status(self): { "setup_required": await self._is_setup_required(), "skip_default_password_auth": self._can_skip_default_password_auth(), + "temporary_login_token_enabled": self._temporary_login_token_enabled(), "password_upgrade_required": not await is_password_storage_upgraded( self.db, self.config, @@ -221,19 +234,28 @@ async def login(self): storage_upgraded = await is_password_storage_upgraded(self.db, self.config) password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ - req_username = ( - post_data.get("username") if isinstance(post_data, dict) else None - ) - req_password = ( - post_data.get("password") if isinstance(post_data, dict) else None - ) - totp_code = post_data.get("code") if isinstance(post_data, dict) else None - trust_device_flag = ( - post_data.get("trust_device_flag") is True - if isinstance(post_data, dict) - else False - ) + login_type = post_data.get("login_type") + if login_type == "temporary_token": + req_token = post_data.get("temporary_token") + if not isinstance(req_token, str) or not self._verify_temporary_login_token( + req_token + ): + await asyncio.sleep(3) + return await self._error_response("临时 Token 无效", 401) + return await self._create_login_response( + username, + storage_upgraded, + password, + jwt_max_age=DASHBOARD_TEMPORARY_LOGIN_JWT_MAX_AGE, + ) + + req_username = post_data.get("username") + req_password = post_data.get("password") + totp_code = post_data.get("code") + trust_device_flag = post_data.get("trust_device_flag") is True if not isinstance(req_username, str) or not isinstance(req_password, str): return Response().error("Invalid request payload").__dict__ @@ -291,6 +313,24 @@ async def login(self): else: return await self._error_response("恢复码无效", 401) + return await self._create_login_response( + username, + storage_upgraded, + password, + totp_verified=totp_verified, + trust_device_flag=trust_device_flag, + ) + + async def _create_login_response( + self, + username: str, + storage_upgraded: bool, + password: str, + *, + totp_verified: bool = False, + trust_device_flag: bool = False, + jwt_max_age: int = DASHBOARD_JWT_COOKIE_MAX_AGE, + ): change_pwd_hint = False legacy_pwd_hint = is_legacy_dashboard_password(password) password_change_required = await is_password_change_required( @@ -308,7 +348,7 @@ async def login(self): logger.warning("为了保证安全,请尽快修改默认密码。") if password_change_required and not DEMO_MODE: change_pwd_hint = True - token = self.generate_jwt(username) + token = self.generate_jwt(username, max_age=jwt_max_age) login_data = { "token": token, "username": username, @@ -318,7 +358,7 @@ async def login(self): } payload = Response().ok(login_data) response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) + self._set_dashboard_jwt_cookie(response, token, max_age=jwt_max_age) if totp_verified and trust_device_flag: raw_token = await issue_totp_trusted_device(self.config, self.db) @@ -334,6 +374,13 @@ async def login(self): ) return response + def _temporary_login_token_enabled(self) -> bool: + token_hash = getattr(self.config, "_dashboard_temporary_login_token_hash", "") + return isinstance(token_hash, str) and bool(token_hash) + + def _verify_temporary_login_token(self, token: str) -> bool: + return verify_dashboard_temporary_login_token(self.config, token) + async def logout(self): response = await make_response( jsonify(Response().ok(None, "已退出登录").__dict__) @@ -396,11 +443,11 @@ async def edit_account(self): return Response().ok(None, "Updated account successfully").__dict__ - def generate_jwt(self, username): + def generate_jwt(self, username, *, max_age: int = DASHBOARD_JWT_COOKIE_MAX_AGE): payload = { "username": username, "exp": datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(days=7), + + datetime.timedelta(seconds=max_age), } jwt_token = self.config["dashboard"].get("jwt_secret", None) if not jwt_token: @@ -463,11 +510,15 @@ def _use_secure_dashboard_jwt_cookie() -> bool: ) @staticmethod - def _set_dashboard_jwt_cookie(response, token: str) -> None: + def _set_dashboard_jwt_cookie( + response, + token: str, + max_age: int = DASHBOARD_JWT_COOKIE_MAX_AGE, + ) -> None: response.set_cookie( DASHBOARD_JWT_COOKIE_NAME, token, - max_age=DASHBOARD_JWT_COOKIE_MAX_AGE, + max_age=max_age, httponly=True, samesite="Strict", secure=AuthRoute._use_secure_dashboard_jwt_cookie(), diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 9888da8f5f..3ad98b59f8 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -430,7 +430,6 @@ async def auth_middleware(self): r = jsonify(Response().error("Token 无效").__dict__) r.status_code = 401 return r - username = payload.get("username") if not isinstance(username, str) or not username.strip(): raise jwt.InvalidTokenError("missing username in token payload") @@ -565,15 +564,37 @@ def _init_jwt_secret(self) -> None: 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: + temporary_login_token = getattr( + self.config, + "_dashboard_temporary_login_token", + None, + ) + if not generated_password and not temporary_login_token: return f" ➜ Username: {username}\n ✨✨✨\n" - credentials_display = ( - f" ➜ Initial username: {username}\n" - f" ➜ Initial password: {generated_password}\n" - " ➜ Change it after logging in\n ✨✨✨\n" - ) - object.__setattr__(self.config, "_generated_dashboard_password", None) + lines = [f" ➜ Initial username: {username}\n"] + if generated_password: + lines.extend( + [ + f" ➜ Initial password: {generated_password}\n", + " ➜ Change it after logging in\n", + ] + ) + object.__setattr__(self.config, "_generated_dashboard_password", None) + if temporary_login_token: + lines.extend( + [ + f" ➜ Temporary login token: {temporary_login_token}\n", + " ➜ The token is valid until this AstrBot process exits\n", + ] + ) + object.__setattr__( + self.config, + "_dashboard_temporary_login_token", + None, + ) + lines.append(" ✨✨✨\n") + credentials_display = "".join(lines) return credentials_display @staticmethod diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 8e5fd76cd1..0def62754b 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 271 icons */ +/* Auto-generated MDI subset – 273 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -464,6 +464,10 @@ content: "\F1036"; } +.mdi-file-search-outline::before { + content: "\F0C7D"; +} + .mdi-file-upload::before { content: "\F0A4D"; } @@ -688,6 +692,10 @@ content: "\F16B2"; } +.mdi-logout::before { + content: "\F0343"; +} + .mdi-magnify::before { content: "\F0349"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 181ce7f861..bc0cd32398 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 931625c6d7..2538f13e5e 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index d83f06152f..c086ac5df0 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -7,6 +7,7 @@ "buttons": { "update": "Update", "account": "Account", + "logout": "Log Out", "theme": { "light": "Light Mode", "dark": "Dark Mode" diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 526c080be0..96737aefe7 100644 --- a/dashboard/src/i18n/locales/en-US/features/auth.json +++ b/dashboard/src/i18n/locales/en-US/features/auth.json @@ -3,6 +3,14 @@ "username": "Username", "password": "Password", "defaultHint": "If this is your first login, check the logs for the default password.", + "temporaryToken": { + "link": "Log in with temporary token", + "title": "Temporary Token Login", + "subtitle": "Use the temporary token printed in the startup logs.", + "token": "Temporary Token", + "submit": "Log in with Temporary Token", + "backToLogin": "Back to Login" + }, "totp": { "code": "Verification code", "verify": "Verify", @@ -60,4 +68,4 @@ "switchToDark": "Switch to Dark Theme", "switchToLight": "Switch to Light Theme" } -} +} diff --git a/dashboard/src/i18n/locales/ru-RU/core/header.json b/dashboard/src/i18n/locales/ru-RU/core/header.json index 3124cd9719..65dbf0a9c8 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/header.json +++ b/dashboard/src/i18n/locales/ru-RU/core/header.json @@ -7,6 +7,7 @@ "buttons": { "update": "Обновить", "account": "Аккаунт", + "logout": "Выйти", "theme": { "light": "Светлая тема", "dark": "Темная тема" diff --git a/dashboard/src/i18n/locales/ru-RU/features/auth.json b/dashboard/src/i18n/locales/ru-RU/features/auth.json index 97f2a71f74..b91cd9d9aa 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/auth.json +++ b/dashboard/src/i18n/locales/ru-RU/features/auth.json @@ -3,6 +3,14 @@ "username": "Имя пользователя", "password": "Пароль", "defaultHint": "Если это первый вход, проверьте пароль по умолчанию в логах.", + "temporaryToken": { + "link": "Войти с временным токеном", + "title": "Вход по временному токену", + "subtitle": "Используйте временный токен, напечатанный в логах запуска.", + "token": "Временный токен", + "submit": "Войти по временному токену", + "backToLogin": "Назад к входу" + }, "totp": { "code": "Код подтверждения", "verify": "Проверить", diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json index 8897ad0af5..b49c3b444e 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/header.json +++ b/dashboard/src/i18n/locales/zh-CN/core/header.json @@ -7,6 +7,7 @@ "buttons": { "update": "更新", "account": "账户", + "logout": "退出登录", "theme": { "light": "浅色模式", "dark": "深色模式" diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index ad886f105b..b48d51ada1 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/auth.json +++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json @@ -3,6 +3,14 @@ "username": "用户名", "password": "密码", "defaultHint": "如果这是首次登录,请在日志中查看默认密码。", + "temporaryToken": { + "link": "输入临时 Token 登录", + "title": "临时 Token 登录", + "subtitle": "使用启动日志中输出的临时 Token 登录。", + "token": "临时 Token", + "submit": "使用临时 Token 登录", + "backToLogin": "返回账号登录" + }, "totp": { "code": "验证码", "verify": "验证", diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index c41b566e70..d0f2b2fc0e 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -390,6 +390,11 @@ function accountEdit() { }); } +function logout() { + const authStore = useAuthStore(); + authStore.logout(); +} + function getVersion() { axios .get("/api/stat/version") @@ -1134,6 +1139,15 @@ onMounted(async () => { t("core.header.accountDialog.title") }} + + + + {{ + t("core.header.buttons.logout") + }} + diff --git a/dashboard/src/stores/auth.ts b/dashboard/src/stores/auth.ts index 7550497db2..1c8bb6af8e 100644 --- a/dashboard/src/stores/auth.ts +++ b/dashboard/src/stores/auth.ts @@ -1,49 +1,49 @@ -import { defineStore } from 'pinia'; -import { router } from '@/router'; -import axios from 'axios'; +import { defineStore } from "pinia"; +import { router } from "@/router"; +import axios from "axios"; export const useAuthStore = defineStore("auth", { state: () => ({ // @ts-ignore - username: '', + username: "", returnUrl: null, }), actions: { async finishAuthenticatedSession(data: any): Promise { this.username = data.username; - localStorage.setItem('user', this.username); - localStorage.setItem('token', data.token); + localStorage.setItem("user", this.username); + localStorage.setItem("token", data.token); const passwordUpgradeRequired = !!data?.password_upgrade_required; const passwordWarning = !!data?.change_pwd_hint || (!!data?.legacy_pwd_hint && !passwordUpgradeRequired); if (passwordWarning) { - localStorage.setItem('change_pwd_hint', 'true'); + localStorage.setItem("change_pwd_hint", "true"); if (data?.legacy_pwd_hint && !passwordUpgradeRequired) { - localStorage.setItem('legacy_pwd_hint', 'true'); + localStorage.setItem("legacy_pwd_hint", "true"); } else { - localStorage.removeItem('legacy_pwd_hint'); + localStorage.removeItem("legacy_pwd_hint"); } } else { - localStorage.removeItem('change_pwd_hint'); - localStorage.removeItem('legacy_pwd_hint'); + localStorage.removeItem("change_pwd_hint"); + localStorage.removeItem("legacy_pwd_hint"); } if (passwordUpgradeRequired) { - localStorage.setItem('password_upgrade_required', 'true'); + localStorage.setItem("password_upgrade_required", "true"); } else { - localStorage.removeItem('password_upgrade_required'); + localStorage.removeItem("password_upgrade_required"); } const onboardingCompleted = await this.checkOnboardingCompleted(); this.returnUrl = null; if (passwordWarning) { - router.push('/auth/setup'); + router.push("/auth/setup"); return; } if (onboardingCompleted) { - router.push('/dashboard/default'); + router.push("/dashboard/default"); } else { - router.push('/welcome'); + router.push("/welcome"); } }, async login( @@ -51,22 +51,43 @@ export const useAuthStore = defineStore("auth", { password: string, code?: string, trustDeviceToken = false, - ): Promise<'totp_required' | void> { + ): Promise<"totp_required" | void> { try { - const res = await axios.post('/api/auth/login', { - username: username, - password: password, - code: code, - trust_device_flag: trustDeviceToken, - }, { - validateStatus: (status) => (status >= 200 && status < 300) || status === 401 - }); + const res = await axios.post( + "/api/auth/login", + { + username: username, + password: password, + code: code, + trust_device_flag: trustDeviceToken, + }, + { + validateStatus: (status) => + (status >= 200 && status < 300) || status === 401, + }, + ); if (res.status === 401 && res.data?.data?.totp_required) { - return 'totp_required'; + return "totp_required"; + } + + if (res.data.status === "error") { + return Promise.reject(res.data.message); } - if (res.data.status === 'error') { + await this.finishAuthenticatedSession(res.data.data); + } catch (error) { + return Promise.reject(error); + } + }, + async loginWithTemporaryToken(token: string): Promise { + try { + const res = await axios.post("/api/auth/login", { + login_type: "temporary_token", + temporary_token: token, + }); + + if (res.data.status === "error") { return Promise.reject(res.data.message); } @@ -81,14 +102,16 @@ export const useAuthStore = defineStore("auth", { confirmPassword: string, ): Promise { try { - const endpoint = this.has_token() ? '/api/auth/setup-authenticated' : '/api/auth/setup'; + const endpoint = this.has_token() + ? "/api/auth/setup-authenticated" + : "/api/auth/setup"; const res = await axios.post(endpoint, { username: username, password: password, confirm_password: confirmPassword, }); - if (res.data.status === 'error') { + if (res.data.status === "error") { return Promise.reject(res.data.message); } @@ -100,44 +123,46 @@ export const useAuthStore = defineStore("auth", { async checkOnboardingCompleted(): Promise { try { // 1. 检查平台配置 - const platformRes = await axios.get('/api/config/get'); - const hasPlatform = (platformRes.data.data.config.platform || []).length > 0; + const platformRes = await axios.get("/api/config/get"); + const hasPlatform = + (platformRes.data.data.config.platform || []).length > 0; if (!hasPlatform) return false; // 2. 检查提供者配置 - const providerRes = await axios.get('/api/config/provider/template'); + const providerRes = await axios.get("/api/config/provider/template"); const providers = providerRes.data.data?.providers || []; const sources = providerRes.data.data?.provider_sources || []; const sourceMap = new Map(); sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type)); - + const hasProvider = providers.some((provider: any) => { - if (provider.provider_type) return provider.provider_type === 'chat_completion'; + if (provider.provider_type) + return provider.provider_type === "chat_completion"; if (provider.provider_source_id) { const type = sourceMap.get(provider.provider_source_id); - if (type === 'chat_completion') return true; + if (type === "chat_completion") return true; } - return String(provider.type || '').includes('chat_completion'); + return String(provider.type || "").includes("chat_completion"); }); return hasProvider; } catch (e) { - console.error('Failed to check onboarding status:', e); + console.error("Failed to check onboarding status:", e); return false; } }, logout() { - this.username = ''; - localStorage.removeItem('user'); - localStorage.removeItem('token'); - localStorage.removeItem('change_pwd_hint'); - localStorage.removeItem('legacy_pwd_hint'); - localStorage.removeItem('password_upgrade_required'); - void axios.post('/api/auth/logout').catch(() => undefined); - router.push('/auth/login'); + this.username = ""; + localStorage.removeItem("user"); + localStorage.removeItem("token"); + localStorage.removeItem("change_pwd_hint"); + localStorage.removeItem("legacy_pwd_hint"); + localStorage.removeItem("password_upgrade_required"); + void axios.post("/api/auth/logout").catch(() => undefined); + router.push("/auth/login"); }, has_token(): boolean { - return !!localStorage.getItem('token'); - } - } + return !!localStorage.getItem("token"); + }, + }, }); diff --git a/dashboard/src/views/authentication/authForms/AuthLogin.vue b/dashboard/src/views/authentication/authForms/AuthLogin.vue index 4a8967dd03..68b5669de7 100644 --- a/dashboard/src/views/authentication/authForms/AuthLogin.vue +++ b/dashboard/src/views/authentication/authForms/AuthLogin.vue @@ -1,43 +1,63 @@ + + diff --git a/docs/zh/faq.md b/docs/zh/faq.md index 46f47c63c5..12744dc950 100644 --- a/docs/zh/faq.md +++ b/docs/zh/faq.md @@ -26,6 +26,36 @@ Set dashboard.host in data/cmd_config.json to enable remote access. 其中的 `UiYVpZxnW8k22IWqf0ru5pOy` 就是默认密码。在使用默认密码登录后,会自动进入设置账户环节。 +### 管理面板登录相关安全配置 + +如果需要在本机初始化场景中跳过默认密码验证,可以设置环境变量 `ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH=true`。兼容旧变量名 `DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH`。该能力只会在管理面板监听地址为 `127.0.0.1`、`localhost` 或 `::1` 时生效;如果 `dashboard.host` 是 `0.0.0.0` 或其他非本机地址,即使设置了环境变量也不会跳过密码验证。 + +管理面板还内置了登录相关限流配置,位于 `data/cmd_config.json` 的 `dashboard.auth_rate_limit`: + +```json +{ + "dashboard": { + "auth_rate_limit": { + "enable": true, + "average_interval": 1.0, + "max_burst": 3 + } + } +} +``` + +该限流只应用于以下接口: + +| 接口 | 说明 | +| --- | --- | +| `/api/auth/login` | 管理面板账号密码登录,以及临时 Token 登录 | +| `/api/auth/totp/setup` | TOTP 配置/验证 | +| `/api/config/astrbot/update` | 触发 AstrBot 更新 | + +限流按客户端 IP 统计,上述接口共享同一个令牌桶。默认配置表示同一 IP 最多可瞬时请求 3 次,之后平均每 1 秒恢复 1 次请求额度。达到限制时会返回 HTTP 429。 + +如果 AstrBot 部署在反向代理后,默认会使用直接连接 IP 统计限流。只有在确认代理会正确传递客户端 IP 时,才建议开启 `dashboard.trust_proxy_headers`,开启后会尝试读取 `X-Forwarded-For` 或 `X-Real-IP`。 + ### 管理面板的密码忘记了 如果你忘记了 AstrBot 管理面板的密码,你可以在 `AstrBot/data/cmd_config.json` 配置文件中找到 `"dashboard"` 字段,如下: diff --git a/main.py b/main.py index 0676a159e7..9c0b447705 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import asyncio import mimetypes import os +import secrets import sys from pathlib import Path @@ -116,7 +117,9 @@ async def check_dashboard_files(webui_dir: str | None = None): return data_dist_path -async def main_async(webui_dir_arg: str | None) -> None: +async def main_async( + webui_dir_arg: str | None, print_login_token: bool = False +) -> None: """主异步入口""" # 检查仪表板文件 webui_dir = await check_dashboard_files(webui_dir_arg) @@ -131,7 +134,12 @@ async def main_async(webui_dir_arg: str | None) -> None: # 打印 logo logger.info(logo_tmpl) - core_lifecycle = InitialLoader(db, log_broker) + temporary_login_token = secrets.token_urlsafe(32) if print_login_token else None + core_lifecycle = InitialLoader( + db, + log_broker, + dashboard_temporary_login_token=temporary_login_token, + ) core_lifecycle.webui_dir = webui_dir await core_lifecycle.start() @@ -144,6 +152,11 @@ async def main_async(webui_dir_arg: str | None) -> None: help="Specify the directory path for WebUI static files", default=None, ) + parser.add_argument( + "--print-login-token", + action="store_true", + help="Print a temporary dashboard login token for this process", + ) args = parser.parse_args() check_env() @@ -153,4 +166,4 @@ async def main_async(webui_dir_arg: str | None) -> None: LogManager.set_queue_handler(logger, log_broker) # 只使用一次 asyncio.run() - asyncio.run(main_async(args.webui_dir)) + asyncio.run(main_async(args.webui_dir, args.print_login_token)) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index f2c31c89cf..48b95f3148 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,5 +1,6 @@ import asyncio import copy +import hashlib import io import os import re @@ -12,6 +13,7 @@ from types import SimpleNamespace from urllib.parse import parse_qs, urlsplit, urlunsplit +import jwt import pyotp import pytest import pytest_asyncio @@ -40,7 +42,10 @@ set_password_change_required, set_password_storage_upgraded, ) -from astrbot.dashboard.routes.auth import DASHBOARD_JWT_COOKIE_NAME +from astrbot.dashboard.routes.auth import ( + DASHBOARD_JWT_COOKIE_NAME, + DASHBOARD_TEMPORARY_LOGIN_JWT_MAX_AGE, +) from astrbot.dashboard.routes.plugin import PluginRoute from astrbot.dashboard.server import AstrBotDashboard from tests.fixtures.helpers import ( @@ -334,6 +339,100 @@ async def test_auth_login( assert "Secure" not in jwt_cookie_header +@pytest.mark.asyncio +async def test_auth_login_with_temporary_token( + app: Quart, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch: pytest.MonkeyPatch, +): + async def skip_sleep(_seconds): + return None + + monkeypatch.setattr("astrbot.dashboard.routes.auth.asyncio.sleep", skip_sleep) + monkeypatch.setitem(app.config, "DASHBOARD_JWT_COOKIE_SECURE", False) + + token = f"temporary-token-{uuid.uuid4()}" + token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + previous_hash = getattr( + core_lifecycle_td.astrbot_config, + "_dashboard_temporary_login_token_hash", + None, + ) + try: + object.__setattr__( + core_lifecycle_td.astrbot_config, + "_dashboard_temporary_login_token_hash", + token_hash, + ) + + test_client = app.test_client() + response = await test_client.get("/api/auth/setup-status") + data = await response.get_json() + assert data["status"] == "ok" + assert data["data"]["temporary_login_token_enabled"] is True + + response = await test_client.post( + "/api/auth/login", + json={ + "login_type": "temporary_token", + "temporary_token": "wrong-token", + }, + ) + data = await response.get_json() + assert response.status_code == 401 + assert data["status"] == "error" + + response = await test_client.post( + "/api/auth/login", + json={ + "login_type": "temporary_token", + "temporary_token": token, + }, + ) + data = await response.get_json() + assert data["status"] == "ok" + assert ( + data["data"]["username"] + == core_lifecycle_td.astrbot_config["dashboard"]["username"] + ) + dashboard_jwt = data["data"]["token"] + assert dashboard_jwt + set_cookie_headers = response.headers.getlist("Set-Cookie") + jwt_cookie_header = next( + ( + value + for value in set_cookie_headers + if DASHBOARD_JWT_COOKIE_NAME in value + ), + "", + ) + assert jwt_cookie_header + assert f"Max-Age={DASHBOARD_TEMPORARY_LOGIN_JWT_MAX_AGE}" in jwt_cookie_header + + jwt_payload = jwt.decode( + dashboard_jwt, + core_lifecycle_td.astrbot_config["dashboard"]["jwt_secret"], + algorithms=["HS256"], + ) + now_ts = datetime.now().timestamp() + expires_in = jwt_payload["exp"] - now_ts + assert 0 < expires_in <= DASHBOARD_TEMPORARY_LOGIN_JWT_MAX_AGE + + response = await test_client.get( + "/api/stat/version", + headers={"Authorization": f"Bearer {dashboard_jwt}"}, + ) + data = await response.get_json() + assert response.status_code == 200 + assert data["status"] == "ok" + finally: + object.__setattr__( + core_lifecycle_td.astrbot_config, + "_dashboard_temporary_login_token_hash", + previous_hash, + ) + + @pytest.mark.asyncio async def test_auth_login_secure_cookie_override( app: Quart, @@ -374,7 +473,11 @@ async def test_auth_rate_limit_uses_same_bucket_across_paths( cfg = core_lifecycle_td.astrbot_config["dashboard"] rl_original = cfg.get("auth_rate_limit", {}) tp_original = cfg.get("trust_proxy_headers", False) - cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1} + cfg["auth_rate_limit"] = { + "enable": True, + "average_interval": 3600.0, + "max_burst": 1, + } cfg["trust_proxy_headers"] = True try: @@ -406,7 +509,11 @@ async def test_auth_rate_limit_separates_different_client_ips( cfg = core_lifecycle_td.astrbot_config["dashboard"] rl_original = cfg.get("auth_rate_limit", {}) tp_original = cfg.get("trust_proxy_headers", False) - cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1} + cfg["auth_rate_limit"] = { + "enable": True, + "average_interval": 3600.0, + "max_burst": 1, + } cfg["trust_proxy_headers"] = True try: @@ -450,7 +557,11 @@ async def test_auth_rate_limit_ignores_proxy_headers_by_default( cfg = core_lifecycle_td.astrbot_config["dashboard"] rl_original = cfg.get("auth_rate_limit", {}) tp_original = cfg.get("trust_proxy_headers", False) - cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1} + cfg["auth_rate_limit"] = { + "enable": True, + "average_interval": 3600.0, + "max_burst": 1, + } cfg["trust_proxy_headers"] = False try: