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
+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