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 临时密码) 访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码)
↓ ↓
登录成功 → 签发 access token(前端持有)+ refresh token(httpOnly cookie)
↓
Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA
正式用户 TOTP/Passkey → 直接进入控制面板 正式用户 TOTP/Passkey → 直接进入控制面板
临时账户 TOTP → 强制跳转 /mfa-setup 配置登录方式,配置完成后临时 TOTP 立即失效 临时账户 TOTP → 强制跳转 /mfa-setup 配置登录方式,配置完成后临时 TOTP 立即失效
``` ```
**会话管理**:
- 控制端「设置 → 踢掉当前账号的所有设备」:所有登录该账号的设备(控制端 + 显示端,含当前设备)立即下线,**所有登录用户可用**
- Root「用户管理 → 踢掉所有用户」:所有用户的控制端与显示端全部下线(含 Root 自身),需二次验证
**用户管理(仅 Root)**: **用户管理(仅 Root)**:
- 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成) - 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成)
- 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码) - 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码)
@@ -61,8 +67,9 @@ Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA
- 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时) - 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时)
- CSRF 防护(Origin/Referer 校验) - CSRF 防护(Origin/Referer 校验)
- 敏感操作二次验证(用户管理、MFA 配置需重新确认身份) - 敏感操作二次验证(用户管理、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)限制脚本/样式来源 - 内容安全策略(CSP)限制脚本/样式来源
- 铃声上传仅允许音频格式+MIME 校验 - 铃声上传仅允许音频格式+MIME 校验
- 第三方日历数据 HTML 转义防止 XSS - 第三方日历数据 HTML 转义防止 XSS
@@ -225,8 +232,8 @@ location / {
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 Secure Cookie(Nginx 反代时启用) | | `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 refresh token cookie 的 Secure 属性(Nginx 反代时启用) |
| `FLASK_SECRET_KEY` | 自动随机生成 | 固定密钥使会话跨重启保持有效 | | `FLASK_SECRET_KEY` | 自动随机生成 | 保留兼容;认证已由 access/refresh 双 token 承载,不再依赖 Flask session |
| `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` | | `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` |
| `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 | | `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 |
| `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) | | `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) |
@@ -241,26 +248,35 @@ location / {
├── requirements.txt ├── requirements.txt
├── backend/ ├── backend/
│ ├── app.py # Flask 应用工厂 + 安全配置 │ ├── app.py # Flask 应用工厂 + 安全配置
│ ├── auth.py # MFA 认证(TOTP/Passkey/恢复码/临时密码) │ ├── auth.py # 双 Token 认证(TOTP/Passkey/恢复码/临时密码)
│ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证 │ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证
│ ├── jwt_util.py # Ed25519 JWT 签名(和风天气) │ ├── jwt_util.py # Ed25519 JWT 签名(和风天气)
│ ├── models.py # SQLite 数据库模型与迁移 │ ├── models.py # SQLite 模型、schema 版本迁移、token 管理
│ ├── routes.py # 全部 API 路由 + CSRF 防护 │ ├── routes.py # 全部 API 路由 + CSRF 防护 + SSE ticket
│ └── *_mgr.py # 各功能模块管理器 │ └── *_mgr.py # 各功能模块管理器
└── frontend/ └── frontend/
├── templates/ # HTML 模板 ├── templates/ # HTML 模板
└── static/ └── static/
├── css/ # 样式 ├── css/ # 样式
├── js/ # JavaScript(纯原生,无框架) ├── js/ # JavaScript(纯原生,无框架)
│ ├── token.js # 双 Token 客户端(access 存储/刷新/401 重试/SSE ticket)
│ ├── control.js # 控制端逻辑
│ └── display.js # 显示端逻辑(账户时区渲染)
└── ringtones/ # 自定义铃声 └── 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`。 旧版 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", template_folder="../frontend/templates",
static_folder="../frontend/static", static_folder="../frontend/static",
) )
# Generate a secure random key at startup. # Generate a secure random key at startup (kept for compatibility).
# Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts).
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)
# Defensive check: ensure key is never empty # Defensive check: ensure key is never empty
if not app.config["SECRET_KEY"]: 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) # 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) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Session hardening for production # Dual-token auth: access token in Authorization header, refresh token in
https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes") # an httpOnly cookie. No Flask session cookies are used anymore.
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = https
app.config["SESSION_PERMANENT"] = False
# Strict CORS: only allow same-origin requests (no wildcard for cookie auth) # Strict CORS: only allow same-origin requests (no wildcard for cookie auth)
CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only 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 # Content size limit: 5MB for uploads
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
# Session lifetime: 24 hours idle timeout
app.config["PERMANENT_SESSION_LIFETIME"] = 86400
# Global event bus # Global event bus
app.event_bus = EventBus() 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: Multi-user support:
- Sessions carry the authenticated `user_id` (Root = 0). - Every request resolves its user from the access token (`g.user_id`).
- The unified login (6-digit code) searches every active user's TOTP secret, - Access tokens snapshot the user's `session_version`; bumping it (delete user,
then temp accounts' temp-TOTP secrets, then Root's temp password. kick devices) invalidates every outstanding access token immediately.
- Session version is per-user (stored in the users table), so deleting a user - Refresh tokens are stored hashed in `auth_tokens`; deleting them (logout of
or kicking all sessions immediately invalidates their cookies. 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 functools
import os
import secrets import secrets
import string import string
import time 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 ( from backend.models import (
get_auth_config, update_auth_config, get_temp_password, set_temp_password, get_auth_config, update_auth_config, get_temp_password, set_temp_password,
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured, 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 ──────────────────────────── # ─── Token / cookie constants ────────────────
SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated REFRESH_COOKIE = "refresh_token" # httpOnly, SameSite=Lax, Secure under HTTPS
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 def _https_enabled() -> bool:
SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation) return os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
SKEY_USER = "user_id" # int: id of the authenticated user
def _db_path(preferred: str = None) -> str: 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") return current_app.config.get("DB_PATH", "data.db")
def _validate_version(): # ─── Auth context (per-request, populated by before_request) ───
"""If the session version is stale (kick_all / user deleted), clear the session."""
uid = session.get(SKEY_USER) def load_auth_context():
if uid is None: """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 return
# Cache the validation result for the duration of this request. row = find_access_token(token)
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid: if not row:
return return
ver = session.get(SKEY_VERSION) g.user_id = row["user_id"]
if ver is None: g.auth_view = row["view"]
return g.temp_pending = bool(row["temp"])
stored = get_user_session_version(uid) g.auth_authenticated = True
if stored == -1 or ver != stored: g.access_token_id = row["id"]
clear_session() g.reauth_at = row.get("reauth_at") or 0
return
g._sv_ok = True
g._sv_uid = uid 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: def is_authenticated() -> bool:
"""Returns True if the user has completed MFA authentication.""" """True when the request carries a valid, non-temp access token."""
_validate_version() try:
return session.get(SKEY_AUTH) is True return (getattr(_g(), "auth_authenticated", False)
and not getattr(_g(), "temp_pending", False))
except Exception:
return False
def is_temp_pending() -> bool: def is_temp_pending() -> bool:
"""True if logged in with a temp password or temp account and must set up a login method.""" """True when logged in with a temp password / temp account (MFA setup required)."""
_validate_version() try:
return session.get(SKEY_TEMP) is True return (getattr(_g(), "auth_authenticated", False)
and bool(getattr(_g(), "temp_pending", False)))
except Exception:
return False
def current_user_id() -> int | None: 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: try:
from flask import has_request_context from flask import has_request_context
if has_request_context(): if has_request_context():
_validate_version() uid = getattr(_g(), "user_id", None)
uid = session.get(SKEY_USER)
if uid is not None: if uid is not None:
return int(uid) return int(uid)
except Exception: except Exception:
@@ -103,64 +127,104 @@ def is_admin() -> bool:
return is_admin_user(uid) return is_admin_user(uid)
def set_authenticated(view: str = "control", user_id: int = None): # ─── Token issuance / refresh / revocation ───
if user_id is None:
user_id = session.get(SKEY_USER) or 0 def issue_tokens(user_id: int, view: str = "control", temp: bool = False) -> dict:
session[SKEY_AUTH] = True """Create a fresh access+refresh token pair. The refresh token must be
session[SKEY_TEMP] = False delivered to the client via an httpOnly cookie by the caller."""
session[SKEY_VIEW] = view access = create_auth_token(user_id, "access", view=view, temp=temp)
session[SKEY_USER] = user_id refresh = create_auth_token(user_id, "refresh", view=view, temp=temp)
session[SKEY_VERSION] = get_user_session_version(user_id) return {"access_token": access, "refresh_token": refresh}
# A fresh authentication must not inherit the temp-setup re-auth marker.
session.pop(SKEY_REAUTH, None)
session.permanent = True
def set_temp_pending(view: str = "control", user_id: int = None): def set_refresh_cookie(resp, refresh: str):
"""Mark the session as logged-in-but-must-configure (temp password / temp account).""" """Attach the refresh token as an httpOnly cookie on the response."""
if user_id is None: resp.set_cookie(
user_id = session.get(SKEY_USER) or 0 REFRESH_COOKIE, refresh,
session[SKEY_AUTH] = False max_age=REFRESH_TOKEN_TTL,
session[SKEY_TEMP] = True httponly=True,
session[SKEY_VIEW] = view samesite="Lax",
session[SKEY_USER] = user_id secure=_https_enabled(),
session[SKEY_VERSION] = get_user_session_version(user_id) path="/",
session.permanent = True )
def clear_session(): def clear_refresh_cookie(resp):
session.pop(SKEY_AUTH, None) resp.delete_cookie(REFRESH_COOKIE, path="/")
session.pop(SKEY_TEMP, None)
session.pop(SKEY_VIEW, None)
session.pop(SKEY_REAUTH, None) def rotate_refresh(refresh_raw: str) -> dict | None:
session.pop(SKEY_USER, None) """Validate a refresh token, revoke it and issue a fresh pair (rotation).
session.pop(SKEY_VERSION, None) 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 ───────────────── # ─── Re-authentication guard ─────────────────
# Sensitive operations (user management, MFA changes) require re-confirming # Sensitive operations (user management, MFA changes) require re-confirming
# identity within the last N seconds. TOTP-first with a Passkey fallback # identity within the last N seconds. The marker lives on the ACCESS TOKEN row,
# (the fallback ceremony is handled by the routes layer). # 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 _REAUTH_TTL = 300 # 5 minutes
def require_reauth(): def require_reauth() -> bool:
"""Check if recent re-authentication is still valid. """Check if recent re-authentication is still valid for THIS access token.
Skips the check for temp_pending users (first-time MFA setup flow). Skips the check for temp_pending users (first-time MFA setup flow).
""" """
if is_temp_pending(): if is_temp_pending():
mark_reauth() mark_reauth()
return True return True
last = session.get(SKEY_REAUTH) try:
if last and (time.time() - last) < _REAUTH_TTL: last = getattr(g, "reauth_at", 0) or 0
return True except Exception:
return False return False
return bool(last) and (time.time() - last) < _REAUTH_TTL
def mark_reauth(): def mark_reauth():
"""Mark re-authentication as done now.""" """Mark re-authentication as done now for the current access token."""
session[SKEY_REAUTH] = time.time() 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 ────────────────────────────── # ─── Decorators ──────────────────────────────
@@ -168,7 +232,7 @@ def mark_reauth():
def _unauthorized(request): def _unauthorized(request):
if request.is_json or request.path.startswith("/api/"): if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401 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)) 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 sqlite3
import os import os
import sys import sys
import time
SCHEMA = """ 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 ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
remark TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '',
@@ -195,6 +219,7 @@ USER_DATA_TABLES = [
"reminders", "weather_config", "weather_cache", "weather_hourly_cache", "reminders", "weather_config", "weather_cache", "weather_hourly_cache",
"weather_minutely_cache", "weather_warning_cache", "clock_config", "weather_minutely_cache", "weather_warning_cache", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state", "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_cache", "weather_hourly_cache", "weather_minutely_cache",
"weather_warning_cache", "weather_config", "clock_config", "weather_warning_cache", "weather_config", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config", "alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
"auth_tokens",
] ]
try: try:
for table in ordered: 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): def finalize_temp_account(user_id: int, db_path: str = None):
"""Convert a temp account into a formal account: """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 = get_db(_db_path(db_path))
conn.execute( 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=?", "UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
(user_id,) (user_id,)
) )
conn.execute(
"DELETE FROM auth_tokens WHERE user_id=? AND temp=1",
(user_id,)
)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -479,6 +510,211 @@ def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool
return True 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) ────────── # ─── Auth / MFA helpers (per user) ──────────
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict: def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
@@ -598,6 +834,9 @@ def _ensure_defaults(conn):
import secrets import secrets
import string 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 user (id 0)
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone() root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
if root is None: if root is None:
@@ -659,6 +898,10 @@ def _ensure_defaults(conn):
# Stores passwords that were just generated, for the startup script to print. # Stores passwords that were just generated, for the startup script to print.
_EMITTED_PASSWORDS: list = [] _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): def _table_columns(conn, table_name):
return [row["name"] for row in conn.execute(f"PRAGMA table_info({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"): 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. - No database file: create it from the latest SCHEMA and create Root.
- Existing database: rebuild it from the latest SCHEMA, migrate compatible - Existing database:
data (legacy single-user data becomes Root's), fill missing columns with * no `meta.version` (legacy DB) → rebuild + migrate compatible data
SCHEMA defaults, then replace the old file (keeping a backup). * 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): if not os.path.exists(db_path):
conn = get_db(db_path) conn = get_db(db_path)
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
_ensure_defaults(conn) _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() conn.close()
return 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. # Now fill defaults for any tables that are still empty + Root bootstrap.
_ensure_defaults(conn_new) _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_old.close()
conn_new.close() conn_new.close()
@@ -772,3 +1046,14 @@ def init_db(db_path: str = "data.db"):
os.remove(backup_path) os.remove(backup_path)
os.rename(db_path, backup_path) os.rename(db_path, backup_path)
os.rename(new_db_path, db_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 ( from flask import (
Blueprint, request, jsonify, Response, Blueprint, request, jsonify, Response,
render_template, current_app, stream_with_context, 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 import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
from backend.auth import ( from backend.auth import (
login_required, login_or_temp_required, display_login_required, admin_required, login_required, login_or_temp_required, display_login_required, admin_required,
is_authenticated, is_temp_pending, is_admin, current_user_id, is_authenticated, is_temp_pending, is_admin, current_user_id,
set_authenticated, set_temp_pending, clear_session,
verify_login, verify_totp, verify_recovery_code, verify_login, verify_totp, verify_recovery_code,
check_rate_limit, record_failure, record_success, check_rate_limit, record_failure, record_success,
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password, 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 ( from backend.models import (
get_auth_config, update_auth_config, set_temp_password, clear_temp_password, 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, get_users, get_user, create_user, update_user, delete_user,
is_temp_account, is_admin_user, finalize_temp_account, is_temp_account, is_admin_user, finalize_temp_account,
find_user_by_credential_id, bump_all_session_versions, find_user_by_credential_id, bump_all_session_versions,
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
) )
from backend.webauthn_mgr import ( from backend.webauthn_mgr import (
begin_registration, complete_registration, 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 @api.before_request
def _check_origin(): def _check_origin():
"""Protect state-changing requests against CSRF by verifying Origin/Referer. """Protect state-changing requests against CSRF by verifying Origin/Referer.
@@ -199,6 +207,8 @@ def api_login():
data = request.get_json() or {} data = request.get_json() or {}
code = data.get("code", "") code = data.get("code", "")
view = data.get("view", "control") view = data.get("view", "control")
if view not in ("control", "display"):
view = "control" # 未认证端点:非法值降级,避免触发 DB CHECK 约束 500
result = verify_login(code) result = verify_login(code)
if result is None: if result is None:
@@ -213,21 +223,26 @@ def api_login():
if temp_account: if temp_account:
# Temp account: server-held temp TOTP matched → force registration flow. # Temp account: server-held temp TOTP matched → force registration flow.
record_success() 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 resp["must_configure_mfa"] = True
else: else:
cfg = get_auth_config(user_id=uid) cfg = get_auth_config(user_id=uid)
if not cfg.get("totp_enabled"): if not cfg.get("totp_enabled"):
return jsonify({"error": "TOTP 尚未启用"}), 403 return jsonify({"error": "TOTP 尚未启用"}), 403
record_success() record_success()
set_authenticated(view, uid) tokens = issue_tokens(uid, view=view)
resp["access_token"] = tokens["access_token"]
else: # temp_password (Root initial bootstrap) else: # temp_password (Root initial bootstrap)
record_success() 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 resp["must_configure_mfa"] = True
return jsonify(resp) response = jsonify(resp)
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Code Login ───────────────────── # ─── Recovery Code Login ─────────────────────
@@ -242,12 +257,16 @@ def api_login_recovery():
data = request.get_json() or {} data = request.get_json() or {}
code = data.get("code", "") code = data.get("code", "")
view = data.get("view", "control") view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
uid = verify_recovery_code(code) uid = verify_recovery_code(code)
if uid is not None: if uid is not None:
record_success() record_success()
set_authenticated(view, uid) tokens = issue_tokens(uid, view=view)
return jsonify({"ok": True}) response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
record_failure() record_failure()
return jsonify({"error": "恢复码错误或已使用"}), 401 return jsonify({"error": "恢复码错误或已使用"}), 401
@@ -356,6 +375,8 @@ def api_webauthn_login_complete():
data = request.get_json() or {} data = request.get_json() or {}
view = data.get("view", "control") view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
credential = data.get("credential", {}) credential = data.get("credential", {})
credential_b64 = credential.get("id", "") credential_b64 = credential.get("id", "")
@@ -392,8 +413,10 @@ def api_webauthn_login_complete():
record_failure() record_failure()
return jsonify({"error": "Passkey 凭证不匹配"}), 401 return jsonify({"error": "Passkey 凭证不匹配"}), 401
record_success() record_success()
set_authenticated(view, uid) tokens = issue_tokens(uid, view=view)
return jsonify({"ok": True}) response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
except ValueError as e: except ValueError as e:
record_failure() record_failure()
return jsonify({"error": str(e)}), 401 return jsonify({"error": str(e)}), 401
@@ -401,12 +424,36 @@ def api_webauthn_login_complete():
return jsonify({"error": f"Passkey 验证失败: {e}"}), 500 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"]) @api.route("/api/auth/logout", methods=["POST"])
def api_logout(): def api_logout():
clear_session() """Revoke this device's refresh token and clear its cookie.
return jsonify({"ok": True}) Other devices of the same account are unaffected.
"""
logout_current_device()
response = jsonify({"ok": True})
clear_refresh_cookie(response)
return response
# ─── Auth Status ───────────────────────────── # ─── Auth Status ─────────────────────────────
@@ -484,9 +531,11 @@ def api_register_request():
@api.route("/mfa-setup") @api.route("/mfa-setup")
def mfa_setup_page(): def mfa_setup_page():
"""MFA configuration page. Shown after temp-password / temp-account login.""" """MFA configuration page. Shown after temp-password / temp-account login.
if not is_temp_pending() and not is_authenticated(): Authenticated via the httpOnly refresh cookie (page navigation).
return redirect(url_for("api.login_page", next="/mfa-setup")) """
if not load_page_context():
return redirect(url_for("api.login_page", next="/mfa-setup", view="control"))
return render_template("mfa_setup.html") return render_template("mfa_setup.html")
@@ -532,8 +581,12 @@ def api_webauthn_register_complete():
if is_temp_account(uid): if is_temp_account(uid):
finalize_temp_account(uid) finalize_temp_account(uid)
finalized = True finalized = True
# The user proved access to the account — mark the session authenticated. # The user proved access to the account — issue a formal token pair.
set_authenticated(session.get("mfa_view", "control"), uid) 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}) return jsonify({"ok": True, "finalized": finalized})
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@@ -602,13 +655,19 @@ def api_totp_verify():
if is_temp_account(uid): if is_temp_account(uid):
finalize_temp_account(uid) finalize_temp_account(uid)
finalized = True finalized = True
set_authenticated(session.get("mfa_view", "control"), uid)
return jsonify({ resp = {
"ok": True, "ok": True,
"recovery_codes": get_recovery_codes(user_id=uid), "recovery_codes": get_recovery_codes(user_id=uid),
"finalized": finalized, "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 return jsonify({"error": "验证码错误,请重试"}), 400
@@ -631,8 +690,10 @@ def api_mfa_complete():
finalize_temp_account(uid) finalize_temp_account(uid)
clear_temp_password(user_id=uid) clear_temp_password(user_id=uid)
update_auth_config(uid, must_configure_mfa=0) update_auth_config(uid, must_configure_mfa=0)
set_authenticated(session.get("mfa_view", "control"), uid) tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
return jsonify({"ok": True}) response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Codes ────────────────────────── # ─── Recovery Codes ──────────────────────────
@@ -977,6 +1038,20 @@ def admin_provision_passkey_complete(user_id):
return jsonify({"error": f"Passkey 注册失败: {e}"}), 500 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 # DISPLAY PAGES
# ════════════════════════════════════════════ # ════════════════════════════════════════════
@@ -984,11 +1059,13 @@ def admin_provision_passkey_complete(user_id):
@api.route("/") @api.route("/")
@api.route("/index") @api.route("/index")
def display_page(): 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()) cfg = get_display_config(user_id=_cur())
if cfg.get("mode") == "custom": if cfg.get("mode") == "custom":
return render_template("display_custom.html", custom_html=cfg.get("custom_html", "")) 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") return render_template("display.html")
@@ -997,8 +1074,13 @@ def display_page():
# ════════════════════════════════════════════ # ════════════════════════════════════════════
@api.route("/control") @api.route("/control")
@login_required
def control_page(): 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") return render_template("control.html")
@@ -1006,12 +1088,49 @@ def control_page():
# DISPLAY / SSE / STATE # 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") @api.route("/api/events")
def sse_stream(): def sse_stream():
"""Server-Sent Events for real-time updates (scoped to the current user).""" """Server-Sent Events for real-time updates (scoped to the current user).
if not is_authenticated() and not is_temp_pending(): 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 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(): def generate():
q = current_app.event_bus.subscribe(user_id=uid) 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) update_schedule = weather_mgr.get_next_update_times(user_id=uid)
minutely = weather_mgr.get_cached_minutely(user_id=uid) minutely = weather_mgr.get_cached_minutely(user_id=uid)
warnings = weather_mgr.get_cached_warnings(user_id=uid) warnings = weather_mgr.get_cached_warnings(user_id=uid)
from backend.time_util import get_tz_str
return jsonify({ return jsonify({
"events": events, "events": events,
@@ -1067,6 +1187,7 @@ def get_display_state():
"update_schedule": update_schedule, "update_schedule": update_schedule,
"minutely": minutely, "minutely": minutely,
"warnings": warnings, "warnings": warnings,
"timezone": get_tz_str(),
}) })
@@ -1133,13 +1254,15 @@ def force_refresh_display():
@api.route("/api/display/kick_all", methods=["POST"]) @api.route("/api/display/kick_all", methods=["POST"])
@admin_required @login_required
def kick_all_displays(): def kick_own_devices():
"""Kick ALL sessions (including all users) by bumping every user's version. """Kick every device logged into the CURRENT account — control AND display
Old session cookies become invalid immediately. Also broadcasts SSE. devices, including this control device itself. All sessions end immediately.
""" """
bump_all_session_versions() uid = _cur()
current_app.event_bus.publish("kick_all", {}) 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}) return jsonify({"ok": True})
+3 -3
View File
@@ -41,11 +41,11 @@ def get_thread_user():
def _ctx_user_id(): 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: try:
from flask import has_request_context, session from flask import has_request_context, g
if has_request_context(): if has_request_context():
uid = session.get("user_id") uid = getattr(g, "user_id", None)
if uid is not None: if uid is not None:
return int(uid) return int(uid)
except Exception: 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) expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
if not expected_challenge: if not expected_challenge:
# Self-registration fallback: the browser may not echo the user handle, # 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: try:
from flask import session from backend.auth import current_user_id
uid = session.get("user_id") uid = current_user_id()
if uid is not None: if uid is not None:
challenge_key = f"reg_{uid}" challenge_key = f"reg_{uid}"
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
+27 -24
View File
@@ -16,29 +16,10 @@
}); });
// ─── Helpers ────────────────────────────── // ─── Helpers ──────────────────────────────
// 统一请求封装:自动附加 Authorization: Bearer <access>,401 时自动刷新并重试,
// 刷新失败则清空 token 并跳转登录页(见 token.js)。
function api(method, url, data, callback) { function api(method, url, data, callback) {
var xhr = new XMLHttpRequest(); Auth.request(method, url, data, callback);
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();
}
} }
// HTML escape for untrusted data — prevents XSS in innerHTML // HTML escape for untrusted data — prevents XSS in innerHTML
@@ -542,9 +523,12 @@
$('kick-all-btn').addEventListener('click', function () { $('kick-all-btn').addEventListener('click', function () {
var msg = $('mode-msg'); var msg = $('mode-msg');
showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () {
msg.textContent = '正在下线全部设备...';
api('POST', '/api/display/kick_all', null, function (res) { api('POST', '/api/display/kick_all', null, function (res) {
if (res.ok) { if (res.ok) {
msg.textContent = '已踢掉所有显示端'; // 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成
msg.textContent = '已下线本账号全部设备,即将跳转登录页';
msg.style.color = '#2e7d32'; msg.style.color = '#2e7d32';
} else { } else {
msg.textContent = res.error || '操作失败'; msg.textContent = res.error || '操作失败';
@@ -552,6 +536,7 @@
} }
}); });
}); });
});
// ─── Timezone ────────────────────────────── // ─── Timezone ──────────────────────────────
function loadTimezone() { function loadTimezone() {
@@ -579,7 +564,7 @@
// ─── Logout ──────────────────────────────── // ─── Logout ────────────────────────────────
$('logout-btn').addEventListener('click', function () { $('logout-btn').addEventListener('click', function () {
api('POST', '/api/auth/logout', null, function () { Auth.logout(function () {
location.href = '/login?view=control'; location.href = '/login?view=control';
}); });
}); });
@@ -1369,6 +1354,24 @@
$('user-refresh-btn').addEventListener('click', loadUsers); $('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 ───────────────────────────────── // ─── Init ─────────────────────────────────
loadGroups(); loadGroups();
updateEventGroupSelects(); updateEventGroupSelects();
+100 -139
View File
@@ -35,6 +35,7 @@
update_schedule: {}, update_schedule: {},
_cdModalMode: false, // true = countdown modal, false = alarm modal _cdModalMode: false, // true = countdown modal, false = alarm modal
_editingCountdown: null, _editingCountdown: null,
timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state)
}; };
// ─── DOM refs ───────────────────────────── // ─── DOM refs ─────────────────────────────
@@ -48,6 +49,48 @@
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .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 ────────────────────── // ─── Panel switching ──────────────────────
function switchPanel(name) { function switchPanel(name) {
if (state.alarmActive && name !== 'alarm') return; // lock during alarm if (state.alarmActive && name !== 'alarm') return; // lock during alarm
@@ -74,18 +117,8 @@
// ─── Data fetching ──────────────────────── // ─── Data fetching ────────────────────────
function fetchState() { function fetchState() {
var xhr = new XMLHttpRequest(); Auth.request('GET', '/api/display/state', null, function (data, status) {
xhr.open('GET', '/api/display/state'); if (status !== 200) return;
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
state.weather = data.weather || {}; state.weather = data.weather || {};
state.hourly = data.hourly || []; state.hourly = data.hourly || [];
state.events = data.events || []; state.events = data.events || [];
@@ -95,15 +128,25 @@
state.minutely = data.minutely || null; state.minutely = data.minutely || null;
state.warnings = data.warnings || null; state.warnings = data.warnings || null;
state.groups = data.groups || []; state.groups = data.groups || [];
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
renderAll(); renderAll();
} });
};
xhr.send();
} }
// ─── SSE for real-time events ───────────── // ─── SSE for real-time events ─────────────
function connectSSE() { 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) { es.onmessage = function (e) {
try { try {
var msg = JSON.parse(e.data); var msg = JSON.parse(e.data);
@@ -112,24 +155,19 @@
}; };
es.onerror = function () { es.onerror = function () {
es.close(); es.close();
clearInterval(state._sseGuard);
setTimeout(connectSSE, 5000); setTimeout(connectSSE, 5000);
}; };
// SSE doesn't give us status codes; if auth fails the browser will error-loop. // 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. // We add a small guard: if we get no messages for 30s, try a normal fetch to check auth.
state._sseGuard = setInterval(function () { state._sseGuard = setInterval(function () {
var g = new XMLHttpRequest(); Auth.request('GET', '/api/display/state', null, function (data, st) {
g.open('GET', '/api/display/state'); if (st === 401) {
g.onload = function () {
if (g.status === 401) {
var res = JSON.parse(g.responseText || '{}');
if (res.login_required) {
es.close(); es.close();
clearInterval(state._sseGuard); clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
} }
} });
};
g.send();
}, 30000); }, 30000);
} }
@@ -175,12 +213,9 @@
// 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画) // 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画)
if (window.ThemeBridge) ThemeBridge.fetch(); if (window.ThemeBridge) ThemeBridge.fetch();
} else if (msg.type === 'kick_all') { } else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest(); Auth.logout(function () {
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}; });
xhr.send();
} }
} }
@@ -401,20 +436,9 @@
// ─── Alarm CRUD ─────────────────────────── // ─── Alarm CRUD ───────────────────────────
function apiJson(method, url, data, callback) { function apiJson(method, url, data, callback) {
var xhr = new XMLHttpRequest(); Auth.request(method, url, data, function (res, status) {
xhr.open(method, url); if (callback) callback(res, status);
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);
} }
function deleteAlarm(id) { function deleteAlarm(id) {
@@ -841,12 +865,9 @@
var lockBtn = $('lock-btn'); var lockBtn = $('lock-btn');
if (lockBtn) { if (lockBtn) {
lockBtn.addEventListener('click', function () { lockBtn.addEventListener('click', function () {
var xhr = new XMLHttpRequest(); Auth.logout(function () {
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}; });
xhr.send();
}); });
} }
@@ -943,34 +964,19 @@
// ─── Pomodoro controls (reuse /control endpoints) ───────────── // ─── Pomodoro controls (reuse /control endpoints) ─────────────
function pomodoroApi(action) { function pomodoroApi(action) {
var xhr = new XMLHttpRequest(); Auth.request('POST', '/api/pomodoro/' + action, null, function () {
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;
}
}
fetchState(); fetchState();
}; });
xhr.send();
} }
function fetchPomodoro() { function fetchPomodoro() {
var xhr = new XMLHttpRequest(); Auth.request('GET', '/api/pomodoro', null, function (data, status) {
xhr.open('GET', '/api/pomodoro'); if (status === 200) {
xhr.onload = function () { state.pomodoro = data || {};
if (xhr.status === 200) {
try {
state.pomodoro = JSON.parse(xhr.responseText);
renderPomodoro(); renderPomodoro();
renderPomodoroCard(); renderPomodoroCard();
} catch (_) {}
} }
}; });
xhr.send();
} }
// Card pomodoro buttons (in sidebar card, not the panel) // Card pomodoro buttons (in sidebar card, not the panel)
@@ -1004,10 +1010,8 @@
} }
function renderTimeCard() { function renderTimeCard() {
var now = new Date(); var n = _tzNow();
var h = String(now.getHours()).padStart(2, '0'); $('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi);
var m = String(now.getMinutes()).padStart(2, '0');
$('time-value').textContent = h + ':' + m;
} }
function renderWeatherCard() { function renderWeatherCard() {
@@ -1026,8 +1030,7 @@
} }
function renderCalendarCard() { function renderCalendarCard() {
var now = new Date(); var nowStr = _nowLocalKey();
var nowStr = formatDateTimeLocal(now);
var upcoming = null; var upcoming = null;
for (var i = 0; i < state.events.length; i++) { for (var i = 0; i < state.events.length; i++) {
var ev = state.events[i]; var ev = state.events[i];
@@ -1052,19 +1055,10 @@
return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13); 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() { function renderAlarmCard() {
var now = new Date(); var n = _tzNow();
var currentMinutes = now.getHours() * 60 + now.getMinutes(); var currentMinutes = n.h * 60 + n.mi;
var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay()); var todayWeekday = String(n.wd === 0 ? 7 : n.wd);
var upcoming = null; var upcoming = null;
var upcomingDiff = null; var upcomingDiff = null;
@@ -1208,15 +1202,12 @@
} }
function renderTimePanel() { function renderTimePanel() {
var now = new Date(); var n = _tzNow();
$('detailed-time').textContent = $('detailed-time').textContent =
String(now.getHours()).padStart(2, '0') + ':' + _pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s);
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
var days = ['日', '一', '二', '三', '四', '五', '六']; var days = ['日', '一', '二', '三', '四', '五', '六'];
$('date-str').textContent = $('date-str').textContent =
now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()]; n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd];
} }
function renderWeatherPanel() { function renderWeatherPanel() {
@@ -1268,8 +1259,9 @@
return; return;
} }
var html = ''; var html = '';
var nowHour = new Date().getHours(); var n = _tzNow();
var nowMin = new Date().getMinutes(); var nowHour = n.h;
var nowMin = n.mi;
hourly.forEach(function (h) { hourly.forEach(function (h) {
var hh = parseInt(h.time.split(':')[0], 10); var hh = parseInt(h.time.split(':')[0], 10);
var hm = parseInt(h.time.split(':')[1], 10); var hm = parseInt(h.time.split(':')[1], 10);
@@ -1376,49 +1368,16 @@
function _renderWeatherUpdateInfo() { function _renderWeatherUpdateInfo() {
var s = state.update_schedule || {}; var s = state.update_schedule || {};
var wUpdated = s.weather_updated_at || ''; // 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析)
// 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); }
var fmt = function (d) { var fmt = function (d) {
if (!d) return '--'; if (!d) return '--';
// Server sends UTC datetime — parse as UTC, display in local timezone return String(d).replace('T', ' ').substring(0, 16);
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');
}; };
var html = '<div class="update-info">' + var html = '<div class="update-info">' +
'<div><span class="label">实况更新:</span> ' + fmt(wUpdated) + '<div><span class="label">实况更新:</span> ' + fmt(s.weather_updated_at) +
' <span class="label">下次:</span> ' + fmtDate(nextW) + '</div>' + ' <span class="label">下次:</span> ' + fmt(s.weather_next_update) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmtDate(nextH) + '</div>' + '<div><span class="label">逐时下次:</span> ' + fmt(s.hourly_next_update) + '</div>' +
'</div>'; '</div>';
// Append after hourly list // Append after hourly list
@@ -1443,9 +1402,12 @@
if (!groups[dk]) groups[dk] = []; if (!groups[dk]) groups[dk] = [];
groups[dk].push(ev); groups[dk].push(ev);
}); });
var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, ''); var n = _tzNow();
var t = new Date(); t.setDate(t.getDate() + 1); var todayKey = String(n.y) + _pad2(n.mo) + _pad2(n.d);
var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, ''); // 明天:基于账户时区的年月日 +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 = ''; var html = '';
Object.keys(groups).sort().forEach(function (dk) { Object.keys(groups).sort().forEach(function (dk) {
var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' : var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' :
@@ -1579,8 +1541,7 @@
} }
// When weather panel is open, check if hourly needs refresh (past :01, stale) // When weather panel is open, check if hourly needs refresh (past :01, stale)
if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') { if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') {
var now = new Date(); var min = _tzNow().mi;
var min = now.getMinutes();
var s = state.update_schedule || {}; var s = state.update_schedule || {};
// If it's past :05 and the hourly was likely last fetched before this hour // If it's past :05 and the hourly was likely last fetched before this hour
if (min > 5) { if (min > 5) {
+4 -11
View File
@@ -26,18 +26,11 @@
} }
function fetchTheme(cb) { function fetchTheme(cb) {
var xhr = new XMLHttpRequest(); // noRedirect: 登录页/未登录时 401 静默失败(保持默认主题),绝不跳转造成循环
xhr.open('GET', '/api/theme'); Auth.request('GET', '/api/theme', null, function (d, status) {
xhr.onload = function () { if (status === 200 && d.effective) apply(d.effective);
if (xhr.status === 200) {
var d = {};
try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
if (d.effective) apply(d.effective);
if (cb) cb(d); if (cb) cb(d);
} }, { noRedirect: true });
};
xhr.onerror = function () { /* 网络失败保持当前主题 */ };
xhr.send();
} }
function initTheme() { 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-admin" class="btn-primary">👨‍💼 管理员代注册</button>
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</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="user-refresh-btn" class="btn-secondary">刷新列表</button>
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
</div> </div>
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
<div id="pending-requests" style="margin-bottom:16px;"></div> <div id="pending-requests" style="margin-bottom:16px;"></div>
<div id="user-list"></div> <div id="user-list"></div>
</div> </div>
@@ -308,8 +310,9 @@
</div> </div>
</div> </div>
<div style="margin-top:8px;display:flex;gap:8px;"> <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>
<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 id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div> </div>
<div class="section"> <div class="section">
@@ -388,6 +391,7 @@
</main> </main>
</div> </div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
<script src="/static/js/passkey_bridge.js"></script> <script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/control.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> <audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
</div> </div>
<script src="/static/js/token.js"></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
<script src="/static/js/display.js"></script> <script src="/static/js/display.js"></script>
</body> </body>
+9 -1
View File
@@ -75,6 +75,7 @@
</div> </div>
</div> </div>
<script src="/static/js/token.js"></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
@@ -149,7 +150,13 @@
// SSE: listen for control commands (force_refresh / mode_change / custom_notification) // SSE: listen for control commands (force_refresh / mode_change / custom_notification)
function connectSSE() { 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) { es.onmessage = function (e) {
try { try {
var msg = JSON.parse(e.data); var msg = JSON.parse(e.data);
@@ -164,6 +171,7 @@
es.close(); es.close();
setTimeout(connectSSE, 5000); setTimeout(connectSSE, 5000);
}; };
});
} }
connectSSE(); connectSSE();
})(); })();
+10 -34
View File
@@ -155,6 +155,7 @@
.theme-light .alt-btn:active { background: #e4e9f0; } .theme-light .alt-btn:active { background: #e4e9f0; }
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; } .theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
</style> </style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script> <script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</head> </head>
@@ -227,17 +228,15 @@
} }
// ─── Auth status check ────────────────── // ─── Auth status check ──────────────────
var xhr = new XMLHttpRequest(); // 携带本地 access token 检查(token 有效则直接放行,避免重复登录)
xhr.open('GET', '/api/auth/status'); Auth.fetchWithAuth('/api/auth/status')
xhr.onload = function () { .then(function (r) { return r.json(); })
if (xhr.status === 200) { .then(function (status) {
var status = JSON.parse(xhr.responseText);
if (status.authenticated) { if (status.authenticated) {
_redirect(redirect); _redirect(redirect);
} }
} })
}; .catch(function () {});
xhr.send();
// ─── TOTP dots ────────────────────────── // ─── TOTP dots ──────────────────────────
function updateDots(ok) { function updateDots(ok) {
@@ -259,6 +258,7 @@
xhr.onload = function () { xhr.onload = function () {
if (xhr.status === 200) { if (xhr.status === 200) {
var res = JSON.parse(xhr.responseText || '{}'); var res = JSON.parse(xhr.responseText || '{}');
if (res.access_token) Auth.setAccess(res.access_token);
if (res.method === 'temp_password' || res.must_configure_mfa) { if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup'); _redirect('/mfa-setup');
} else { } else {
@@ -317,6 +317,7 @@
verify.onload = function () { verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}'); var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) { if (verify.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
msgEl.textContent = '✅ 登录成功!'; msgEl.textContent = '✅ 登录成功!';
setTimeout(function () { _redirect(redirect); }, 300); setTimeout(function () { _redirect(redirect); }, 300);
} else { } else {
@@ -368,6 +369,7 @@
xhr.onload = function () { xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}'); var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) { if (xhr.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
_redirect(redirect); _redirect(redirect);
} else { } else {
msgEl.textContent = res.error || '恢复码错误'; 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 ──────────────────────────── // ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer // Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) { function _base64ToArrayBuffer(b64) {
+7 -6
View File
@@ -172,6 +172,7 @@
} }
.divider::before, .divider::after { background: var(--divider); } .divider::before, .divider::after { background: var(--divider); }
</style> </style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script> <script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</head> </head>
@@ -247,12 +248,10 @@
}); });
function checkStatus(callback) { function checkStatus(callback) {
var xhr = new XMLHttpRequest(); Auth.fetchWithAuth('/api/auth/status')
xhr.open('GET', '/api/auth/status'); .then(function (r) { return r.json(); })
xhr.onload = function () { .then(function (data) { callback(data); })
if (xhr.status === 200) callback(JSON.parse(xhr.responseText)); .catch(function () { callback({}); });
};
xhr.send();
} }
function showLoginForm(mfaConfigured) { function showLoginForm(mfaConfigured) {
@@ -280,6 +279,7 @@
xhr.onload = function () { xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}'); var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) { if (xhr.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
if (res.method === 'temp_password' || res.must_configure_mfa) { if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup'); _redirect('/mfa-setup');
} else { } else {
@@ -358,6 +358,7 @@
verify.onload = function () { verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}'); var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) { if (verify.status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
statusEl.textContent = '✅ 登录成功!'; statusEl.textContent = '✅ 登录成功!';
_redirect(redirect); _redirect(redirect);
} else { } else {
+25 -60
View File
@@ -141,6 +141,7 @@
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; } .theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; } .theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style> </style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script> <script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</head> </head>
@@ -207,11 +208,8 @@
// ─── Check MFA status ────────────────── // ─── Check MFA status ──────────────────
function checkMFAStatus() { function checkMFAStatus() {
var xhr = new XMLHttpRequest(); Auth.request('GET', '/api/auth/mfa/status', null, function (s, status) {
xhr.open('GET', '/api/auth/mfa/status'); if (status !== 200) return;
xhr.onload = function () {
if (xhr.status === 200) {
var s = JSON.parse(xhr.responseText);
// Temp accounts must configure ONE login method to activate the account. // Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) { if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号'; document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
@@ -224,12 +222,7 @@
showRecoveryCodes(); showRecoveryCodes();
} }
updateFinishBtn(); updateFinishBtn();
} else if (xhr.status === 401) { });
// Not logged in — redirect to login
location.href = '/login?view=control&next=/mfa-setup';
}
};
xhr.send();
} }
checkMFAStatus(); checkMFAStatus();
@@ -251,33 +244,26 @@
btn.disabled = true; btn.disabled = true;
statusEl.textContent = '正在准备注册...'; statusEl.textContent = '正在准备注册...';
var xhr = new XMLHttpRequest(); Auth.request('POST', '/api/auth/webauthn/register/begin', null, function (options, st) {
xhr.open('POST', '/api/auth/webauthn/register/begin'); if (st !== 200) {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) {
statusEl.textContent = '❌ 获取注册参数失败'; statusEl.textContent = '❌ 获取注册参数失败';
btn.disabled = false; btn.disabled = false;
return; return;
} }
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController // Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options) PasskeyBridge.creation(options)
.then(function (credential) { .then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user'; credential.user_name = options.user && options.user.name ? options.user.name : 'user';
var verify = new XMLHttpRequest(); Auth.request('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res, st2) {
verify.open('POST', '/api/auth/webauthn/register/complete'); if (st2 === 200) {
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
statusEl.textContent = '✅ Passkey 注册成功!'; statusEl.textContent = '✅ Passkey 注册成功!';
passkeyDone = true; passkeyDone = true;
updatePasskeyUI(true); updatePasskeyUI(true);
checkRecoveryAndFinish(); checkRecoveryAndFinish();
if (res.finalized) { if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...'; statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500); setTimeout(function () { location.href = '/'; }, 2500);
} }
@@ -285,15 +271,13 @@
statusEl.textContent = '❌ ' + (res.error || '注册失败'); statusEl.textContent = '❌ ' + (res.error || '注册失败');
btn.disabled = false; btn.disabled = false;
} }
}; });
verify.send(JSON.stringify({ credential: credential }));
}) })
.catch(function (err) { .catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消'); statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false; btn.disabled = false;
}); });
}; });
xhr.send();
}); });
// ─── TOTP ────────────────────────────── // ─── TOTP ──────────────────────────────
@@ -318,16 +302,11 @@
document.getElementById('setup-totp-btn').textContent = '取消'; document.getElementById('setup-totp-btn').textContent = '取消';
// Request TOTP setup from server // Request TOTP setup from server
var xhr = new XMLHttpRequest(); Auth.request('POST', '/api/auth/totp/setup', null, function (data, status) {
xhr.open('POST', '/api/auth/totp/setup'); if (status !== 200) return;
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) return;
var data = JSON.parse(xhr.responseText);
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">'; 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; document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
}; });
xhr.send();
}); });
document.getElementById('totp-verify-btn').addEventListener('click', function () { document.getElementById('totp-verify-btn').addEventListener('click', function () {
@@ -335,12 +314,8 @@
var msg = document.getElementById('totp-msg'); var msg = document.getElementById('totp-msg');
msg.textContent = ''; msg.textContent = '';
var xhr = new XMLHttpRequest(); Auth.request('POST', '/api/auth/totp/verify', { code: code }, function (res, st) {
xhr.open('POST', '/api/auth/totp/verify'); if (st === 200) {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
msg.className = 'msg success'; msg.className = 'msg success';
msg.textContent = '✅ TOTP 已启用!'; msg.textContent = '✅ TOTP 已启用!';
totpDone = true; totpDone = true;
@@ -354,14 +329,14 @@
} }
checkRecoveryAndFinish(); checkRecoveryAndFinish();
if (res.finalized) { if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
msg.textContent = '✅ 账号已激活!即将进入控制面板...'; msg.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500); setTimeout(function () { location.href = '/'; }, 2500);
} }
} else { } else {
msg.textContent = res.error || '验证失败'; msg.textContent = res.error || '验证失败';
} }
}; });
xhr.send(JSON.stringify({ code: code }));
}); });
// ─── Recovery codes ──────────────────── // ─── Recovery codes ────────────────────
@@ -373,17 +348,11 @@
} }
function showRecoveryCodes() { function showRecoveryCodes() {
var xhr = new XMLHttpRequest(); Auth.request('GET', '/api/auth/recovery-codes', null, function (data, status) {
xhr.open('GET', '/api/auth/recovery-codes'); if (status === 200 && data.codes && data.codes.length > 0) {
xhr.onload = function () {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
if (data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes); showRecoveryCodesList(data.codes);
} }
} });
};
xhr.send();
} }
function checkRecoveryAndFinish() { function checkRecoveryAndFinish() {
@@ -417,18 +386,14 @@
document.getElementById('finish-btn').addEventListener('click', function () { document.getElementById('finish-btn').addEventListener('click', function () {
var msg = document.getElementById('finish-msg'); var msg = document.getElementById('finish-msg');
msg.textContent = '正在完成设置...'; msg.textContent = '正在完成设置...';
var xhr = new XMLHttpRequest(); Auth.request('POST', '/api/auth/mfa/complete', null, function (res, status) {
xhr.open('POST', '/api/auth/mfa/complete'); if (status === 200) {
xhr.setRequestHeader('Content-Type', 'application/json'); if (res.access_token) Auth.setAccess(res.access_token);
xhr.onload = function () {
if (xhr.status === 200) {
location.href = '/'; location.href = '/';
} else { } else {
var res = JSON.parse(xhr.responseText || '{}');
msg.textContent = res.error || '完成失败,请重试'; msg.textContent = res.error || '完成失败,请重试';
} }
}; });
xhr.send();
}); });
// ─── Helpers ──────────────────────────── // ─── 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 Run: python3 tests/test_multi_user.py
Covers: Root bootstrap, legacy/new-DB migration, admin registration (both modes), Covers: Root bootstrap, legacy/new-DB migration + schema-version gate,
temp-account lifecycle, data isolation, admin-only enforcement, re-auth admin registration (both modes), temp-account lifecycle, data isolation,
(TOTP + passkey fallback), deletion, kick-all, SSE scoping, rate limits, CSRF. 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 base64
import io import io
import json import json
import os import os
import shutil import shutil
import sqlite3
import sys import sys
import tempfile import tempfile
import time import time
@@ -21,17 +24,55 @@ import pyotp
from backend.app import create_app from backend.app import create_app
from backend.models import ( from backend.models import (
init_db, _EMITTED_PASSWORDS, get_auth_config, get_temp_password, init_db, _EMITTED_PASSWORDS, DB_VERSION, _read_db_version, get_db,
get_users, get_user, is_temp_account, get_temp_totp_secret, get_auth_config, get_temp_password, get_users, get_user,
get_active_user_ids, is_temp_account, get_temp_totp_secret, get_active_user_ids,
create_user, update_auth_config as uac,
) )
from backend.events import EventBus from backend.events import EventBus
PASS, FAIL = 0, 0 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): def _groups_for(db):
import sqlite3
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0] n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0]
conn.close() conn.close()
@@ -39,7 +80,6 @@ def _groups_for(db):
def _count(db, table, user_id): def _count(db, table, user_id):
import sqlite3
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0] n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
conn.close() conn.close()
@@ -62,34 +102,40 @@ def fresh_app(tmp):
return app, db return app, db
def login_temp(app, code, view="control"): def do_login(client, code, view="control"):
return app.test_client().post("/api/auth/login", json={"code": code, "view": view}) """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): 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") r = client.post("/api/auth/totp/setup")
if r.status_code != 200: if r.status_code != 200:
return None, r return None, r
secret = r.get_json()["secret"] secret = r.get_json()["secret"]
code = pyotp.TOTP(secret).now() code = pyotp.TOTP(secret).now()
r2 = client.post("/api/auth/totp/verify", json={"code": code}) 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 return secret, r2
def root_client(app, db, setup_mfa=True): 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() c = app.test_client()
pw = get_temp_password(db, 0) pw = get_temp_password(db, 0)
r = c.post("/api/auth/login", json={"code": pw, "view": "control"}) tc, _ = do_login(c, pw)
assert r.status_code == 200, r.get_json()
if setup_mfa: if setup_mfa:
secret, _ = setup_totp(c) secret, _ = setup_totp(tc)
assert secret, "root TOTP setup failed" 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 assert st["authenticated"], st
return c return tc
def reauth_totp(client, secret): 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") check("Root role is admin", root["role"] == "admin" and root["remark"] == "Root")
pw = get_temp_password(db, 0) pw = get_temp_password(db, 0)
check("Root temp password generated", bool(pw)) 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")) check("Temp-password login → temp_pending", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
c = app.test_client() check("Login returns access token", bool(r.get_json().get("access_token")))
c.post("/api/auth/login", json={"code": pw, "view": "control"}) 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() st = c.get("/api/auth/status").get_json()
check("Status reports temp_pending", st["temp_pending"] is True) check("Status reports temp_pending", st["temp_pending"] is True)
secret, _ = setup_totp(c) 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 is admin in status", st["user"]["is_admin"] is True)
check("Root remark visible (not id)", st["user"]["remark"] == "Root") check("Root remark visible (not id)", st["user"]["remark"] == "Root")
# TOTP login path # TOTP login path
c2 = app.test_client() c2, _ = do_login(app.test_client(), pyotp.TOTP(secret).now())
r = c2.post("/api/auth/login", json={"code": pyotp.TOTP(secret).now(), "view": "control"}) check("Root TOTP login works", bool(c2.access)
check("Root TOTP login works", r.status_code == 200 and not r.get_json().get("must_configure_mfa")) and not c2.get("/api/auth/status").get_json().get("temp_pending"))
# wrong code # 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) 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()
print("=" * 60) print("=" * 60)
@@ -138,8 +188,7 @@ print("TEST 2: Legacy single-user DB migration → data becomes Root's")
print("=" * 60) print("=" * 60)
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth) # Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
legacy = os.path.join(tmp, "legacy.db") legacy = os.path.join(tmp, "legacy.db")
import sqlite3 as _sq _lc = sqlite3.connect(legacy)
_lc = _sq.connect(legacy)
_lc.executescript(""" _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_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); 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 calendar groups → Root (user 0)", _groups_for(legacy) >= 3)
check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1) 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))) check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0)))
import sqlite3
conn = sqlite3.connect(legacy) conn = sqlite3.connect(legacy)
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")] tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
conn.close() conn.close()
check("camera_config table dropped", "camera_config" not in tables) check("camera_config table dropped", "camera_config" not in tables)
check("users table exists", "users" 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()
print("=" * 60) 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) print("=" * 60)
db3 = os.path.join(tmp, "roundtrip.db") db3 = os.path.join(tmp, "roundtrip.db")
init_db(db3) 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) 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.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
conn.commit() conn.commit()
conn.close() conn.close()
init_db(db3) # simulate an old DB (version 0 → migration path) by rewriting meta
users = get_users(db3) conn = sqlite3.connect(db3b)
check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users)) conn.execute("UPDATE meta SET version=0 WHERE id=1")
conn = sqlite3.connect(db3) conn.commit()
n = conn.execute("SELECT COUNT(*) FROM alarms WHERE user_id=? AND label='test'", (uid,)).fetchone()[0]
conn.close() 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()
print("=" * 60) print("=" * 60)
print("TEST 4: MFA-configured DB migration → no new temp password") print("TEST 4: MFA-configured DB migration → no new temp password")
print("=" * 60) print("=" * 60)
db4 = os.path.join(tmp, "mfa.db") db4 = os.path.join(tmp, "mfa.db")
init_db(db4)
c = app_ = None
app4 = create_app(db_path=db4) app4 = create_app(db_path=db4)
c4 = root_client(app4, db4) c4 = root_client(app4, db4)
_EMITTED_PASSWORDS.clear() _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) == "") check("No temp password for MFA-configured root", get_temp_password(db4, 0) == "")
cfg = get_auth_config(db4, 0) cfg = get_auth_config(db4, 0)
check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0) check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0)
@@ -214,7 +291,6 @@ print("=" * 60)
db5 = os.path.join(tmp, "adm.db") db5 = os.path.join(tmp, "adm.db")
app5 = create_app(db_path=db5) app5 = create_app(db_path=db5)
root = root_client(app5, db5) root = root_client(app5, db5)
root_secret = None # captured below via get_auth_config
root_secret = get_auth_config(db5, 0)["totp_secret"] root_secret = get_auth_config(db5, 0)["totp_secret"]
# Reauth required first # Reauth required first
r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"}) 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"] secret_b = r.get_json()["secret"]
root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()}) root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()})
# User A logs in # User A logs in
ua = app5.test_client() ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
r = ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) check("User A TOTP login", bool(ua.access))
check("User A TOTP login", r.status_code == 200)
st = ua.get("/api/auth/status").get_json() 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 authenticated, not admin", st["authenticated"] and st["user"]["is_admin"] is False)
check("User A remark correct", st["user"]["remark"] == "User A") 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) 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"]) 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 # user logs in with the temp TOTP
tu_client = app5.test_client() tu_client, _ = do_login(app5.test_client(), pyotp.TOTP(temp_secret).now())
r = tu_client.post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"}) check("Temp user login via temp TOTP → must_configure_mfa", bool(tu_client.access))
check("Temp user login via temp TOTP", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
st = tu_client.get("/api/auth/status").get_json() 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"]) 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") r = tu_client.post("/api/auth/totp/setup")
new_secret = r.get_json()["secret"] new_secret = r.get_json()["secret"]
r = tu_client.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(new_secret).now()}) 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("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) 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) == "") check("Temp TOTP secret cleared", get_temp_totp_secret(temp_id, db5) == "")
r = root.get(f"/api/admin/users/{temp_id}/totp") r = root.get(f"/api/admin/users/{temp_id}/totp")
check("Admin can no longer see temp TOTP", r.status_code == 400) check("Admin can no longer see temp TOTP", r.status_code == 400)
@@ -313,8 +395,7 @@ print()
print("=" * 60) print("=" * 60)
print("TEST 8: Data isolation between users") print("TEST 8: Data isolation between users")
print("=" * 60) print("=" * 60)
ua = app5.test_client() ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
# User A creates data # User A creates data
r = ua.post("/api/calendar/groups", json={"name": "A's Group"}) r = ua.post("/api/calendar/groups", json={"name": "A's Group"})
group_a = r.get_json()["id"] 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}) 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) check("User A creates data", r.status_code == 201)
# User B (the default-remark user) sees none of it # User B (the default-remark user) sees none of it
ub = app5.test_client() ub, _ = do_login(app5.test_client(), pyotp.TOTP(secret_b).now())
ub.post("/api/auth/login", json={"code": pyotp.TOTP(secret_b).now(), "view": "control"})
r = ub.get("/api/calendar/groups").get_json() r = ub.get("/api/calendar/groups").get_json()
check("User B sees no groups", r == []) check("User B sees no groups", r == [])
r = ub.get("/api/alarms").get_json() r = ub.get("/api/alarms").get_json()
@@ -346,8 +426,7 @@ print("=" * 60)
print("TEST 9: Edit remark + delete user + session invalidation") print("TEST 9: Edit remark + delete user + session invalidation")
print("=" * 60) print("=" * 60)
# Fresh root session → no re-auth marker → admin ops must demand reauth. # Fresh root session → no re-auth marker → admin ops must demand reauth.
fresh_root = app5.test_client() fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
fresh_root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
r = fresh_root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"}) 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")) check("Edit remark without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required"))
rr = reauth_totp(root, root_secret) 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}") r = root.delete(f"/api/admin/users/{user_b_id}")
check("Delete user with reauth", r.status_code == 200) check("Delete user with reauth", r.status_code == 200)
check("User gone", get_user(user_b_id, db5) is None) 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") 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 # cannot delete root / self
r = root.delete("/api/admin/users/0") r = root.delete("/api/admin/users/0")
check("Cannot delete Root", r.status_code == 400) check("Cannot delete Root", r.status_code == 400)
@@ -369,26 +450,95 @@ check("Self-delete guard (root)", r.status_code == 400)
print() print()
print("=" * 60) 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) print("=" * 60)
ua2 = app5.test_client() # Two devices of the same account + one root device
ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) 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") r = ua2.post("/api/display/kick_all")
check("Regular user blocked from kick_all", r.status_code == 403) check("User kicks own account devices → 200", r.status_code == 200)
r = root.post("/api/display/kick_all") check("Device 1 (controller itself) kicked", ua2.get("/api/alarms").status_code == 401)
check("Admin kick_all works", r.status_code == 200) check("Device 2 kicked", ua2_2.get("/api/alarms").status_code == 401)
r = ua2.get("/api/alarms") check("Kicked device refresh also dead", ua2_2.post("/api/auth/refresh").status_code == 401)
check("User A session invalidated by kick_all", r.status_code == 401) check("Root unaffected by user's self-kick", root.get("/api/alarms").status_code == 200)
r = root.get("/api/alarms") # Root kicks every user (including itself) — other logged-in users die too
check("Root session also invalidated by kick_all", r.status_code == 401) ua2_3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
# re-login works after kick_all rr = reauth_totp(root, root_secret)
r = root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"}) r = root.post("/api/admin/kick_all_users")
check("Re-login after kick_all", r.status_code == 200) 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()
print("=" * 60) print("=" * 60)
print("TEST 11: SSE / EventBus user scoping") print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)")
print("=" * 60) 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() bus = EventBus()
q1 = bus.subscribe(user_id=1) q1 = bus.subscribe(user_id=1)
q2 = bus.subscribe(user_id=2) q2 = bus.subscribe(user_id=2)
@@ -407,7 +557,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None)
print() print()
print("=" * 60) print("=" * 60)
print("TEST 12: Rate limit on register-request") print("TEST 13: Rate limit on register-request")
print("=" * 60) print("=" * 60)
for i in range(5): for i in range(5):
app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"}) 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()
print("=" * 60) print("=" * 60)
print("TEST 13: CSRF origin check on admin operations") print("TEST 14: CSRF origin check on admin operations")
print("=" * 60) 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"}, r = root.post("/api/admin/users", json={"remark": "CSRF", "mode": "admin_fills"},
headers={"Origin": "http://evil.example"}) headers={"Origin": "http://evil.example"})
check("Cross-origin admin POST blocked", r.status_code == 403) check("Cross-origin admin POST blocked", r.status_code == 403)
r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"}) r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"})
check("Cross-origin GET allowed (safe)", r.status_code == 200) 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()
print("=" * 60) print("=" * 60)
print("TEST 14: Passkey flows (mocked ceremonies)") print("TEST 15: Passkey flows (mocked ceremonies)")
print("=" * 60) print("=" * 60)
from backend import webauthn_mgr from backend import webauthn_mgr
import backend.routes as routes_mod 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), "ok": True, "credential_id": _cred_hex(credential),
} }
db14 = os.path.join(tmp, "passkey.db") db15 = os.path.join(tmp, "passkey.db")
app14 = create_app(db_path=db14) app15 = create_app(db_path=db15)
root14 = root_client(app14, db14) root15 = root_client(app15, db15)
root14_secret = get_auth_config(db14, 0)["totp_secret"] root15_secret = get_auth_config(db15, 0)["totp_secret"]
rr = reauth_totp(root14, root14_secret) rr = reauth_totp(root15, root15_secret)
r = root14.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"}) r = root15.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"})
pk_uid = r.get_json()["user"]["id"] pk_uid = r.get_json()["user"]["id"]
# admin provisions passkey (ceremony in admin browser) # 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()) 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) check("Admin passkey complete stored", r.status_code == 200)
# passkey login for that user # passkey login for that user
c = app14.test_client() raw15 = app15.test_client()
r = c.post("/api/auth/webauthn/login/begin") r = raw15.post("/api/auth/webauthn/login/begin")
check("Passkey login begin", r.status_code == 200) check("Passkey login begin", r.status_code == 200)
r = c.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}}) r = raw15.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}})
check("Passkey login identifies user", r.status_code == 200) check("Passkey login identifies user", r.status_code == 200 and r.get_json().get("access_token"))
st = c.get("/api/auth/status").get_json() 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") check("Logged in as the passkey user", st["user"]["remark"] == "PK User")
# passkey reauth (admin, TOTP-less fallback): give root a passkey first # 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=db15)
uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db14) raw15b = app15.test_client()
root14_c2 = app14.test_client() root15b, _ = do_login(raw15b, pyotp.TOTP(root15_secret).now())
root14_c2.post("/api/auth/login", json={"code": pyotp.TOTP(root14_secret).now(), "view": "control"}) r = root15b.post("/api/auth/reauth/webauthn/begin")
r = root14_c2.post("/api/auth/reauth/webauthn/begin")
check("Passkey reauth begin", r.status_code == 200) check("Passkey reauth begin", r.status_code == 200)
# complete with an unknown credential should fail (root's stored differs) # 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) 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) check("Passkey reauth succeeds with matching credential", r.status_code == 200)
# passkey first-config for a temp account → finalized via passkey # 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"] tuid = r.get_json()["user"]["id"]
tsec = get_temp_totp_secret(tuid, db14) tsec = get_temp_totp_secret(tuid, db15)
tc = app14.test_client() tc15, _ = do_login(app15.test_client(), pyotp.TOTP(tsec).now())
tc.post("/api/auth/login", json={"code": pyotp.TOTP(tsec).now(), "view": "control"}) r = tc15.post("/api/auth/webauthn/register/begin")
r = tc.post("/api/auth/webauthn/register/begin")
check("Temp user passkey register begin", r.status_code == 200) 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 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) check("Finalized passkey carries new access token", bool(r.get_json().get("access_token")))
r = root14.get(f"/api/admin/users/{tuid}/totp") 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) check("Temp TOTP gone after passkey finalize", r.status_code == 400)
# restore # restore
@@ -513,49 +672,46 @@ routes_mod.complete_authentication = orig_complete_auth
print() print()
print("=" * 60) 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) print("=" * 60)
from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id 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") db16 = os.path.join(tmp, "sched.db")
app15 = create_app(db_path=db15) app16 = create_app(db_path=db16)
xid = cu("Sched User", db_path=db15) xid = create_user("Sched User", db_path=db16)
conn = sqlite3.connect(db15) conn = sqlite3.connect(db16)
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,)) conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
conn.commit() conn.commit()
conn.close() conn.close()
set_thread_user(xid) set_thread_user(xid)
with app15.app_context(): with app16.app_context():
check("Thread user resolves", _ctx_user_id() == xid) check("Thread user resolves", _ctx_user_id() == xid)
check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York") check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York")
clear_thread_user() clear_thread_user()
with app15.app_context(): with app16.app_context():
check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai") check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai")
# manager calls without request context are user-scoped via thread context # manager calls without request context are user-scoped via thread context
set_thread_user(xid) set_thread_user(xid)
with app15.app_context(): with app16.app_context():
from backend import alarm_mgr, countdown_mgr from backend import alarm_mgr, countdown_mgr
r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid) r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid)
check("Alarm created for thread user", r["id"] > 0) check("Alarm created for thread user", r["id"] > 0)
alarms = alarm_mgr.get_alarms() alarms = alarm_mgr.get_alarms()
check("Manager resolves thread user without request context", len(alarms) == 1) check("Manager resolves thread user without request context", len(alarms) == 1)
clear_thread_user() clear_thread_user()
with app15.app_context(): with app16.app_context():
alarms0 = alarm_mgr.get_alarms(user_id=0) alarms0 = alarm_mgr.get_alarms(user_id=0)
check("Root sees no other user's alarms", alarms0 == []) check("Root sees no other user's alarms", alarms0 == [])
# scheduler boots and runs per-user jobs without crashing # scheduler boots and runs per-user jobs without crashing
from backend.scheduler import start_scheduler, stop_scheduler from backend.scheduler import start_scheduler, stop_scheduler
start_scheduler(app15) start_scheduler(app16)
import time
time.sleep(1.5) time.sleep(1.5)
stop_scheduler() stop_scheduler()
check("Scheduler starts and stops cleanly", True) check("Scheduler starts and stops cleanly", True)
print() print()
print("=" * 60) 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) print("=" * 60)
from backend.url_util import is_safe_public_url, is_safe_https_host 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) 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: 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) check("Host: public host accepted", is_safe_https_host("example.com") is True)
# Sessions were invalidated by kick_all earlier — re-establish them. # Re-establish sessions (kicked earlier) — Root included
ua = app5.test_client() root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"}) 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. # User B was deleted in TEST 9 — create a fresh user C for cross-user checks.
rr = reauth_totp(root, root_secret) rr = reauth_totp(root, root_secret)
r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"}) 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") r = root.post(f"/api/admin/users/{uc_id}/totp/begin")
secret_c = r.get_json()["secret"] secret_c = r.get_json()["secret"]
root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()}) root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()})
uc = app5.test_client() uc, _ = do_login(app5.test_client(), pyotp.TOTP(secret_c).now())
uc.post("/api/auth/login", json={"code": pyotp.TOTP(secret_c).now(), "view": "control"})
# reauth requires an authenticated session (no public TOTP oracle) # reauth requires an authenticated session (no public TOTP oracle)
anon = app5.test_client() 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) 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"}) 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) 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"}) 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) 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") 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") r = root.post(f"/api/admin/users/{fd_id}/totp/begin")
fd_secret = r.get_json()["secret"] fd_secret = r.get_json()["secret"]
root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()}) root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()})
fc = app5.test_client() fc, _ = do_login(app5.test_client(), pyotp.TOTP(fd_secret).now())
fc.post("/api/auth/login", json={"code": pyotp.TOTP(fd_secret).now(), "view": "control"})
g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json() 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/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"})
fc.post("/api/alarms", json={"label": "a", "time": "10:00"}) fc.post("/api/alarms", json={"label": "a", "time": "10:00"})
fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999}) 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] 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() conn.close()
check("Cascade test user has data", sum(before.values()) >= 5) check("Cascade test user has data", sum(before.values()) >= 5)
rr = reauth_totp(root, root_secret) rr = reauth_totp(root, root_secret)
r = root.delete(f"/api/admin/users/{fd_id}") r = root.delete(f"/api/admin/users/{fd_id}")
check("Delete full-data user succeeds", r.status_code == 200) 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} after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before}
conn.close() conn.close()
check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after)) 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()
print("=" * 60) print("=" * 60)
@@ -679,13 +833,9 @@ print("TEST 19: 账户独立主题(dark/light/auto)")
print("=" * 60) print("=" * 60)
db19 = os.path.join(tmp, "theme.db") db19 = os.path.join(tmp, "theme.db")
app19 = create_app(db_path=db19) app19 = create_app(db_path=db19)
c19 = app19.test_client()
pw19 = get_temp_password(db19, 0) pw19 = get_temp_password(db19, 0)
c19.post("/api/auth/login", json={"code": pw19, "view": "control"}) c19, _ = do_login(app19.test_client(), pw19)
c19.post("/api/auth/totp/setup") setup_totp(c19)
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")
r = c19.get("/api/theme") r = c19.get("/api/theme")
check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark") 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() anon19 = app19.test_client()
check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401) 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()
print("=" * 60) print("=" * 60)
print(f"RESULT: {PASS} passed, {FAIL} failed") print(f"RESULT: {PASS} passed, {FAIL} failed")