diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 461f85cad4..a3b5429328 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -252,6 +252,17 @@ "host": "0.0.0.0", "port": 6185, "disable_access_log": True, + "trust_proxy_headers": False, + "auth_rate_limit": { + "enable": True, + "average_interval": 1.0, + "max_burst": 3, + }, + "totp": { + "enable": False, + "secret": "", + "recovery_code_hash": "", + }, "ssl": { "enable": False, "cert_file": "", @@ -2966,6 +2977,10 @@ "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, "dashboard.ssl.enable": {"type": "bool"}, + "dashboard.trust_proxy_headers": {"type": "bool"}, + "dashboard.auth_rate_limit.enable": {"type": "bool"}, + "dashboard.auth_rate_limit.average_interval": {"type": "float"}, + "dashboard.auth_rate_limit.max_burst": {"type": "int"}, "dashboard.ssl.cert_file": { "type": "string", "condition": {"dashboard.ssl.enable": True}, @@ -4208,6 +4223,34 @@ "type": "bool", "hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。", }, + "dashboard.trust_proxy_headers": { + "description": "信任代理请求头获取客户端 IP", + "type": "bool", + "hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。", + }, + "dashboard.auth_rate_limit.enable": { + "description": "启用登录验证速率限制", + "type": "bool", + "hint": "关闭后将不对登录、TOTP 等身份验证接口进行速率限制。", + }, + "dashboard.auth_rate_limit.average_interval": { + "description": "登录验证速率限制平均间隔(秒)", + "type": "float", + "hint": "两次身份验证请求之间的最小平均间隔时间。例如设置为 1.0 表示每秒最多处理 1 个请求。", + "condition": {"dashboard.auth_rate_limit.enable": True}, + }, + "dashboard.auth_rate_limit.max_burst": { + "description": "登录验证速率限制最大突发数", + "type": "int", + "hint": "允许的瞬时最大突发请求数。例如设置为 3 表示在短时间内最多连续处理 3 个请求。", + "condition": {"dashboard.auth_rate_limit.enable": True}, + }, + "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..05dcab1c56 --- /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(datetime.timezone.utc) + 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 2824f7ee69..2094d15907 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, @@ -57,6 +70,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() @@ -77,6 +93,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__ @@ -147,6 +238,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__ @@ -154,43 +251,96 @@ 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) + if req_password == "astrbot": + return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ + if is_legacy_dashboard_password(password): + return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ + 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) - if req_password == "astrbot": - return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - if is_legacy_dashboard_password(password): - return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - 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( @@ -245,6 +395,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 @@ -286,6 +438,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 a39337b5ff..c53ed922a0 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 @@ -41,6 +42,38 @@ from .routes.subagent import SubAgentRoute from .routes.t2i import T2iRoute +_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): port: int @@ -246,6 +279,36 @@ 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 + ): + rl_config = self.config.get("dashboard", {}).get("auth_rate_limit", {}) + rl_enabled = rl_config.get("enable", True) + if rl_enabled: + average_interval = float(rl_config.get("average_interval", 1.0)) + max_burst = int(rl_config.get("max_burst", 3)) + if average_interval <= 0: + average_interval = 1.0 + if max_burst <= 0: + max_burst = 3 + refill_rate = 1.0 / average_interval + client_ip = self._get_request_client_ip() + limiter = _rate_limiters.get(client_ip) + if limiter is None: + limiter = _AuthRateLimiter( + capacity=max_burst, refill_rate=refill_rate + ) + _rate_limiters[client_ip] = 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", @@ -295,6 +358,26 @@ async def auth_middleware(self): r.status_code = 401 return r + def _get_request_client_ip(self) -> str: + trust_proxy_headers = bool( + self.config.get("dashboard", {}).get("trust_proxy_headers", False) + ) + if trust_proxy_headers: + forwarded_for = request.headers.get("X-Forwarded-For", "").strip() + if forwarded_for: + first_ip = forwarded_for.split(",", 1)[0].strip() + if first_ip and first_ip.lower() != "unknown": + return first_ip + + real_ip = request.headers.get("X-Real-IP", "").strip() + if real_ip and real_ip.lower() != "unknown": + return real_ip + + remote_addr = request.remote_addr + if remote_addr: + return str(remote_addr) + return "unknown" + @staticmethod def _extract_dashboard_jwt() -> str | None: auth_header = request.headers.get("Authorization", "").strip() 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 7549c05d40..b24c499775 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 a749e1d378..a4127b9f65 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/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 534358439b..332d5745da 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1088,6 +1088,24 @@ "hint": "When disabled, AstrBot will not upload anonymous usage statistics." }, "dashboard": { + "trust_proxy_headers": { + "description": "Trust Proxy Headers for Client IP", + "hint": "When disabled, ignore X-Forwarded-For/X-Real-IP and use the connection address only." + }, + "auth_rate_limit": { + "enable": { + "description": "Enable Login Rate Limiting", + "hint": "When disabled, authentication endpoints (login, TOTP, etc.) will not be rate-limited." + }, + "average_interval": { + "description": "Rate Limit Average Interval (seconds)", + "hint": "Minimum average interval between authentication requests. For example, 1.0 means at most 1 request per second." + }, + "max_burst": { + "description": "Rate Limit Max Burst", + "hint": "Maximum number of consecutive burst requests allowed. For example, 3 allows up to 3 requests in a short burst." + } + }, "ssl": { "enable": { "description": "Enable WebUI HTTPS", @@ -1105,6 +1123,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 d196cb37a3..a92b2d6355 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1089,6 +1089,24 @@ "hint": "После отключения AstrBot не будет отправлять анонимные данные об использовании." }, "dashboard": { + "trust_proxy_headers": { + "description": "Доверять прокси-заголовкам для IP клиента", + "hint": "Если выключено, X-Forwarded-For/X-Real-IP игнорируются и используется только адрес соединения." + }, + "auth_rate_limit": { + "enable": { + "description": "Включить ограничение скорости входа", + "hint": "Если выключено, конечные точки аутентификации (вход, TOTP и т.д.) не будут ограничены по скорости." + }, + "average_interval": { + "description": "Средний интервал ограничения скорости (сек)", + "hint": "Минимальный средний интервал между запросами аутентификации. Например, 1.0 означает не более 1 запроса в секунду." + }, + "max_burst": { + "description": "Максимальный всплеск ограничения скорости", + "hint": "Максимальное количество последовательных всплесков запросов. Например, 3 допускает до 3 запросов за короткий всплеск." + } + }, "ssl": { "enable": { "description": "Включить HTTPS для WebUI", @@ -1106,6 +1124,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 c8d9d572af..7244be8d60 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1090,6 +1090,24 @@ "hint": "禁用后,AstrBot 将不再上传匿名使用统计数据。" }, "dashboard": { + "trust_proxy_headers": { + "description": "信任代理请求头获取客户端 IP", + "hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。" + }, + "auth_rate_limit": { + "enable": { + "description": "启用登录验证速率限制", + "hint": "关闭后将不对登录、TOTP 等身份验证接口进行速率限制。" + }, + "average_interval": { + "description": "登录验证速率限制平均间隔(秒)", + "hint": "两次身份验证请求之间的最小平均间隔时间。例如设置为 1.0 表示每秒最多处理 1 个请求。" + }, + "max_burst": { + "description": "登录验证速率限制最大突发数", + "hint": "允许的瞬时最大突发请求数。例如设置为 3 表示在短时间内最多连续处理 3 个请求。" + } + }, "ssl": { "enable": { "description": "启用 WebUI HTTPS", @@ -1107,6 +1125,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(); +} -