Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion astrbot/core/initial_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import asyncio
import hashlib
import traceback

from astrbot.core import LogBroker, logger
Expand All @@ -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)
Expand All @@ -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
Expand Down
87 changes: 69 additions & 18 deletions astrbot/dashboard/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio
import datetime
import hashlib
import os
import secrets

import jwt
import pyotp
Expand Down Expand Up @@ -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"}
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
Comment on lines +248 to +253
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ Password Reset Usability Trap when Logged in via Temporary Token

When a user logs in using a temporary token (typically to recover access after forgetting their password), they are issued a standard JWT. However, if they attempt to reset or update their password via the WebUI (which calls the /api/auth/account/edit endpoint), the request will fail because edit_account strictly requires the original password to be verified:

if not verify_dashboard_password(password, req_password):
    return Response().error("原密码错误").__dict__

Since the user logged in via the temporary token precisely because they do not know or want to bypass the original password, they are trapped and cannot set a new password through the UI.

Suggested Solution:

  1. Add an is_temporary: bool = False claim to the JWT payload when generated via temporary token login.
  2. Propagate this claim to g.is_temporary in the authentication middleware (server.py).
  3. In edit_account, bypass the original password verification if getattr(g, "is_temporary", False) is True.


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__

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
Expand Down
37 changes: 29 additions & 8 deletions astrbot/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -464,6 +464,10 @@
content: "\F1036";
}

.mdi-file-search-outline::before {
content: "\F0C7D";
}

.mdi-file-upload::before {
content: "\F0A4D";
}
Expand Down Expand Up @@ -688,6 +692,10 @@
content: "\F16B2";
}

.mdi-logout::before {
content: "\F0343";
}

.mdi-magnify::before {
content: "\F0349";
}
Expand Down
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions dashboard/src/i18n/locales/en-US/core/header.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"buttons": {
"update": "Update",
"account": "Account",
"logout": "Log Out",
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/i18n/locales/en-US/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -60,4 +68,4 @@
"switchToDark": "Switch to Dark Theme",
"switchToLight": "Switch to Light Theme"
}
}
}
1 change: 1 addition & 0 deletions dashboard/src/i18n/locales/ru-RU/core/header.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"logout": "Выйти",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Если это первый вход, проверьте пароль по умолчанию в логах.",
"temporaryToken": {
"link": "Войти с временным токеном",
"title": "Вход по временному токену",
"subtitle": "Используйте временный токен, напечатанный в логах запуска.",
"token": "Временный токен",
"submit": "Войти по временному токену",
"backToLogin": "Назад к входу"
},
"totp": {
"code": "Код подтверждения",
"verify": "Проверить",
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/locales/zh-CN/core/header.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"buttons": {
"update": "更新",
"account": "账户",
"logout": "退出登录",
"theme": {
"light": "浅色模式",
"dark": "深色模式"
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"username": "用户名",
"password": "密码",
"defaultHint": "如果这是首次登录,请在日志中查看默认密码。",
"temporaryToken": {
"link": "输入临时 Token 登录",
"title": "临时 Token 登录",
"subtitle": "使用启动日志中输出的临时 Token 登录。",
"token": "临时 Token",
"submit": "使用临时 Token 登录",
"backToLogin": "返回账号登录"
},
"totp": {
"code": "验证码",
"verify": "验证",
Expand Down
Loading
Loading