bug fix
This commit is contained in:
+63
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user