From b7af1066d55de0590976b5940f3dcf7ea6c6b906 Mon Sep 17 00:00:00 2001 From: Raven95676 Date: Thu, 14 May 2026 19:48:45 +0800 Subject: [PATCH 1/7] feat: add TOTP two-factor authentication for dashboard login --- astrbot/core/config/default.py | 11 + astrbot/core/db/po.py | 15 + astrbot/core/utils/totp.py | 210 ++++++++++++ astrbot/dashboard/routes/auth.py | 220 +++++++++++-- astrbot/dashboard/server.py | 46 +++ .../mdi-subset/materialdesignicons-subset.css | 10 +- .../materialdesignicons-webfont-subset.woff | Bin 18772 -> 18896 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 15112 -> 15164 bytes .../src/components/shared/AstrBotConfigV4.vue | 2 + .../components/shared/ConfigItemRenderer.vue | 12 + .../shared/DashboardTotpDisableDialog.vue | 159 +++++++++ .../shared/DashboardTotpManageDialog.vue | 101 ++++++ .../shared/DashboardTotpManager.vue | 210 ++++++++++++ .../shared/DashboardTotpRecoveryDialog.vue | 101 ++++++ .../DashboardTotpRotateRecoveryDialog.vue | 143 ++++++++ .../shared/DashboardTotpSetupDialog.vue | 309 ++++++++++++++++++ .../src/i18n/locales/en-US/features/auth.json | 28 ++ .../en-US/features/config-metadata.json | 44 +++ .../src/i18n/locales/ru-RU/features/auth.json | 90 +++-- .../ru-RU/features/config-metadata.json | 44 +++ .../src/i18n/locales/zh-CN/features/auth.json | 36 +- .../zh-CN/features/config-metadata.json | 44 +++ dashboard/src/main.ts | 10 + dashboard/src/router/index.ts | 10 +- dashboard/src/stores/auth.ts | 33 +- .../authentication/authForms/AuthLogin.vue | 202 +++++++++--- .../authForms/stages/AuthStageAccount.vue | 72 ++++ .../authForms/stages/AuthStageRecovery.vue | 80 +++++ .../authForms/stages/AuthStageTotp.vue | 80 +++++ pyproject.toml | 1 + requirements.txt | 1 + 31 files changed, 2201 insertions(+), 123 deletions(-) create mode 100644 astrbot/core/utils/totp.py create mode 100644 dashboard/src/components/shared/DashboardTotpDisableDialog.vue create mode 100644 dashboard/src/components/shared/DashboardTotpManageDialog.vue create mode 100644 dashboard/src/components/shared/DashboardTotpManager.vue create mode 100644 dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue create mode 100644 dashboard/src/components/shared/DashboardTotpRotateRecoveryDialog.vue create mode 100644 dashboard/src/components/shared/DashboardTotpSetupDialog.vue create mode 100644 dashboard/src/views/authentication/authForms/stages/AuthStageAccount.vue create mode 100644 dashboard/src/views/authentication/authForms/stages/AuthStageRecovery.vue create mode 100644 dashboard/src/views/authentication/authForms/stages/AuthStageTotp.vue diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3e5dee89c8..67a1c05239 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -252,6 +252,11 @@ "host": "0.0.0.0", "port": 6185, "disable_access_log": True, + "totp": { + "enable": False, + "secret": "", + "recovery_code_hash": "", + }, "ssl": { "enable": False, "cert_file": "", @@ -4180,6 +4185,12 @@ "type": "bool", "hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。", }, + "dashboard.totp.enable": { + "description": "启用 WebUI TOTP 双因素认证", + "type": "bool", + "hint": "启用后,登录 WebUI 需要额外输入验证码。", + "_special": "dashboard_totp_manager", + }, "dashboard.ssl.cert_file": { "description": "SSL 证书文件路径", "type": "string", diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 0d3b9822a3..acc4df4589 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -382,6 +382,21 @@ class ApiKey(TimestampMixin, SQLModel, table=True): ) +class DashboardTrustedDevice(TimestampMixin, SQLModel, table=True): + """Trusted dashboard device token used to skip TOTP for a limited time.""" + + __tablename__: str = "dashboard_trusted_devices" + + id: int | None = Field( + default=None, + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + ) + token_hash: str = Field(max_length=64, nullable=False, unique=True, index=True) + totp_secret_hash: str = Field(max_length=64, nullable=False, index=True) + expires_at: datetime = Field(nullable=False, index=True) + + class ChatUIProject(TimestampMixin, SQLModel, table=True): """This class represents projects for organizing ChatUI conversations. diff --git a/astrbot/core/utils/totp.py b/astrbot/core/utils/totp.py new file mode 100644 index 0000000000..31a1b5bff9 --- /dev/null +++ b/astrbot/core/utils/totp.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import asyncio +import base64 +import datetime +import hashlib +import hmac +import secrets + +import pyotp +from sqlmodel import col, delete, select + +from astrbot.core.db.po import DashboardTrustedDevice + +TOTP_TRUSTED_DEVICE_COOKIE_NAME = "astrbot_totp_trusted_device" +TOTP_TRUSTED_DEVICE_MAX_AGE = 30 * 24 * 60 * 60 +RECOVERY_CODE_GROUP_COUNT = 4 +RECOVERY_CODE_GROUP_LENGTH = 8 +RECOVERY_CODE_LENGTH = RECOVERY_CODE_GROUP_COUNT * RECOVERY_CODE_GROUP_LENGTH +_RECOVERY_CODE_KDF_ITERATIONS = 600_000 +_RECOVERY_CODE_KDF_SALT_BYTES = 16 +_RECOVERY_CODE_KDF_ALGORITHM = "pbkdf2_sha256" + +_last_totp_timecode: dict[str, int] = {} +_totp_replay_lock = asyncio.Lock() + + +def _get_totp_config(config) -> dict: + totp_config = config.get("dashboard", {}).get("totp", {}) + return totp_config if isinstance(totp_config, dict) else {} + + +def is_totp_enabled(config) -> bool: + """TOTP is fully configured and operational (enable + secret + recovery hash all present).""" + totp_config = _get_totp_config(config) + if not totp_config.get("enable", False): + return False + secret = totp_config.get("secret", "") + if not isinstance(secret, str) or not secret.strip(): + return False + recovery_code_hash = totp_config.get("recovery_code_hash", "") + if not isinstance(recovery_code_hash, str) or not recovery_code_hash.strip(): + return False + return True + + +def _get_verified_totp_timecode(secret: str, code: str) -> int | None: + code = code.strip() + try: + totp = pyotp.TOTP(secret.strip()) + now = datetime.datetime.now() + for offset in (-1, 0, 1): + candidate_time = now + datetime.timedelta(seconds=offset * totp.interval) + if hmac.compare_digest(str(totp.at(candidate_time)), code): + return int(totp.timecode(candidate_time)) + except Exception: + return None + return None + + +async def consume_totp_code(secret: str, code: str) -> bool: + global _last_totp_timecode + timecode = _get_verified_totp_timecode(secret, code) + if timecode is None: + return False + secret = secret.strip() + async with _totp_replay_lock: + if _last_totp_timecode.get(secret, -1) >= timecode: + return False + _last_totp_timecode[secret] = timecode + return True + + +async def consume_configured_totp_code(config, code: str) -> bool: + if not is_totp_enabled(config): + return False + secret = _get_totp_config(config).get("secret", "") + return await consume_totp_code(secret, code) + + +def _hash_totp_trusted_device_token(config, token: str) -> str: + jwt_secret = config["dashboard"].get("jwt_secret", "") + if not isinstance(jwt_secret, str) or not jwt_secret: + return "" + return hmac.new( + jwt_secret.encode("utf-8"), + token.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def _hash_totp_secret(config) -> str: + secret = _get_totp_config(config).get("secret", "") + if not isinstance(secret, str) or not secret.strip(): + return "" + return hashlib.sha256(secret.strip().encode("utf-8")).hexdigest() + + +async def is_totp_trusted_device_valid(config, db, cookie_token: str) -> bool: + if not cookie_token: + return False + token_hash = _hash_totp_trusted_device_token(config, cookie_token) + totp_secret_hash = _hash_totp_secret(config) + if not token_hash or not totp_secret_hash: + return False + + await _cleanup_expired_totp_trusted_devices(db) + async with db.get_db() as session: + result = await session.execute( + select(DashboardTrustedDevice).where( + col(DashboardTrustedDevice.token_hash) == token_hash, + col(DashboardTrustedDevice.totp_secret_hash) == totp_secret_hash, + col(DashboardTrustedDevice.expires_at) + > datetime.datetime.now(datetime.timezone.utc), + ) + ) + return result.scalar_one_or_none() is not None + + +async def issue_totp_trusted_device(config, db) -> str | None: + """Issue a trusted device token, save to DB, and return the raw token for cookie.""" + raw_token = secrets.token_urlsafe(48) + token_hash = _hash_totp_trusted_device_token(config, raw_token) + totp_secret_hash = _hash_totp_secret(config) + if not token_hash or not totp_secret_hash: + return None + + expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=TOTP_TRUSTED_DEVICE_MAX_AGE + ) + async with db.get_db() as session: + async with session.begin(): + await session.execute( + delete(DashboardTrustedDevice).where( + col(DashboardTrustedDevice.token_hash) == token_hash + ) + ) + trusted_device = DashboardTrustedDevice.model_validate( + { + "token_hash": token_hash, + "totp_secret_hash": totp_secret_hash, + "expires_at": expires_at, + } + ) + session.add(trusted_device) + return raw_token + + +async def _cleanup_expired_totp_trusted_devices(db) -> None: + async with db.get_db() as session: + async with session.begin(): + await session.execute( + delete(DashboardTrustedDevice).where( + col(DashboardTrustedDevice.expires_at) + <= datetime.datetime.now(datetime.timezone.utc) + ) + ) + + +async def revoke_user_trusted_devices(db) -> None: + async with db.get_db() as session: + async with session.begin(): + await session.execute(delete(DashboardTrustedDevice)) + + +def generate_recovery_code() -> tuple[str, str]: + raw = secrets.token_bytes(20) + recovery_code = base64.b32encode(raw).decode("ascii").rstrip("=") + salt = secrets.token_hex(_RECOVERY_CODE_KDF_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + "sha256", + recovery_code.encode("utf-8"), + bytes.fromhex(salt), + _RECOVERY_CODE_KDF_ITERATIONS, + ).hex() + kdf_hash = f"{_RECOVERY_CODE_KDF_ALGORITHM}${_RECOVERY_CODE_KDF_ITERATIONS}${salt}${digest}" + parts = [ + recovery_code[i : i + RECOVERY_CODE_GROUP_LENGTH] + for i in range(0, len(recovery_code), RECOVERY_CODE_GROUP_LENGTH) + ] + return "-".join(parts), kdf_hash + + +def verify_recovery_code(config, code: str) -> bool: + """Verify a recovery code against configured recovery_code_hash (PBKDF2).""" + cleaned = "".join(char for char in code.upper() if char.isalnum()) + if len(cleaned) != RECOVERY_CODE_LENGTH: + return False + totp_config = _get_totp_config(config) + stored_hash = totp_config.get("recovery_code_hash", "") + if not isinstance(stored_hash, str) or not stored_hash: + return False + + parts = stored_hash.split("$") + if len(parts) != 4 or parts[0] != _RECOVERY_CODE_KDF_ALGORITHM: + return False + try: + iterations = int(parts[1]) + salt = parts[2] + expected_digest = parts[3] + except (ValueError, IndexError): + return False + + candidate = hashlib.pbkdf2_hmac( + "sha256", + cleaned.encode("utf-8"), + bytes.fromhex(salt), + iterations, + ).hex() + return hmac.compare_digest(candidate, expected_digest) diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 20195a8582..66913120eb 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -3,6 +3,7 @@ import os import jwt +import pyotp from quart import current_app, g, jsonify, make_response, request from astrbot import logger @@ -13,6 +14,18 @@ validate_dashboard_password, verify_dashboard_password, ) +from astrbot.core.utils.totp import ( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + TOTP_TRUSTED_DEVICE_MAX_AGE, + consume_configured_totp_code, + consume_totp_code, + generate_recovery_code, + is_totp_enabled, + is_totp_trusted_device_valid, + issue_totp_trusted_device, + revoke_user_trusted_devices, + verify_recovery_code, +) from astrbot.dashboard.password_state import ( get_dashboard_password_hash, is_password_change_required, @@ -41,6 +54,9 @@ def __init__(self, context: RouteContext, db) -> None: "/auth/setup-status": ("GET", self.setup_status), "/auth/setup": ("POST", self.setup), "/auth/setup-authenticated": ("POST", self.setup_authenticated), + "/auth/totp/setup": ("POST", self.totp_setup), + "/auth/totp/verify-setup": ("POST", self.totp_verify_setup), + "/auth/totp/disable": ("POST", self.totp_disable), "/auth/account/edit": ("POST", self.edit_account), } self.register_routes() @@ -61,6 +77,81 @@ async def setup_status(self): .__dict__ ) + async def totp_setup(self): + is_rotation = is_totp_enabled(self.config) + if is_rotation: + post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ + code = post_data.get("code") + if not isinstance(code, str) or not code.strip(): + return Response().error("当前 TOTP 验证码是轮换所必需的").__dict__ + if not await consume_configured_totp_code(self.config, code): + return Response().error("当前 TOTP 验证码无效").__dict__ + + secret = pyotp.random_base32() + return ( + Response() + .ok( + { + "secret": secret, + } + ) + .__dict__ + ) + + async def totp_verify_setup(self): + post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ + + secret = post_data.get("secret") + code = post_data.get("code") + if not isinstance(secret, str) or not secret.strip(): + return Response().error("Invalid request payload").__dict__ + if not isinstance(code, str) or not code.strip(): + return Response().error("Invalid request payload").__dict__ + + if not await consume_totp_code(secret, code): + return Response().error("TOTP 验证码无效").__dict__ + + recovery_code, recovery_code_hash = generate_recovery_code() + + return ( + Response() + .ok( + { + "recovery_code": recovery_code, + "recovery_code_hash": recovery_code_hash, + }, + "TOTP verified", + ) + .__dict__ + ) + + async def totp_disable(self): + post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ + + code = post_data.get("code") + if not isinstance(code, str) or not code.strip(): + return Response().error("Invalid code").__dict__ + + if not await consume_configured_totp_code( + self.config, code + ) and not verify_recovery_code(self.config, code): + return Response().error("凭据无效").__dict__ + + self.config["dashboard"]["totp"] = { + "enable": False, + "secret": "", + "recovery_code_hash": "", + } + await revoke_user_trusted_devices(self.db) + self.config.save_config() + return Response().ok(None, "TOTP disabled").__dict__ + async def setup(self): if not self._can_skip_default_password_auth(): return Response().error("Setup without password is not enabled").__dict__ @@ -131,6 +222,12 @@ async def login(self): 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 + ) if not isinstance(req_username, str) or not isinstance(req_password, str): return Response().error("Invalid request payload").__dict__ @@ -138,39 +235,92 @@ async def login(self): password, req_password ) - if login_verified: - change_pwd_hint = False - legacy_pwd_hint = is_legacy_dashboard_password(password) - password_change_required = await is_password_change_required( - self.db, - self.config, + if not login_verified: + await asyncio.sleep(3) + return await self._error_response( + "用户名或密码错误", + 401, ) - if ( - storage_upgraded - and username == "astrbot" - and is_default_dashboard_password(password) - and not DEMO_MODE + + totp_verified = False + + if is_totp_enabled(self.config): + cookie_token = request.cookies.get( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, "" + ).strip() + if not await is_totp_trusted_device_valid( + self.config, self.db, cookie_token ): - change_pwd_hint = True - legacy_pwd_hint = True - logger.warning("为了保证安全,请尽快修改默认密码。") - if password_change_required and not DEMO_MODE: - change_pwd_hint = True - token = self.generate_jwt(username) - payload = Response().ok( - { - "token": token, - "username": username, - "change_pwd_hint": change_pwd_hint, - "legacy_pwd_hint": legacy_pwd_hint, - "password_upgrade_required": not storage_upgraded, - }, - ) - response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) - return response - await asyncio.sleep(3) - return Response().error("用户名或密码错误").__dict__ + if not isinstance(totp_code, str) or not totp_code.strip(): + response = await make_response( + jsonify( + { + "status": "error", + "message": "需要 TOTP 验证", + "data": {"totp_required": True}, + } + ) + ) + response.status_code = 401 + return response + if len(totp_code) == 6 and totp_code.isdigit(): + if await consume_configured_totp_code(self.config, totp_code): + totp_verified = True + else: + return await self._error_response("TOTP 验证码无效", 401) + elif verify_recovery_code(self.config, totp_code): + self.config["dashboard"]["totp"] = { + "enable": False, + "secret": "", + "recovery_code_hash": "", + } + await revoke_user_trusted_devices(self.db) + self.config.save_config() + else: + return await self._error_response("恢复码无效", 401) + + change_pwd_hint = False + legacy_pwd_hint = is_legacy_dashboard_password(password) + password_change_required = await is_password_change_required( + self.db, + self.config, + ) + if ( + storage_upgraded + and username == "astrbot" + and is_default_dashboard_password(password) + and not DEMO_MODE + ): + change_pwd_hint = True + legacy_pwd_hint = True + logger.warning("为了保证安全,请尽快修改默认密码。") + if password_change_required and not DEMO_MODE: + change_pwd_hint = True + token = self.generate_jwt(username) + login_data = { + "token": token, + "username": username, + "change_pwd_hint": change_pwd_hint, + "legacy_pwd_hint": legacy_pwd_hint, + "password_upgrade_required": not storage_upgraded, + } + payload = Response().ok(login_data) + response = await make_response(jsonify(payload.__dict__)) + self._set_dashboard_jwt_cookie(response, token) + + if totp_verified and trust_device_flag: + raw_token = await issue_totp_trusted_device(self.config, self.db) + if raw_token: + response.set_cookie( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + raw_token, + max_age=TOTP_TRUSTED_DEVICE_MAX_AGE, + httponly=True, + samesite="Strict", + secure=AuthRoute._use_secure_dashboard_jwt_cookie(), + path="/api/auth", + ) + return response async def logout(self): response = await make_response( @@ -225,6 +375,8 @@ async def edit_account(self): set_dashboard_password_hashes(self.config, new_pwd) await set_password_storage_upgraded(self.db, self.config, True) await set_password_change_required(self.db, self.config, False) + if is_totp_enabled(self.config): + await revoke_user_trusted_devices(self.db) if new_username: self.config["dashboard"]["username"] = new_username @@ -266,6 +418,12 @@ async def _is_setup_required(self) -> bool: dashboard_config.get("pbkdf2_password", "") ) + @staticmethod + async def _error_response(message: str, status_code: int): + response = await make_response(jsonify(Response().error(message).__dict__)) + response.status_code = status_code + return response + def _can_skip_default_password_auth(self) -> bool: if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV): return False diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 1211d4f750..187f49d78c 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -3,6 +3,7 @@ import logging import os import socket +import time from datetime import datetime from pathlib import Path from typing import Protocol, cast @@ -39,6 +40,37 @@ # Static assets shipped inside the wheel (built during `hatch build`). _BUNDLED_DIST = Path(__file__).parent / "dist" +_RATE_LIMITED_ENDPOINTS: frozenset = frozenset( + { + "/api/auth/totp/disable", + "/api/auth/totp/setup", + "/api/auth/login", + "/api/auth/totp/verify-setup", + } +) + + +class _AuthRateLimiter: + def __init__(self, capacity: int, refill_rate: float): + self.capacity = capacity + self.refill_rate = refill_rate + self.tokens = float(capacity) + self.last_refill = time.monotonic() + self.lock = asyncio.Lock() + + async def acquire(self) -> bool: + async with self.lock: + now = time.monotonic() + elapsed = now - self.last_refill + self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate) + self.last_refill = now + if self.tokens >= 1: + self.tokens -= 1 + return True + return False + + +_rate_limiters: dict[str, _AuthRateLimiter] = {} class _AddrWithPort(Protocol): @@ -241,6 +273,20 @@ async def auth_middleware(self): await self.db.touch_api_key(api_key.key_id) return None + if os.environ.get("ASTRBOT_TEST_MODE") != "true" and request.path in _RATE_LIMITED_ENDPOINTS: + limiter = _rate_limiters.get(request.path) + if limiter is None: + limiter = _AuthRateLimiter(capacity=3, refill_rate=1.0) + _rate_limiters[request.path] = limiter + if not await limiter.acquire(): + r = jsonify( + Response() + .error("验证尝试过于频繁,系统可能正在遭受暴力破解") + .__dict__ + ) + r.status_code = 429 + return r + allowed_exact_endpoints = { "/api/auth/login", "/api/auth/logout", diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index a3507d2ab7..99b2f7463c 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 – 263 icons */ +/* Auto-generated MDI subset – 265 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -592,6 +592,10 @@ content: "\F0309"; } +.mdi-key-variant::before { + content: "\F030B"; +} + .mdi-label::before { content: "\F0315"; } @@ -904,6 +908,10 @@ content: "\F0CC8"; } +.mdi-shield-key::before { + content: "\F0BC4"; +} + .mdi-shuffle-variant::before { content: "\F049F"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 7549c05d40fdc4a562d4084e2f74307b83774ce1..b24c4997757e6f9e49c68914f404045d05c249d2 100644 GIT binary patch delta 16956 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 Q!H$_hGK=x#Xk}pl07hs4001BW001Nd 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 literal 15112 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 diff --git a/dashboard/src/components/shared/AstrBotConfigV4.vue b/dashboard/src/components/shared/AstrBotConfigV4.vue index b08357e85b..5ec9542c3d 100644 --- a/dashboard/src/components/shared/AstrBotConfigV4.vue +++ b/dashboard/src/components/shared/AstrBotConfigV4.vue @@ -280,6 +280,7 @@ function getSpecialSubtype(value) { v-else v-model="createSelectorModel(itemKey).value" :item-meta="itemMeta || null" + :config-root="iterable" :show-fullscreen-btn="!!itemMeta?.editor_mode" @open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)" /> @@ -360,6 +361,7 @@ function getSpecialSubtype(value) { v-else v-model="createSelectorModel(itemKey).value" :item-meta="itemMeta || null" + :config-root="iterable" :show-fullscreen-btn="!!itemMeta?.editor_mode" @open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)" /> diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 5211f8a2ec..f791dd540c 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -45,6 +45,13 @@ + + + + + diff --git a/dashboard/src/components/shared/DashboardTotpManageDialog.vue b/dashboard/src/components/shared/DashboardTotpManageDialog.vue new file mode 100644 index 0000000000..8bacbb52f0 --- /dev/null +++ b/dashboard/src/components/shared/DashboardTotpManageDialog.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/dashboard/src/components/shared/DashboardTotpManager.vue b/dashboard/src/components/shared/DashboardTotpManager.vue new file mode 100644 index 0000000000..70414c3cdd --- /dev/null +++ b/dashboard/src/components/shared/DashboardTotpManager.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue b/dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue new file mode 100644 index 0000000000..f908387c84 --- /dev/null +++ b/dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/dashboard/src/components/shared/DashboardTotpRotateRecoveryDialog.vue b/dashboard/src/components/shared/DashboardTotpRotateRecoveryDialog.vue new file mode 100644 index 0000000000..e11518d07e --- /dev/null +++ b/dashboard/src/components/shared/DashboardTotpRotateRecoveryDialog.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/dashboard/src/components/shared/DashboardTotpSetupDialog.vue b/dashboard/src/components/shared/DashboardTotpSetupDialog.vue new file mode 100644 index 0000000000..c94361a7ae --- /dev/null +++ b/dashboard/src/components/shared/DashboardTotpSetupDialog.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 23651a52f3..7410b4c59e 100644 --- a/dashboard/src/i18n/locales/en-US/features/auth.json +++ b/dashboard/src/i18n/locales/en-US/features/auth.json @@ -3,6 +3,23 @@ "username": "Username", "password": "Password", "defaultHint": "If this is your first login, check the logs for the default password.", + "totp": { + "code": "Verification code", + "verify": "Verify", + "trustDevice": "Trust this device for 30 days" + }, + "recovery": { + "title": "Recovery Code Login", + "subtitle": "Lost access to your authenticator app? Use a recovery code to log in.", + "code": "Recovery Code", + "submit": "Log in with Recovery Code", + "useRecoveryCode": "Can't use TOTP?", + "backToLogin": "Back to Login", + "savedWarning": "If lost, account access cannot be restored through normal means.", + "continue": "Continue", + "acknowledge": "I have saved my recovery codes", + "totpDisableWarning": "Using a recovery code will disable two-factor authentication." + }, "setup": { "title": "Set Up Account", "subtitle": "Create the account used to manage AstrBot", @@ -11,6 +28,17 @@ "confirmPassword": "Confirm new password", "passwordHint": "Use at least 8 characters with uppercase, lowercase, and a number.", "submit": "Complete Setup", + "totp": { + "code": "Verification code", + "qrAlt": "TOTP QR code", + "title": "Complete TOTP Setup", + "subtitle": "Scan the QR code with your authenticator app to enable two-factor authentication.", + "step2Hint": "Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy) and enter the code below.", + "verify": "Verify & Complete", + "verifyError": "Unable to verify the code. Enter the latest code from your authenticator app.", + "disableError": "Unable to disable TOTP. Please try again.", + "back": "Back" + }, "validation": { "usernameRequired": "Enter a username", "usernameMinLength": "Username must be at least 3 characters", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index bfb46ab6a0..6cfa700437 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1105,6 +1105,50 @@ "description": "SSL CA Certificate File Path", "hint": "Optional. Path to CA certificate file." } + }, + "totp": { + "enable": { + "description": "Enable WebUI TOTP", + "hint": "When enabled, a TOTP code is required during dashboard login." + }, + "manage": "Manage", + "configuration": "TOTP", + "statusPending": "Setup required", + "statusEnabled": "Enabled", + "setupRequiredHint": "TOTP is enabled but not yet configured. Click Manage to complete setup.", + "setupTitle": "Set up TOTP", + "setupSubtitle": "Scan this QR code in your authenticator app, then enter a verification code.", + "setupConfirm": "Verify and continue", + "activeSubtitle": "Use this QR code or secret to add another authenticator device.", + "rotateTitle": "Rotate TOTP Secret", + "rotateSubtitle": "Generate a new secret, then enter a code from your authenticator to confirm the replacement.", + "rotate": "Rotate", + "rotateRecovery": "Rotate Recovery Code", + "rotateRecoveryTitle": "Rotate Recovery Code", + "rotateRecoverySubtitle": "Enter a verification code from your authenticator app to generate a new recovery code.", + "rotateRecoveryCode": "Verification Code", + "rotateRecoveryConfirm": "Generate New Recovery Code", + "rotateRecoveryMissingSecret": "TOTP secret is missing. Please complete setup first.", + "rotateConfirm": "Confirm Rotation", + "rotateCancel": "Cancel", + "rotateCode": "Verification Code", + "rotateCodeHint": "Enter the code from your authenticator app to confirm the new key.", + "rotateError": "Invalid code, please try again.", + "recoveryTitle": "Recovery Codes", + "recoverySubtitle": "This recovery code is shown once. Save it before continuing.", + "recoveryWarning": "If lost, account access cannot be restored through normal means.", + "recoveryAcknowledge": "I have saved my recovery codes", + "recoveryClose": "Done", + "disableTitle": "Disable TOTP", + "disableSubtitle": "Enter a verification code to disable two-factor authentication.", + "disableRecoverySubtitle": "Enter a recovery code to disable two-factor authentication.", + "disableCode": "Verification Code", + "disableRecoveryCode": "Recovery Code", + "disableConfirm": "Disable", + "disableCancel": "Cancel", + "disableError": "Verification failed. Please try again.", + "disableUseRecovery": "Can't use TOTP?", + "disableUseCode": "Use verification code" } }, "timezone": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/auth.json b/dashboard/src/i18n/locales/ru-RU/features/auth.json index 8465dea5a9..37ee445d45 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/auth.json +++ b/dashboard/src/i18n/locales/ru-RU/features/auth.json @@ -1,34 +1,62 @@ -{ - "login": "Вход", - "username": "Имя пользователя", - "password": "Пароль", - "defaultHint": "Если это ваш первый вход, проверьте пароль по умолчанию в логах.", - "setup": { - "title": "Настройка аккаунта", - "subtitle": "Создайте аккаунт для управления AstrBot", - "username": "Новое имя пользователя", - "password": "Новый пароль", - "confirmPassword": "Подтвердите новый пароль", - "passwordHint": "Минимум 8 символов, включая заглавную букву, строчную букву и цифру.", - "submit": "Завершить настройку", - "validation": { - "usernameRequired": "Введите имя пользователя", - "usernameMinLength": "Имя пользователя должно содержать минимум 3 символа", - "passwordRequired": "Введите пароль", - "passwordMinLength": "Пароль должен содержать минимум 8 символов", - "passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву", - "passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву", - "passwordDigit": "Пароль должен содержать хотя бы одну цифру", - "confirmPasswordRequired": "Подтвердите пароль", - "passwordMatch": "Пароли не совпадают" - } +{ + "login": "Вход", + "username": "Имя пользователя", + "password": "Пароль", + "defaultHint": "Если это первый вход, проверьте пароль по умолчанию в логах.", + "totp": { + "code": "Код подтверждения", + "verify": "Проверить", + "trustDevice": "Доверять этому устройству 30 дней" + }, + "recovery": { + "title": "Вход по коду восстановления", + "subtitle": "Потеряли доступ к приложению-аутентификатору? Используйте код восстановления для входа.", + "code": "Код восстановления", + "submit": "Войти по коду восстановления", + "useRecoveryCode": "Не можете использовать TOTP?", + "backToLogin": "Назад к входу", + "savedWarning": "При утере этого кода восстановить доступ к учётной записи обычными средствами будет невозможно.", + "continue": "Продолжить", + "acknowledge": "Я сохранил(а) коды восстановления", + "totpDisableWarning": "Использование кода восстановления отключит двухфакторную аутентификацию." + }, + "setup": { + "title": "Настройка аккаунта", + "subtitle": "Создайте аккаунт для управления AstrBot", + "username": "Новое имя пользователя", + "password": "Новый пароль", + "confirmPassword": "Подтвердите новый пароль", + "passwordHint": "Минимум 8 символов, включая заглавную букву, строчную букву и цифру.", + "submit": "Завершить настройку", + "totp": { + "code": "Код подтверждения", + "qrAlt": "QR-код TOTP", + "title": "Завершить настройку TOTP", + "subtitle": "Отсканируйте QR-код в приложении-аутентификаторе, чтобы включить двухфакторную аутентификацию.", + "step2Hint": "Отсканируйте этот QR-код в приложении-аутентификаторе (например, Google Authenticator, Authy) и введите код ниже.", + "verify": "Проверить и завершить", + "verifyError": "Не удалось проверить код. Введите последний код из приложения-аутентификатора.", + "disableError": "Не удалось отключить TOTP. Пожалуйста, попробуйте снова.", + "back": "Назад" }, - "logo": { - "title": "Панель управления AstrBot", - "subtitle": "Добро пожаловать" - }, - "theme": { - "switchToDark": "Перейти на темную тему", - "switchToLight": "Перейти на светлую тему" + "validation": { + "usernameRequired": "Введите имя пользователя", + "usernameMinLength": "Имя пользователя должно содержать минимум 3 символа", + "passwordRequired": "Введите пароль", + "passwordMinLength": "Пароль должен содержать минимум 8 символов", + "passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву", + "passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву", + "passwordDigit": "Пароль должен содержать хотя бы одну цифру", + "confirmPasswordRequired": "Подтвердите пароль", + "passwordMatch": "Пароли не совпадают" } + }, + "logo": { + "title": "Панель управления AstrBot", + "subtitle": "Добро пожаловать" + }, + "theme": { + "switchToDark": "Перейти на темную тему", + "switchToLight": "Перейти на светлую тему" + } } diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 9f593fc503..b8eade166a 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1106,6 +1106,50 @@ "description": "Путь к сертификату CA SSL", "hint": "Опционально. Путь к сертификату CA." } + }, + "totp": { + "enable": { + "description": "Включить TOTP для WebUI", + "hint": "Когда включено, TOTP-код требуется для входа в панель управления." + }, + "manage": "Управление", + "configuration": "TOTP", + "statusPending": "Требуется настройка", + "statusEnabled": "Включено", + "setupRequiredHint": "TOTP включен, но ещё не настроен. Откройте «Управление», чтобы завершить настройку.", + "setupTitle": "Настройка TOTP", + "setupSubtitle": "Отсканируйте QR-код в приложении-аутентификаторе и введите код подтверждения.", + "setupConfirm": "Подтвердить и продолжить", + "activeSubtitle": "Используйте этот QR-код или секрет для добавления нового устройства-аутентификатора.", + "rotateTitle": "Смена секрета TOTP", + "rotateSubtitle": "Сгенерируйте новый секрет и подтвердите его перед заменой текущего.", + "rotate": "Сменить", + "rotateRecovery": "Сменить код восстановления", + "rotateRecoveryTitle": "Смена кода восстановления", + "rotateRecoverySubtitle": "Введите код из приложения-аутентификатора, чтобы сгенерировать новый код восстановления.", + "rotateRecoveryCode": "Код подтверждения", + "rotateRecoveryConfirm": "Создать новый код", + "rotateRecoveryMissingSecret": "Отсутствует TOTP-секрет. Сначала завершите настройку.", + "rotateConfirm": "Подтвердить смену", + "rotateCancel": "Отмена", + "rotateCode": "Код подтверждения", + "rotateCodeHint": "Введите код из приложения-аутентификатора для подтверждения нового ключа.", + "rotateError": "Неверный код, попробуйте снова.", + "recoveryTitle": "Коды восстановления", + "recoverySubtitle": "Этот код показывается один раз. Сохраните его перед продолжением.", + "recoveryWarning": "При утере этого кода восстановить доступ к учётной записи обычными средствами будет невозможно.", + "recoveryAcknowledge": "Я сохранил(а) коды восстановления", + "recoveryClose": "Готово", + "disableTitle": "Отключить TOTP", + "disableSubtitle": "Введите код подтверждения для отключения двухфакторной аутентификации.", + "disableRecoverySubtitle": "Введите код восстановления для отключения двухфакторной аутентификации.", + "disableCode": "Код подтверждения", + "disableRecoveryCode": "Код восстановления", + "disableConfirm": "Отключить", + "disableCancel": "Отмена", + "disableError": "Ошибка проверки. Попробуйте снова.", + "disableUseRecovery": "Не можете использовать TOTP?", + "disableUseCode": "Использовать код подтверждения" } }, "timezone": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index c6cc4efe32..00b1b511ee 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/auth.json +++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json @@ -2,18 +2,46 @@ "login": "登录", "username": "用户名", "password": "密码", - "defaultHint": "如果是第一次登录,请留意日志输出的默认密码", + "defaultHint": "如果这是首次登录,请在日志中查看默认密码。", + "totp": { + "code": "验证码", + "verify": "验证", + "trustDevice": "信任此设备 30 天" + }, + "recovery": { + "title": "恢复码登录", + "subtitle": "无法使用认证器应用时,可通过恢复码登录。", + "code": "恢复码", + "submit": "使用恢复码登录", + "useRecoveryCode": "无法使用 TOTP?", + "backToLogin": "返回登录", + "savedWarning": "若恢复码丢失将无法通过常规途径恢复账户访问权限。", + "continue": "继续", + "acknowledge": "我已保存恢复码", + "totpDisableWarning": "使用恢复码登录将禁用双因素认证。" + }, "setup": { "title": "设置账户", "subtitle": "创建用于管理 AstrBot 的账户", "username": "新用户名", "password": "新密码", "confirmPassword": "确认新密码", - "passwordHint": "长度至少 8 位,且包含大写字母、小写字母和数字", + "passwordHint": "长度至少 8 位,并包含大写字母、小写字母和数字。", "submit": "完成设置", + "totp": { + "code": "验证码", + "qrAlt": "TOTP 二维码", + "title": "完成 TOTP 配置", + "subtitle": "使用认证器应用扫描二维码,以完成双因素认证配置。", + "step2Hint": "使用认证器应用(如 Google Authenticator、Authy)扫描此二维码,然后输入验证码。", + "verify": "验证并完成", + "verifyError": "验证失败,请输入认证器应用中的最新验证码。", + "disableError": "无法关闭 TOTP,请重试。", + "back": "返回" + }, "validation": { "usernameRequired": "请输入用户名", - "usernameMinLength": "用户名长度至少3位", + "usernameMinLength": "用户名长度至少 3 位", "passwordRequired": "请输入密码", "passwordMinLength": "密码长度至少 8 位", "passwordUppercase": "密码必须包含至少一个大写字母", @@ -31,4 +59,4 @@ "switchToDark": "切换到深色主题", "switchToLight": "切换到浅色主题" } -} +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 59c0104de2..b22c8ffdab 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1107,6 +1107,50 @@ "description": "SSL CA 证书文件路径", "hint": "可选。用于指定 CA 证书文件路径。" } + }, + "totp": { + "enable": { + "description": "启用 WebUI TOTP 双因素认证", + "hint": "启用后,登录 WebUI 需要额外输入验证码。" + }, + "manage": "管理", + "configuration": "TOTP", + "statusPending": "需完成设置", + "statusEnabled": "已启用", + "setupRequiredHint": "TOTP 已开启但尚未完成配置,请点击“管理”完成初始化。", + "setupTitle": "设置 TOTP", + "setupSubtitle": "请使用认证器应用扫描二维码,然后输入验证码。", + "setupConfirm": "验证并继续", + "activeSubtitle": "可使用此二维码和密钥添加新的认证器设备。", + "rotateTitle": "更换 TOTP 密钥", + "rotateSubtitle": "生成新密钥并完成验证后,将替换当前密钥。", + "rotate": "更换密钥", + "rotateRecovery": "更换恢复码", + "rotateRecoveryTitle": "更换恢复码", + "rotateRecoverySubtitle": "请输入认证器中的验证码以生成新的恢复码。", + "rotateRecoveryCode": "验证码", + "rotateRecoveryConfirm": "生成新恢复码", + "rotateRecoveryMissingSecret": "TOTP 密钥缺失,请先完成初始化。", + "rotateConfirm": "确认更换", + "rotateCancel": "取消", + "rotateCode": "验证码", + "rotateCodeHint": "输入认证器应用中的验证码以确认新密钥。", + "rotateError": "验证码无效,请重试。", + "recoveryTitle": "恢复码", + "recoverySubtitle": "恢复码仅展示一次,请在继续前妥善保存。", + "recoveryWarning": "若恢复码丢失将无法通过常规途径恢复账户访问权限。", + "recoveryAcknowledge": "我已保存恢复码", + "recoveryClose": "完成", + "disableTitle": "关闭 TOTP", + "disableSubtitle": "输入验证码以确认关闭双因素认证。", + "disableRecoverySubtitle": "输入恢复码以确认关闭双因素认证。", + "disableCode": "验证码", + "disableRecoveryCode": "恢复码", + "disableConfirm": "确认关闭", + "disableCancel": "取消", + "disableError": "验证失败,请重试。", + "disableUseRecovery": "无法使用TOTP?", + "disableUseCode": "使用验证码" } }, "timezone": { diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 61cf487ab4..ce5514207c 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -128,6 +128,16 @@ axios.interceptors.request.use((config) => { return config; }); +axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 429 && error.response?.data?.message) { + return Promise.reject(error.response.data.message); + } + return Promise.reject(error); + } +); + // Keep fetch() calls consistent with axios by automatically attaching the JWT. // Some parts of the UI use fetch directly; without this, those requests will 401. const _origFetch = window.fetch.bind(window); diff --git a/dashboard/src/router/index.ts b/dashboard/src/router/index.ts index 32f138ddb6..85a99f65ff 100644 --- a/dashboard/src/router/index.ts +++ b/dashboard/src/router/index.ts @@ -17,7 +17,12 @@ export const router = createRouter({ interface AuthStore { username: string; returnUrl: string | null; - login(username: string, password: string): Promise; + login( + username: string, + password: string, + code?: string, + trustDeviceToken?: boolean, + ): Promise; logout(): void; has_token(): boolean; } @@ -41,7 +46,8 @@ router.beforeEach(async (to, from, next) => { if (authRequired && !auth.has_token()) { auth.returnUrl = to.fullPath; return next('/auth/login'); - } else next(); + } + return next(); } else { next(); } diff --git a/dashboard/src/stores/auth.ts b/dashboard/src/stores/auth.ts index bb8b331c16..7550497db2 100644 --- a/dashboard/src/stores/auth.ts +++ b/dashboard/src/stores/auth.ts @@ -6,7 +6,7 @@ export const useAuthStore = defineStore("auth", { state: () => ({ // @ts-ignore username: '', - returnUrl: null + returnUrl: null, }), actions: { async finishAuthenticatedSession(data: any): Promise { @@ -46,13 +46,26 @@ export const useAuthStore = defineStore("auth", { router.push('/welcome'); } }, - async login(username: string, password: string): Promise { + async login( + username: string, + password: string, + code?: string, + trustDeviceToken = false, + ): Promise<'totp_required' | void> { try { const res = await axios.post('/api/auth/login', { username: username, - password: password + 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'; + } + if (res.data.status === 'error') { return Promise.reject(res.data.message); } @@ -62,13 +75,17 @@ export const useAuthStore = defineStore("auth", { return Promise.reject(error); } }, - async setup(username: string, password: string, confirmPassword: string): Promise { + async setup( + username: string, + password: string, + confirmPassword: string, + ): Promise { try { - const setupEndpoint = this.has_token() ? '/api/auth/setup-authenticated' : '/api/auth/setup'; - const res = await axios.post(setupEndpoint, { + 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 + confirm_password: confirmPassword, }); if (res.data.status === 'error') { diff --git a/dashboard/src/views/authentication/authForms/AuthLogin.vue b/dashboard/src/views/authentication/authForms/AuthLogin.vue index a17649ec8c..14635bce9d 100644 --- a/dashboard/src/views/authentication/authForms/AuthLogin.vue +++ b/dashboard/src/views/authentication/authForms/AuthLogin.vue @@ -1,60 +1,141 @@ +function goToAccountStage() { + stage.value = 'account'; + apiError.value = ''; + resetTotpStage(); +} -