This commit is contained in:
喆OvO
2026-08-12 09:50:37 +08:00
parent cc43873ad2
commit 15b54f204e
10 changed files with 360 additions and 41 deletions
+63 -3
View File
@@ -218,6 +218,18 @@ CREATE TABLE IF NOT EXISTS pomodoro_state (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id);
-- WebAuthn challenge store: one-time, short-TTL challenges shared across
-- Gunicorn workers via PostgreSQL (in-memory stores were per-process and
-- caused 'challenge was not expected' failures under multiple workers).
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
key TEXT NOT NULL,
challenge TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_challenges_key ON webauthn_challenges(key);
"""
@@ -825,14 +837,58 @@ def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int |
return row["user_id"] if row else None
# ─── WebAuthn challenge store ────────────────
# Challenges are persisted in PostgreSQL so registration/login ceremonies work
# across Gunicorn workers (in-memory stores were per-process). Consumed
# atomically (DELETE ... RETURNING) and pruned by TTL.
WEBAUTHN_CHALLENGE_TTL = 300 # 5 minutes
def save_webauthn_challenge(key: str, challenge: str,
ttl: int = WEBAUTHN_CHALLENGE_TTL,
db_path: str = None):
"""Store a challenge (base64url string) under `key`, replacing any previous."""
conn = get_db(_db_path(db_path))
try:
conn.execute("DELETE FROM webauthn_challenges WHERE expires_at < ?", (int(time.time()),))
now = int(time.time())
conn.execute(
"INSERT INTO webauthn_challenges (key, challenge, created_at, expires_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (key) DO UPDATE SET challenge=excluded.challenge, "
"created_at=excluded.created_at, expires_at=excluded.expires_at",
(key, challenge, now, now + ttl)
)
conn.commit()
finally:
conn.close()
def pop_webauthn_challenge(key: str, db_path: str = None) -> str | None:
"""Atomically consume and return the challenge for `key` (None when absent/expired)."""
conn = get_db(_db_path(db_path))
try:
conn.execute("DELETE FROM webauthn_challenges WHERE expires_at < ?", (int(time.time()),))
row = conn.execute(
"DELETE FROM webauthn_challenges WHERE key=? AND expires_at >= ? RETURNING challenge",
(key, int(time.time()))
).fetchone()
conn.commit()
return row["challenge"] if row else None
finally:
conn.close()
# ─── DB init / migration ─────────────────────
# 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 upgraded on next start.
DB_VERSION = 2
# SCHEMA changes (new table, column, index, constraint, ...) so existing
# databases get upgraded on next start. Rule: ANY database change ⇒ bump.
DB_VERSION = 3
def _ensure_defaults(conn):
@@ -1103,7 +1159,11 @@ def init_db(db_path: str = "data.db"):
)
if old_version is not None and old_version == DB_VERSION:
# Same version — no upgrade needed. Just ensure defaults exist.
# Same version — no upgrade needed. Run the idempotent DDL anyway so
# any newly-added tables (e.g. webauthn_challenges) exist on
# existing databases without bumping the schema version.
conn.executescript(SCHEMA)
conn.commit()
_ensure_defaults(conn)
_fix_timestamp_defaults(conn)
conn.commit()