Flask Session -> Double Token
This commit is contained in:
+290
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user