Flask Session -> Double Token
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+142
-78
@@ -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
@@ -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
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,14 +523,18 @@
|
|||||||
|
|
||||||
$('kick-all-btn').addEventListener('click', function () {
|
$('kick-all-btn').addEventListener('click', function () {
|
||||||
var msg = $('mode-msg');
|
var msg = $('mode-msg');
|
||||||
api('POST', '/api/display/kick_all', null, function (res) {
|
showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () {
|
||||||
if (res.ok) {
|
msg.textContent = '正在下线全部设备...';
|
||||||
msg.textContent = '已踢掉所有显示端';
|
api('POST', '/api/display/kick_all', null, function (res) {
|
||||||
msg.style.color = '#2e7d32';
|
if (res.ok) {
|
||||||
} else {
|
// 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成
|
||||||
msg.textContent = res.error || '操作失败';
|
msg.textContent = '已下线本账号全部设备,即将跳转登录页';
|
||||||
msg.style.color = '#E05555';
|
msg.style.color = '#2e7d32';
|
||||||
}
|
} else {
|
||||||
|
msg.textContent = res.error || '操作失败';
|
||||||
|
msg.style.color = '#E05555';
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
+115
-154
@@ -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, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 账户时区时间 ─────────────────────────
|
||||||
|
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
|
||||||
|
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
|
||||||
|
function _pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function _tzNow() {
|
||||||
|
var tz = state.timezone || 'Asia/Shanghai';
|
||||||
|
var out = { y: 1970, mo: 1, d: 1, h: 0, mi: 0, s: 0, wd: 0 };
|
||||||
|
try {
|
||||||
|
var parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hourCycle: 'h23', weekday: 'short'
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
var wdMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||||
|
parts.forEach(function (p) {
|
||||||
|
switch (p.type) {
|
||||||
|
case 'year': out.y = parseInt(p.value, 10); break;
|
||||||
|
case 'month': out.mo = parseInt(p.value, 10); break; // 1-12
|
||||||
|
case 'day': out.d = parseInt(p.value, 10); break;
|
||||||
|
case 'hour': out.h = parseInt(p.value, 10); break;
|
||||||
|
case 'minute': out.mi = parseInt(p.value, 10); break;
|
||||||
|
case 'second': out.s = parseInt(p.value, 10); break;
|
||||||
|
case 'weekday': out.wd = wdMap[p.value] || 0; break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
var l = new Date();
|
||||||
|
out = { y: l.getFullYear(), mo: l.getMonth() + 1, d: l.getDate(),
|
||||||
|
h: l.getHours(), mi: l.getMinutes(), s: l.getSeconds(), wd: l.getDay() };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 "YYYYMMDDTHHMMSS" 比较键(与后端本地时间字符串可比)
|
||||||
|
function _nowLocalKey() {
|
||||||
|
var n = _tzNow();
|
||||||
|
return String(n.y) + _pad2(n.mo) + _pad2(n.d) + 'T' +
|
||||||
|
_pad2(n.h) + _pad2(n.mi) + _pad2(n.s);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Panel switching ──────────────────────
|
// ─── 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,36 +117,36 @@
|
|||||||
|
|
||||||
// ─── 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 () {
|
state.weather = data.weather || {};
|
||||||
if (xhr.status === 401) {
|
state.hourly = data.hourly || [];
|
||||||
var res = JSON.parse(xhr.responseText || '{}');
|
state.events = data.events || [];
|
||||||
if (res.login_required) {
|
state.alarms = data.alarms || [];
|
||||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
state.countdowns = data.countdowns || [];
|
||||||
return;
|
state.update_schedule = data.update_schedule || {};
|
||||||
}
|
state.minutely = data.minutely || null;
|
||||||
}
|
state.warnings = data.warnings || null;
|
||||||
if (xhr.status === 200) {
|
state.groups = data.groups || [];
|
||||||
var data = JSON.parse(xhr.responseText);
|
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
|
||||||
state.weather = data.weather || {};
|
renderAll();
|
||||||
state.hourly = data.hourly || [];
|
});
|
||||||
state.events = data.events || [];
|
|
||||||
state.alarms = data.alarms || [];
|
|
||||||
state.countdowns = data.countdowns || [];
|
|
||||||
state.update_schedule = data.update_schedule || {};
|
|
||||||
state.minutely = data.minutely || null;
|
|
||||||
state.warnings = data.warnings || null;
|
|
||||||
state.groups = data.groups || [];
|
|
||||||
renderAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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 () {
|
es.close();
|
||||||
if (g.status === 401) {
|
clearInterval(state._sseGuard);
|
||||||
var res = JSON.parse(g.responseText || '{}');
|
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||||
if (res.login_required) {
|
|
||||||
es.close();
|
|
||||||
clearInterval(state._sseGuard);
|
|
||||||
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) {
|
renderPomodoro();
|
||||||
try {
|
renderPomodoroCard();
|
||||||
state.pomodoro = JSON.parse(xhr.responseText);
|
|
||||||
renderPomodoro();
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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) {
|
if (cb) cb(d);
|
||||||
var d = {};
|
}, { noRedirect: true });
|
||||||
try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
|
|
||||||
if (d.effective) apply(d.effective);
|
|
||||||
if (cb) cb(d);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function () { /* 网络失败保持当前主题 */ };
|
|
||||||
xhr.send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/token.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -149,21 +150,28 @@
|
|||||||
|
|
||||||
// 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
|
||||||
es.onmessage = function (e) {
|
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
|
||||||
try {
|
if (status !== 201 || !res.ticket) {
|
||||||
var msg = JSON.parse(e.data);
|
setTimeout(connectSSE, 5000);
|
||||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
return;
|
||||||
location.reload();
|
}
|
||||||
} else if (msg.type === 'custom_notification') {
|
var es = new EventSource('/api/events?ticket=' + encodeURIComponent(res.ticket));
|
||||||
showCustomNotification(msg.data);
|
es.onmessage = function (e) {
|
||||||
}
|
try {
|
||||||
} catch (_) {}
|
var msg = JSON.parse(e.data);
|
||||||
};
|
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||||
es.onerror = function () {
|
location.reload();
|
||||||
es.close();
|
} else if (msg.type === 'custom_notification') {
|
||||||
setTimeout(connectSSE, 5000);
|
showCustomNotification(msg.data);
|
||||||
};
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
es.onerror = function () {
|
||||||
|
es.close();
|
||||||
|
setTimeout(connectSSE, 5000);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
connectSSE();
|
connectSSE();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,29 +208,21 @@
|
|||||||
|
|
||||||
// ─── 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 () {
|
// Temp accounts must configure ONE login method to activate the account.
|
||||||
if (xhr.status === 200) {
|
if (s.is_temp_account) {
|
||||||
var s = JSON.parse(xhr.responseText);
|
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
|
||||||
// Temp accounts must configure ONE login method to activate the account.
|
document.querySelector('.subtitle').textContent =
|
||||||
if (s.is_temp_account) {
|
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
|
||||||
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
|
|
||||||
document.querySelector('.subtitle').textContent =
|
|
||||||
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
|
|
||||||
}
|
|
||||||
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
|
|
||||||
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
|
|
||||||
if (s.recovery_codes_remaining > 0) {
|
|
||||||
showRecoveryCodes();
|
|
||||||
}
|
|
||||||
updateFinishBtn();
|
|
||||||
} else if (xhr.status === 401) {
|
|
||||||
// Not logged in — redirect to login
|
|
||||||
location.href = '/login?view=control&next=/mfa-setup';
|
|
||||||
}
|
}
|
||||||
};
|
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
|
||||||
xhr.send();
|
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
|
||||||
|
if (s.recovery_codes_remaining > 0) {
|
||||||
|
showRecoveryCodes();
|
||||||
|
}
|
||||||
|
updateFinishBtn();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
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 () {
|
showRecoveryCodesList(data.codes);
|
||||||
if (xhr.status === 200) {
|
|
||||||
var data = JSON.parse(xhr.responseText);
|
|
||||||
if (data.codes && data.codes.length > 0) {
|
|
||||||
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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user