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
+3 -11
View File
@@ -22,8 +22,7 @@ def create_app(db_path: str = "data.db") -> Flask:
template_folder="../frontend/templates",
static_folder="../frontend/static",
)
# Generate a secure random key at startup.
# Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts).
# Generate a secure random key at startup (kept for compatibility).
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)
# Defensive check: ensure key is never empty
if not app.config["SECRET_KEY"]:
@@ -32,12 +31,8 @@ def create_app(db_path: str = "data.db") -> Flask:
# Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Session hardening for production
https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = https
app.config["SESSION_PERMANENT"] = False
# Dual-token auth: access token in Authorization header, refresh token in
# an httpOnly cookie. No Flask session cookies are used anymore.
# Strict CORS: only allow same-origin requests (no wildcard for cookie auth)
CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only
@@ -45,9 +40,6 @@ def create_app(db_path: str = "data.db") -> Flask:
# Content size limit: 5MB for uploads
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
# Session lifetime: 24 hours idle timeout
app.config["PERMANENT_SESSION_LIFETIME"] = 86400
# Global event bus
app.event_bus = EventBus()
+142 -78
View File
@@ -1,30 +1,38 @@
"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password.
"""Dual-token authentication: short-lived access token (Authorization header,
validated per-request) + long-lived refresh token (httpOnly cookie, rotated
on each refresh). TOTP / Passkey / recovery codes / temp password still drive
the login ceremony; the result is a token pair instead of a Flask session.
Multi-user support:
- Sessions carry the authenticated `user_id` (Root = 0).
- The unified login (6-digit code) searches every active user's TOTP secret,
then temp accounts' temp-TOTP secrets, then Root's temp password.
- Session version is per-user (stored in the users table), so deleting a user
or kicking all sessions immediately invalidates their cookies.
- Every request resolves its user from the access token (`g.user_id`).
- Access tokens snapshot the user's `session_version`; bumping it (delete user,
kick devices) invalidates every outstanding access token immediately.
- Refresh tokens are stored hashed in `auth_tokens`; deleting them (logout of
one device / kick) ends the corresponding device session.
- Temp-account / temp-password logins get `temp=1` tokens; the user is forced
through MFA setup until they complete it, then a fresh non-temp pair is
issued.
"""
import functools
import os
import secrets
import string
import time
from flask import session, redirect, url_for, request, jsonify, current_app, g
from flask import g, redirect, url_for, request, jsonify, current_app
from backend.models import (
get_auth_config, update_auth_config, get_temp_password, set_temp_password,
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured,
get_user_session_version, user_exists, is_admin_user, get_db,
user_exists, is_admin_user, get_db,
create_auth_token, find_access_token,
delete_auth_token, REFRESH_TOKEN_TTL, ACCESS_TOKEN_TTL,
)
# ─── Session keys ────────────────────────────
SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated
SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required
SKEY_VIEW = "mfa_view" # str: "control" or "display"
SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication
SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation)
SKEY_USER = "user_id" # int: id of the authenticated user
# ─── Token / cookie constants ────────────────
REFRESH_COOKIE = "refresh_token" # httpOnly, SameSite=Lax, Secure under HTTPS
def _https_enabled() -> bool:
return os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
def _db_path(preferred: str = None) -> str:
@@ -33,44 +41,60 @@ def _db_path(preferred: str = None) -> str:
return current_app.config.get("DB_PATH", "data.db")
def _validate_version():
"""If the session version is stale (kick_all / user deleted), clear the session."""
uid = session.get(SKEY_USER)
if uid is None:
# ─── Auth context (per-request, populated by before_request) ───
def load_auth_context():
"""Parse `Authorization: Bearer <access>` and fill `g` with the auth context.
Runs for every request (cheap: no token → no-op)."""
token = _extract_bearer()
if not token:
return
# Cache the validation result for the duration of this request.
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid:
row = find_access_token(token)
if not row:
return
ver = session.get(SKEY_VERSION)
if ver is None:
return
stored = get_user_session_version(uid)
if stored == -1 or ver != stored:
clear_session()
return
g._sv_ok = True
g._sv_uid = uid
g.user_id = row["user_id"]
g.auth_view = row["view"]
g.temp_pending = bool(row["temp"])
g.auth_authenticated = True
g.access_token_id = row["id"]
g.reauth_at = row.get("reauth_at") or 0
def _extract_bearer() -> str | None:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return None
def _g():
return g
def is_authenticated() -> bool:
"""Returns True if the user has completed MFA authentication."""
_validate_version()
return session.get(SKEY_AUTH) is True
"""True when the request carries a valid, non-temp access token."""
try:
return (getattr(_g(), "auth_authenticated", False)
and not getattr(_g(), "temp_pending", False))
except Exception:
return False
def is_temp_pending() -> bool:
"""True if logged in with a temp password or temp account and must set up a login method."""
_validate_version()
return session.get(SKEY_TEMP) is True
"""True when logged in with a temp password / temp account (MFA setup required)."""
try:
return (getattr(_g(), "auth_authenticated", False)
and bool(getattr(_g(), "temp_pending", False)))
except Exception:
return False
def current_user_id() -> int | None:
"""Return the id of the current user (session user, or scheduler thread user)."""
"""Return the id of the current user (token user, or scheduler thread user)."""
try:
from flask import has_request_context
if has_request_context():
_validate_version()
uid = session.get(SKEY_USER)
uid = getattr(_g(), "user_id", None)
if uid is not None:
return int(uid)
except Exception:
@@ -103,64 +127,104 @@ def is_admin() -> bool:
return is_admin_user(uid)
def set_authenticated(view: str = "control", user_id: int = None):
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = True
session[SKEY_TEMP] = False
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
# A fresh authentication must not inherit the temp-setup re-auth marker.
session.pop(SKEY_REAUTH, None)
session.permanent = True
# ─── Token issuance / refresh / revocation ───
def issue_tokens(user_id: int, view: str = "control", temp: bool = False) -> dict:
"""Create a fresh access+refresh token pair. The refresh token must be
delivered to the client via an httpOnly cookie by the caller."""
access = create_auth_token(user_id, "access", view=view, temp=temp)
refresh = create_auth_token(user_id, "refresh", view=view, temp=temp)
return {"access_token": access, "refresh_token": refresh}
def set_temp_pending(view: str = "control", user_id: int = None):
"""Mark the session as logged-in-but-must-configure (temp password / temp account)."""
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = False
session[SKEY_TEMP] = True
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
session.permanent = True
def set_refresh_cookie(resp, refresh: str):
"""Attach the refresh token as an httpOnly cookie on the response."""
resp.set_cookie(
REFRESH_COOKIE, refresh,
max_age=REFRESH_TOKEN_TTL,
httponly=True,
samesite="Lax",
secure=_https_enabled(),
path="/",
)
def clear_session():
session.pop(SKEY_AUTH, None)
session.pop(SKEY_TEMP, None)
session.pop(SKEY_VIEW, None)
session.pop(SKEY_REAUTH, None)
session.pop(SKEY_USER, None)
session.pop(SKEY_VERSION, None)
def clear_refresh_cookie(resp):
resp.delete_cookie(REFRESH_COOKIE, path="/")
def rotate_refresh(refresh_raw: str) -> dict | None:
"""Validate a refresh token, revoke it and issue a fresh pair (rotation).
Atomic (single transaction): a replayed old token can never mint twice.
Returns the new {access_token, refresh_token} or None when invalid."""
from backend.models import rotate_refresh_token
return rotate_refresh_token(refresh_raw)
def logout_current_device():
"""Revoke this device's refresh token (from the httpOnly cookie) and also
the presented access token, if any (so a stolen access dies immediately)."""
raw = request.cookies.get(REFRESH_COOKIE)
if raw:
delete_auth_token(raw, kind="refresh")
acc = _extract_bearer()
if acc:
delete_auth_token(acc, kind="access")
def load_page_context() -> bool:
"""Authenticate a top-level page navigation via the httpOnly refresh cookie
(browsers cannot attach Authorization headers to navigations).
Fills g.user_id / g.auth_view / g.temp_pending on success.
Returns False when unauthenticated."""
raw = request.cookies.get(REFRESH_COOKIE)
if not raw:
return False
from backend.models import find_refresh_token
row = find_refresh_token(raw)
if not row:
return False
g.user_id = row["user_id"]
g.auth_view = row["view"]
g.temp_pending = bool(row["temp"])
g.page_authenticated = True
return True
# ─── Re-authentication guard ─────────────────
# Sensitive operations (user management, MFA changes) require re-confirming
# identity within the last N seconds. TOTP-first with a Passkey fallback
# (the fallback ceremony is handled by the routes layer).
# identity within the last N seconds. The marker lives on the ACCESS TOKEN row,
# so it is per-device (each device's token must re-auth independently) and
# lost on token rotation (a fresh access token requires a fresh re-auth).
_REAUTH_TTL = 300 # 5 minutes
def require_reauth():
"""Check if recent re-authentication is still valid.
def require_reauth() -> bool:
"""Check if recent re-authentication is still valid for THIS access token.
Skips the check for temp_pending users (first-time MFA setup flow).
"""
if is_temp_pending():
mark_reauth()
return True
last = session.get(SKEY_REAUTH)
if last and (time.time() - last) < _REAUTH_TTL:
return True
return False
try:
last = getattr(g, "reauth_at", 0) or 0
except Exception:
return False
return bool(last) and (time.time() - last) < _REAUTH_TTL
def mark_reauth():
"""Mark re-authentication as done now."""
session[SKEY_REAUTH] = time.time()
"""Mark re-authentication as done now for the current access token."""
try:
from flask import g as _g
from backend.models import mark_token_reauth
mark_token_reauth(getattr(_g, "access_token_id", None))
except Exception as e:
try:
current_app.logger.warning(f"mark_reauth failed: {e}")
except Exception:
pass
# ─── Decorators ──────────────────────────────
@@ -168,7 +232,7 @@ def mark_reauth():
def _unauthorized(request):
if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401
view_param = session.get(SKEY_VIEW, "control")
view_param = getattr(_g(), "auth_view", None) or "control"
return redirect(url_for("api.login_page", next=request.path, view=view_param))
+290 -5
View File
@@ -13,9 +13,33 @@ Multi-user design:
import sqlite3
import os
import sys
import time
SCHEMA = """
-- Global schema version (single row). All new columns must carry a DEFAULT.
CREATE TABLE IF NOT EXISTS meta (
id INTEGER PRIMARY KEY DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1
);
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
token_hash TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
view TEXT NOT NULL DEFAULT 'display' CHECK(view IN ('control','display')),
temp INTEGER NOT NULL DEFAULT 0,
session_version INTEGER NOT NULL DEFAULT 0,
reauth_at INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remark TEXT NOT NULL DEFAULT '',
@@ -195,6 +219,7 @@ USER_DATA_TABLES = [
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
"weather_minutely_cache", "weather_warning_cache", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state",
"auth_tokens",
]
@@ -311,6 +336,7 @@ def delete_user(user_id: int, db_path: str = None):
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
"weather_warning_cache", "weather_config", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
"auth_tokens",
]
try:
for table in ordered:
@@ -364,7 +390,8 @@ def get_temp_totp_secret(user_id: int, db_path: str = None) -> str:
def finalize_temp_account(user_id: int, db_path: str = None):
"""Convert a temp account into a formal account:
invalidates the temp TOTP and clears the temp flag.
invalidates the temp TOTP, clears the temp flag, and revokes every
temp=1 token (a temp refresh must never mint formal tokens afterwards).
"""
conn = get_db(_db_path(db_path))
conn.execute(
@@ -375,6 +402,10 @@ def finalize_temp_account(user_id: int, db_path: str = None):
"UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
(user_id,)
)
conn.execute(
"DELETE FROM auth_tokens WHERE user_id=? AND temp=1",
(user_id,)
)
conn.commit()
conn.close()
@@ -479,6 +510,211 @@ def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool
return True
# ─── Auth tokens (dual-token auth) ───────────
# Access tokens are short-lived and validated on every request; refresh tokens
# are long-lived and stored in an httpOnly cookie. Only SHA-256 hashes are
# stored in the DB. Kicking a user/device = delete their tokens + bump the
# user's session_version (which invalidates every outstanding access token).
ACCESS_TOKEN_TTL = 15 * 60 # 15 minutes
REFRESH_TOKEN_TTL = 30 * 24 * 3600 # 30 days (sliding, renewed on each refresh)
def _hash_token(raw: str) -> str:
import hashlib
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _prune_expired_tokens(conn):
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (int(time.time()),))
def create_auth_token(user_id: int, kind: str, view: str = "display",
temp: bool = False, ttl: int = None,
db_path: str = None) -> str:
"""Create an auth token row and return the raw (unhashed) token.
Only the SHA-256 hash is persisted."""
import secrets as _secrets
if ttl is None:
ttl = ACCESS_TOKEN_TTL if kind == "access" else REFRESH_TOKEN_TTL
raw = _secrets.token_urlsafe(48 if kind == "refresh" else 32)
conn = get_db(_db_path(db_path))
try:
_prune_expired_tokens(conn)
ver_row = conn.execute(
"SELECT session_version FROM users WHERE id=?", (user_id,)
).fetchone()
ver = ver_row["session_version"] if ver_row else 0
conn.execute(
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
"session_version, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(user_id, _hash_token(raw), kind, view, 1 if temp else 0, ver,
int(time.time()) + ttl)
)
conn.commit()
return raw
finally:
conn.close()
def _token_lookup(raw: str, kind: str, db_path: str = None) -> dict | None:
"""Look up a token by its raw value. For access tokens the row is only
valid while it matches the user's current session_version (kick support).
Returns None when invalid/expired/user inactive/version bumped."""
if not raw:
return None
conn = get_db(_db_path(db_path))
try:
h = _hash_token(raw)
row = conn.execute(
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind=?",
(h, kind)
).fetchone()
if not row:
return None
if row["expires_at"] <= int(time.time()):
return None
if row["user_status"] != "active":
return None
if kind == "access" and row["session_version"] != _cur_session_version(row["user_id"], conn):
return None
return dict(row)
finally:
conn.close()
def _cur_session_version(user_id: int, conn) -> int:
r = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
return r["session_version"] if r else -1
def find_access_token(raw: str, db_path: str = None) -> dict | None:
"""Validate an access token. Returns the row dict or None."""
return _token_lookup(raw, "access", db_path)
def rotate_refresh_token(raw: str, db_path: str = None) -> dict | None:
"""Atomically validate and rotate a refresh token (concurrency-safe).
Returns a fresh {access_token, refresh_token} pair or None. The old refresh
token is consumed inside a single transaction, so a replayed/concurrent
duplicate can never mint two valid pairs."""
if not raw:
return None
import secrets as _secrets
conn = get_db(_db_path(db_path))
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
(_hash_token(raw),)
).fetchone()
if (not row or row["expires_at"] <= int(time.time())
or row["user_status"] != "active"):
conn.rollback()
return None
cur = conn.execute("DELETE FROM auth_tokens WHERE id=?", (row["id"],))
if cur.rowcount == 0:
conn.rollback()
return None
now = int(time.time())
acc_raw = _secrets.token_urlsafe(32)
ref_raw = _secrets.token_urlsafe(48)
ver = row["session_version"]
conn.execute(
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
"session_version, expires_at) VALUES (?, ?, 'access', ?, ?, ?, ?)",
(row["user_id"], _hash_token(acc_raw), row["view"], row["temp"],
ver, now + ACCESS_TOKEN_TTL)
)
conn.execute(
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
"session_version, expires_at) VALUES (?, ?, 'refresh', ?, ?, ?, ?)",
(row["user_id"], _hash_token(ref_raw), row["view"], row["temp"],
ver, now + REFRESH_TOKEN_TTL)
)
conn.commit()
return {"access_token": acc_raw, "refresh_token": ref_raw}
finally:
conn.close()
def find_refresh_token(raw: str, db_path: str = None) -> dict | None:
"""Validate a refresh token (status check only; version is not compared
here because a refresh token belongs to one device and is deleted on
kick, not merely invalidated)."""
if not raw:
return None
conn = get_db(_db_path(db_path))
try:
h = _hash_token(raw)
row = conn.execute(
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
(h,)
).fetchone()
if not row:
return None
if row["expires_at"] <= int(time.time()):
return None
if row["user_status"] != "active":
return None
return dict(row)
finally:
conn.close()
def mark_token_reauth(token_row_id: int, db_path: str = None):
"""Stamp the current access token row as re-authenticated (per-device)."""
if not token_row_id:
return
conn = get_db(_db_path(db_path))
try:
conn.execute("UPDATE auth_tokens SET reauth_at=? WHERE id=?",
(int(time.time()), token_row_id))
conn.commit()
finally:
conn.close()
def delete_auth_token(raw: str, kind: str = None, db_path: str = None):
"""Delete a single token row by raw value (logout of one device)."""
if not raw:
return
conn = get_db(_db_path(db_path))
try:
h = _hash_token(raw)
if kind:
conn.execute("DELETE FROM auth_tokens WHERE token_hash=? AND kind=?", (h, kind))
else:
conn.execute("DELETE FROM auth_tokens WHERE token_hash=?", (h,))
conn.commit()
finally:
conn.close()
def revoke_user_tokens(user_id: int, db_path: str = None):
"""Delete every token of a user (kick all devices of one account).
Callers should also bump_user_session_version() to invalidate access tokens."""
conn = get_db(_db_path(db_path))
try:
conn.execute("DELETE FROM auth_tokens WHERE user_id=?", (user_id,))
conn.commit()
finally:
conn.close()
def revoke_all_tokens(db_path: str = None):
"""Delete every token of every user (Root: kick all users)."""
conn = get_db(_db_path(db_path))
try:
conn.execute("DELETE FROM auth_tokens")
conn.commit()
finally:
conn.close()
# ─── Auth / MFA helpers (per user) ──────────
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
@@ -598,6 +834,9 @@ def _ensure_defaults(conn):
import secrets
import string
# Schema-version row (single row, id=1).
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
# Root user (id 0)
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
if root is None:
@@ -659,6 +898,10 @@ def _ensure_defaults(conn):
# Stores passwords that were just generated, for the startup script to print.
_EMITTED_PASSWORDS: list = []
# Current schema version supported by this program. Bump it whenever the
# SCHEMA changes so existing databases get rebuilt+migrated on next start.
DB_VERSION = 1
def _table_columns(conn, table_name):
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
@@ -694,17 +937,44 @@ def _migrate_auth_config(conn_new, conn_old):
def init_db(db_path: str = "data.db"):
"""Initialize the database.
"""Initialize the database with schema-version aware migration.
- No database file: create it from the latest SCHEMA and create Root.
- Existing database: rebuild it from the latest SCHEMA, migrate compatible
data (legacy single-user data becomes Root's), fill missing columns with
SCHEMA defaults, then replace the old file (keeping a backup).
- Existing database:
* no `meta.version` (legacy DB) → rebuild + migrate compatible data
* DB version < DB_VERSION → rebuild + migrate
* DB version == DB_VERSION → use as-is (fast path)
* DB version > DB_VERSION → abort with an error (DB is newer
than this program)
Rebuild copies compatible data (legacy single-user data becomes Root's)
and fills missing columns with SCHEMA defaults.
"""
if not os.path.exists(db_path):
conn = get_db(db_path)
conn.executescript(SCHEMA)
_ensure_defaults(conn)
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
conn.commit()
conn.close()
return
conn_probe = get_db(db_path)
old_version = _read_db_version(conn_probe)
conn_probe.close()
if old_version is not None and old_version > DB_VERSION:
raise SystemExit(
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
)
if old_version is not None and old_version == DB_VERSION:
# Same version — no rebuild needed. Just ensure defaults exist.
conn = get_db(db_path)
_ensure_defaults(conn)
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
conn.commit()
conn.close()
return
@@ -764,6 +1034,10 @@ def init_db(db_path: str = "data.db"):
# Now fill defaults for any tables that are still empty + Root bootstrap.
_ensure_defaults(conn_new)
# Stamp the migrated database with the program's schema version.
conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
conn_new.commit()
conn_old.close()
conn_new.close()
@@ -772,3 +1046,14 @@ def init_db(db_path: str = "data.db"):
os.remove(backup_path)
os.rename(db_path, backup_path)
os.rename(new_db_path, db_path)
def _read_db_version(conn) -> int | None:
"""Read the schema version from the `meta` table.
Returns None when the DB is legacy (no meta table / no version column).
"""
try:
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
return int(row["version"]) if row else None
except Exception:
return None
+159 -36
View File
@@ -18,16 +18,17 @@ from datetime import datetime
from flask import (
Blueprint, request, jsonify, Response,
render_template, current_app, stream_with_context,
session, redirect, url_for,
g, redirect, url_for,
)
from backend import calendar_mgr, weather_mgr, alarm_mgr, pomodoro_mgr, countdown_mgr
from backend.auth import (
login_required, login_or_temp_required, display_login_required, admin_required,
is_authenticated, is_temp_pending, is_admin, current_user_id,
set_authenticated, set_temp_pending, clear_session,
verify_login, verify_totp, verify_recovery_code,
check_rate_limit, record_failure, record_success,
require_reauth, mark_reauth, generate_recovery_codes, generate_temp_password,
load_auth_context, issue_tokens, set_refresh_cookie, clear_refresh_cookie,
rotate_refresh, logout_current_device, load_page_context, REFRESH_COOKIE,
)
from backend.models import (
get_auth_config, update_auth_config, set_temp_password, clear_temp_password,
@@ -37,6 +38,7 @@ from backend.models import (
get_users, get_user, create_user, update_user, delete_user,
is_temp_account, is_admin_user, finalize_temp_account,
find_user_by_credential_id, bump_all_session_versions,
bump_user_session_version, revoke_user_tokens, revoke_all_tokens,
)
from backend.webauthn_mgr import (
begin_registration, complete_registration,
@@ -53,9 +55,15 @@ def _cur() -> int:
# ════════════════════════════════════════════
# CSRF / Origin Check
# AUTH CONTEXT / CSRF
# ════════════════════════════════════════════
@api.before_request
def _load_token():
"""Resolve the access token (Authorization header) into g.user_id / view / temp."""
load_auth_context()
@api.before_request
def _check_origin():
"""Protect state-changing requests against CSRF by verifying Origin/Referer.
@@ -199,6 +207,8 @@ def api_login():
data = request.get_json() or {}
code = data.get("code", "")
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control" # 未认证端点:非法值降级,避免触发 DB CHECK 约束 500
result = verify_login(code)
if result is None:
@@ -213,21 +223,26 @@ def api_login():
if temp_account:
# Temp account: server-held temp TOTP matched → force registration flow.
record_success()
set_temp_pending(view, uid)
tokens = issue_tokens(uid, view=view, temp=True)
resp["access_token"] = tokens["access_token"]
resp["must_configure_mfa"] = True
else:
cfg = get_auth_config(user_id=uid)
if not cfg.get("totp_enabled"):
return jsonify({"error": "TOTP 尚未启用"}), 403
record_success()
set_authenticated(view, uid)
tokens = issue_tokens(uid, view=view)
resp["access_token"] = tokens["access_token"]
else: # temp_password (Root initial bootstrap)
record_success()
set_temp_pending(view, uid)
tokens = issue_tokens(uid, view=view, temp=True)
resp["access_token"] = tokens["access_token"]
resp["must_configure_mfa"] = True
return jsonify(resp)
response = jsonify(resp)
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Code Login ─────────────────────
@@ -242,12 +257,16 @@ def api_login_recovery():
data = request.get_json() or {}
code = data.get("code", "")
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
uid = verify_recovery_code(code)
if uid is not None:
record_success()
set_authenticated(view, uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=view)
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
record_failure()
return jsonify({"error": "恢复码错误或已使用"}), 401
@@ -356,6 +375,8 @@ def api_webauthn_login_complete():
data = request.get_json() or {}
view = data.get("view", "control")
if view not in ("control", "display"):
view = "control"
credential = data.get("credential", {})
credential_b64 = credential.get("id", "")
@@ -392,8 +413,10 @@ def api_webauthn_login_complete():
record_failure()
return jsonify({"error": "Passkey 凭证不匹配"}), 401
record_success()
set_authenticated(view, uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=view)
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
except ValueError as e:
record_failure()
return jsonify({"error": str(e)}), 401
@@ -401,12 +424,36 @@ def api_webauthn_login_complete():
return jsonify({"error": f"Passkey 验证失败: {e}"}), 500
# ─── Logout ──────────────────────────────────
# ─── Refresh / Logout ───────────────────────
@api.route("/api/auth/refresh", methods=["POST"])
def api_refresh():
"""Exchange the httpOnly refresh cookie for a fresh access+refresh pair.
The refresh token is rotated: the old one is consumed immediately.
CSRF: not exempted from the Origin/Referer check (cookie-authenticated).
"""
raw = request.cookies.get(REFRESH_COOKIE)
if not raw:
return jsonify({"error": "Unauthorized", "login_required": True}), 401
tokens = rotate_refresh(raw)
if not tokens:
response = jsonify({"error": "Unauthorized", "login_required": True})
clear_refresh_cookie(response)
return response, 401
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
@api.route("/api/auth/logout", methods=["POST"])
def api_logout():
clear_session()
return jsonify({"ok": True})
"""Revoke this device's refresh token and clear its cookie.
Other devices of the same account are unaffected.
"""
logout_current_device()
response = jsonify({"ok": True})
clear_refresh_cookie(response)
return response
# ─── Auth Status ─────────────────────────────
@@ -484,9 +531,11 @@ def api_register_request():
@api.route("/mfa-setup")
def mfa_setup_page():
"""MFA configuration page. Shown after temp-password / temp-account login."""
if not is_temp_pending() and not is_authenticated():
return redirect(url_for("api.login_page", next="/mfa-setup"))
"""MFA configuration page. Shown after temp-password / temp-account login.
Authenticated via the httpOnly refresh cookie (page navigation).
"""
if not load_page_context():
return redirect(url_for("api.login_page", next="/mfa-setup", view="control"))
return render_template("mfa_setup.html")
@@ -532,8 +581,12 @@ def api_webauthn_register_complete():
if is_temp_account(uid):
finalize_temp_account(uid)
finalized = True
# The user proved access to the account — mark the session authenticated.
set_authenticated(session.get("mfa_view", "control"), uid)
# The user proved access to the account — issue a formal token pair.
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
response = jsonify({"ok": True, "finalized": True,
"access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
return jsonify({"ok": True, "finalized": finalized})
except ValueError as e:
return jsonify({"error": str(e)}), 400
@@ -602,13 +655,19 @@ def api_totp_verify():
if is_temp_account(uid):
finalize_temp_account(uid)
finalized = True
set_authenticated(session.get("mfa_view", "control"), uid)
return jsonify({
resp = {
"ok": True,
"recovery_codes": get_recovery_codes(user_id=uid),
"finalized": finalized,
})
}
if finalized:
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
resp["access_token"] = tokens["access_token"]
response = jsonify(resp)
set_refresh_cookie(response, tokens["refresh_token"])
return response
return jsonify(resp)
return jsonify({"error": "验证码错误,请重试"}), 400
@@ -631,8 +690,10 @@ def api_mfa_complete():
finalize_temp_account(uid)
clear_temp_password(user_id=uid)
update_auth_config(uid, must_configure_mfa=0)
set_authenticated(session.get("mfa_view", "control"), uid)
return jsonify({"ok": True})
tokens = issue_tokens(uid, view=getattr(g, "auth_view", "control"))
response = jsonify({"ok": True, "access_token": tokens["access_token"]})
set_refresh_cookie(response, tokens["refresh_token"])
return response
# ─── Recovery Codes ──────────────────────────
@@ -977,6 +1038,20 @@ def admin_provision_passkey_complete(user_id):
return jsonify({"error": f"Passkey 注册失败: {e}"}), 500
@api.route("/api/admin/kick_all_users", methods=["POST"])
@admin_required
def admin_kick_all_users():
"""Root: kick EVERY user's control + display devices. All sessions end
immediately (including Root's own devices). High-risk → re-auth required.
"""
if not require_reauth():
return jsonify({"error": "需要重新验证身份", "reauth_required": True}), 403
bump_all_session_versions() # invalidates every access token
revoke_all_tokens() # invalidates every refresh token
current_app.event_bus.publish("kick_all", {"reason": "admin_kick_all"})
return jsonify({"ok": True})
# ════════════════════════════════════════════
# DISPLAY PAGES
# ════════════════════════════════════════════
@@ -984,11 +1059,13 @@ def admin_provision_passkey_complete(user_id):
@api.route("/")
@api.route("/index")
def display_page():
# 页面导航无法发送 Authorization header → 用 refresh cookie 鉴权。
# custom 模式同样需要登录(避免匿名访问泄露账户自定义 HTML)。
if not load_page_context():
return redirect(url_for("api.login_page", next=request.path))
cfg = get_display_config(user_id=_cur())
if cfg.get("mode") == "custom":
return render_template("display_custom.html", custom_html=cfg.get("custom_html", ""))
if not is_authenticated() and not is_temp_pending():
return redirect(url_for("api.login_page", next=request.path))
return render_template("display.html")
@@ -997,8 +1074,13 @@ def display_page():
# ════════════════════════════════════════════
@api.route("/control")
@login_required
def control_page():
"""Control panel. Page navigation authenticates via the refresh cookie;
temp-pending users are sent to MFA setup."""
if not load_page_context():
return redirect(url_for("api.login_page", next="/control", view="control"))
if getattr(g, "temp_pending", False):
return redirect(url_for("api.mfa_setup_page"))
return render_template("control.html")
@@ -1006,12 +1088,49 @@ def control_page():
# DISPLAY / SSE / STATE
# ════════════════════════════════════════════
# One-time, short-lived tickets used to open the SSE stream (EventSource
# cannot send Authorization headers). The ticket is consumed on connect.
_SSE_TICKETS: dict = {} # ticket -> {"user_id": int, "view": str, "expires": float}
_SSE_TICKET_TTL = 60
def _clean_sse_tickets():
now = _time.time()
expired = [k for k, v in _SSE_TICKETS.items() if v["expires"] < now]
for k in expired:
_SSE_TICKETS.pop(k, None)
@api.route("/api/events/ticket", methods=["POST"])
@display_login_required
def sse_ticket():
"""Issue a one-time ticket for opening the SSE stream."""
_clean_sse_tickets()
from backend.models import get_user_session_version
ticket = secrets.token_urlsafe(24)
_SSE_TICKETS[ticket] = {
"user_id": _cur(),
"view": getattr(g, "auth_view", "display"),
"session_version": get_user_session_version(_cur()),
"expires": _time.time() + _SSE_TICKET_TTL,
}
return jsonify({"ticket": ticket}), 201
@api.route("/api/events")
def sse_stream():
"""Server-Sent Events for real-time updates (scoped to the current user)."""
if not is_authenticated() and not is_temp_pending():
"""Server-Sent Events for real-time updates (scoped to the current user).
Authenticated via a one-time ticket from /api/events/ticket.
"""
ticket = request.args.get("ticket", "")
entry = _SSE_TICKETS.pop(ticket, None) if ticket else None
if not entry or entry["expires"] < _time.time():
return jsonify({"error": "Unauthorized", "login_required": True}), 401
uid = _cur()
# A ticket is void once the user was kicked / deleted (version bumped).
from backend.models import get_user_session_version
if get_user_session_version(entry["user_id"]) != entry.get("session_version"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401
uid = entry["user_id"]
def generate():
q = current_app.event_bus.subscribe(user_id=uid)
@@ -1055,6 +1174,7 @@ def get_display_state():
update_schedule = weather_mgr.get_next_update_times(user_id=uid)
minutely = weather_mgr.get_cached_minutely(user_id=uid)
warnings = weather_mgr.get_cached_warnings(user_id=uid)
from backend.time_util import get_tz_str
return jsonify({
"events": events,
@@ -1067,6 +1187,7 @@ def get_display_state():
"update_schedule": update_schedule,
"minutely": minutely,
"warnings": warnings,
"timezone": get_tz_str(),
})
@@ -1133,13 +1254,15 @@ def force_refresh_display():
@api.route("/api/display/kick_all", methods=["POST"])
@admin_required
def kick_all_displays():
"""Kick ALL sessions (including all users) by bumping every user's version.
Old session cookies become invalid immediately. Also broadcasts SSE.
@login_required
def kick_own_devices():
"""Kick every device logged into the CURRENT account — control AND display
devices, including this control device itself. All sessions end immediately.
"""
bump_all_session_versions()
current_app.event_bus.publish("kick_all", {})
uid = _cur()
bump_user_session_version(uid) # invalidates every access token of this user
revoke_user_tokens(uid) # invalidates every refresh token of this user
current_app.event_bus.publish("kick_all", {"reason": "self_kick"}, user_id=uid)
return jsonify({"ok": True})
+3 -3
View File
@@ -41,11 +41,11 @@ def get_thread_user():
def _ctx_user_id():
"""Resolve the current user id: request session first, then thread-local."""
"""Resolve the current user id: request auth context first, then thread-local."""
try:
from flask import has_request_context, session
from flask import has_request_context, g
if has_request_context():
uid = session.get("user_id")
uid = getattr(g, "user_id", None)
if uid is not None:
return int(uid)
except Exception:
+3 -3
View File
@@ -155,10 +155,10 @@ def complete_registration(request, credential: dict) -> dict:
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
if not expected_challenge:
# Self-registration fallback: the browser may not echo the user handle,
# so resolve the target from the session user.
# so resolve the target from the authenticated request context.
try:
from flask import session
uid = session.get("user_id")
from backend.auth import current_user_id
uid = current_user_id()
if uid is not None:
challenge_key = f"reg_{uid}"
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)