Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
26c114379f fix: dashboard test 2026-06-05 12:06:41 +08:00
Soulter
41ec03b285 feat: supports one-time-token login 2026-06-05 12:05:58 +08:00
19 changed files with 619 additions and 124 deletions

View File

@@ -6,6 +6,7 @@
"""
import asyncio
import hashlib
import traceback
from astrbot.core import LogBroker, logger
@@ -17,11 +18,17 @@ from astrbot.dashboard.server import AstrBotDashboard
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 @@ class InitialLoader:
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

View File

@@ -1,6 +1,8 @@
import asyncio
import datetime
import hashlib
import os
import secrets
import jwt
import pyotp
@@ -43,6 +45,7 @@ from .route import Response, Route, RouteContext
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 @@ LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE = (
)
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 @@ class AuthRoute(Route):
{
"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 @@ class AuthRoute(Route):
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 @@ class AuthRoute(Route):
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 @@ class AuthRoute(Route):
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 @@ class AuthRoute(Route):
}
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 @@ class AuthRoute(Route):
)
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 @@ class AuthRoute(Route):
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 @@ class AuthRoute(Route):
)
@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(),

View File

@@ -430,7 +430,6 @@ class AstrBotDashboard:
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 @@ class AstrBotDashboard:
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

View File

@@ -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";
}

View File

@@ -7,6 +7,7 @@
"buttons": {
"update": "Update",
"account": "Account",
"logout": "Log Out",
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"

View File

@@ -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"
}
}
}

View File

@@ -7,6 +7,7 @@
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"logout": "Выйти",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"

View File

@@ -3,6 +3,14 @@
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Если это первый вход, проверьте пароль по умолчанию в логах.",
"temporaryToken": {
"link": "Войти с временным токеном",
"title": "Вход по временному токену",
"subtitle": "Используйте временный токен, напечатанный в логах запуска.",
"token": "Временный токен",
"submit": "Войти по временному токену",
"backToLogin": "Назад к входу"
},
"totp": {
"code": "Код подтверждения",
"verify": "Проверить",

View File

@@ -7,6 +7,7 @@
"buttons": {
"update": "更新",
"account": "账户",
"logout": "退出登录",
"theme": {
"light": "浅色模式",
"dark": "深色模式"

View File

@@ -3,6 +3,14 @@
"username": "用户名",
"password": "密码",
"defaultHint": "如果这是首次登录,请在日志中查看默认密码。",
"temporaryToken": {
"link": "输入临时 Token 登录",
"title": "临时 Token 登录",
"subtitle": "使用启动日志中输出的临时 Token 登录。",
"token": "临时 Token",
"submit": "使用临时 Token 登录",
"backToLogin": "返回账号登录"
},
"totp": {
"code": "验证码",
"verify": "验证",

View File

@@ -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")
}}</v-list-item-title>
</v-list-item>
<v-list-item @click="logout" class="styled-menu-item" rounded="md">
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title>{{
t("core.header.buttons.logout")
}}</v-list-item-title>
</v-list-item>
</StyledMenu>
<!-- 更新对话框 -->

View File

@@ -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<void> {
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') {
if (res.data.status === "error") {
return Promise.reject(res.data.message);
}
await this.finishAuthenticatedSession(res.data.data);
} catch (error) {
return Promise.reject(error);
}
},
async loginWithTemporaryToken(token: string): Promise<void> {
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<void> {
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<boolean> {
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");
},
},
});

View File

@@ -1,43 +1,63 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useModuleI18n } from '@/i18n/composables';
import AuthStageAccount from './stages/AuthStageAccount.vue';
import AuthStageTotp from './stages/AuthStageTotp.vue';
import AuthStageRecovery from './stages/AuthStageRecovery.vue';
import axios from "axios";
import { onMounted, ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import { useModuleI18n } from "@/i18n/composables";
import AuthStageAccount from "./stages/AuthStageAccount.vue";
import AuthStageTotp from "./stages/AuthStageTotp.vue";
import AuthStageRecovery from "./stages/AuthStageRecovery.vue";
import AuthStageTemporaryToken from "./stages/AuthStageTemporaryToken.vue";
const { tm: t } = useModuleI18n('features/auth');
const { tm: t } = useModuleI18n("features/auth");
const authStore = useAuthStore();
const username = ref('');
const password = ref('');
const totpCode = ref('');
const username = ref("");
const password = ref("");
const totpCode = ref("");
const trustTotpDevice = ref(false);
const recoveryCode = ref('');
const recoveryCode = ref("");
const temporaryToken = ref("");
const temporaryTokenLoginEnabled = ref(false);
const loading = ref(false);
const apiError = ref('');
const stage = ref<'account' | 'totp' | 'recovery'>('account');
const apiError = ref("");
const stage = ref<"account" | "totp" | "recovery" | "temporary-token">(
"account",
);
function syncReturnUrl() {
// @ts-ignore
authStore.returnUrl = new URLSearchParams(window.location.search).get(
"redirect",
);
}
function resetTotpStage() {
totpCode.value = '';
totpCode.value = "";
trustTotpDevice.value = false;
}
function goToAccountStage() {
stage.value = 'account';
apiError.value = '';
stage.value = "account";
apiError.value = "";
resetTotpStage();
temporaryToken.value = "";
}
function goToTotpStage() {
stage.value = 'totp';
apiError.value = '';
stage.value = "totp";
apiError.value = "";
}
function goToRecoveryStage() {
stage.value = 'recovery';
apiError.value = '';
recoveryCode.value = '';
stage.value = "recovery";
apiError.value = "";
recoveryCode.value = "";
}
function goToTemporaryTokenStage() {
stage.value = "temporary-token";
apiError.value = "";
temporaryToken.value = "";
}
async function submitAccountStage() {
@@ -45,16 +65,15 @@ async function submitAccountStage() {
return;
}
loading.value = true;
apiError.value = '';
apiError.value = "";
try {
// @ts-ignore
authStore.returnUrl = new URLSearchParams(window.location.search).get('redirect');
syncReturnUrl();
const res = await authStore.login(username.value, password.value);
if (res === 'totp_required') {
if (res === "totp_required") {
goToTotpStage();
}
} catch (err) {
apiError.value = String(err || '') || 'Login failed';
apiError.value = String(err || "") || "Login failed";
} finally {
loading.value = false;
}
@@ -65,7 +84,7 @@ async function submitTotpStage() {
return;
}
loading.value = true;
apiError.value = '';
apiError.value = "";
try {
await authStore.login(
username.value,
@@ -74,12 +93,43 @@ async function submitTotpStage() {
trustTotpDevice.value,
);
} catch (err) {
apiError.value = String(err || '') || 'Verification failed';
apiError.value = String(err || "") || "Verification failed";
} finally {
loading.value = false;
}
}
async function submitTemporaryTokenStage() {
const token = temporaryToken.value.trim();
if (!token) {
return;
}
loading.value = true;
apiError.value = "";
try {
syncReturnUrl();
await authStore.loginWithTemporaryToken(token);
} catch (err) {
apiError.value = String(err || "") || "Temporary token login failed";
} finally {
loading.value = false;
}
}
async function loadTemporaryTokenLoginStatus() {
try {
const res = await axios.get("/api/auth/setup-status");
temporaryTokenLoginEnabled.value =
!!res.data?.data?.temporary_login_token_enabled;
} catch {
temporaryTokenLoginEnabled.value = false;
}
}
onMounted(() => {
void loadTemporaryTokenLoginStatus();
});
defineExpose({ stage });
async function submitRecoveryStage() {
@@ -87,11 +137,11 @@ async function submitRecoveryStage() {
return;
}
loading.value = true;
apiError.value = '';
apiError.value = "";
try {
await authStore.login(username.value, password.value, recoveryCode.value);
} catch (err) {
apiError.value = String(err || '') || 'Recovery login failed';
apiError.value = String(err || "") || "Recovery login failed";
} finally {
loading.value = false;
}
@@ -100,15 +150,29 @@ async function submitRecoveryStage() {
<template>
<div class="mt-4 login-form">
<AuthStageAccount
v-if="stage === 'account'"
:username="username"
:password="password"
:loading="loading"
@update:username="(value) => (username = value)"
@update:password="(value) => (password = value)"
@submit="submitAccountStage"
/>
<template v-if="stage === 'account'">
<AuthStageAccount
:username="username"
:password="password"
:loading="loading"
@update:username="(value) => (username = value)"
@update:password="(value) => (password = value)"
@submit="submitAccountStage"
/>
<div v-if="temporaryTokenLoginEnabled" class="temporary-token-link-row">
<span
class="temporary-token-login-link"
role="button"
tabindex="0"
@click="goToTemporaryTokenStage"
@keyup.enter="goToTemporaryTokenStage"
@keyup.space.prevent="goToTemporaryTokenStage"
>
{{ t("temporaryToken.link") }}
</span>
</div>
</template>
<AuthStageTotp
v-else-if="stage === 'totp'"
@@ -123,6 +187,15 @@ async function submitRecoveryStage() {
@use-recovery="goToRecoveryStage"
/>
<AuthStageTemporaryToken
v-else-if="stage === 'temporary-token'"
:token="temporaryToken"
:loading="loading"
@update:token="(value) => (temporaryToken = value)"
@submit="submitTemporaryTokenStage"
@back="goToAccountStage"
/>
<AuthStageRecovery
v-else
:code="recoveryCode"
@@ -133,7 +206,12 @@ async function submitRecoveryStage() {
/>
<div v-if="apiError" class="mt-4 error-container">
<v-alert color="error" variant="tonal" icon="mdi-alert-circle" border="start">
<v-alert
color="error"
variant="tonal"
icon="mdi-alert-circle"
border="start"
>
{{ apiError }}
</v-alert>
</div>
@@ -224,5 +302,30 @@ async function submitRecoveryStage() {
color: rgba(var(--v-theme-on-surface), 0.85);
}
.temporary-token-subtitle {
margin-top: 4px;
font-size: 0.82rem;
color: rgba(var(--v-theme-on-surface), 0.62);
line-height: 1.4;
}
.temporary-token-link-row {
margin-top: 14px;
text-align: center;
}
.temporary-token-login-link {
color: rgb(var(--v-theme-primary));
cursor: pointer;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.temporary-token-login-link:focus-visible {
border-radius: 4px;
outline: 2px solid rgba(var(--v-theme-primary), 0.35);
outline-offset: 3px;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useModuleI18n } from "@/i18n/composables";
const { tm: t } = useModuleI18n("features/auth");
const props = defineProps<{
token: string;
loading: boolean;
}>();
const emit = defineEmits<{
(e: "update:token", value: string): void;
(e: "submit"): void;
(e: "back"): void;
}>();
function onSubmit() {
emit("submit");
}
</script>
<template>
<div class="account-stage-header">
<div>
<div class="account-stage-user">{{ t("temporaryToken.title") }}</div>
<div class="temporary-token-subtitle">
{{ t("temporaryToken.subtitle") }}
</div>
</div>
<v-btn
variant="text"
size="small"
icon="mdi-arrow-left"
:disabled="props.loading"
@click="emit('back')"
>
<v-tooltip activator="parent" location="top">
{{ t("temporaryToken.backToLogin") }}
</v-tooltip>
</v-btn>
</div>
<v-text-field
:model-value="props.token"
:label="t('temporaryToken.token')"
class="mt-4 pwd-input"
required
variant="outlined"
hide-details="auto"
type="password"
prepend-inner-icon="mdi-key-variant"
:disabled="props.loading"
@update:model-value="(value: string) => emit('update:token', value)"
@keyup.enter="onSubmit"
></v-text-field>
<v-btn
color="secondary"
block
class="login-btn mt-8"
variant="flat"
size="large"
:loading="props.loading"
:disabled="props.loading || !props.token"
@click="onSubmit"
>
<span class="login-btn-text">{{ t("temporaryToken.submit") }}</span>
</v-btn>
</template>

View File

@@ -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"` 字段,如下:

19
main.py
View File

@@ -2,6 +2,7 @@ import argparse
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 @@ if __name__ == "__main__":
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 @@ if __name__ == "__main__":
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))

View File

@@ -1,5 +1,6 @@
import asyncio
import copy
import hashlib
import io
import os
import re
@@ -12,6 +13,7 @@ from pathlib import Path
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 @@ from astrbot.dashboard.password_state import (
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: