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")
}}
+
+
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 @@
+
+
+
+
+ emit('update:token', value)"
+ @keyup.enter="onSubmit"
+ >
+
+
+ {{ t("temporaryToken.submit") }}
+
+
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: