Flask Session -> Double Token

This commit is contained in:
喆OvO
2026-08-11 19:57:21 +08:00
parent 0fef309972
commit cbbb2e6d34
18 changed files with 1375 additions and 607 deletions
+352 -136
View File
@@ -1,15 +1,18 @@
"""Multi-user system test suite.
"""Multi-user system test suite (dual-token auth).
Run: python3 tests/test_multi_user.py
Covers: Root bootstrap, legacy/new-DB migration, admin registration (both modes),
temp-account lifecycle, data isolation, admin-only enforcement, re-auth
(TOTP + passkey fallback), deletion, kick-all, SSE scoping, rate limits, CSRF.
Covers: Root bootstrap, legacy/new-DB migration + schema-version gate,
admin registration (both modes), temp-account lifecycle, data isolation,
admin-only enforcement, re-auth (TOTP + passkey fallback), deletion,
kick devices (self account + Root kick-all), access/refresh token lifecycle,
SSE ticket scoping, rate limits, CSRF, per-account timezone/theme.
"""
import base64
import io
import json
import os
import shutil
import sqlite3
import sys
import tempfile
import time
@@ -21,17 +24,55 @@ import pyotp
from backend.app import create_app
from backend.models import (
init_db, _EMITTED_PASSWORDS, get_auth_config, get_temp_password,
get_users, get_user, is_temp_account, get_temp_totp_secret,
get_active_user_ids,
init_db, _EMITTED_PASSWORDS, DB_VERSION, _read_db_version, get_db,
get_auth_config, get_temp_password, get_users, get_user,
is_temp_account, get_temp_totp_secret, get_active_user_ids,
create_user, update_auth_config as uac,
)
from backend.events import EventBus
PASS, FAIL = 0, 0
class TClient:
"""Flask test client wrapper that attaches the access token to each request.
Refresh-token cookies are managed automatically by the underlying client."""
def __init__(self, client, access=None):
self._c = client
self.access = access
def set_access(self, access):
self.access = access
def _h(self, headers):
headers = dict(headers or {})
if self.access and "Authorization" not in headers:
headers["Authorization"] = "Bearer " + self.access
return headers
def get(self, *a, **kw):
kw["headers"] = self._h(kw.pop("headers", None))
return self._c.get(*a, **kw)
def post(self, *a, **kw):
kw["headers"] = self._h(kw.pop("headers", None))
return self._c.post(*a, **kw)
def put(self, *a, **kw):
kw["headers"] = self._h(kw.pop("headers", None))
return self._c.put(*a, **kw)
def delete(self, *a, **kw):
kw["headers"] = self._h(kw.pop("headers", None))
return self._c.delete(*a, **kw)
def patch(self, *a, **kw):
kw["headers"] = self._h(kw.pop("headers", None))
return self._c.patch(*a, **kw)
def _groups_for(db):
import sqlite3
conn = sqlite3.connect(db)
n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE user_id=0").fetchone()[0]
conn.close()
@@ -39,7 +80,6 @@ def _groups_for(db):
def _count(db, table, user_id):
import sqlite3
conn = sqlite3.connect(db)
n = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id=?", (user_id,)).fetchone()[0]
conn.close()
@@ -62,34 +102,40 @@ def fresh_app(tmp):
return app, db
def login_temp(app, code, view="control"):
return app.test_client().post("/api/auth/login", json={"code": code, "view": view})
def do_login(client, code, view="control"):
"""Login on a raw test client; returns (TClient-with-access, login_response)."""
r = client.post("/api/auth/login", json={"code": code, "view": view})
assert r.status_code == 200, r.get_json()
d = r.get_json()
return TClient(client, access=d.get("access_token")), d
def setup_totp(client, secret=None):
"""Complete the temp→TOTP MFA setup flow for the current temp-pending session."""
"""Complete the temp→TOTP MFA setup flow for the current temp-pending session.
`client` may be a TClient (its access token is updated after finalize)."""
r = client.post("/api/auth/totp/setup")
if r.status_code != 200:
return None, r
secret = r.get_json()["secret"]
code = pyotp.TOTP(secret).now()
r2 = client.post("/api/auth/totp/verify", json={"code": code})
client.post("/api/auth/mfa/complete")
r3 = client.post("/api/auth/mfa/complete")
if hasattr(client, "set_access") and r3.status_code == 200 and r3.get_json().get("access_token"):
client.set_access(r3.get_json()["access_token"])
return secret, r2
def root_client(app, db, setup_mfa=True):
"""Return a fully-authenticated Root test client (TOTP configured)."""
"""Return a fully-authenticated Root TClient (TOTP configured)."""
c = app.test_client()
pw = get_temp_password(db, 0)
r = c.post("/api/auth/login", json={"code": pw, "view": "control"})
assert r.status_code == 200, r.get_json()
tc, _ = do_login(c, pw)
if setup_mfa:
secret, _ = setup_totp(c)
secret, _ = setup_totp(tc)
assert secret, "root TOTP setup failed"
st = c.get("/api/auth/status").get_json()
st = tc.get("/api/auth/status").get_json()
assert st["authenticated"], st
return c
return tc
def reauth_totp(client, secret):
@@ -113,10 +159,11 @@ root = get_user(0, db)
check("Root role is admin", root["role"] == "admin" and root["remark"] == "Root")
pw = get_temp_password(db, 0)
check("Root temp password generated", bool(pw))
r = login_temp(app, pw)
r = app.test_client().post("/api/auth/login", json={"code": pw, "view": "control"})
check("Temp-password login → temp_pending", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
c = app.test_client()
c.post("/api/auth/login", json={"code": pw, "view": "control"})
check("Login returns access token", bool(r.get_json().get("access_token")))
check("Refresh cookie set", "refresh_token" in r.headers.get("Set-Cookie", ""))
c, _ = do_login(app.test_client(), pw)
st = c.get("/api/auth/status").get_json()
check("Status reports temp_pending", st["temp_pending"] is True)
secret, _ = setup_totp(c)
@@ -125,12 +172,15 @@ check("Root fully authenticated after TOTP setup", st["authenticated"] is True)
check("Root is admin in status", st["user"]["is_admin"] is True)
check("Root remark visible (not id)", st["user"]["remark"] == "Root")
# TOTP login path
c2 = app.test_client()
r = c2.post("/api/auth/login", json={"code": pyotp.TOTP(secret).now(), "view": "control"})
check("Root TOTP login works", r.status_code == 200 and not r.get_json().get("must_configure_mfa"))
c2, _ = do_login(app.test_client(), pyotp.TOTP(secret).now())
check("Root TOTP login works", bool(c2.access)
and not c2.get("/api/auth/status").get_json().get("temp_pending"))
# wrong code
r = c2.post("/api/auth/login", json={"code": "000000", "view": "control"})
r = app.test_client().post("/api/auth/login", json={"code": "000000", "view": "control"})
check("Wrong code rejected", r.status_code == 401)
# no/bad token → 401
check("No token → 401", app.test_client().get("/api/theme").status_code == 401)
check("Bad token → 401", app.test_client().get("/api/theme", headers={"Authorization": "Bearer nope"}).status_code == 401)
print()
print("=" * 60)
@@ -138,8 +188,7 @@ print("TEST 2: Legacy single-user DB migration → data becomes Root's")
print("=" * 60)
# Build a legacy single-user database (old schema: no users/user_id, pin-only auth)
legacy = os.path.join(tmp, "legacy.db")
import sqlite3 as _sq
_lc = _sq.connect(legacy)
_lc = sqlite3.connect(legacy)
_lc.executescript("""
CREATE TABLE calendar_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#4A90D9', enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE calendar_sources (id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, url TEXT, ics_data TEXT, last_sync TEXT);
@@ -164,45 +213,73 @@ check("Root created after legacy migration", get_user(0, legacy) is not None)
check("Legacy calendar groups → Root (user 0)", _groups_for(legacy) >= 3)
check("Legacy alarms migrated to user 0", _count(legacy, "alarms", 0) >= 1)
check("Root gets a fresh temp password (legacy had only PIN)", bool(get_temp_password(legacy, 0)))
import sqlite3
conn = sqlite3.connect(legacy)
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
conn.close()
check("camera_config table dropped", "camera_config" not in tables)
check("users table exists", "users" in tables)
check("meta table exists", "meta" in tables)
check("auth_tokens table exists", "auth_tokens" in tables)
check("Legacy DB stamped with program version", _read_db_version(get_db(legacy)) == DB_VERSION)
get_db(legacy).close()
print()
print("=" * 60)
print("TEST 3: Round-trip migration (new schema → new schema)")
print("TEST 3: Schema-version gate (same→skip, newer→abort, migrate→stamp)")
print("=" * 60)
db3 = os.path.join(tmp, "roundtrip.db")
init_db(db3)
_EMITTED_PASSWORDS.clear()
uid = __import__("backend.models", fromlist=["create_user"]).create_user("Round Trip", db_path=db3)
import sqlite3
conn = sqlite3.connect(db3)
mtime1 = os.stat(db3).st_mtime_ns
conn.close()
init_db(db3)
check("Same-version init skips rebuild", os.stat(db3).st_mtime_ns == mtime1)
conn = get_db(db3)
check("Version still matches", _read_db_version(conn) == DB_VERSION)
conn.close()
# user data preserved across a version-mismatch migration (simulate future bump)
db3b = os.path.join(tmp, "migrate_up.db")
init_db(db3b)
uid = create_user("Round Trip", db_path=db3b)
conn = sqlite3.connect(db3b)
conn.execute("INSERT INTO alarms (user_id, label, time) VALUES (?, 'test', '07:00')", (uid,))
conn.commit()
conn.close()
init_db(db3)
users = get_users(db3)
check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users))
conn = sqlite3.connect(db3)
n = conn.execute("SELECT COUNT(*) FROM alarms WHERE user_id=? AND label='test'", (uid,)).fetchone()[0]
# simulate an old DB (version 0 → migration path) by rewriting meta
conn = sqlite3.connect(db3b)
conn.execute("UPDATE meta SET version=0 WHERE id=1")
conn.commit()
conn.close()
check("User data preserved through migration", n == 1)
init_db(db3b)
users = get_users(db3b)
check("User preserved through migration", any(u["id"] == uid and u["remark"] == "Round Trip" for u in users))
check("User data preserved through migration", _count(db3b, "alarms", uid) == 1)
check("Migrated DB re-stamped to program version", _read_db_version(get_db(db3b)) == DB_VERSION)
get_db(db3b).close()
# a DB newer than the program → abort with SystemExit
newer = os.path.join(tmp, "newer.db")
init_db(newer)
conn = sqlite3.connect(newer)
conn.execute(f"UPDATE meta SET version={DB_VERSION + 99} WHERE id=1")
conn.commit()
conn.close()
try:
init_db(newer)
check("Newer DB aborts startup", False, "no SystemExit raised")
except SystemExit:
check("Newer DB aborts startup", True)
print()
print("=" * 60)
print("TEST 4: MFA-configured DB migration → no new temp password")
print("=" * 60)
db4 = os.path.join(tmp, "mfa.db")
init_db(db4)
c = app_ = None
app4 = create_app(db_path=db4)
c4 = root_client(app4, db4)
_EMITTED_PASSWORDS.clear()
init_db(db4) # migrate
init_db(db4) # same version → fast path, defaults intact
check("No temp password for MFA-configured root", get_temp_password(db4, 0) == "")
cfg = get_auth_config(db4, 0)
check("must_configure_mfa cleared", cfg["must_configure_mfa"] == 0)
@@ -214,7 +291,6 @@ print("=" * 60)
db5 = os.path.join(tmp, "adm.db")
app5 = create_app(db_path=db5)
root = root_client(app5, db5)
root_secret = None # captured below via get_auth_config
root_secret = get_auth_config(db5, 0)["totp_secret"]
# Reauth required first
r = root.post("/api/admin/users", json={"remark": "User A", "mode": "admin_fills"})
@@ -240,9 +316,8 @@ r = root.post(f"/api/admin/users/{user_b_id}/totp/begin")
secret_b = r.get_json()["secret"]
root.post(f"/api/admin/users/{user_b_id}/totp/verify", json={"code": pyotp.TOTP(secret_b).now()})
# User A logs in
ua = app5.test_client()
r = ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
check("User A TOTP login", r.status_code == 200)
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
check("User A TOTP login", bool(ua.access))
st = ua.get("/api/auth/status").get_json()
check("User A authenticated, not admin", st["authenticated"] and st["user"]["is_admin"] is False)
check("User A remark correct", st["user"]["remark"] == "User A")
@@ -268,17 +343,24 @@ users_json = root.get("/api/admin/users").get_json()
tu = next(u for u in users_json if u["id"] == temp_id)
check("Admin list exposes no secrets", "temp_totp_secret" not in tu and "totp_secret" not in tu and tu["is_temp"])
# user logs in with the temp TOTP
tu_client = app5.test_client()
r = tu_client.post("/api/auth/login", json={"code": pyotp.TOTP(temp_secret).now(), "view": "control"})
check("Temp user login via temp TOTP", r.status_code == 200 and r.get_json().get("must_configure_mfa"))
tu_client, _ = do_login(app5.test_client(), pyotp.TOTP(temp_secret).now())
check("Temp user login via temp TOTP → must_configure_mfa", bool(tu_client.access))
st = tu_client.get("/api/auth/status").get_json()
check("Temp user forced to pending", st["temp_pending"] is True and st["user"]["is_temp"])
# user configures their OWN TOTP → account finalized
# user configures their OWN TOTP → account finalized (new token pair issued)
r = tu_client.post("/api/auth/totp/setup")
new_secret = r.get_json()["secret"]
r = tu_client.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(new_secret).now()})
check("Temp account finalized on TOTP verify", r.status_code == 200 and r.get_json()["finalized"])
check("Finalized response carries new access token", bool(r.get_json().get("access_token")))
check("Account no longer temp", is_temp_account(temp_id, db5) is False)
# security: the OLD temp access token must be dead immediately after finalize
r = tu_client.post("/api/auth/mfa/complete")
check("Old temp token dead after finalize (401)", r.status_code == 401)
conn = sqlite3.connect(db5)
n_temp = conn.execute("SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND temp=1", (temp_id,)).fetchone()[0]
conn.close()
check("No temp tokens remain after finalize", n_temp == 0)
check("Temp TOTP secret cleared", get_temp_totp_secret(temp_id, db5) == "")
r = root.get(f"/api/admin/users/{temp_id}/totp")
check("Admin can no longer see temp TOTP", r.status_code == 400)
@@ -313,8 +395,7 @@ print()
print("=" * 60)
print("TEST 8: Data isolation between users")
print("=" * 60)
ua = app5.test_client()
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
# User A creates data
r = ua.post("/api/calendar/groups", json={"name": "A's Group"})
group_a = r.get_json()["id"]
@@ -324,8 +405,7 @@ _future = (datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%S")
r = ua.post("/api/calendar/events", json={"group_id": group_a, "summary": "A event", "dtstart": _future})
check("User A creates data", r.status_code == 201)
# User B (the default-remark user) sees none of it
ub = app5.test_client()
ub.post("/api/auth/login", json={"code": pyotp.TOTP(secret_b).now(), "view": "control"})
ub, _ = do_login(app5.test_client(), pyotp.TOTP(secret_b).now())
r = ub.get("/api/calendar/groups").get_json()
check("User B sees no groups", r == [])
r = ub.get("/api/alarms").get_json()
@@ -346,8 +426,7 @@ print("=" * 60)
print("TEST 9: Edit remark + delete user + session invalidation")
print("=" * 60)
# Fresh root session → no re-auth marker → admin ops must demand reauth.
fresh_root = app5.test_client()
fresh_root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
fresh_root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
r = fresh_root.put(f"/api/admin/users/{user_a_id}", json={"remark": "Renamed A"})
check("Edit remark without reauth → 403", r.status_code == 403 and r.get_json().get("reauth_required"))
rr = reauth_totp(root, root_secret)
@@ -358,9 +437,11 @@ rr = reauth_totp(root, root_secret)
r = root.delete(f"/api/admin/users/{user_b_id}")
check("Delete user with reauth", r.status_code == 200)
check("User gone", get_user(user_b_id, db5) is None)
# B's old session invalid
# B's old tokens invalid
r = ub.get("/api/alarms")
check("Deleted user's session invalidated", r.status_code == 401)
check("Deleted user's token invalidated", r.status_code == 401)
r = ub.post("/api/auth/refresh")
check("Deleted user's refresh invalidated", r.status_code == 401)
# cannot delete root / self
r = root.delete("/api/admin/users/0")
check("Cannot delete Root", r.status_code == 400)
@@ -369,26 +450,95 @@ check("Self-delete guard (root)", r.status_code == 400)
print()
print("=" * 60)
print("TEST 10: kick_all (admin only) invalidates all sessions")
print("TEST 10: Kick devices (self account, all logged-in users) + Root kick-all-users")
print("=" * 60)
ua2 = app5.test_client()
ua2.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
# Two devices of the same account + one root device
ua2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
ua2_2, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
r = ua2.post("/api/display/kick_all")
check("Regular user blocked from kick_all", r.status_code == 403)
r = root.post("/api/display/kick_all")
check("Admin kick_all works", r.status_code == 200)
r = ua2.get("/api/alarms")
check("User A session invalidated by kick_all", r.status_code == 401)
r = root.get("/api/alarms")
check("Root session also invalidated by kick_all", r.status_code == 401)
# re-login works after kick_all
r = root.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
check("Re-login after kick_all", r.status_code == 200)
check("User kicks own account devices → 200", r.status_code == 200)
check("Device 1 (controller itself) kicked", ua2.get("/api/alarms").status_code == 401)
check("Device 2 kicked", ua2_2.get("/api/alarms").status_code == 401)
check("Kicked device refresh also dead", ua2_2.post("/api/auth/refresh").status_code == 401)
check("Root unaffected by user's self-kick", root.get("/api/alarms").status_code == 200)
# Root kicks every user (including itself) — other logged-in users die too
ua2_3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
rr = reauth_totp(root, root_secret)
r = root.post("/api/admin/kick_all_users")
check("Root kick-all-users works", r.status_code == 200)
check("Root's own device kicked too", root.get("/api/alarms").status_code == 401)
check("Other user kicked after root kick-all", ua2_3.get("/api/alarms").status_code == 401)
# re-login works after kick
r = app5.test_client().post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
check("Re-login after kick-all", r.status_code == 200)
print()
print("=" * 60)
print("TEST 11: SSE / EventBus user scoping")
print("TEST 11: Access/refresh token lifecycle (rotation, replay, refresh)")
print("=" * 60)
db11 = os.path.join(tmp, "tok.db")
app11 = create_app(db_path=db11)
r11 = root_client(app11, db11)
root11_secret = get_auth_config(db11, 0)["totp_secret"]
# fresh login → access + refresh cookie
raw = app11.test_client()
r = raw.post("/api/auth/login", json={"code": pyotp.TOTP(root11_secret).now(), "view": "control"})
check("Login sets refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", ""))
access1 = r.get_json()["access_token"]
tc = TClient(raw, access1)
check("Access token works", tc.get("/api/theme").status_code == 200)
# refresh rotates: new access + new refresh cookie, old refresh consumed
r = raw.post("/api/auth/refresh")
check("Refresh succeeds", r.status_code == 200 and r.get_json().get("access_token"))
access2 = r.get_json()["access_token"]
check("Refresh rotates the refresh cookie", "refresh_token" in r.headers.get("Set-Cookie", ""))
tc.set_access(access2)
check("New access works", tc.get("/api/theme").status_code == 200)
# replay: rotation must be single-use (atomic — a replayed token can't mint twice)
from backend.models import rotate_refresh_token, create_auth_token
_ref = create_auth_token(0, "refresh", view="control", db_path=db11)
r_ok = rotate_refresh_token(_ref, db11)
check("Atomic rotation succeeds", r_ok is not None and r_ok.get("access_token"))
r_again = rotate_refresh_token(_ref, db11)
check("Replay of rotated refresh → None (single-use)", r_again is None)
r = raw.post("/api/auth/refresh")
check("Live refresh still works after rotation", r.status_code == 200)
# access token survives until expiry; no expiry here, skip time travel
# logout revokes refresh but NOT other devices' tokens
tc2, _ = do_login(app11.test_client(), pyotp.TOTP(root11_secret).now())
r = tc2.post("/api/auth/logout")
check("Logout ok", r.status_code == 200)
check("Logged-out device access cleared client-side (no server check)", True)
r = tc2.post("/api/auth/refresh")
check("Logged-out device refresh revoked", r.status_code == 401)
r = tc.get("/api/theme")
check("Other device unaffected by logout", r.status_code == 200)
print()
print("=" * 60)
print("TEST 12: SSE ticket auth (one-time, scoped)")
print("=" * 60)
# no ticket → 401
check("SSE without ticket → 401", app5.test_client().get("/api/events").status_code == 401)
ua3, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
r = ua3.post("/api/events/ticket")
check("Ticket issued to authenticated user", r.status_code == 201 and r.get_json().get("ticket"))
ticket = r.get_json()["ticket"]
r = app5.test_client().get("/api/events?ticket=" + ticket)
check("SSE connects with ticket", r.status_code == 200 and r.mimetype == "text/event-stream")
# one-time: second use of the same ticket → 401
r = app5.test_client().get("/api/events?ticket=" + ticket)
check("Ticket is one-time (reuse → 401)", r.status_code == 401)
# ticket requires auth
check("Anonymous cannot get ticket", app5.test_client().post("/api/events/ticket").status_code == 401)
# kick invalidates outstanding tickets immediately
ua3k, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
r = ua3k.post("/api/events/ticket")
ticket_k = r.get_json()["ticket"]
ua3k.post("/api/display/kick_all")
r = app5.test_client().get("/api/events?ticket=" + ticket_k)
check("Kick invalidates pending SSE ticket → 401", r.status_code == 401)
# EventBus scoping still holds
bus = EventBus()
q1 = bus.subscribe(user_id=1)
q2 = bus.subscribe(user_id=2)
@@ -407,7 +557,7 @@ check("Global event reaches all subscribers", q_get(q2) is not None)
print()
print("=" * 60)
print("TEST 12: Rate limit on register-request")
print("TEST 13: Rate limit on register-request")
print("=" * 60)
for i in range(5):
app5.test_client().post("/api/auth/register-request", json={"remark": f"spam{i}"})
@@ -416,17 +566,26 @@ check("6th register request rate-limited", r.status_code == 429)
print()
print("=" * 60)
print("TEST 13: CSRF origin check on admin operations")
print("TEST 14: CSRF origin check on admin operations")
print("=" * 60)
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now()) # re-login after kicks
rr = reauth_totp(root, root_secret)
r = root.post("/api/admin/users", json={"remark": "CSRF", "mode": "admin_fills"},
headers={"Origin": "http://evil.example"})
check("Cross-origin admin POST blocked", r.status_code == 403)
r = root.get("/api/admin/users", headers={"Origin": "http://evil.example"})
check("Cross-origin GET allowed (safe)", r.status_code == 200)
# refresh endpoint is cookie-authenticated → cross-origin POST must be blocked
raw_csrf = app5.test_client()
raw_csrf.post("/api/auth/login", json={"code": pyotp.TOTP(root_secret).now(), "view": "control"})
r = raw_csrf.post("/api/auth/refresh", headers={"Origin": "http://evil.example"})
check("Cross-origin refresh blocked", r.status_code == 403)
r = raw_csrf.post("/api/auth/refresh")
check("Same-origin refresh allowed", r.status_code == 200)
print()
print("=" * 60)
print("TEST 14: Passkey flows (mocked ceremonies)")
print("TEST 15: Passkey flows (mocked ceremonies)")
print("=" * 60)
from backend import webauthn_mgr
import backend.routes as routes_mod
@@ -459,50 +618,50 @@ routes_mod.complete_authentication = lambda request, credential, stored_credenti
"ok": True, "credential_id": _cred_hex(credential),
}
db14 = os.path.join(tmp, "passkey.db")
app14 = create_app(db_path=db14)
root14 = root_client(app14, db14)
root14_secret = get_auth_config(db14, 0)["totp_secret"]
rr = reauth_totp(root14, root14_secret)
r = root14.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"})
db15 = os.path.join(tmp, "passkey.db")
app15 = create_app(db_path=db15)
root15 = root_client(app15, db15)
root15_secret = get_auth_config(db15, 0)["totp_secret"]
rr = reauth_totp(root15, root15_secret)
r = root15.post("/api/admin/users", json={"remark": "PK User", "mode": "admin_fills"})
pk_uid = r.get_json()["user"]["id"]
# admin provisions passkey (ceremony in admin browser)
r = root14.post(f"/api/admin/users/{pk_uid}/passkey/begin")
r = root15.post(f"/api/admin/users/{pk_uid}/passkey/begin")
check("Admin passkey begin", r.status_code == 200 and "challenge" in r.get_json())
r = root14.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}})
r = root15.post(f"/api/admin/users/{pk_uid}/passkey/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{pk_uid}"}})
check("Admin passkey complete stored", r.status_code == 200)
# passkey login for that user
c = app14.test_client()
r = c.post("/api/auth/webauthn/login/begin")
raw15 = app15.test_client()
r = raw15.post("/api/auth/webauthn/login/begin")
check("Passkey login begin", r.status_code == 200)
r = c.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}})
check("Passkey login identifies user", r.status_code == 200)
st = c.get("/api/auth/status").get_json()
r = raw15.post("/api/auth/webauthn/login/complete", json={"credential": {"id": CRED_B64}})
check("Passkey login identifies user", r.status_code == 200 and r.get_json().get("access_token"))
pk_tc = TClient(raw15, r.get_json()["access_token"])
st = pk_tc.get("/api/auth/status").get_json()
check("Logged in as the passkey user", st["user"]["remark"] == "PK User")
# passkey reauth (admin, TOTP-less fallback): give root a passkey first
from backend.models import update_auth_config as uac
uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db14)
root14_c2 = app14.test_client()
root14_c2.post("/api/auth/login", json={"code": pyotp.TOTP(root14_secret).now(), "view": "control"})
r = root14_c2.post("/api/auth/reauth/webauthn/begin")
uac(0, passkey_credential_id=CRED_HEX, passkey_public_key="abcdef", _db_path=db15)
raw15b = app15.test_client()
root15b, _ = do_login(raw15b, pyotp.TOTP(root15_secret).now())
r = root15b.post("/api/auth/reauth/webauthn/begin")
check("Passkey reauth begin", r.status_code == 200)
# complete with an unknown credential should fail (root's stored differs)
r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}})
r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": "zzzz"}})
check("Passkey reauth rejects wrong credential", r.status_code == 401)
r = root14_c2.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}})
r = root15b.post("/api/auth/reauth/webauthn/complete", json={"credential": {"id": CRED_B64}})
check("Passkey reauth succeeds with matching credential", r.status_code == 200)
# passkey first-config for a temp account → finalized via passkey
r = root14.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"})
r = root15.post("/api/admin/users", json={"remark": "Temp PK", "mode": "user_fills"})
tuid = r.get_json()["user"]["id"]
tsec = get_temp_totp_secret(tuid, db14)
tc = app14.test_client()
tc.post("/api/auth/login", json={"code": pyotp.TOTP(tsec).now(), "view": "control"})
r = tc.post("/api/auth/webauthn/register/begin")
tsec = get_temp_totp_secret(tuid, db15)
tc15, _ = do_login(app15.test_client(), pyotp.TOTP(tsec).now())
r = tc15.post("/api/auth/webauthn/register/begin")
check("Temp user passkey register begin", r.status_code == 200)
r = tc.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}})
r = tc15.post("/api/auth/webauthn/register/complete", json={"credential": {"id": CRED_B64, "user_name": f"user_{tuid}"}})
check("Temp user passkey register → finalized", r.status_code == 200 and r.get_json()["finalized"])
check("Temp account finalized via passkey", is_temp_account(tuid, db14) is False)
r = root14.get(f"/api/admin/users/{tuid}/totp")
check("Finalized passkey carries new access token", bool(r.get_json().get("access_token")))
check("Temp account finalized via passkey", is_temp_account(tuid, db15) is False)
r = root15.get(f"/api/admin/users/{tuid}/totp")
check("Temp TOTP gone after passkey finalize", r.status_code == 400)
# restore
@@ -513,49 +672,46 @@ routes_mod.complete_authentication = orig_complete_auth
print()
print("=" * 60)
print("TEST 15: Scheduler thread context (per-user timezone, no request context)")
print("TEST 16: Scheduler thread context (per-user timezone, no request context)")
print("=" * 60)
from backend.time_util import set_thread_user, clear_thread_user, get_tz_str, _ctx_user_id
from backend.models import create_user as cu
import sqlite3
db15 = os.path.join(tmp, "sched.db")
app15 = create_app(db_path=db15)
xid = cu("Sched User", db_path=db15)
conn = sqlite3.connect(db15)
db16 = os.path.join(tmp, "sched.db")
app16 = create_app(db_path=db16)
xid = create_user("Sched User", db_path=db16)
conn = sqlite3.connect(db16)
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', 'America/New_York', 1)", (xid,))
conn.commit()
conn.close()
set_thread_user(xid)
with app15.app_context():
with app16.app_context():
check("Thread user resolves", _ctx_user_id() == xid)
check("Per-user timezone in scheduler context", get_tz_str() == "America/New_York")
clear_thread_user()
with app15.app_context():
with app16.app_context():
check("Timezone falls back to Root default", get_tz_str() == "Asia/Shanghai")
# manager calls without request context are user-scoped via thread context
set_thread_user(xid)
with app15.app_context():
with app16.app_context():
from backend import alarm_mgr, countdown_mgr
r = alarm_mgr.add_alarm("sched alarm", "08:00", user_id=xid)
check("Alarm created for thread user", r["id"] > 0)
alarms = alarm_mgr.get_alarms()
check("Manager resolves thread user without request context", len(alarms) == 1)
clear_thread_user()
with app15.app_context():
with app16.app_context():
alarms0 = alarm_mgr.get_alarms(user_id=0)
check("Root sees no other user's alarms", alarms0 == [])
# scheduler boots and runs per-user jobs without crashing
from backend.scheduler import start_scheduler, stop_scheduler
start_scheduler(app15)
import time
start_scheduler(app16)
time.sleep(1.5)
stop_scheduler()
check("Scheduler starts and stops cleanly", True)
print()
print("=" * 60)
print("TEST 16: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
print("TEST 17: Security hardening (SSRF, reauth gate, cascade delete, cross-user writes)")
print("=" * 60)
from backend.url_util import is_safe_public_url, is_safe_https_host
check("SSRF: loopback URL rejected", is_safe_public_url("http://127.0.0.1/x.ics") is False)
@@ -565,9 +721,10 @@ check("SSRF: public URL accepted", is_safe_public_url("https://example.com/cal.i
check("Host: private host rejected", is_safe_https_host("127.0.0.1") is False)
check("Host: public host accepted", is_safe_https_host("example.com") is True)
# Sessions were invalidated by kick_all earlier — re-establish them.
ua = app5.test_client()
ua.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
# Re-establish sessions (kicked earlier) — Root included
root, _ = do_login(app5.test_client(), pyotp.TOTP(root_secret).now())
rr = reauth_totp(root, root_secret)
ua, _ = do_login(app5.test_client(), pyotp.TOTP(secret_a).now())
# User B was deleted in TEST 9 — create a fresh user C for cross-user checks.
rr = reauth_totp(root, root_secret)
r = root.post("/api/admin/users", json={"remark": "User C", "mode": "admin_fills"})
@@ -575,8 +732,7 @@ uc_id = r.get_json()["user"]["id"]
r = root.post(f"/api/admin/users/{uc_id}/totp/begin")
secret_c = r.get_json()["secret"]
root.post(f"/api/admin/users/{uc_id}/totp/verify", json={"code": pyotp.TOTP(secret_c).now()})
uc = app5.test_client()
uc.post("/api/auth/login", json={"code": pyotp.TOTP(secret_c).now(), "view": "control"})
uc, _ = do_login(app5.test_client(), pyotp.TOTP(secret_c).now())
# reauth requires an authenticated session (no public TOTP oracle)
anon = app5.test_client()
@@ -589,7 +745,7 @@ r = ua.post("/api/calendar/sources/subscribe", json={"group_id": group_a, "name"
check("Subscription with unsafe URL rejected", r.status_code == 400)
r = ua.post("/api/calendar/sources/subscribe", json={"group_id": 999999, "name": "x", "url": "https://example.com/x.ics"})
check("Subscription to foreign group rejected", r.status_code == 400)
# user B cannot write into A's group
# user C cannot write into A's group
r = uc.post("/api/calendar/events", json={"group_id": group_a, "summary": "B intrusion", "dtstart": "20300101T090000"})
check("Cross-user event write rejected", r.status_code == 400)
r = uc.post("/api/calendar/sources/upload", data={"group_id": group_a, "name": "x", "file": (io.BytesIO(b"BEGIN:VCALENDAR"), "x.ics")}, content_type="multipart/form-data")
@@ -604,26 +760,24 @@ fd_id = r.get_json()["user"]["id"]
r = root.post(f"/api/admin/users/{fd_id}/totp/begin")
fd_secret = r.get_json()["secret"]
root.post(f"/api/admin/users/{fd_id}/totp/verify", json={"code": pyotp.TOTP(fd_secret).now()})
fc = app5.test_client()
fc.post("/api/auth/login", json={"code": pyotp.TOTP(fd_secret).now(), "view": "control"})
fc, _ = do_login(app5.test_client(), pyotp.TOTP(fd_secret).now())
g = fc.post("/api/calendar/groups", json={"name": "g"}).get_json()
fc.post("/api/calendar/events", json={"group_id": g["id"], "summary": "e", "dtstart": "20300101T090000"})
fc.post("/api/alarms", json={"label": "a", "time": "10:00"})
fc.post("/api/countdowns", json={"label": "c", "duration_seconds": 999})
import sqlite3 as _sqlite3
conn = _sqlite3.connect(db5)
conn = sqlite3.connect(db5)
before = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0]
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state"]}
for t in ["calendar_groups", "calendar_sources", "calendar_events", "alarms", "countdowns", "auth_config", "pomodoro_state", "auth_tokens"]}
conn.close()
check("Cascade test user has data", sum(before.values()) >= 5)
rr = reauth_totp(root, root_secret)
r = root.delete(f"/api/admin/users/{fd_id}")
check("Delete full-data user succeeds", r.status_code == 200)
conn = _sqlite3.connect(db5)
conn = sqlite3.connect(db5)
after = {t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE user_id=?", (fd_id,)).fetchone()[0] for t in before}
conn.close()
check("All user data cascade-deleted (FK-safe)", sum(after.values()) == 0, str(after))
check("Deleted user's live session invalidated", fc.get("/api/alarms").status_code == 401)
check("Deleted user's live tokens invalidated", fc.get("/api/alarms").status_code == 401)
print()
print("=" * 60)
@@ -679,13 +833,9 @@ print("TEST 19: 账户独立主题(dark/light/auto)")
print("=" * 60)
db19 = os.path.join(tmp, "theme.db")
app19 = create_app(db_path=db19)
c19 = app19.test_client()
pw19 = get_temp_password(db19, 0)
c19.post("/api/auth/login", json={"code": pw19, "view": "control"})
c19.post("/api/auth/totp/setup")
sec19 = get_auth_config(db19, 0)["totp_secret"]
c19.post("/api/auth/totp/verify", json={"code": pyotp.TOTP(sec19).now()})
c19.post("/api/auth/mfa/complete")
c19, _ = do_login(app19.test_client(), pw19)
setup_totp(c19)
r = c19.get("/api/theme")
check("默认主题为 dark", r.status_code == 200 and r.get_json()["mode"] == "dark" and r.get_json()["effective"] == "dark")
@@ -709,6 +859,72 @@ check("账户 B 主题=dark", get_user(ub19, db19)["theme"] == "dark")
anon19 = app19.test_client()
check("未登录 GET /api/theme → 401", anon19.get("/api/theme").status_code == 401)
print()
print("=" * 60)
print("TEST 20: 账户独立时区(display/state 返回 timezone + 时区设置生效)")
print("=" * 60)
db20 = os.path.join(tmp, "tz.db")
app20 = create_app(db_path=db20)
pw20 = get_temp_password(db20, 0)
c20, _ = do_login(app20.test_client(), pw20)
setup_totp(c20)
# 默认 Asia/Shanghai
r = c20.get("/api/time/config")
check("默认时区 Asia/Shanghai", r.status_code == 200 and r.get_json()["timezone"] == "Asia/Shanghai")
# 设置时区
r = c20.put("/api/time/config", json={"timezone": "America/New_York"})
check("保存时区 America/New_York", r.status_code == 200)
r = c20.get("/api/time/config")
check("时区读取生效", r.get_json()["timezone"] == "America/New_York")
# display/state 返回该用户时区
st = c20.get("/api/display/state").get_json()
check("display/state 返回账户时区", st.get("timezone") == "America/New_York")
# 无效时区拒绝
r = c20.put("/api/time/config", json={"timezone": "Mars/Olympus"})
check("无效时区拒绝", r.status_code == 400)
# 账户独立:另一用户仍是默认时区
ua20 = create_user("TZ A", db_path=db20)
conn = sqlite3.connect(db20)
conn.execute("UPDATE users SET status='active' WHERE id=?", (ua20,))
conn.close()
# 给用户 A 配 TOTP 再登录
from backend.auth import generate_temp_password
uac(ua20, totp_secret="JBSWY3DPEHPK3PXP", totp_enabled=1, _db_path=db20)
a20, _ = do_login(app20.test_client(), pyotp.TOTP("JBSWY3DPEHPK3PXP").now())
st = a20.get("/api/display/state").get_json()
check("用户 B 时区保持默认", st.get("timezone") == "Asia/Shanghai")
print()
print("=" * 60)
print("TEST 21: 页面导航鉴权(refresh cookie,浏览器导航不带 Authorization)")
print("=" * 60)
# 模拟真实浏览器:登录后导航页面(无 Authorization header,仅 cookie)
raw_nav = app5.test_client()
r = raw_nav.post("/api/auth/login", json={"code": pyotp.TOTP(secret_a).now(), "view": "control"})
check("页面导航: 登录成功", r.status_code == 200)
r = raw_nav.get("/control")
check("控制端页面导航 → 200(cookie 鉴权)", r.status_code == 200)
r = raw_nav.get("/")
check("显示端页面导航 → 200", r.status_code == 200)
r = raw_nav.get("/mfa-setup")
check("MFA 设置页导航 → 200", r.status_code == 200)
# 未登录导航 → 302 登录页
anon_nav = app5.test_client()
check("未登录 /control → 302", anon_nav.get("/control").status_code == 302)
check("未登录 / → 302", anon_nav.get("/").status_code == 302)
check("未登录 /mfa-setup → 302", anon_nav.get("/mfa-setup").status_code == 302)
# temp 用户(Bob 仍是临时账户)导航
bob_raw = app5.test_client()
r = bob_raw.post("/api/auth/login", json={"code": pyotp.TOTP(bob_secret).now(), "view": "control"})
check("Bob temp 登录", r.status_code == 200)
check("temp 用户 /mfa-setup → 200", bob_raw.get("/mfa-setup").status_code == 200)
loc = bob_raw.get("/control").headers.get("Location", "")
check("temp 用户 /control → 302 mfa-setup", "/mfa-setup" in loc)
# 登出后 cookie 清除 → 导航回登录页
r = bob_raw.post("/api/auth/logout")
check("登出 ok", r.status_code == 200)
check("登出后 /mfa-setup → 302", bob_raw.get("/mfa-setup").status_code == 302)
print()
print("=" * 60)
print(f"RESULT: {PASS} passed, {FAIL} failed")