diff --git a/README.md b/README.md index ad21ad6..c5392eb 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,17 @@ ↓ 访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码) ↓ +登录成功 → 签发 access token(前端持有)+ refresh token(httpOnly cookie) + ↓ Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA 正式用户 TOTP/Passkey → 直接进入控制面板 临时账户 TOTP → 强制跳转 /mfa-setup 配置登录方式,配置完成后临时 TOTP 立即失效 ``` +**会话管理**: +- 控制端「设置 → 踢掉当前账号的所有设备」:所有登录该账号的设备(控制端 + 显示端,含当前设备)立即下线,**所有登录用户可用** +- Root「用户管理 → 踢掉所有用户」:所有用户的控制端与显示端全部下线(含 Root 自身),需二次验证 + **用户管理(仅 Root)**: - 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成) - 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码) @@ -61,8 +67,9 @@ Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA - 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时) - CSRF 防护(Origin/Referer 校验) - 敏感操作二次验证(用户管理、MFA 配置需重新确认身份) -- 会话 Cookie HttpOnly + SameSite=Lax + Secure(HTTPS 下) -- 会话版本按用户存储:删除用户 / "踢掉所有显示端" 立即失效 +- **双 Token 认证**:短期 access token(`Authorization: Bearer`,15 分钟)+ 长期 refresh token(httpOnly cookie,30 天滑动续期、每次刷新轮换) +- Access token 快照会话版本:删除用户 / 踢设备 / 踢所有用户后**立即**使全部 access token 失效 +- 显示端 SSE 通过一次性短时 ticket 鉴权(EventSource 无法带自定义 header) - 内容安全策略(CSP)限制脚本/样式来源 - 铃声上传仅允许音频格式+MIME 校验 - 第三方日历数据 HTML 转义防止 XSS @@ -225,8 +232,8 @@ location / { | 变量 | 默认值 | 说明 | |------|--------|------| -| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 Secure Cookie(Nginx 反代时启用) | -| `FLASK_SECRET_KEY` | 自动随机生成 | 固定密钥使会话跨重启保持有效 | +| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 refresh token cookie 的 Secure 属性(Nginx 反代时启用) | +| `FLASK_SECRET_KEY` | 自动随机生成 | 保留兼容;认证已由 access/refresh 双 token 承载,不再依赖 Flask session | | `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` | | `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 | | `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) | @@ -241,26 +248,35 @@ location / { ├── requirements.txt ├── backend/ │ ├── app.py # Flask 应用工厂 + 安全配置 -│ ├── auth.py # MFA 认证(TOTP/Passkey/恢复码/临时密码) +│ ├── auth.py # 双 Token 认证(TOTP/Passkey/恢复码/临时密码) │ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证 │ ├── jwt_util.py # Ed25519 JWT 签名(和风天气) -│ ├── models.py # SQLite 数据库模型与迁移 -│ ├── routes.py # 全部 API 路由 + CSRF 防护 +│ ├── models.py # SQLite 模型、schema 版本迁移、token 管理 +│ ├── routes.py # 全部 API 路由 + CSRF 防护 + SSE ticket │ └── *_mgr.py # 各功能模块管理器 └── frontend/ ├── templates/ # HTML 模板 └── static/ ├── css/ # 样式 ├── js/ # JavaScript(纯原生,无框架) + │ ├── token.js # 双 Token 客户端(access 存储/刷新/401 重试/SSE ticket) + │ ├── control.js # 控制端逻辑 + │ └── display.js # 显示端逻辑(账户时区渲染) └── ringtones/ # 自定义铃声 ``` --- -### 数据库迁移(升级无需重写数据) +### 数据库迁移(版本化,升级无需重写数据) + +数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 1)。每次启动时比对: + +| 情况 | 行为 | +|------|------| +| 数据库版本 == 程序版本 | 直接使用,跳过重建 | +| 无版本字段(旧版数据库)或 数据库版本 < 程序版本 | 重建数据库并迁移(旧版单用户数据自动归属 Root (id=0),新增字段一律用 DEFAULT 填充,`camera_config` 等已删除的表自动丢弃) | +| 数据库版本 > 程序版本 | **报错并终止启动**(数据库比程序新,需先升级程序) | -启动时会自动检测并迁移数据库:旧版单用户数据库的数据自动归属 Root (id=0); -新增的字段一律带 DEFAULT 并在迁移时自动填充;`camera_config` 等已删除的表自动丢弃。 旧版 4 位 PIN 不会被迁移。运行测试:`python3 tests/test_multi_user.py`。 ## 注意 diff --git a/backend/app.py b/backend/app.py index 9caad36..54e6fa6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -22,8 +22,7 @@ def create_app(db_path: str = "data.db") -> Flask: template_folder="../frontend/templates", static_folder="../frontend/static", ) - # Generate a secure random key at startup. - # Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts). + # Generate a secure random key at startup (kept for compatibility). app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) # Defensive check: ensure key is never empty if not app.config["SECRET_KEY"]: @@ -32,12 +31,8 @@ def create_app(db_path: str = "data.db") -> Flask: # Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) - # Session hardening for production - https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes") - app.config["SESSION_COOKIE_HTTPONLY"] = True - app.config["SESSION_COOKIE_SAMESITE"] = "Lax" - app.config["SESSION_COOKIE_SECURE"] = https - app.config["SESSION_PERMANENT"] = False + # Dual-token auth: access token in Authorization header, refresh token in + # an httpOnly cookie. No Flask session cookies are used anymore. # Strict CORS: only allow same-origin requests (no wildcard for cookie auth) CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only @@ -45,9 +40,6 @@ def create_app(db_path: str = "data.db") -> Flask: # Content size limit: 5MB for uploads app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 - # Session lifetime: 24 hours idle timeout - app.config["PERMANENT_SESSION_LIFETIME"] = 86400 - # Global event bus app.event_bus = EventBus() diff --git a/backend/auth.py b/backend/auth.py index 1fc92f9..230c06f 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,30 +1,38 @@ -"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password. +"""Dual-token authentication: short-lived access token (Authorization header, +validated per-request) + long-lived refresh token (httpOnly cookie, rotated +on each refresh). TOTP / Passkey / recovery codes / temp password still drive +the login ceremony; the result is a token pair instead of a Flask session. Multi-user support: -- Sessions carry the authenticated `user_id` (Root = 0). -- The unified login (6-digit code) searches every active user's TOTP secret, - then temp accounts' temp-TOTP secrets, then Root's temp password. -- Session version is per-user (stored in the users table), so deleting a user - or kicking all sessions immediately invalidates their cookies. +- Every request resolves its user from the access token (`g.user_id`). +- Access tokens snapshot the user's `session_version`; bumping it (delete user, + kick devices) invalidates every outstanding access token immediately. +- Refresh tokens are stored hashed in `auth_tokens`; deleting them (logout of + one device / kick) ends the corresponding device session. +- Temp-account / temp-password logins get `temp=1` tokens; the user is forced + through MFA setup until they complete it, then a fresh non-temp pair is + issued. """ import functools +import os import secrets import string import time -from flask import session, redirect, url_for, request, jsonify, current_app, g +from flask import g, redirect, url_for, request, jsonify, current_app from backend.models import ( get_auth_config, update_auth_config, get_temp_password, set_temp_password, clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured, - get_user_session_version, user_exists, is_admin_user, get_db, + user_exists, is_admin_user, get_db, + create_auth_token, find_access_token, + delete_auth_token, REFRESH_TOKEN_TTL, ACCESS_TOKEN_TTL, ) -# ─── Session keys ──────────────────────────── -SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated -SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required -SKEY_VIEW = "mfa_view" # str: "control" or "display" -SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication -SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation) -SKEY_USER = "user_id" # int: id of the authenticated user +# ─── Token / cookie constants ──────────────── +REFRESH_COOKIE = "refresh_token" # httpOnly, SameSite=Lax, Secure under HTTPS + + +def _https_enabled() -> bool: + return os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes") def _db_path(preferred: str = None) -> str: @@ -33,44 +41,60 @@ def _db_path(preferred: str = None) -> str: return current_app.config.get("DB_PATH", "data.db") -def _validate_version(): - """If the session version is stale (kick_all / user deleted), clear the session.""" - uid = session.get(SKEY_USER) - if uid is None: +# ─── Auth context (per-request, populated by before_request) ─── + +def load_auth_context(): + """Parse `Authorization: Bearer ` and fill `g` with the auth context. + Runs for every request (cheap: no token → no-op).""" + token = _extract_bearer() + if not token: return - # Cache the validation result for the duration of this request. - if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid: + row = find_access_token(token) + if not row: return - ver = session.get(SKEY_VERSION) - if ver is None: - return - stored = get_user_session_version(uid) - if stored == -1 or ver != stored: - clear_session() - return - g._sv_ok = True - g._sv_uid = uid + g.user_id = row["user_id"] + g.auth_view = row["view"] + g.temp_pending = bool(row["temp"]) + g.auth_authenticated = True + g.access_token_id = row["id"] + g.reauth_at = row.get("reauth_at") or 0 + + +def _extract_bearer() -> str | None: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:].strip() + return None + + +def _g(): + return g def is_authenticated() -> bool: - """Returns True if the user has completed MFA authentication.""" - _validate_version() - return session.get(SKEY_AUTH) is True + """True when the request carries a valid, non-temp access token.""" + try: + return (getattr(_g(), "auth_authenticated", False) + and not getattr(_g(), "temp_pending", False)) + except Exception: + return False def is_temp_pending() -> bool: - """True if logged in with a temp password or temp account and must set up a login method.""" - _validate_version() - return session.get(SKEY_TEMP) is True + """True when logged in with a temp password / temp account (MFA setup required).""" + try: + return (getattr(_g(), "auth_authenticated", False) + and bool(getattr(_g(), "temp_pending", False))) + except Exception: + return False def current_user_id() -> int | None: - """Return the id of the current user (session user, or scheduler thread user).""" + """Return the id of the current user (token user, or scheduler thread user).""" try: from flask import has_request_context if has_request_context(): - _validate_version() - uid = session.get(SKEY_USER) + uid = getattr(_g(), "user_id", None) if uid is not None: return int(uid) except Exception: @@ -103,64 +127,104 @@ def is_admin() -> bool: return is_admin_user(uid) -def set_authenticated(view: str = "control", user_id: int = None): - if user_id is None: - user_id = session.get(SKEY_USER) or 0 - session[SKEY_AUTH] = True - session[SKEY_TEMP] = False - session[SKEY_VIEW] = view - session[SKEY_USER] = user_id - session[SKEY_VERSION] = get_user_session_version(user_id) - # A fresh authentication must not inherit the temp-setup re-auth marker. - session.pop(SKEY_REAUTH, None) - session.permanent = True +# ─── Token issuance / refresh / revocation ─── + +def issue_tokens(user_id: int, view: str = "control", temp: bool = False) -> dict: + """Create a fresh access+refresh token pair. The refresh token must be + delivered to the client via an httpOnly cookie by the caller.""" + access = create_auth_token(user_id, "access", view=view, temp=temp) + refresh = create_auth_token(user_id, "refresh", view=view, temp=temp) + return {"access_token": access, "refresh_token": refresh} -def set_temp_pending(view: str = "control", user_id: int = None): - """Mark the session as logged-in-but-must-configure (temp password / temp account).""" - if user_id is None: - user_id = session.get(SKEY_USER) or 0 - session[SKEY_AUTH] = False - session[SKEY_TEMP] = True - session[SKEY_VIEW] = view - session[SKEY_USER] = user_id - session[SKEY_VERSION] = get_user_session_version(user_id) - session.permanent = True +def set_refresh_cookie(resp, refresh: str): + """Attach the refresh token as an httpOnly cookie on the response.""" + resp.set_cookie( + REFRESH_COOKIE, refresh, + max_age=REFRESH_TOKEN_TTL, + httponly=True, + samesite="Lax", + secure=_https_enabled(), + path="/", + ) -def clear_session(): - session.pop(SKEY_AUTH, None) - session.pop(SKEY_TEMP, None) - session.pop(SKEY_VIEW, None) - session.pop(SKEY_REAUTH, None) - session.pop(SKEY_USER, None) - session.pop(SKEY_VERSION, None) +def clear_refresh_cookie(resp): + resp.delete_cookie(REFRESH_COOKIE, path="/") + + +def rotate_refresh(refresh_raw: str) -> dict | None: + """Validate a refresh token, revoke it and issue a fresh pair (rotation). + Atomic (single transaction): a replayed old token can never mint twice. + Returns the new {access_token, refresh_token} or None when invalid.""" + from backend.models import rotate_refresh_token + return rotate_refresh_token(refresh_raw) + + +def logout_current_device(): + """Revoke this device's refresh token (from the httpOnly cookie) and also + the presented access token, if any (so a stolen access dies immediately).""" + raw = request.cookies.get(REFRESH_COOKIE) + if raw: + delete_auth_token(raw, kind="refresh") + acc = _extract_bearer() + if acc: + delete_auth_token(acc, kind="access") + + +def load_page_context() -> bool: + """Authenticate a top-level page navigation via the httpOnly refresh cookie + (browsers cannot attach Authorization headers to navigations). + Fills g.user_id / g.auth_view / g.temp_pending on success. + Returns False when unauthenticated.""" + raw = request.cookies.get(REFRESH_COOKIE) + if not raw: + return False + from backend.models import find_refresh_token + row = find_refresh_token(raw) + if not row: + return False + g.user_id = row["user_id"] + g.auth_view = row["view"] + g.temp_pending = bool(row["temp"]) + g.page_authenticated = True + return True # ─── Re-authentication guard ───────────────── # Sensitive operations (user management, MFA changes) require re-confirming -# identity within the last N seconds. TOTP-first with a Passkey fallback -# (the fallback ceremony is handled by the routes layer). +# identity within the last N seconds. The marker lives on the ACCESS TOKEN row, +# so it is per-device (each device's token must re-auth independently) and +# lost on token rotation (a fresh access token requires a fresh re-auth). _REAUTH_TTL = 300 # 5 minutes -def require_reauth(): - """Check if recent re-authentication is still valid. +def require_reauth() -> bool: + """Check if recent re-authentication is still valid for THIS access token. Skips the check for temp_pending users (first-time MFA setup flow). """ if is_temp_pending(): mark_reauth() return True - last = session.get(SKEY_REAUTH) - if last and (time.time() - last) < _REAUTH_TTL: - return True - return False + try: + last = getattr(g, "reauth_at", 0) or 0 + except Exception: + return False + return bool(last) and (time.time() - last) < _REAUTH_TTL def mark_reauth(): - """Mark re-authentication as done now.""" - session[SKEY_REAUTH] = time.time() + """Mark re-authentication as done now for the current access token.""" + try: + from flask import g as _g + from backend.models import mark_token_reauth + mark_token_reauth(getattr(_g, "access_token_id", None)) + except Exception as e: + try: + current_app.logger.warning(f"mark_reauth failed: {e}") + except Exception: + pass # ─── Decorators ────────────────────────────── @@ -168,7 +232,7 @@ def mark_reauth(): def _unauthorized(request): if request.is_json or request.path.startswith("/api/"): return jsonify({"error": "Unauthorized", "login_required": True}), 401 - view_param = session.get(SKEY_VIEW, "control") + view_param = getattr(_g(), "auth_view", None) or "control" return redirect(url_for("api.login_page", next=request.path, view=view_param)) diff --git a/backend/models.py b/backend/models.py index b3cbaa6..ba1906c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -13,9 +13,33 @@ Multi-user design: import sqlite3 import os import sys +import time SCHEMA = """ +-- Global schema version (single row). All new columns must carry a DEFAULT. +CREATE TABLE IF NOT EXISTS meta ( + id INTEGER PRIMARY KEY DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1 +); + +-- Auth tokens (dual-token auth): access (short-lived, per-request) and +-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored. +CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + token_hash TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')), + view TEXT NOT NULL DEFAULT 'display' CHECK(view IN ('control','display')), + temp INTEGER NOT NULL DEFAULT 0, + session_version INTEGER NOT NULL DEFAULT 0, + reauth_at INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); + CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, remark TEXT NOT NULL DEFAULT '', @@ -195,6 +219,7 @@ USER_DATA_TABLES = [ "reminders", "weather_config", "weather_cache", "weather_hourly_cache", "weather_minutely_cache", "weather_warning_cache", "clock_config", "alarms", "countdowns", "display_config", "pomodoro_state", + "auth_tokens", ] @@ -311,6 +336,7 @@ def delete_user(user_id: int, db_path: str = None): "weather_cache", "weather_hourly_cache", "weather_minutely_cache", "weather_warning_cache", "weather_config", "clock_config", "alarms", "countdowns", "display_config", "pomodoro_state", "auth_config", + "auth_tokens", ] try: for table in ordered: @@ -364,7 +390,8 @@ def get_temp_totp_secret(user_id: int, db_path: str = None) -> str: def finalize_temp_account(user_id: int, db_path: str = None): """Convert a temp account into a formal account: - invalidates the temp TOTP and clears the temp flag. + invalidates the temp TOTP, clears the temp flag, and revokes every + temp=1 token (a temp refresh must never mint formal tokens afterwards). """ conn = get_db(_db_path(db_path)) conn.execute( @@ -375,6 +402,10 @@ def finalize_temp_account(user_id: int, db_path: str = None): "UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?", (user_id,) ) + conn.execute( + "DELETE FROM auth_tokens WHERE user_id=? AND temp=1", + (user_id,) + ) conn.commit() conn.close() @@ -479,6 +510,211 @@ def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool return True +# ─── Auth tokens (dual-token auth) ─────────── +# Access tokens are short-lived and validated on every request; refresh tokens +# are long-lived and stored in an httpOnly cookie. Only SHA-256 hashes are +# stored in the DB. Kicking a user/device = delete their tokens + bump the +# user's session_version (which invalidates every outstanding access token). + +ACCESS_TOKEN_TTL = 15 * 60 # 15 minutes +REFRESH_TOKEN_TTL = 30 * 24 * 3600 # 30 days (sliding, renewed on each refresh) + + +def _hash_token(raw: str) -> str: + import hashlib + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _prune_expired_tokens(conn): + conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (int(time.time()),)) + + +def create_auth_token(user_id: int, kind: str, view: str = "display", + temp: bool = False, ttl: int = None, + db_path: str = None) -> str: + """Create an auth token row and return the raw (unhashed) token. + Only the SHA-256 hash is persisted.""" + import secrets as _secrets + if ttl is None: + ttl = ACCESS_TOKEN_TTL if kind == "access" else REFRESH_TOKEN_TTL + raw = _secrets.token_urlsafe(48 if kind == "refresh" else 32) + conn = get_db(_db_path(db_path)) + try: + _prune_expired_tokens(conn) + ver_row = conn.execute( + "SELECT session_version FROM users WHERE id=?", (user_id,) + ).fetchone() + ver = ver_row["session_version"] if ver_row else 0 + conn.execute( + "INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, " + "session_version, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, _hash_token(raw), kind, view, 1 if temp else 0, ver, + int(time.time()) + ttl) + ) + conn.commit() + return raw + finally: + conn.close() + + +def _token_lookup(raw: str, kind: str, db_path: str = None) -> dict | None: + """Look up a token by its raw value. For access tokens the row is only + valid while it matches the user's current session_version (kick support). + Returns None when invalid/expired/user inactive/version bumped.""" + if not raw: + return None + conn = get_db(_db_path(db_path)) + try: + h = _hash_token(raw) + row = conn.execute( + "SELECT t.*, u.status AS user_status FROM auth_tokens t " + "JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind=?", + (h, kind) + ).fetchone() + if not row: + return None + if row["expires_at"] <= int(time.time()): + return None + if row["user_status"] != "active": + return None + if kind == "access" and row["session_version"] != _cur_session_version(row["user_id"], conn): + return None + return dict(row) + finally: + conn.close() + + +def _cur_session_version(user_id: int, conn) -> int: + r = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone() + return r["session_version"] if r else -1 + + +def find_access_token(raw: str, db_path: str = None) -> dict | None: + """Validate an access token. Returns the row dict or None.""" + return _token_lookup(raw, "access", db_path) + + +def rotate_refresh_token(raw: str, db_path: str = None) -> dict | None: + """Atomically validate and rotate a refresh token (concurrency-safe). + Returns a fresh {access_token, refresh_token} pair or None. The old refresh + token is consumed inside a single transaction, so a replayed/concurrent + duplicate can never mint two valid pairs.""" + if not raw: + return None + import secrets as _secrets + conn = get_db(_db_path(db_path)) + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT t.*, u.status AS user_status FROM auth_tokens t " + "JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'", + (_hash_token(raw),) + ).fetchone() + if (not row or row["expires_at"] <= int(time.time()) + or row["user_status"] != "active"): + conn.rollback() + return None + cur = conn.execute("DELETE FROM auth_tokens WHERE id=?", (row["id"],)) + if cur.rowcount == 0: + conn.rollback() + return None + now = int(time.time()) + acc_raw = _secrets.token_urlsafe(32) + ref_raw = _secrets.token_urlsafe(48) + ver = row["session_version"] + conn.execute( + "INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, " + "session_version, expires_at) VALUES (?, ?, 'access', ?, ?, ?, ?)", + (row["user_id"], _hash_token(acc_raw), row["view"], row["temp"], + ver, now + ACCESS_TOKEN_TTL) + ) + conn.execute( + "INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, " + "session_version, expires_at) VALUES (?, ?, 'refresh', ?, ?, ?, ?)", + (row["user_id"], _hash_token(ref_raw), row["view"], row["temp"], + ver, now + REFRESH_TOKEN_TTL) + ) + conn.commit() + return {"access_token": acc_raw, "refresh_token": ref_raw} + finally: + conn.close() + + +def find_refresh_token(raw: str, db_path: str = None) -> dict | None: + """Validate a refresh token (status check only; version is not compared + here because a refresh token belongs to one device and is deleted on + kick, not merely invalidated).""" + if not raw: + return None + conn = get_db(_db_path(db_path)) + try: + h = _hash_token(raw) + row = conn.execute( + "SELECT t.*, u.status AS user_status FROM auth_tokens t " + "JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'", + (h,) + ).fetchone() + if not row: + return None + if row["expires_at"] <= int(time.time()): + return None + if row["user_status"] != "active": + return None + return dict(row) + finally: + conn.close() + + +def mark_token_reauth(token_row_id: int, db_path: str = None): + """Stamp the current access token row as re-authenticated (per-device).""" + if not token_row_id: + return + conn = get_db(_db_path(db_path)) + try: + conn.execute("UPDATE auth_tokens SET reauth_at=? WHERE id=?", + (int(time.time()), token_row_id)) + conn.commit() + finally: + conn.close() + + +def delete_auth_token(raw: str, kind: str = None, db_path: str = None): + """Delete a single token row by raw value (logout of one device).""" + if not raw: + return + conn = get_db(_db_path(db_path)) + try: + h = _hash_token(raw) + if kind: + conn.execute("DELETE FROM auth_tokens WHERE token_hash=? AND kind=?", (h, kind)) + else: + conn.execute("DELETE FROM auth_tokens WHERE token_hash=?", (h,)) + conn.commit() + finally: + conn.close() + + +def revoke_user_tokens(user_id: int, db_path: str = None): + """Delete every token of a user (kick all devices of one account). + Callers should also bump_user_session_version() to invalidate access tokens.""" + conn = get_db(_db_path(db_path)) + try: + conn.execute("DELETE FROM auth_tokens WHERE user_id=?", (user_id,)) + conn.commit() + finally: + conn.close() + + +def revoke_all_tokens(db_path: str = None): + """Delete every token of every user (Root: kick all users).""" + conn = get_db(_db_path(db_path)) + try: + conn.execute("DELETE FROM auth_tokens") + conn.commit() + finally: + conn.close() + + # ─── Auth / MFA helpers (per user) ────────── def get_auth_config(db_path: str = None, user_id: int = 0) -> dict: @@ -598,6 +834,9 @@ def _ensure_defaults(conn): import secrets import string + # Schema-version row (single row, id=1). + conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,)) + # Root user (id 0) root = conn.execute("SELECT id FROM users WHERE id=0").fetchone() if root is None: @@ -659,6 +898,10 @@ def _ensure_defaults(conn): # Stores passwords that were just generated, for the startup script to print. _EMITTED_PASSWORDS: list = [] +# Current schema version supported by this program. Bump it whenever the +# SCHEMA changes so existing databases get rebuilt+migrated on next start. +DB_VERSION = 1 + def _table_columns(conn, table_name): return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")] @@ -694,17 +937,44 @@ def _migrate_auth_config(conn_new, conn_old): def init_db(db_path: str = "data.db"): - """Initialize the database. + """Initialize the database with schema-version aware migration. - No database file: create it from the latest SCHEMA and create Root. - - Existing database: rebuild it from the latest SCHEMA, migrate compatible - data (legacy single-user data becomes Root's), fill missing columns with - SCHEMA defaults, then replace the old file (keeping a backup). + - Existing database: + * no `meta.version` (legacy DB) → rebuild + migrate compatible data + * DB version < DB_VERSION → rebuild + migrate + * DB version == DB_VERSION → use as-is (fast path) + * DB version > DB_VERSION → abort with an error (DB is newer + than this program) + + Rebuild copies compatible data (legacy single-user data becomes Root's) + and fills missing columns with SCHEMA defaults. """ if not os.path.exists(db_path): conn = get_db(db_path) conn.executescript(SCHEMA) _ensure_defaults(conn) + conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,)) + conn.commit() + conn.close() + return + + conn_probe = get_db(db_path) + old_version = _read_db_version(conn_probe) + conn_probe.close() + + if old_version is not None and old_version > DB_VERSION: + raise SystemExit( + f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 " + f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。" + ) + + if old_version is not None and old_version == DB_VERSION: + # Same version — no rebuild needed. Just ensure defaults exist. + conn = get_db(db_path) + _ensure_defaults(conn) + conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,)) + conn.commit() conn.close() return @@ -764,6 +1034,10 @@ def init_db(db_path: str = "data.db"): # Now fill defaults for any tables that are still empty + Root bootstrap. _ensure_defaults(conn_new) + # Stamp the migrated database with the program's schema version. + conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,)) + conn_new.commit() + conn_old.close() conn_new.close() @@ -772,3 +1046,14 @@ def init_db(db_path: str = "data.db"): os.remove(backup_path) os.rename(db_path, backup_path) os.rename(new_db_path, db_path) + + +def _read_db_version(conn) -> int | None: + """Read the schema version from the `meta` table. + Returns None when the DB is legacy (no meta table / no version column). + """ + try: + row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone() + return int(row["version"]) if row else None + except Exception: + return None diff --git a/backend/routes.py b/backend/routes.py index 99fe441..e69503a 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -18,16 +18,17 @@ from datetime import datetime from flask import ( Blueprint, request, jsonify, Response, render_template, current_app, stream_with_context, - session, redirect, url_for, + g, redirect, url_for, ) from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr from backend.auth import ( login_required, login_or_temp_required, display_login_required, admin_required, is_authenticated, is_temp_pending, is_admin, current_user_id, - set_authenticated, set_temp_pending, clear_session, verify_login, verify_totp, verify_recovery_code, check_rate_limit, record_failure, record_success, require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password, + load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie, + rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE, ) from backend.models import ( get_auth_config, update_auth_config, set_temp_password, clear_temp_password, @@ -37,6 +38,7 @@ from backend.models import ( get_users, get_user, create_user, update_user, delete_user, is_temp_account, is_admin_user, finalize_temp_account, find_user_by_credential_id, bump_all_session_versions, + bump_user_session_version, revoke_user_tokens, revoke_all_tokens, ) from backend.webauthn_mgr import ( begin_registration, complete_registration, @@ -53,9 +55,15 @@ def _cur() -> int: # ════════════════════════════════════════════ -# CSRF / Origin Check +# AUTH CONTEXT / CSRF # ════════════════════════════════════════════ +@api.before_request +def _load_token(): + """Resolve the access token (Authorization header) into g.user_id / view / temp.""" + load_auth_context() + + @api.before_request def _check_origin(): """Protect state-changing requests against CSRF by verifying Origin/Referer. @@ -199,6 +207,8 @@ def api_login(): data = request.get_json() or {} code = data.get("code", "") view = data.get("view", "control") + if view not in ("control", "display"): + view = "control" # 未认证端点:非法值降级,避免触发 DB CHECK 约束 500 result = verify_login(code) if result is None: @@ -213,21 +223,26 @@ def api_login(): if temp_account: # Temp account: server-held temp TOTP matched → force registration flow. record_success() - set_temp_pending(view, uid) + tokens = issue_tokens(uid, view=view, temp=True) + resp["access_token"] = tokens["access_token"] resp["must_configure_mfa"] = True else: cfg = get_auth_config(user_id=uid) if not cfg.get("totp_enabled"): return jsonify({"error": "TOTP 尚未启用"}), 403 record_success() - set_authenticated(view, uid) + tokens = issue_tokens(uid, view=view) + resp["access_token"] = tokens["access_token"] else: # temp_password (Root initial bootstrap) record_success() - set_temp_pending(view, uid) + tokens = issue_tokens(uid, view=view, temp=True) + resp["access_token"] = tokens["access_token"] resp["must_configure_mfa"] = True - return jsonify(resp) + response = jsonify(resp) + set_refresh_cookie(response, tokens["refresh_token"]) + return response # ─── Recovery Code Login ───────────────────── @@ -242,12 +257,16 @@ def api_login_recovery(): data = request.get_json() or {} code = data.get("code", "") view = data.get("view", "control") + if view not in ("control", "display"): + view = "control" uid = verify_recovery_code(code) if uid is not None: record_success() - set_authenticated(view, uid) - return jsonify({"ok": True}) + tokens = issue_tokens(uid, view=view) + response = jsonify({"ok": True, "access_token": tokens["access_token"]}) + set_refresh_cookie(response, tokens["refresh_token"]) + return response record_failure() return jsonify({"error": "恢复码错误或已使用"}), 401 @@ -356,6 +375,8 @@ def api_webauthn_login_complete(): data = request.get_json() or {} view = data.get("view", "control") + if view not in ("control", "display"): + view = "control" credential = data.get("credential", {}) credential_b64 = credential.get("id", "") @@ -392,8 +413,10 @@ def api_webauthn_login_complete(): record_failure() return jsonify({"error": "Passkey 凭证不匹配"}), 401 record_success() - set_authenticated(view, uid) - return jsonify({"ok": True}) + tokens = issue_tokens(uid, view=view) + response = jsonify({"ok": True, "access_token": tokens["access_token"]}) + set_refresh_cookie(response, tokens["refresh_token"]) + return response except ValueError as e: record_failure() return jsonify({"error": str(e)}), 401 @@ -401,12 +424,36 @@ def api_webauthn_login_complete(): return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 -# ─── Logout ────────────────────────────────── +# ─── Refresh / Logout ─────────────────────── + +@api.route("/api/auth/refresh", methods=["POST"]) +def api_refresh(): + """Exchange the httpOnly refresh cookie for a fresh access+refresh pair. + The refresh token is rotated: the old one is consumed immediately. + CSRF: not exempted from the Origin/Referer check (cookie-authenticated). + """ + raw = request.cookies.get(REFRESH_COOKIE) + if not raw: + return jsonify({"error": "Unauthorized", "login_required": True}), 401 + tokens = rotate_refresh(raw) + if not tokens: + response = jsonify({"error": "Unauthorized", "login_required": True}) + clear_refresh_cookie(response) + return response, 401 + response = jsonify({"ok": True, "access_token": tokens["access_token"]}) + set_refresh_cookie(response, tokens["refresh_token"]) + return response + @api.route("/api/auth/logout", methods=["POST"]) def api_logout(): - clear_session() - return jsonify({"ok": True}) + """Revoke this device's refresh token and clear its cookie. + Other devices of the same account are unaffected. + """ + logout_current_device() + response = jsonify({"ok": True}) + clear_refresh_cookie(response) + return response # ─── Auth Status ───────────────────────────── @@ -484,9 +531,11 @@ def api_register_request(): @api.route("/mfa-setup") def mfa_setup_page(): - """MFA configuration page. Shown after temp-password / temp-account login.""" - if not is_temp_pending() and not is_authenticated(): - return redirect(url_for("api.login_page", next="/mfa-setup")) + """MFA configuration page. Shown after temp-password / temp-account login. + Authenticated via the httpOnly refresh cookie (page navigation). + """ + if not load_page_context(): + return redirect(url_for("api.login_page", next="/mfa-setup", view="control")) return render_template("mfa_setup.html") @@ -532,8 +581,12 @@ def api_webauthn_register_complete(): if is_temp_account(uid): finalize_temp_account(uid) finalized = True - # The user proved access to the account — mark the session authenticated. - set_authenticated(session.get("mfa_view", "control"), uid) + # The user proved access to the account — issue a formal token pair. + tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) + response = jsonify({"ok": True, "finalized": True, + "access_token": tokens["access_token"]}) + set_refresh_cookie(response, tokens["refresh_token"]) + return response return jsonify({"ok": True, "finalized": finalized}) except ValueError as e: return jsonify({"error": str(e)}), 400 @@ -602,13 +655,19 @@ def api_totp_verify(): if is_temp_account(uid): finalize_temp_account(uid) finalized = True - set_authenticated(session.get("mfa_view", "control"), uid) - return jsonify({ + resp = { "ok": True, "recovery_codes": get_recovery_codes(user_id=uid), "finalized": finalized, - }) + } + if finalized: + tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) + resp["access_token"] = tokens["access_token"] + response = jsonify(resp) + set_refresh_cookie(response, tokens["refresh_token"]) + return response + return jsonify(resp) return jsonify({"error": "验证码错误,请重试"}), 400 @@ -631,8 +690,10 @@ def api_mfa_complete(): finalize_temp_account(uid) clear_temp_password(user_id=uid) update_auth_config(uid, must_configure_mfa=0) - set_authenticated(session.get("mfa_view", "control"), uid) - return jsonify({"ok": True}) + tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control")) + response = jsonify({"ok": True, "access_token": tokens["access_token"]}) + set_refresh_cookie(response, tokens["refresh_token"]) + return response # ─── Recovery Codes ────────────────────────── @@ -977,6 +1038,20 @@ def admin_provision_passkey_complete(user_id): return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 +@api.route("/api/admin/kick_all_users", methods=["POST"]) +@admin_required +def admin_kick_all_users(): + """Root: kick EVERY user's control + display devices. All sessions end + immediately (including Root's own devices). High-risk → re-auth required. + """ + if not require_reauth(): + return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403 + bump_all_session_versions() # invalidates every access token + revoke_all_tokens() # invalidates every refresh token + current_app.event_bus.publish("kick_all", {"reason": "admin_kick_all"}) + return jsonify({"ok": True}) + + # ════════════════════════════════════════════ # DISPLAY PAGES # ════════════════════════════════════════════ @@ -984,11 +1059,13 @@ def admin_provision_passkey_complete(user_id): @api.route("/") @api.route("/index") def display_page(): + # 页面导航无法发送 Authorization header → 用 refresh cookie 鉴权。 + # custom 模式同样需要登录(避免匿名访问泄露账户自定义 HTML)。 + if not load_page_context(): + return redirect(url_for("api.login_page", next=request.path)) cfg = get_display_config(user_id=_cur()) if cfg.get("mode") == "custom": return render_template("display_custom.html", custom_html=cfg.get("custom_html", "")) - if not is_authenticated() and not is_temp_pending(): - return redirect(url_for("api.login_page", next=request.path)) return render_template("display.html") @@ -997,8 +1074,13 @@ def display_page(): # ════════════════════════════════════════════ @api.route("/control") -@login_required def control_page(): + """Control panel. Page navigation authenticates via the refresh cookie; + temp-pending users are sent to MFA setup.""" + if not load_page_context(): + return redirect(url_for("api.login_page", next="/control", view="control")) + if getattr(g, "temp_pending", False): + return redirect(url_for("api.mfa_setup_page")) return render_template("control.html") @@ -1006,12 +1088,49 @@ def control_page(): # DISPLAY / SSE / STATE # ════════════════════════════════════════════ +# One-time, short-lived tickets used to open the SSE stream (EventSource +# cannot send Authorization headers). The ticket is consumed on connect. +_SSE_TICKETS: dict = {} # ticket -> {"user_id": int, "view": str, "expires": float} +_SSE_TICKET_TTL = 60 + + +def _clean_sse_tickets(): + now = _time.time() + expired = [k for k, v in _SSE_TICKETS.items() if v["expires"] < now] + for k in expired: + _SSE_TICKETS.pop(k, None) + + +@api.route("/api/events/ticket", methods=["POST"]) +@display_login_required +def sse_ticket(): + """Issue a one-time ticket for opening the SSE stream.""" + _clean_sse_tickets() + from backend.models import get_user_session_version + ticket = secrets.token_urlsafe(24) + _SSE_TICKETS[ticket] = { + "user_id": _cur(), + "view": getattr(g, "auth_view", "display"), + "session_version": get_user_session_version(_cur()), + "expires": _time.time() + _SSE_TICKET_TTL, + } + return jsonify({"ticket": ticket}), 201 + + @api.route("/api/events") def sse_stream(): - """Server-Sent Events for real-time updates (scoped to the current user).""" - if not is_authenticated() and not is_temp_pending(): + """Server-Sent Events for real-time updates (scoped to the current user). + Authenticated via a one-time ticket from /api/events/ticket. + """ + ticket = request.args.get("ticket", "") + entry = _SSE_TICKETS.pop(ticket, None) if ticket else None + if not entry or entry["expires"] < _time.time(): return jsonify({"error": "Unauthorized", "login_required": True}), 401 - uid = _cur() + # A ticket is void once the user was kicked / deleted (version bumped). + from backend.models import get_user_session_version + if get_user_session_version(entry["user_id"]) != entry.get("session_version"): + return jsonify({"error": "Unauthorized", "login_required": True}), 401 + uid = entry["user_id"] def generate(): q = current_app.event_bus.subscribe(user_id=uid) @@ -1055,6 +1174,7 @@ def get_display_state(): update_schedule = weather_mgr.get_next_update_times(user_id=uid) minutely = weather_mgr.get_cached_minutely(user_id=uid) warnings = weather_mgr.get_cached_warnings(user_id=uid) + from backend.time_util import get_tz_str return jsonify({ "events": events, @@ -1067,6 +1187,7 @@ def get_display_state(): "update_schedule": update_schedule, "minutely": minutely, "warnings": warnings, + "timezone": get_tz_str(), }) @@ -1133,13 +1254,15 @@ def force_refresh_display(): @api.route("/api/display/kick_all", methods=["POST"]) -@admin_required -def kick_all_displays(): - """Kick ALL sessions (including all users) by bumping every user's version. - Old session cookies become invalid immediately. Also broadcasts SSE. +@login_required +def kick_own_devices(): + """Kick every device logged into the CURRENT account — control AND display + devices, including this control device itself. All sessions end immediately. """ - bump_all_session_versions() - current_app.event_bus.publish("kick_all", {}) + uid = _cur() + bump_user_session_version(uid) # invalidates every access token of this user + revoke_user_tokens(uid) # invalidates every refresh token of this user + current_app.event_bus.publish("kick_all", {"reason": "self_kick"}, user_id=uid) return jsonify({"ok": True}) diff --git a/backend/time_util.py b/backend/time_util.py index bcf4b32..ba3ab76 100644 --- a/backend/time_util.py +++ b/backend/time_util.py @@ -41,11 +41,11 @@ def get_thread_user(): def _ctx_user_id(): - """Resolve the current user id: request session first, then thread-local.""" + """Resolve the current user id: request auth context first, then thread-local.""" try: - from flask import has_request_context, session + from flask import has_request_context, g if has_request_context(): - uid = session.get("user_id") + uid = getattr(g, "user_id", None) if uid is not None: return int(uid) except Exception: diff --git a/backend/webauthn_mgr.py b/backend/webauthn_mgr.py index 97e9ec0..b72c76f 100644 --- a/backend/webauthn_mgr.py +++ b/backend/webauthn_mgr.py @@ -155,10 +155,10 @@ def complete_registration(request, credential: dict) -> dict: expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) if not expected_challenge: # Self-registration fallback: the browser may not echo the user handle, - # so resolve the target from the session user. + # so resolve the target from the authenticated request context. try: - from flask import session - uid = session.get("user_id") + from backend.auth import current_user_id + uid = current_user_id() if uid is not None: challenge_key = f"reg_{uid}" expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) diff --git a/frontend/static/js/control.js b/frontend/static/js/control.js index c5d5471..01f8110 100644 --- a/frontend/static/js/control.js +++ b/frontend/static/js/control.js @@ -16,29 +16,10 @@ }); // ─── Helpers ────────────────────────────── + // 统一请求封装:自动附加 Authorization: Bearer ,401 时自动刷新并重试, + // 刷新失败则清空 token 并跳转登录页(见 token.js)。 function api(method, url, data, callback) { - var xhr = new XMLHttpRequest(); - xhr.open(method, url); - if (data && !(data instanceof FormData)) { - xhr.setRequestHeader('Content-Type', 'application/json'); - } - xhr.onload = function () { - if (xhr.status === 401) { - var res = JSON.parse(xhr.responseText || '{}'); - if (res.login_required) { - location.href = '/login?view=control&next=' + encodeURIComponent(location.pathname + location.search); - return; - } - } - if (callback) callback(JSON.parse(xhr.responseText || '{}')); - }; - if (data instanceof FormData) { - xhr.send(data); - } else if (data) { - xhr.send(JSON.stringify(data)); - } else { - xhr.send(); - } + Auth.request(method, url, data, callback); } // HTML escape for untrusted data — prevents XSS in innerHTML @@ -542,14 +523,18 @@ $('kick-all-btn').addEventListener('click', function () { var msg = $('mode-msg'); - api('POST', '/api/display/kick_all', null, function (res) { - if (res.ok) { - msg.textContent = '已踢掉所有显示端'; - msg.style.color = '#2e7d32'; - } else { - msg.textContent = res.error || '操作失败'; - msg.style.color = '#E05555'; - } + showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () { + msg.textContent = '正在下线全部设备...'; + api('POST', '/api/display/kick_all', null, function (res) { + if (res.ok) { + // 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成 + msg.textContent = '已下线本账号全部设备,即将跳转登录页'; + msg.style.color = '#2e7d32'; + } else { + msg.textContent = res.error || '操作失败'; + msg.style.color = '#E05555'; + } + }); }); }); @@ -579,7 +564,7 @@ // ─── Logout ──────────────────────────────── $('logout-btn').addEventListener('click', function () { - api('POST', '/api/auth/logout', null, function () { + Auth.logout(function () { location.href = '/login?view=control'; }); }); @@ -1369,6 +1354,24 @@ $('user-refresh-btn').addEventListener('click', loadUsers); + // Root: kick every user's devices (control + display, all accounts) + var kickAllUsersBtn = $('kick-all-users-btn'); + if (kickAllUsersBtn) { + kickAllUsersBtn.addEventListener('click', function () { + showConfirm('踢掉所有用户', '所有用户的控制端与显示端将立即全部下线(包含当前设备),确定?', function () { + _reauthThen(function () { + api('POST', '/api/admin/kick_all_users', null, function (res) { + if (res.ok) { + toast('已踢掉所有用户,即将下线'); + } else { + toast(res.error || '操作失败'); + } + }); + }); + }); + }); + } + // ─── Init ───────────────────────────────── loadGroups(); updateEventGroupSelects(); diff --git a/frontend/static/js/display.js b/frontend/static/js/display.js index 838095e..435a49b 100644 --- a/frontend/static/js/display.js +++ b/frontend/static/js/display.js @@ -35,6 +35,7 @@ update_schedule: {}, _cdModalMode: false, // true = countdown modal, false = alarm modal _editingCountdown: null, + timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state) }; // ─── DOM refs ───────────────────────────── @@ -48,6 +49,48 @@ .replace(/"/g, '"').replace(/'/g, '''); } + // ─── 账户时区时间 ───────────────────────── + // 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断 + // 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。 + function _pad2(n) { return String(n).padStart(2, '0'); } + + function _tzNow() { + var tz = state.timezone || 'Asia/Shanghai'; + var out = { y: 1970, mo: 1, d: 1, h: 0, mi: 0, s: 0, wd: 0 }; + try { + var parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hourCycle: 'h23', weekday: 'short' + }).formatToParts(new Date()); + var wdMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + parts.forEach(function (p) { + switch (p.type) { + case 'year': out.y = parseInt(p.value, 10); break; + case 'month': out.mo = parseInt(p.value, 10); break; // 1-12 + case 'day': out.d = parseInt(p.value, 10); break; + case 'hour': out.h = parseInt(p.value, 10); break; + case 'minute': out.mi = parseInt(p.value, 10); break; + case 'second': out.s = parseInt(p.value, 10); break; + case 'weekday': out.wd = wdMap[p.value] || 0; break; + } + }); + } catch (e) { + var l = new Date(); + out = { y: l.getFullYear(), mo: l.getMonth() + 1, d: l.getDate(), + h: l.getHours(), mi: l.getMinutes(), s: l.getSeconds(), wd: l.getDay() }; + } + return out; + } + + // 生成 "YYYYMMDDTHHMMSS" 比较键(与后端本地时间字符串可比) + function _nowLocalKey() { + var n = _tzNow(); + return String(n.y) + _pad2(n.mo) + _pad2(n.d) + 'T' + + _pad2(n.h) + _pad2(n.mi) + _pad2(n.s); + } + // ─── Panel switching ────────────────────── function switchPanel(name) { if (state.alarmActive && name !== 'alarm') return; // lock during alarm @@ -74,36 +117,36 @@ // ─── Data fetching ──────────────────────── function fetchState() { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/display/state'); - xhr.onload = function () { - if (xhr.status === 401) { - var res = JSON.parse(xhr.responseText || '{}'); - if (res.login_required) { - location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - return; - } - } - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - state.weather = data.weather || {}; - state.hourly = data.hourly || []; - state.events = data.events || []; - state.alarms = data.alarms || []; - state.countdowns = data.countdowns || []; - state.update_schedule = data.update_schedule || {}; - state.minutely = data.minutely || null; - state.warnings = data.warnings || null; - state.groups = data.groups || []; - renderAll(); - } - }; - xhr.send(); + Auth.request('GET', '/api/display/state', null, function (data, status) { + if (status !== 200) return; + state.weather = data.weather || {}; + state.hourly = data.hourly || []; + state.events = data.events || []; + state.alarms = data.alarms || []; + state.countdowns = data.countdowns || []; + state.update_schedule = data.update_schedule || {}; + state.minutely = data.minutely || null; + state.warnings = data.warnings || null; + state.groups = data.groups || []; + state.timezone = data.timezone || state.timezone || 'Asia/Shanghai'; + renderAll(); + }); } // ─── SSE for real-time events ───────────── function connectSSE() { - var es = new EventSource('/api/events'); + // EventSource 不能带 Authorization header → 先换一次性 ticket + Auth.request('POST', '/api/events/ticket', null, function (res, status) { + if (status !== 201 || !res.ticket) { + setTimeout(connectSSE, 5000); + return; + } + openSSE('/api/events?ticket=' + encodeURIComponent(res.ticket)); + }); + } + + function openSSE(url) { + var es = new EventSource(url); es.onmessage = function (e) { try { var msg = JSON.parse(e.data); @@ -112,24 +155,19 @@ }; es.onerror = function () { es.close(); + clearInterval(state._sseGuard); setTimeout(connectSSE, 5000); }; // SSE doesn't give us status codes; if auth fails the browser will error-loop. // We add a small guard: if we get no messages for 30s, try a normal fetch to check auth. state._sseGuard = setInterval(function () { - var g = new XMLHttpRequest(); - g.open('GET', '/api/display/state'); - g.onload = function () { - if (g.status === 401) { - var res = JSON.parse(g.responseText || '{}'); - if (res.login_required) { - es.close(); - clearInterval(state._sseGuard); - location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - } + Auth.request('GET', '/api/display/state', null, function (data, st) { + if (st === 401) { + es.close(); + clearInterval(state._sseGuard); + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); } - }; - g.send(); + }); }, 30000); } @@ -175,12 +213,9 @@ // 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画) if (window.ThemeBridge) ThemeBridge.fetch(); } else if (msg.type === 'kick_all') { - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/logout'); - xhr.onload = function () { + Auth.logout(function () { location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - }; - xhr.send(); + }); } } @@ -401,20 +436,9 @@ // ─── Alarm CRUD ─────────────────────────── function apiJson(method, url, data, callback) { - var xhr = new XMLHttpRequest(); - xhr.open(method, url); - if (data) { xhr.setRequestHeader('Content-Type', 'application/json'); } - xhr.onload = function () { - if (xhr.status === 401) { - var res = JSON.parse(xhr.responseText || '{}'); - if (res.login_required) { - location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - return; - } - } - if (callback) callback(JSON.parse(xhr.responseText || '{}')); - }; - xhr.send(data ? JSON.stringify(data) : null); + Auth.request(method, url, data, function (res, status) { + if (callback) callback(res, status); + }); } function deleteAlarm(id) { @@ -841,12 +865,9 @@ var lockBtn = $('lock-btn'); if (lockBtn) { lockBtn.addEventListener('click', function () { - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/logout'); - xhr.onload = function () { + Auth.logout(function () { location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - }; - xhr.send(); + }); }); } @@ -943,34 +964,19 @@ // ─── Pomodoro controls (reuse /control endpoints) ───────────── function pomodoroApi(action) { - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/pomodoro/' + action); - xhr.onload = function () { - if (xhr.status === 401) { - var res = JSON.parse(xhr.responseText || '{}'); - if (res.login_required) { - location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); - return; - } - } + Auth.request('POST', '/api/pomodoro/' + action, null, function () { fetchState(); - }; - xhr.send(); + }); } function fetchPomodoro() { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/pomodoro'); - xhr.onload = function () { - if (xhr.status === 200) { - try { - state.pomodoro = JSON.parse(xhr.responseText); - renderPomodoro(); - renderPomodoroCard(); - } catch (_) {} + Auth.request('GET', '/api/pomodoro', null, function (data, status) { + if (status === 200) { + state.pomodoro = data || {}; + renderPomodoro(); + renderPomodoroCard(); } - }; - xhr.send(); + }); } // Card pomodoro buttons (in sidebar card, not the panel) @@ -1004,10 +1010,8 @@ } function renderTimeCard() { - var now = new Date(); - var h = String(now.getHours()).padStart(2, '0'); - var m = String(now.getMinutes()).padStart(2, '0'); - $('time-value').textContent = h + ':' + m; + var n = _tzNow(); + $('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi); } function renderWeatherCard() { @@ -1026,8 +1030,7 @@ } function renderCalendarCard() { - var now = new Date(); - var nowStr = formatDateTimeLocal(now); + var nowStr = _nowLocalKey(); var upcoming = null; for (var i = 0; i < state.events.length; i++) { var ev = state.events[i]; @@ -1052,19 +1055,10 @@ return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13); } - function formatDateTimeLocal(d) { - return d.getFullYear() + - String(d.getMonth() + 1).padStart(2, '0') + - String(d.getDate()).padStart(2, '0') + 'T' + - String(d.getHours()).padStart(2, '0') + - String(d.getMinutes()).padStart(2, '0') + - String(d.getSeconds()).padStart(2, '0'); - } - function renderAlarmCard() { - var now = new Date(); - var currentMinutes = now.getHours() * 60 + now.getMinutes(); - var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay()); + var n = _tzNow(); + var currentMinutes = n.h * 60 + n.mi; + var todayWeekday = String(n.wd === 0 ? 7 : n.wd); var upcoming = null; var upcomingDiff = null; @@ -1208,15 +1202,12 @@ } function renderTimePanel() { - var now = new Date(); + var n = _tzNow(); $('detailed-time').textContent = - String(now.getHours()).padStart(2, '0') + ':' + - String(now.getMinutes()).padStart(2, '0') + ':' + - String(now.getSeconds()).padStart(2, '0'); + _pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s); var days = ['日', '一', '二', '三', '四', '五', '六']; $('date-str').textContent = - now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()]; - + n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd]; } function renderWeatherPanel() { @@ -1268,8 +1259,9 @@ return; } var html = ''; - var nowHour = new Date().getHours(); - var nowMin = new Date().getMinutes(); + var n = _tzNow(); + var nowHour = n.h; + var nowMin = n.mi; hourly.forEach(function (h) { var hh = parseInt(h.time.split(':')[0], 10); var hm = parseInt(h.time.split(':')[1], 10); @@ -1376,49 +1368,16 @@ function _renderWeatherUpdateInfo() { var s = state.update_schedule || {}; - var wUpdated = s.weather_updated_at || ''; - - // Compute next update times on the frontend from current local time - var now = new Date(); - var m = now.getMinutes(); - - // Next weather: next :00 or :30 - var nextW = new Date(now); - if (m < 30) { nextW.setMinutes(30, 0, 0); } - else { nextW.setHours(now.getHours() + 1, 0, 0, 0); } - - // Next hourly: next :01 - var nextH = new Date(now); - if (m < 1) { nextH.setMinutes(1, 0, 0); } - else { nextH.setHours(now.getHours() + 1, 1, 0, 0); } - + // 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析) var fmt = function (d) { if (!d) return '--'; - // Server sends UTC datetime — parse as UTC, display in local timezone - var s = String(d).replace('T', ' '); - // Safari needs 'T' not space for ISO parsing - var utcDate = new Date(String(d).replace(' ', 'T') + 'Z'); - if (!isNaN(utcDate.getTime())) { - var pad = function (n) { return String(n).padStart(2, '0'); }; - return utcDate.getFullYear() + '-' + pad(utcDate.getMonth() + 1) + '-' + pad(utcDate.getDate()) + - ' ' + pad(utcDate.getHours()) + ':' + pad(utcDate.getMinutes()); - } - // Fallback to raw string - return s.substring(0, 16); - }; - - var fmtDate = function (d) { - return String(d.getFullYear()) + '-' + - String(d.getMonth() + 1).padStart(2, '0') + '-' + - String(d.getDate()).padStart(2, '0') + ' ' + - String(d.getHours()).padStart(2, '0') + ':' + - String(d.getMinutes()).padStart(2, '0'); + return String(d).replace('T', ' ').substring(0, 16); }; var html = '
' + - '
实况更新: ' + fmt(wUpdated) + - ' 下次: ' + fmtDate(nextW) + '
' + - '
逐时下次: ' + fmtDate(nextH) + '
' + + '
实况更新: ' + fmt(s.weather_updated_at) + + ' 下次: ' + fmt(s.weather_next_update) + '
' + + '
逐时下次: ' + fmt(s.hourly_next_update) + '
' + '
'; // Append after hourly list @@ -1443,9 +1402,12 @@ if (!groups[dk]) groups[dk] = []; groups[dk].push(ev); }); - var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, ''); - var t = new Date(); t.setDate(t.getDate() + 1); - var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, ''); + var n = _tzNow(); + var todayKey = String(n.y) + _pad2(n.mo) + _pad2(n.d); + // 明天:基于账户时区的年月日 +1 天(跨月/跨年交给 Date 处理) + var t = new Date(); + t.setFullYear(n.y, n.mo - 1, n.d + 1); + var tomorrowKey = String(t.getFullYear()) + _pad2(t.getMonth() + 1) + _pad2(t.getDate()); var html = ''; Object.keys(groups).sort().forEach(function (dk) { var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' : @@ -1579,8 +1541,7 @@ } // When weather panel is open, check if hourly needs refresh (past :01, stale) if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') { - var now = new Date(); - var min = now.getMinutes(); + var min = _tzNow().mi; var s = state.update_schedule || {}; // If it's past :05 and the hourly was likely last fetched before this hour if (min > 5) { diff --git a/frontend/static/js/theme.js b/frontend/static/js/theme.js index 9e703b4..43224b8 100644 --- a/frontend/static/js/theme.js +++ b/frontend/static/js/theme.js @@ -26,18 +26,11 @@ } function fetchTheme(cb) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/theme'); - xhr.onload = function () { - if (xhr.status === 200) { - var d = {}; - try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {} - if (d.effective) apply(d.effective); - if (cb) cb(d); - } - }; - xhr.onerror = function () { /* 网络失败保持当前主题 */ }; - xhr.send(); + // noRedirect: 登录页/未登录时 401 静默失败(保持默认主题),绝不跳转造成循环 + Auth.request('GET', '/api/theme', null, function (d, status) { + if (status === 200 && d.effective) apply(d.effective); + if (cb) cb(d); + }, { noRedirect: true }); } function initTheme() { diff --git a/frontend/static/js/token.js b/frontend/static/js/token.js new file mode 100644 index 0000000..56a3bcf --- /dev/null +++ b/frontend/static/js/token.js @@ -0,0 +1,160 @@ +/* token.js — 双 Token 认证助手 + * + * - access token:存 localStorage(短期),每次请求以 `Authorization: Bearer` 发送 + * - refresh token:由服务端放在 httpOnly cookie 里,前端不可读;过期时 + * POST /api/auth/refresh 换取新 access(服务端同时轮换 refresh cookie) + * - 401 处理:并发 401 共享一次刷新(single-flight),刷新成功后重试一次原请求; + * 刷新失败则清空本地 token 并跳转登录页 + * - SSE(EventSource 无法带自定义 header)通过一次性 ticket 建立,见 display.js + */ +(function (global) { + 'use strict'; + + var KEY = 'dashboard_access_token'; + + function getAccess() { + try { return localStorage.getItem(KEY) || ''; } catch (e) { return ''; } + } + + function setAccess(t) { + try { + if (t) { localStorage.setItem(KEY, t); } else { localStorage.removeItem(KEY); } + } catch (e) {} + } + + function clear() { setAccess(''); } + + function _detectView() { + var p = location.pathname; + if (p.indexOf('/control') === 0 || p.indexOf('/mfa-setup') === 0) return 'control'; + return 'display'; + } + + function loginRedirect(view) { + clear(); + var next = encodeURIComponent(location.pathname + location.search); + location.href = '/login?view=' + (view || _detectView()) + '&next=' + next; + } + + // 并发 401 共享同一次刷新(避免同时打多个 refresh 导致 token 轮换竞争) + var _refreshing = null; + function refresh() { + if (!_refreshing) { + _refreshing = new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/refresh'); + xhr.onload = function () { + if (xhr.status === 200) { + try { + var d = JSON.parse(xhr.responseText || '{}'); + if (d.access_token) { setAccess(d.access_token); resolve(d.access_token); return; } + } catch (e) {} + } + reject(new Error('refresh-failed')); + }; + xhr.onerror = function () { reject(new Error('network')); }; + xhr.send(); + }); + _refreshing.then(function () { _refreshing = null; }, + function () { _refreshing = null; }); + } + return _refreshing; + } + + /** + * 统一请求封装(XHR,回调风格) + * request(method, url, data, callback, opts) + * - data: 对象或 FormData;null/undefined 表示无 body + * - opts.view: 跳转登录页时使用的 view(默认按当前路径推断) + * - callback(res, status): 收到响应(含 401 重试后的最终结果) + */ + function request(method, url, data, callback, opts) { + opts = opts || {}; + var view = opts.view || _detectView(); + function doSend(access, retried) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access); + if (data && !(data instanceof FormData)) { + xhr.setRequestHeader('Content-Type', 'application/json'); + } + xhr.onload = function () { + var res = {}; + try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) {} + if (xhr.status === 401) { + if (!retried) { + var fail = function () { + if (opts.noRedirect) { if (callback) callback(res, xhr.status); return; } + // 多 tab 竞态:另一个 tab 可能刚轮换过共享的 refresh cookie, + // 短暂延迟后用新 cookie 再试一次,仍失败才判定会话失效。 + setTimeout(function () { + refresh().then(function () { + doSend(getAccess(), true); + }, function () { + loginRedirect(view); + }); + }, 300); + }; + refresh().then(function () { + doSend(getAccess(), true); + }, fail); + return; + } + // 刷新后重试仍 401 → 会话确实失效(如已被踢),跳转登录页 + if (!opts.noRedirect) { + loginRedirect(view); + return; + } + if (callback) callback(res, xhr.status); + return; + } + if (callback) callback(res, xhr.status); + }; + xhr.onerror = function () { if (callback) callback({}, 0); }; + if (data instanceof FormData) xhr.send(data); + else if (data) xhr.send(JSON.stringify(data)); + else xhr.send(); + } + doSend(getAccess(), false); + } + + /** Promise 风格的 fetch 封装(带 Authorization,用于登录页/Passkey 桥等) */ + function fetchWithAuth(url, options) { + options = options || {}; + var access = getAccess(); + var headers = Object.assign({}, options.headers || {}); + if (access && !headers['Authorization']) headers['Authorization'] = 'Bearer ' + access; + return fetch(url, Object.assign({}, options, { + headers: headers, + credentials: 'same-origin', + })); + } + + /** 主动登出:清本地 access + 通知服务端吊销 refresh cookie(同时吊销本 access) */ + function logout(cb) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/logout'); + var access = getAccess(); + if (access) xhr.setRequestHeader('Authorization', 'Bearer ' + access); + xhr.onload = function () { + clear(); + if (cb) cb(); + }; + xhr.onerror = function () { + clear(); + if (cb) cb(); + }; + xhr.send(); + } + + global.Auth = { + getAccess: getAccess, + setAccess: setAccess, + clear: clear, + refresh: refresh, + request: request, + fetchWithAuth: fetchWithAuth, + logout: logout, + loginRedirect: loginRedirect, + }; +})(window); diff --git a/frontend/templates/control.html b/frontend/templates/control.html index 9077f42..0fad2d9 100644 --- a/frontend/templates/control.html +++ b/frontend/templates/control.html @@ -258,7 +258,9 @@ + +

🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。

@@ -308,8 +310,9 @@
- +
+
包含当前控制端与所有显示端,全部立即下线。
@@ -388,6 +391,7 @@
+ diff --git a/frontend/templates/display.html b/frontend/templates/display.html index b35f2c6..30f7d5a 100644 --- a/frontend/templates/display.html +++ b/frontend/templates/display.html @@ -242,6 +242,7 @@ + diff --git a/frontend/templates/display_custom.html b/frontend/templates/display_custom.html index 4fca2bf..35b74e7 100644 --- a/frontend/templates/display_custom.html +++ b/frontend/templates/display_custom.html @@ -75,6 +75,7 @@ + @@ -227,17 +228,15 @@ } // ─── Auth status check ────────────────── - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/auth/status'); - xhr.onload = function () { - if (xhr.status === 200) { - var status = JSON.parse(xhr.responseText); + // 携带本地 access token 检查(token 有效则直接放行,避免重复登录) + Auth.fetchWithAuth('/api/auth/status') + .then(function (r) { return r.json(); }) + .then(function (status) { if (status.authenticated) { _redirect(redirect); } - } - }; - xhr.send(); + }) + .catch(function () {}); // ─── TOTP dots ────────────────────────── function updateDots(ok) { @@ -259,6 +258,7 @@ xhr.onload = function () { if (xhr.status === 200) { var res = JSON.parse(xhr.responseText || '{}'); + if (res.access_token) Auth.setAccess(res.access_token); if (res.method === 'temp_password' || res.must_configure_mfa) { _redirect('/mfa-setup'); } else { @@ -317,6 +317,7 @@ verify.onload = function () { var res = JSON.parse(verify.responseText || '{}'); if (verify.status === 200) { + if (res.access_token) Auth.setAccess(res.access_token); msgEl.textContent = '✅ 登录成功!'; setTimeout(function () { _redirect(redirect); }, 300); } else { @@ -368,6 +369,7 @@ xhr.onload = function () { var res = JSON.parse(xhr.responseText || '{}'); if (xhr.status === 200) { + if (res.access_token) Auth.setAccess(res.access_token); _redirect(redirect); } else { msgEl.textContent = res.error || '恢复码错误'; @@ -383,32 +385,6 @@ } }); - // ─── SSE background listener ──────────── - (function () { - function connectSSE() { - var es = new EventSource('/api/events'); - es.onmessage = function (e) { - try { - var msg = JSON.parse(e.data); - if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') { - location.href = '/'; - } else if (msg.type === 'kick_all') { - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/logout'); - xhr.onload = function () { location.reload(); }; - xhr.send(); - } - } catch (_) {} - }; - es.onerror = function () { - es.close(); - setTimeout(connectSSE, 5000); - }; - } - - connectSSE(); - })(); - // ─── Helpers ──────────────────────────── // Convert base64url (from server/webauthn) to ArrayBuffer function _base64ToArrayBuffer(b64) { diff --git a/frontend/templates/login_control.html b/frontend/templates/login_control.html index 033a9da..6f8bcf2 100644 --- a/frontend/templates/login_control.html +++ b/frontend/templates/login_control.html @@ -172,6 +172,7 @@ } .divider::before, .divider::after { background: var(--divider); } + @@ -247,12 +248,10 @@ }); function checkStatus(callback) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/auth/status'); - xhr.onload = function () { - if (xhr.status === 200) callback(JSON.parse(xhr.responseText)); - }; - xhr.send(); + Auth.fetchWithAuth('/api/auth/status') + .then(function (r) { return r.json(); }) + .then(function (data) { callback(data); }) + .catch(function () { callback({}); }); } function showLoginForm(mfaConfigured) { @@ -280,6 +279,7 @@ xhr.onload = function () { var res = JSON.parse(xhr.responseText || '{}'); if (xhr.status === 200) { + if (res.access_token) Auth.setAccess(res.access_token); if (res.method === 'temp_password' || res.must_configure_mfa) { _redirect('/mfa-setup'); } else { @@ -358,6 +358,7 @@ verify.onload = function () { var res = JSON.parse(verify.responseText || '{}'); if (verify.status === 200) { + if (res.access_token) Auth.setAccess(res.access_token); statusEl.textContent = '✅ 登录成功!'; _redirect(redirect); } else { diff --git a/frontend/templates/mfa_setup.html b/frontend/templates/mfa_setup.html index 6fbfa87..36561be 100644 --- a/frontend/templates/mfa_setup.html +++ b/frontend/templates/mfa_setup.html @@ -141,6 +141,7 @@ .theme-light .badge-done { background: #e8f5e9; color: #2e7d32; } .theme-light .badge-pending { background: #fff3e0; color: #e65100; } + @@ -207,29 +208,21 @@ // ─── Check MFA status ────────────────── function checkMFAStatus() { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/auth/mfa/status'); - xhr.onload = function () { - if (xhr.status === 200) { - var s = JSON.parse(xhr.responseText); - // Temp accounts must configure ONE login method to activate the account. - if (s.is_temp_account) { - document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号'; - document.querySelector('.subtitle').textContent = - '请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。'; - } - if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); } - if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); } - if (s.recovery_codes_remaining > 0) { - showRecoveryCodes(); - } - updateFinishBtn(); - } else if (xhr.status === 401) { - // Not logged in — redirect to login - location.href = '/login?view=control&next=/mfa-setup'; + Auth.request('GET', '/api/auth/mfa/status', null, function (s, status) { + if (status !== 200) return; + // Temp accounts must configure ONE login method to activate the account. + if (s.is_temp_account) { + document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号'; + document.querySelector('.subtitle').textContent = + '请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。'; } - }; - xhr.send(); + if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); } + if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); } + if (s.recovery_codes_remaining > 0) { + showRecoveryCodes(); + } + updateFinishBtn(); + }); } checkMFAStatus(); @@ -251,33 +244,26 @@ btn.disabled = true; statusEl.textContent = '正在准备注册...'; - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/webauthn/register/begin'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - if (xhr.status !== 200) { + Auth.request('POST', '/api/auth/webauthn/register/begin', null, function (options, st) { + if (st !== 200) { statusEl.textContent = '❌ 获取注册参数失败'; btn.disabled = false; return; } - var options = JSON.parse(xhr.responseText); // Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController PasskeyBridge.creation(options) .then(function (credential) { credential.user_name = options.user && options.user.name ? options.user.name : 'user'; - var verify = new XMLHttpRequest(); - verify.open('POST', '/api/auth/webauthn/register/complete'); - verify.setRequestHeader('Content-Type', 'application/json'); - verify.onload = function () { - var res = JSON.parse(verify.responseText || '{}'); - if (verify.status === 200) { + Auth.request('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res, st2) { + if (st2 === 200) { statusEl.textContent = '✅ Passkey 注册成功!'; passkeyDone = true; updatePasskeyUI(true); checkRecoveryAndFinish(); if (res.finalized) { + if (res.access_token) Auth.setAccess(res.access_token); statusEl.textContent = '✅ 账号已激活!即将进入控制面板...'; setTimeout(function () { location.href = '/'; }, 2500); } @@ -285,15 +271,13 @@ statusEl.textContent = '❌ ' + (res.error || '注册失败'); btn.disabled = false; } - }; - verify.send(JSON.stringify({ credential: credential })); + }); }) .catch(function (err) { statusEl.textContent = '⏸️ ' + (err.message || '操作取消'); btn.disabled = false; }); - }; - xhr.send(); + }); }); // ─── TOTP ────────────────────────────── @@ -318,16 +302,11 @@ document.getElementById('setup-totp-btn').textContent = '取消'; // Request TOTP setup from server - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/totp/setup'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - if (xhr.status !== 200) return; - var data = JSON.parse(xhr.responseText); + Auth.request('POST', '/api/auth/totp/setup', null, function (data, status) { + if (status !== 200) return; document.getElementById('totp-qr-wrap').innerHTML = 'TOTP QR Code'; document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret; - }; - xhr.send(); + }); }); document.getElementById('totp-verify-btn').addEventListener('click', function () { @@ -335,12 +314,8 @@ var msg = document.getElementById('totp-msg'); msg.textContent = ''; - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/totp/verify'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - var res = JSON.parse(xhr.responseText || '{}'); - if (xhr.status === 200) { + Auth.request('POST', '/api/auth/totp/verify', { code: code }, function (res, st) { + if (st === 200) { msg.className = 'msg success'; msg.textContent = '✅ TOTP 已启用!'; totpDone = true; @@ -354,14 +329,14 @@ } checkRecoveryAndFinish(); if (res.finalized) { + if (res.access_token) Auth.setAccess(res.access_token); msg.textContent = '✅ 账号已激活!即将进入控制面板...'; setTimeout(function () { location.href = '/'; }, 2500); } } else { msg.textContent = res.error || '验证失败'; } - }; - xhr.send(JSON.stringify({ code: code })); + }); }); // ─── Recovery codes ──────────────────── @@ -373,17 +348,11 @@ } function showRecoveryCodes() { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/auth/recovery-codes'); - xhr.onload = function () { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - if (data.codes && data.codes.length > 0) { - showRecoveryCodesList(data.codes); - } + Auth.request('GET', '/api/auth/recovery-codes', null, function (data, status) { + if (status === 200 && data.codes && data.codes.length > 0) { + showRecoveryCodesList(data.codes); } - }; - xhr.send(); + }); } function checkRecoveryAndFinish() { @@ -417,18 +386,14 @@ document.getElementById('finish-btn').addEventListener('click', function () { var msg = document.getElementById('finish-msg'); msg.textContent = '正在完成设置...'; - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/auth/mfa/complete'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - if (xhr.status === 200) { + Auth.request('POST', '/api/auth/mfa/complete', null, function (res, status) { + if (status === 200) { + if (res.access_token) Auth.setAccess(res.access_token); location.href = '/'; } else { - var res = JSON.parse(xhr.responseText || '{}'); msg.textContent = res.error || '完成失败,请重试'; } - }; - xhr.send(); + }); }); // ─── Helpers ──────────────────────────── diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index 068c2f6..f1e1c1a 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -1,15 +1,18 @@ -"""Multi-user system test suite. +"""Multi-user system test suite (dual-token auth). Run: python3 tests/test_multi_user.py -Covers: Root bootstrap, legacy/new-DB migration, admin registration (both modes), -temp-account lifecycle, data isolation, admin-only enforcement, re-auth -(TOTP + passkey fallback), deletion, kick-all, SSE scoping, rate limits, CSRF. +Covers: Root bootstrap, legacy/new-DB migration + schema-version gate, +admin registration (both modes), temp-account lifecycle, data isolation, +admin-only enforcement, re-auth (TOTP + passkey fallback), deletion, +kick devices (self account + Root kick-all), access/refresh token lifecycle, +SSE ticket scoping, rate limits, CSRF, per-account timezone/theme. """ import base64 import io import json import os import shutil +import sqlite3 import sys import tempfile import time @@ -21,17 +24,55 @@ import pyotp from backend.app import create_app from backend.models import ( - init_db, _EMITTED_PASSWORDS, get_auth_config, get_temp_password, - get_users, get_user, is_temp_account, get_temp_totp_secret, - get_active_user_ids, + init_db, _EMITTED_PASSWORDS, DB_VERSION, _read_db_version, get_db, + get_auth_config, get_temp_password, get_users, get_user, + is_temp_account, get_temp_totp_secret, get_active_user_ids, + create_user, update_auth_config as uac, ) from backend.events import EventBus PASS, FAIL = 0, 0 +class TClient: + """Flask test client wrapper that attaches the access token to each request. + Refresh-token cookies are managed automatically by the underlying client.""" + + def __init__(self, client, access=None): + self._c = client + self.access = access + + def set_access(self, access): + self.access = access + + def _h(self, headers): + headers = dict(headers or {}) + if self.access and "Authorization" not in headers: + headers["Authorization"] = "Bearer " + self.access + return headers + + def get(self, *a, **kw): + kw["headers"] = self._h(kw.pop("headers", None)) + return self._c.get(*a, **kw) + + def post(self, *a, **kw): + kw["headers"] = self._h(kw.pop("headers", None)) + return self._c.post(*a, **kw) + + def put(self, *a, **kw): + kw["headers"] = self._h(kw.pop("headers", None)) + return self._c.put(*a, **kw) + + def delete(self, *a, **kw): + kw["headers"] = self._h(kw.pop("headers", None)) + return self._c.delete(*a, **kw) + + def patch(self, *a, **kw): + kw["headers"] = self._h(kw.pop("headers", None)) + return self._c.patch(*a, **kw) + + def _groups_for(db): - import sqlite3 conn = sqlite3.connect(db) n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0] conn.close() @@ -39,7 +80,6 @@ def _groups_for(db): def _count(db, table, user_id): - import sqlite3 conn = sqlite3.connect(db) n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0] conn.close() @@ -62,34 +102,40 @@ def fresh_app(tmp): return app, db -def login_temp(app, code, view="control"): - return app.test_client().post("/api/auth/login", json={"code": code, "view": view}) +def do_login(client, code, view="control"): + """Login on a raw test client; returns (TClient-with-access, login_response).""" + r = client.post("/api/auth/login", json={"code": code, "view": view}) + assert r.status_code == 200, r.get_json() + d = r.get_json() + return TClient(client, access=d.get("access_token")), d def setup_totp(client, secret=None): - """Complete the temp→TOTP MFA setup flow for the current temp-pending session.""" + """Complete the temp→TOTP MFA setup flow for the current temp-pending session. + `client` may be a TClient (its access token is updated after finalize).""" r = client.post("/api/auth/totp/setup") if r.status_code != 200: return None, r secret = r.get_json()["secret"] code = pyotp.TOTP(secret).now() r2 = client.post("/api/auth/totp/verify", json={"code": code}) - client.post("/api/auth/mfa/complete") + r3 = client.post("/api/auth/mfa/complete") + if hasattr(client, "set_access") and r3.status_code == 200 and r3.get_json().get("access_token"): + client.set_access(r3.get_json()["access_token"]) return secret, r2 def root_client(app, db, setup_mfa=True): - """Return a fully-authenticated Root test client (TOTP configured).""" + """Return a fully-authenticated Root TClient (TOTP configured).""" c = app.test_client() pw = get_temp_password(db, 0) - r = c.post("/api/auth/login", json={"code": pw, "view": "control"}) - assert r.status_code == 200, r.get_json() + tc, _ = do_login(c, pw) if setup_mfa: - secret, _ = setup_totp(c) + secret, _ = setup_totp(tc) assert secret, "root TOTP setup failed" - st = c.get("/api/auth/status").get_json() + st = tc.get("/api/auth/status").get_json() assert st["authenticated"], st - return c + return tc def reauth_totp(client, secret): @@ -113,10 +159,11 @@ root = get_user(0, db) check("Root role is admin", root["role"] == "admin" and root["remark"] == "Root") pw = get_temp_password(db, 0) check("Root temp password generated", bool(pw)) -r = login_temp(app, pw) +r = app.test_client().post("/api/auth/login", json={"code": pw, "view": "control"}) check("Temp-password login → temp_pending", r.status_code == 200 and r.get_json().get("must_configure_mfa")) -c = app.test_client() -c.post("/api/auth/login", json={"code": pw, "view": "control"}) +check("Login returns access token", bool(r.get_json().get("access_token"))) +check("Refresh cookie set", "refresh_token" in r.headers.get("Set-Cookie", "")) +c, _ = do_login(app.test_client(), pw) st = c.get("/api/auth/status").get_json() check("Status reports temp_pending", st["temp_pending"] is True) secret, _ = setup_totp(c) @@ -125,12 +172,15 @@ check("Root fully authenticated after TOTP setup", st["authenticated"] is True) check("Root is admin in status", st["user"]["is_admin"] is True) check("Root remark visible (not id)", st["user"]["remark"] == "Root") # TOTP login path -c2 = app.test_client() -r = c2.post("/api/auth/login", json={"code": pyotp.TOTP(secret).now(), "view": "control"}) -check("Root TOTP login works", r.status_code == 200 and not r.get_json().get("must_configure_mfa")) +c2, _ = do_login(app.test_client(), pyotp.TOTP(secret).now()) +check("Root TOTP login works", bool(c2.access) + and not c2.get("/api/auth/status").get_json().get("temp_pending")) # wrong code -r = c2.post("/api/auth/login", json={"code": "000000", "view": "control"}) +r = app.test_client().post("/api/auth/login", json={"code": "000000", "view": "control"}) check("Wrong code rejected", r.status_code == 401) +# no/bad token → 401 +check("No token → 401", app.test_client().get("/api/theme").status_code == 401) +check("Bad token → 401", app.test_client().get("/api/theme", headers={"Authorization": "Bearer nope"}).status_code == 401) print() print("=" * 60) @@ -138,8 +188,7 @@ print("TEST 2: Legacy single-user DB migration → data becomes Root's") print("=" * 60) # Build a legacy single-user database (old schema: no users/user_id, pin-only auth) legacy = os.path.join(tmp, "legacy.db") -import sqlite3 as _sq -_lc = _sq.connect(legacy) +_lc = sqlite3.connect(legacy) _lc.executescript(""" CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now'))); CREATE TABLE calendar_sources (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, url TEXT, ics_data TEXT, last_sync TEXT); @@ -164,45 +213,73 @@ check("Root created after legacy migration", get_user(0, legacy) is not None) check("Legacy calendar groups → Root (user 0)", _groups_for(legacy) >= 3) check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1) check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0))) -import sqlite3 conn = sqlite3.connect(legacy) tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")] conn.close() check("camera_config table dropped", "camera_config" not in tables) check("users table exists", "users" in tables) +check("meta table exists", "meta" in tables) +check("auth_tokens table exists", "auth_tokens" in tables) +check("Legacy DB stamped with program version", _read_db_version(get_db(legacy)) == DB_VERSION) +get_db(legacy).close() print() print("=" * 60) -print("TEST 3: Round-trip migration (new schema → new schema)") +print("TEST 3: Schema-version gate (same→skip, newer→abort, migrate→stamp)") print("=" * 60) db3 = os.path.join(tmp, "roundtrip.db") init_db(db3) -_EMITTED_PASSWORDS.clear() -uid = __import__("backend.models", fromlist=["create_user"]).create_user("Round Trip", db_path=db3) -import sqlite3 conn = sqlite3.connect(db3) +mtime1 = os.stat(db3).st_mtime_ns +conn.close() +init_db(db3) +check("Same-version init skips rebuild", os.stat(db3).st_mtime_ns == mtime1) +conn = get_db(db3) +check("Version still matches", _read_db_version(conn) == DB_VERSION) +conn.close() + +# user data preserved across a version-mismatch migration (simulate future bump) +db3b = os.path.join(tmp, "migrate_up.db") +init_db(db3b) +uid = create_user("Round Trip", db_path=db3b) +conn = sqlite3.connect(db3b) conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,)) conn.commit() conn.close() -init_db(db3) -users = get_users(db3) -check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users)) -conn = sqlite3.connect(db3) -n = conn.execute("SELECT COUNT(*) FROM alarms WHERE user_id=? AND label='test'", (uid,)).fetchone()[0] +# simulate an old DB (version 0 → migration path) by rewriting meta +conn = sqlite3.connect(db3b) +conn.execute("UPDATE meta SET version=0 WHERE id=1") +conn.commit() conn.close() -check("User data preserved through migration", n == 1) +init_db(db3b) +users = get_users(db3b) +check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users)) +check("User data preserved through migration", _count(db3b, "alarms", uid) == 1) +check("Migrated DB re-stamped to program version", _read_db_version(get_db(db3b)) == DB_VERSION) +get_db(db3b).close() + +# a DB newer than the program → abort with SystemExit +newer = os.path.join(tmp, "newer.db") +init_db(newer) +conn = sqlite3.connect(newer) +conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1") +conn.commit() +conn.close() +try: + init_db(newer) + check("Newer DB aborts startup", False, "no SystemExit raised") +except SystemExit: + check("Newer DB aborts startup", True) print() print("=" * 60) print("TEST 4: MFA-configured DB migration → no new temp password") print("=" * 60) db4 = os.path.join(tmp, "mfa.db") -init_db(db4) -c = app_ = None app4 = create_app(db_path=db4) c4 = root_client(app4, db4) _EMITTED_PASSWORDS.clear() -init_db(db4) # migrate +init_db(db4) # same version → fast path, defaults intact check("No temp password for MFA-configured root", get_temp_password(db4, 0) == "") cfg = get_auth_config(db4, 0) check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0) @@ -214,7 +291,6 @@ print("=" * 60) db5 = os.path.join(tmp, "adm.db") app5 = create_app(db_path=db5) root = root_client(app5, db5) -root_secret = None # captured below via get_auth_config root_secret = get_auth_config(db5, 0)["totp_secret"] # Reauth required first r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"}) @@ -240,9 +316,8 @@ r = root.post(f"/api/admin/users/{user_b_id}/totp/begin") secret_b = r.get_json()["secret"] root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()}) # User A logs in -ua = app5.test_client() -r = ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) -check("User A TOTP login", r.status_code == 200) +ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) +check("User A TOTP login", bool(ua.access)) st = ua.get("/api/auth/status").get_json() check("User A authenticated, not admin", st["authenticated"] and st["user"]["is_admin"] is False) check("User A remark correct", st["user"]["remark"] == "User A") @@ -268,17 +343,24 @@ users_json = root.get("/api/admin/users").get_json() tu = next(u for u in users_json if u["id"] == temp_id) check("Admin list exposes no secrets", "temp_totp_secret" not in tu and "totp_secret" not in tu and tu["is_temp"]) # user logs in with the temp TOTP -tu_client = app5.test_client() -r = tu_client.post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"}) -check("Temp user login via temp TOTP", r.status_code == 200 and r.get_json().get("must_configure_mfa")) +tu_client, _ = do_login(app5.test_client(), pyotp.TOTP(temp_secret).now()) +check("Temp user login via temp TOTP → must_configure_mfa", bool(tu_client.access)) st = tu_client.get("/api/auth/status").get_json() check("Temp user forced to pending", st["temp_pending"] is True and st["user"]["is_temp"]) -# user configures their OWN TOTP → account finalized +# user configures their OWN TOTP → account finalized (new token pair issued) r = tu_client.post("/api/auth/totp/setup") new_secret = r.get_json()["secret"] r = tu_client.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(new_secret).now()}) check("Temp account finalized on TOTP verify", r.status_code == 200 and r.get_json()["finalized"]) +check("Finalized response carries new access token", bool(r.get_json().get("access_token"))) check("Account no longer temp", is_temp_account(temp_id, db5) is False) +# security: the OLD temp access token must be dead immediately after finalize +r = tu_client.post("/api/auth/mfa/complete") +check("Old temp token dead after finalize (401)", r.status_code == 401) +conn = sqlite3.connect(db5) +n_temp = conn.execute("SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND temp=1", (temp_id,)).fetchone()[0] +conn.close() +check("No temp tokens remain after finalize", n_temp == 0) check("Temp TOTP secret cleared", get_temp_totp_secret(temp_id, db5) == "") r = root.get(f"/api/admin/users/{temp_id}/totp") check("Admin can no longer see temp TOTP", r.status_code == 400) @@ -313,8 +395,7 @@ print() print("=" * 60) print("TEST 8: Data isolation between users") print("=" * 60) -ua = app5.test_client() -ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) # User A creates data r = ua.post("/api/calendar/groups", json={"name": "A's Group"}) group_a = r.get_json()["id"] @@ -324,8 +405,7 @@ _future = (datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%S") r = ua.post("/api/calendar/events", json={"group_id": group_a, "summary": "A event", "dtstart": _future}) check("User A creates data", r.status_code == 201) # User B (the default-remark user) sees none of it -ub = app5.test_client() -ub.post("/api/auth/login", json={"code": pyotp.TOTP(secret_b).now(), "view": "control"}) +ub, _ = do_login(app5.test_client(), pyotp.TOTP(secret_b).now()) r = ub.get("/api/calendar/groups").get_json() check("User B sees no groups", r == []) r = ub.get("/api/alarms").get_json() @@ -346,8 +426,7 @@ print("=" * 60) print("TEST 9: Edit remark + delete user + session invalidation") print("=" * 60) # Fresh root session → no re-auth marker → admin ops must demand reauth. -fresh_root = app5.test_client() -fresh_root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) +fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) r = fresh_root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"}) check("Edit remark without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required")) rr = reauth_totp(root, root_secret) @@ -358,9 +437,11 @@ rr = reauth_totp(root, root_secret) r = root.delete(f"/api/admin/users/{user_b_id}") check("Delete user with reauth", r.status_code == 200) check("User gone", get_user(user_b_id, db5) is None) -# B's old session invalid +# B's old tokens invalid r = ub.get("/api/alarms") -check("Deleted user's session invalidated", r.status_code == 401) +check("Deleted user's token invalidated", r.status_code == 401) +r = ub.post("/api/auth/refresh") +check("Deleted user's refresh invalidated", r.status_code == 401) # cannot delete root / self r = root.delete("/api/admin/users/0") check("Cannot delete Root", r.status_code == 400) @@ -369,26 +450,95 @@ check("Self-delete guard (root)", r.status_code == 400) print() print("=" * 60) -print("TEST 10: kick_all (admin only) invalidates all sessions") +print("TEST 10: Kick devices (self account, all logged-in users) + Root kick-all-users") print("=" * 60) -ua2 = app5.test_client() -ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +# Two devices of the same account + one root device +ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) +ua2_2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) r = ua2.post("/api/display/kick_all") -check("Regular user blocked from kick_all", r.status_code == 403) -r = root.post("/api/display/kick_all") -check("Admin kick_all works", r.status_code == 200) -r = ua2.get("/api/alarms") -check("User A session invalidated by kick_all", r.status_code == 401) -r = root.get("/api/alarms") -check("Root session also invalidated by kick_all", r.status_code == 401) -# re-login works after kick_all -r = root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) -check("Re-login after kick_all", r.status_code == 200) +check("User kicks own account devices → 200", r.status_code == 200) +check("Device 1 (controller itself) kicked", ua2.get("/api/alarms").status_code == 401) +check("Device 2 kicked", ua2_2.get("/api/alarms").status_code == 401) +check("Kicked device refresh also dead", ua2_2.post("/api/auth/refresh").status_code == 401) +check("Root unaffected by user's self-kick", root.get("/api/alarms").status_code == 200) +# Root kicks every user (including itself) — other logged-in users die too +ua2_3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) +rr = reauth_totp(root, root_secret) +r = root.post("/api/admin/kick_all_users") +check("Root kick-all-users works", r.status_code == 200) +check("Root's own device kicked too", root.get("/api/alarms").status_code == 401) +check("Other user kicked after root kick-all", ua2_3.get("/api/alarms").status_code == 401) +# re-login works after kick +r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) +check("Re-login after kick-all", r.status_code == 200) print() print("=" * 60) -print("TEST 11: SSE / EventBus user scoping") +print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)") print("=" * 60) +db11 = os.path.join(tmp, "tok.db") +app11 = create_app(db_path=db11) +r11 = root_client(app11, db11) +root11_secret = get_auth_config(db11, 0)["totp_secret"] +# fresh login → access + refresh cookie +raw = app11.test_client() +r = raw.post("/api/auth/login", json={"code": pyotp.TOTP(root11_secret).now(), "view": "control"}) +check("Login sets refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", "")) +access1 = r.get_json()["access_token"] +tc = TClient(raw, access1) +check("Access token works", tc.get("/api/theme").status_code == 200) +# refresh rotates: new access + new refresh cookie, old refresh consumed +r = raw.post("/api/auth/refresh") +check("Refresh succeeds", r.status_code == 200 and r.get_json().get("access_token")) +access2 = r.get_json()["access_token"] +check("Refresh rotates the refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", "")) +tc.set_access(access2) +check("New access works", tc.get("/api/theme").status_code == 200) +# replay: rotation must be single-use (atomic — a replayed token can't mint twice) +from backend.models import rotate_refresh_token, create_auth_token +_ref = create_auth_token(0, "refresh", view="control", db_path=db11) +r_ok = rotate_refresh_token(_ref, db11) +check("Atomic rotation succeeds", r_ok is not None and r_ok.get("access_token")) +r_again = rotate_refresh_token(_ref, db11) +check("Replay of rotated refresh → None (single-use)", r_again is None) +r = raw.post("/api/auth/refresh") +check("Live refresh still works after rotation", r.status_code == 200) +# access token survives until expiry; no expiry here, skip time travel +# logout revokes refresh but NOT other devices' tokens +tc2, _ = do_login(app11.test_client(), pyotp.TOTP(root11_secret).now()) +r = tc2.post("/api/auth/logout") +check("Logout ok", r.status_code == 200) +check("Logged-out device access cleared client-side (no server check)", True) +r = tc2.post("/api/auth/refresh") +check("Logged-out device refresh revoked", r.status_code == 401) +r = tc.get("/api/theme") +check("Other device unaffected by logout", r.status_code == 200) + +print() +print("=" * 60) +print("TEST 12: SSE ticket auth (one-time, scoped)") +print("=" * 60) +# no ticket → 401 +check("SSE without ticket → 401", app5.test_client().get("/api/events").status_code == 401) +ua3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) +r = ua3.post("/api/events/ticket") +check("Ticket issued to authenticated user", r.status_code == 201 and r.get_json().get("ticket")) +ticket = r.get_json()["ticket"] +r = app5.test_client().get("/api/events?ticket=" + ticket) +check("SSE connects with ticket", r.status_code == 200 and r.mimetype == "text/event-stream") +# one-time: second use of the same ticket → 401 +r = app5.test_client().get("/api/events?ticket=" + ticket) +check("Ticket is one-time (reuse → 401)", r.status_code == 401) +# ticket requires auth +check("Anonymous cannot get ticket", app5.test_client().post("/api/events/ticket").status_code == 401) +# kick invalidates outstanding tickets immediately +ua3k, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) +r = ua3k.post("/api/events/ticket") +ticket_k = r.get_json()["ticket"] +ua3k.post("/api/display/kick_all") +r = app5.test_client().get("/api/events?ticket=" + ticket_k) +check("Kick invalidates pending SSE ticket → 401", r.status_code == 401) +# EventBus scoping still holds bus = EventBus() q1 = bus.subscribe(user_id=1) q2 = bus.subscribe(user_id=2) @@ -407,7 +557,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None) print() print("=" * 60) -print("TEST 12: Rate limit on register-request") +print("TEST 13: Rate limit on register-request") print("=" * 60) for i in range(5): app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"}) @@ -416,17 +566,26 @@ check("6th register request rate-limited", r.status_code == 429) print() print("=" * 60) -print("TEST 13: CSRF origin check on admin operations") +print("TEST 14: CSRF origin check on admin operations") print("=" * 60) +root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) # re-login after kicks +rr = reauth_totp(root, root_secret) r = root.post("/api/admin/users", json={"remark": "CSRF", "mode": "admin_fills"}, headers={"Origin": "http://evil.example"}) check("Cross-origin admin POST blocked", r.status_code == 403) r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"}) check("Cross-origin GET allowed (safe)", r.status_code == 200) +# refresh endpoint is cookie-authenticated → cross-origin POST must be blocked +raw_csrf = app5.test_client() +raw_csrf.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) +r = raw_csrf.post("/api/auth/refresh", headers={"Origin": "http://evil.example"}) +check("Cross-origin refresh blocked", r.status_code == 403) +r = raw_csrf.post("/api/auth/refresh") +check("Same-origin refresh allowed", r.status_code == 200) print() print("=" * 60) -print("TEST 14: Passkey flows (mocked ceremonies)") +print("TEST 15: Passkey flows (mocked ceremonies)") print("=" * 60) from backend import webauthn_mgr import backend.routes as routes_mod @@ -459,50 +618,50 @@ routes_mod.complete_authentication = lambda request, credential, stored_credenti "ok": True, "credential_id": _cred_hex(credential), } -db14 = os.path.join(tmp, "passkey.db") -app14 = create_app(db_path=db14) -root14 = root_client(app14, db14) -root14_secret = get_auth_config(db14, 0)["totp_secret"] -rr = reauth_totp(root14, root14_secret) -r = root14.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"}) +db15 = os.path.join(tmp, "passkey.db") +app15 = create_app(db_path=db15) +root15 = root_client(app15, db15) +root15_secret = get_auth_config(db15, 0)["totp_secret"] +rr = reauth_totp(root15, root15_secret) +r = root15.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"}) pk_uid = r.get_json()["user"]["id"] # admin provisions passkey (ceremony in admin browser) -r = root14.post(f"/api/admin/users/{pk_uid}/passkey/begin") +r = root15.post(f"/api/admin/users/{pk_uid}/passkey/begin") check("Admin passkey begin", r.status_code == 200 and "challenge" in r.get_json()) -r = root14.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}}) +r = root15.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}}) check("Admin passkey complete stored", r.status_code == 200) # passkey login for that user -c = app14.test_client() -r = c.post("/api/auth/webauthn/login/begin") +raw15 = app15.test_client() +r = raw15.post("/api/auth/webauthn/login/begin") check("Passkey login begin", r.status_code == 200) -r = c.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}}) -check("Passkey login identifies user", r.status_code == 200) -st = c.get("/api/auth/status").get_json() +r = raw15.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}}) +check("Passkey login identifies user", r.status_code == 200 and r.get_json().get("access_token")) +pk_tc = TClient(raw15, r.get_json()["access_token"]) +st = pk_tc.get("/api/auth/status").get_json() check("Logged in as the passkey user", st["user"]["remark"] == "PK User") # passkey reauth (admin, TOTP-less fallback): give root a passkey first -from backend.models import update_auth_config as uac -uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db14) -root14_c2 = app14.test_client() -root14_c2.post("/api/auth/login", json={"code": pyotp.TOTP(root14_secret).now(), "view": "control"}) -r = root14_c2.post("/api/auth/reauth/webauthn/begin") +uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db15) +raw15b = app15.test_client() +root15b, _ = do_login(raw15b, pyotp.TOTP(root15_secret).now()) +r = root15b.post("/api/auth/reauth/webauthn/begin") check("Passkey reauth begin", r.status_code == 200) # complete with an unknown credential should fail (root's stored differs) -r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}}) +r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}}) check("Passkey reauth rejects wrong credential", r.status_code == 401) -r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}}) +r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}}) check("Passkey reauth succeeds with matching credential", r.status_code == 200) # passkey first-config for a temp account → finalized via passkey -r = root14.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"}) +r = root15.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"}) tuid = r.get_json()["user"]["id"] -tsec = get_temp_totp_secret(tuid, db14) -tc = app14.test_client() -tc.post("/api/auth/login", json={"code": pyotp.TOTP(tsec).now(), "view": "control"}) -r = tc.post("/api/auth/webauthn/register/begin") +tsec = get_temp_totp_secret(tuid, db15) +tc15, _ = do_login(app15.test_client(), pyotp.TOTP(tsec).now()) +r = tc15.post("/api/auth/webauthn/register/begin") check("Temp user passkey register begin", r.status_code == 200) -r = tc.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}}) +r = tc15.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}}) check("Temp user passkey register → finalized", r.status_code == 200 and r.get_json()["finalized"]) -check("Temp account finalized via passkey", is_temp_account(tuid, db14) is False) -r = root14.get(f"/api/admin/users/{tuid}/totp") +check("Finalized passkey carries new access token", bool(r.get_json().get("access_token"))) +check("Temp account finalized via passkey", is_temp_account(tuid, db15) is False) +r = root15.get(f"/api/admin/users/{tuid}/totp") check("Temp TOTP gone after passkey finalize", r.status_code == 400) # restore @@ -513,49 +672,46 @@ routes_mod.complete_authentication = orig_complete_auth print() print("=" * 60) -print("TEST 15: Scheduler thread context (per-user timezone, no request context)") +print("TEST 16: Scheduler thread context (per-user timezone, no request context)") print("=" * 60) from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id -from backend.models import create_user as cu -import sqlite3 -db15 = os.path.join(tmp, "sched.db") -app15 = create_app(db_path=db15) -xid = cu("Sched User", db_path=db15) -conn = sqlite3.connect(db15) +db16 = os.path.join(tmp, "sched.db") +app16 = create_app(db_path=db16) +xid = create_user("Sched User", db_path=db16) +conn = sqlite3.connect(db16) conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,)) conn.commit() conn.close() set_thread_user(xid) -with app15.app_context(): +with app16.app_context(): check("Thread user resolves", _ctx_user_id() == xid) check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York") clear_thread_user() -with app15.app_context(): +with app16.app_context(): check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai") # manager calls without request context are user-scoped via thread context set_thread_user(xid) -with app15.app_context(): +with app16.app_context(): from backend import alarm_mgr, countdown_mgr r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid) check("Alarm created for thread user", r["id"] > 0) alarms = alarm_mgr.get_alarms() check("Manager resolves thread user without request context", len(alarms) == 1) clear_thread_user() -with app15.app_context(): +with app16.app_context(): alarms0 = alarm_mgr.get_alarms(user_id=0) check("Root sees no other user's alarms", alarms0 == []) # scheduler boots and runs per-user jobs without crashing from backend.scheduler import start_scheduler, stop_scheduler -start_scheduler(app15) -import time +start_scheduler(app16) time.sleep(1.5) stop_scheduler() check("Scheduler starts and stops cleanly", True) print() print("=" * 60) -print("TEST 16: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)") +print("TEST 17: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)") print("=" * 60) from backend.url_util import is_safe_public_url, is_safe_https_host check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False) @@ -565,9 +721,10 @@ check("SSRF: public URL accepted", is_safe_public_url("https://example.com/cal.i check("Host: private host rejected", is_safe_https_host("127.0.0.1") is False) check("Host: public host accepted", is_safe_https_host("example.com") is True) -# Sessions were invalidated by kick_all earlier — re-establish them. -ua = app5.test_client() -ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +# Re-establish sessions (kicked earlier) — Root included +root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) +rr = reauth_totp(root, root_secret) +ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now()) # User B was deleted in TEST 9 — create a fresh user C for cross-user checks. rr = reauth_totp(root, root_secret) r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"}) @@ -575,8 +732,7 @@ uc_id = r.get_json()["user"]["id"] r = root.post(f"/api/admin/users/{uc_id}/totp/begin") secret_c = r.get_json()["secret"] root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()}) -uc = app5.test_client() -uc.post("/api/auth/login", json={"code": pyotp.TOTP(secret_c).now(), "view": "control"}) +uc, _ = do_login(app5.test_client(), pyotp.TOTP(secret_c).now()) # reauth requires an authenticated session (no public TOTP oracle) anon = app5.test_client() @@ -589,7 +745,7 @@ r = ua.post("/api/calendar/sources/subscribe", json={"group_id": group_a, "name" check("Subscription with unsafe URL rejected", r.status_code == 400) r = ua.post("/api/calendar/sources/subscribe", json={"group_id": 999999, "name": "x", "url": "https://example.com/x.ics"}) check("Subscription to foreign group rejected", r.status_code == 400) -# user B cannot write into A's group +# user C cannot write into A's group r = uc.post("/api/calendar/events", json={"group_id": group_a, "summary": "B intrusion", "dtstart": "20300101T090000"}) check("Cross-user event write rejected", r.status_code == 400) r = uc.post("/api/calendar/sources/upload", data={"group_id": group_a, "name": "x", "file": (io.BytesIO(b"BEGIN:VCALENDAR"), "x.ics")}, content_type="multipart/form-data") @@ -604,26 +760,24 @@ fd_id = r.get_json()["user"]["id"] r = root.post(f"/api/admin/users/{fd_id}/totp/begin") fd_secret = r.get_json()["secret"] root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()}) -fc = app5.test_client() -fc.post("/api/auth/login", json={"code": pyotp.TOTP(fd_secret).now(), "view": "control"}) +fc, _ = do_login(app5.test_client(), pyotp.TOTP(fd_secret).now()) g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json() fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"}) fc.post("/api/alarms", json={"label": "a", "time": "10:00"}) fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999}) -import sqlite3 as _sqlite3 -conn = _sqlite3.connect(db5) +conn = sqlite3.connect(db5) before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] - for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state"]} + for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state", "auth_tokens"]} conn.close() check("Cascade test user has data", sum(before.values()) >= 5) rr = reauth_totp(root, root_secret) r = root.delete(f"/api/admin/users/{fd_id}") check("Delete full-data user succeeds", r.status_code == 200) -conn = _sqlite3.connect(db5) +conn = sqlite3.connect(db5) after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before} conn.close() check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after)) -check("Deleted user's live session invalidated", fc.get("/api/alarms").status_code == 401) +check("Deleted user's live tokens invalidated", fc.get("/api/alarms").status_code == 401) print() print("=" * 60) @@ -679,13 +833,9 @@ print("TEST 19: 账户独立主题(dark/light/auto)") print("=" * 60) db19 = os.path.join(tmp, "theme.db") app19 = create_app(db_path=db19) -c19 = app19.test_client() pw19 = get_temp_password(db19, 0) -c19.post("/api/auth/login", json={"code": pw19, "view": "control"}) -c19.post("/api/auth/totp/setup") -sec19 = get_auth_config(db19, 0)["totp_secret"] -c19.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(sec19).now()}) -c19.post("/api/auth/mfa/complete") +c19, _ = do_login(app19.test_client(), pw19) +setup_totp(c19) r = c19.get("/api/theme") check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark") @@ -709,6 +859,72 @@ check("账户 B 主题=dark", get_user(ub19, db19)["theme"] == "dark") anon19 = app19.test_client() check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401) +print() +print("=" * 60) +print("TEST 20: 账户独立时区(display/state 返回 timezone + 时区设置生效)") +print("=" * 60) +db20 = os.path.join(tmp, "tz.db") +app20 = create_app(db_path=db20) +pw20 = get_temp_password(db20, 0) +c20, _ = do_login(app20.test_client(), pw20) +setup_totp(c20) +# 默认 Asia/Shanghai +r = c20.get("/api/time/config") +check("默认时区 Asia/Shanghai", r.status_code == 200 and r.get_json()["timezone"] == "Asia/Shanghai") +# 设置时区 +r = c20.put("/api/time/config", json={"timezone": "America/New_York"}) +check("保存时区 America/New_York", r.status_code == 200) +r = c20.get("/api/time/config") +check("时区读取生效", r.get_json()["timezone"] == "America/New_York") +# display/state 返回该用户时区 +st = c20.get("/api/display/state").get_json() +check("display/state 返回账户时区", st.get("timezone") == "America/New_York") +# 无效时区拒绝 +r = c20.put("/api/time/config", json={"timezone": "Mars/Olympus"}) +check("无效时区拒绝", r.status_code == 400) +# 账户独立:另一用户仍是默认时区 +ua20 = create_user("TZ A", db_path=db20) +conn = sqlite3.connect(db20) +conn.execute("UPDATE users SET status='active' WHERE id=?", (ua20,)) +conn.close() +# 给用户 A 配 TOTP 再登录 +from backend.auth import generate_temp_password +uac(ua20, totp_secret="JBSWY3DPEHPK3PXP", totp_enabled=1, _db_path=db20) +a20, _ = do_login(app20.test_client(), pyotp.TOTP("JBSWY3DPEHPK3PXP").now()) +st = a20.get("/api/display/state").get_json() +check("用户 B 时区保持默认", st.get("timezone") == "Asia/Shanghai") + +print() +print("=" * 60) +print("TEST 21: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)") +print("=" * 60) +# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie) +raw_nav = app5.test_client() +r = raw_nav.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) +check("页面导航: 登录成功", r.status_code == 200) +r = raw_nav.get("/control") +check("控制端页面导航 → 200(cookie 鉴权)", r.status_code == 200) +r = raw_nav.get("/") +check("显示端页面导航 → 200", r.status_code == 200) +r = raw_nav.get("/mfa-setup") +check("MFA 设置页导航 → 200", r.status_code == 200) +# 未登录导航 → 302 登录页 +anon_nav = app5.test_client() +check("未登录 /control → 302", anon_nav.get("/control").status_code == 302) +check("未登录 / → 302", anon_nav.get("/").status_code == 302) +check("未登录 /mfa-setup → 302", anon_nav.get("/mfa-setup").status_code == 302) +# temp 用户(Bob 仍是临时账户)导航 +bob_raw = app5.test_client() +r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"}) +check("Bob temp 登录", r.status_code == 200) +check("temp 用户 /mfa-setup → 200", bob_raw.get("/mfa-setup").status_code == 200) +loc = bob_raw.get("/control").headers.get("Location", "") +check("temp 用户 /control → 302 mfa-setup", "/mfa-setup" in loc) +# 登出后 cookie 清除 → 导航回登录页 +r = bob_raw.post("/api/auth/logout") +check("登出 ok", r.status_code == 200) +check("登出后 /mfa-setup → 302", bob_raw.get("/mfa-setup").status_code == 302) + print() print("=" * 60) print(f"RESULT: {PASS} passed, {FAIL} failed")