Flask Session -> Double Token

This commit is contained in:
喆OvO
2026-08-11 19:57:21 +08:00
parent 0fef309972
commit cbbb2e6d34
18 changed files with 1375 additions and 607 deletions
+26 -10
View File
@@ -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`。
## 注意
+3 -11
View File
@@ -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()
+141 -77
View File
@@ -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 <access>` 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
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))
+290 -5
View File
@@ -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
+159 -36
View File
@@ -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})
+3 -3
View File
@@ -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:
+3 -3
View File
@@ -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)
+27 -24
View File
@@ -16,29 +16,10 @@
});
// ─── Helpers ──────────────────────────────
// 统一请求封装:自动附加 Authorization: Bearer <access>,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,9 +523,12 @@
$('kick-all-btn').addEventListener('click', function () {
var msg = $('mode-msg');
showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () {
msg.textContent = '正在下线全部设备...';
api('POST', '/api/display/kick_all', null, function (res) {
if (res.ok) {
msg.textContent = '已踢掉所有显示端';
// 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成
msg.textContent = '已下线本账号全部设备,即将跳转登录页';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '操作失败';
@@ -552,6 +536,7 @@
}
});
});
});
// ─── Timezone ──────────────────────────────
function loadTimezone() {
@@ -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();
+100 -139
View File
@@ -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, '&quot;').replace(/'/g, '&#39;');
}
// ─── 账户时区时间 ─────────────────────────
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
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,18 +117,8 @@
// ─── 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);
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 || [];
@@ -95,15 +128,25 @@
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
renderAll();
}
};
xhr.send();
});
}
// ─── 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) {
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);
Auth.request('GET', '/api/pomodoro', null, function (data, status) {
if (status === 200) {
state.pomodoro = data || {};
renderPomodoro();
renderPomodoroCard();
} catch (_) {}
}
};
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 = '<div class="update-info">' +
'<div><span class="label">实况更新:</span> ' + fmt(wUpdated) +
' <span class="label">下次:</span> ' + fmtDate(nextW) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmtDate(nextH) + '</div>' +
'<div><span class="label">实况更新:</span> ' + fmt(s.weather_updated_at) +
' <span class="label">下次:</span> ' + fmt(s.weather_next_update) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmt(s.hourly_next_update) + '</div>' +
'</div>';
// 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) {
+4 -11
View File
@@ -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);
// noRedirect: 登录页/未登录时 401 静默失败(保持默认主题),绝不跳转造成循环
Auth.request('GET', '/api/theme', null, function (d, status) {
if (status === 200 && d.effective) apply(d.effective);
if (cb) cb(d);
}
};
xhr.onerror = function () { /* 网络失败保持当前主题 */ };
xhr.send();
}, { noRedirect: true });
}
function initTheme() {
+160
View File
@@ -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);
+5 -1
View File
@@ -258,7 +258,9 @@
<button type="button" id="user-add-admin" class="btn-primary">👨‍💼 管理员代注册</button>
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</button>
<button type="button" id="user-refresh-btn" class="btn-secondary">刷新列表</button>
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
</div>
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
<div id="pending-requests" style="margin-bottom:16px;"></div>
<div id="user-list"></div>
</div>
@@ -308,8 +310,9 @@
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="kick-all-btn" class="btn-danger">踢掉所有显示端</button>
<button type="button" id="kick-all-btn" class="btn-danger">踢掉当前账号的所有设备</button>
</div>
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section">
@@ -388,6 +391,7 @@
</main>
</div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/control.js"></script>
+1
View File
@@ -242,6 +242,7 @@
<audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
</div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/display.js"></script>
</body>
+9 -1
View File
@@ -75,6 +75,7 @@
</div>
</div>
<script src="/static/js/token.js"></script>
<script>
(function () {
'use strict';
@@ -149,7 +150,13 @@
// SSE: listen for control commands (force_refresh / mode_change / custom_notification)
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;
}
var es = new EventSource('/api/events?ticket=' + encodeURIComponent(res.ticket));
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
@@ -164,6 +171,7 @@
es.close();
setTimeout(connectSSE, 5000);
};
});
}
connectSSE();
})();
+10 -34
View File
@@ -155,6 +155,7 @@
.theme-light .alt-btn:active { background: #e4e9f0; }
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -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) {
+7 -6
View File
@@ -172,6 +172,7 @@
}
.divider::before, .divider::after { background: var(--divider); }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -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 {
+25 -60
View File
@@ -141,6 +141,7 @@
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
@@ -207,11 +208,8 @@
// ─── 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);
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 = '🔐 激活你的账号';
@@ -224,12 +222,7 @@
showRecoveryCodes();
}
updateFinishBtn();
} else if (xhr.status === 401) {
// Not logged in — redirect to login
location.href = '/login?view=control&next=/mfa-setup';
}
};
xhr.send();
});
}
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 = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="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) {
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 ────────────────────────────
+352 -136
View File
@@ -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")