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
+119 -1
View File
@@ -2,14 +2,29 @@
Events are scoped by user_id so that each user's display only receives
updates about their own data (alarms, calendar, pomodoro, ...).
Cross-process broadcast:
The app runs under multiple Gunicorn workers; each worker has its own
in-process EventBus AND its own SSE subscribers. A publish() on worker A
would never reach an SSE connection held by worker B. To fix this,
publish() additionally sends the event over a PostgreSQL LISTEN/NOTIFY
channel; every worker runs a listener thread that re-dispatches remote
events to its local subscribers. Oversized payloads (rare, > ~7KB) are
delivered as a ``db_changed`` signal so the display re-fetches full state.
"""
import json
import os
import queue
import threading
import psycopg2
_NOTIFY_CHANNEL = "iot_events"
_NOTIFY_MAX_LEN = 7000 # stay well under PostgreSQL's 8KB NOTIFY payload limit
class EventBus:
"""Simple pub/sub event system with SSE support.
"""Simple pub/sub event system with SSE + cross-process broadcast support.
publish(event_type, data, user_id=None): when user_id is given, only
subscribers registered for that user receive the event.
@@ -18,6 +33,27 @@ class EventBus:
def __init__(self):
self._subscribers: list[tuple[int | None, queue.Queue]] = []
self._lock = threading.Lock()
self._notify_conn = None
self._notify_lock = threading.Lock()
self._listener = None
def attach(self, db_url: str):
"""Enable cross-process broadcast for THIS process: opens a NOTIFY
connection and starts a LISTEN thread. Call once per worker (start.py
/ run.py). Without attach, publish() still works locally (tests)."""
if self._notify_conn is not None:
return
try:
self._notify_conn = psycopg2.connect(db_url, connect_timeout=5)
self._notify_conn.autocommit = True
except Exception:
self._notify_conn = None
return
self._listener = threading.Thread(
target=self._listen_loop, args=(db_url,), daemon=True,
name="eventbus-listener",
)
self._listener.start()
def subscribe(self, user_id: int | None = None) -> queue.Queue:
"""Subscribe to events. user_id=None receives ALL events (broadcast)."""
@@ -31,6 +67,14 @@ class EventBus:
self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q]
def publish(self, event_type: str, data: dict, user_id: int | None = None):
"""Deliver locally AND broadcast to other workers (best-effort).
The NOTIFY carries this process's PID so our own listener thread can
skip the echo (each subscriber must receive an event exactly once)."""
self._dispatch(event_type, data, user_id)
self._broadcast(event_type, data, user_id)
# ─── local delivery ───────────────────────
def _dispatch(self, event_type: str, data: dict, user_id: int | None = None):
msg = json.dumps({"type": event_type, "data": data})
with self._lock:
dead = []
@@ -44,3 +88,77 @@ class EventBus:
for item in dead:
self._subscribers.remove(item)
# ─── cross-process broadcast ──────────────
def _broadcast(self, event_type: str, data: dict, user_id: int | None):
if self._notify_conn is None:
return
try:
payload = json.dumps(
{"pid": os.getpid(), "type": event_type, "data": data,
"user_id": user_id},
default=str,
)
if len(payload) > _NOTIFY_MAX_LEN:
# Too big for NOTIFY: receivers re-fetch full state from the DB.
payload = json.dumps(
{"pid": os.getpid(), "type": event_type,
"oversized": True, "user_id": user_id}
)
with self._notify_lock:
cur = self._notify_conn.cursor()
cur.execute("SELECT pg_notify(%s, %s)", (_NOTIFY_CHANNEL, payload))
self._notify_conn.commit()
cur.close()
except Exception:
# Broadcast is best-effort (local delivery already happened).
# Drop the dead connection so the next publish reconnects once.
try:
self._notify_conn.close()
except Exception:
pass
self._notify_conn = None
def handle_remote(self, payload: str):
"""Listener-thread entry: re-dispatch a remote event locally (no NOTIFY).
Events broadcast by THIS process are skipped (already delivered locally)."""
try:
msg = json.loads(payload)
if msg.get("pid") == os.getpid():
return # echo of our own broadcast
if msg.get("oversized"):
self._dispatch("db_changed", {}, msg.get("user_id"))
else:
self._dispatch(
msg.get("type", "db_changed"),
msg.get("data") or {},
msg.get("user_id"),
)
except Exception:
pass
def _listen_loop(self, db_url: str):
"""Blocking LISTEN loop with reconnect on DB failure."""
import select
import time
while True:
conn = None
try:
conn = psycopg2.connect(db_url, connect_timeout=5)
conn.autocommit = True
cur = conn.cursor()
cur.execute(f"LISTEN {_NOTIFY_CHANNEL}")
cur.close()
while True:
if select.select([conn], [], [], 1.0)[0]:
conn.poll()
while conn.notifies:
n = conn.notifies.pop(0)
self.handle_remote(n.payload)
except Exception:
time.sleep(2) # DB temporarily unavailable: retry
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
+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()
+7 -1
View File
@@ -323,7 +323,10 @@ def api_reauth_webauthn_begin():
if not stored_id:
return jsonify({"error": "未注册 Passkey"}), 400
try:
options = begin_authentication(request, allow_credential_ids=[stored_id])
options = begin_authentication(
request, allow_credential_ids=[stored_id],
challenge_key=f"reauth_{_cur()}",
)
return jsonify(options)
except Exception as e:
return jsonify({"error": str(e)}), 500
@@ -347,6 +350,7 @@ def api_reauth_webauthn_complete():
request, credential,
stored_credential_id=stored_id,
stored_public_key=stored_key,
challenge_key=f"reauth_{_cur()}",
)
if result.get("credential_id") != stored_id:
return jsonify({"error": "Passkey 凭证不匹配"}), 401
@@ -1644,6 +1648,8 @@ def set_time_config():
if not tz_str:
return jsonify({"error": "timezone is required"}), 400
if set_tz(tz_str):
# 广播给该用户的显示端,立即刷新(跨 worker 由 EventBus 远程广播保证)
current_app.event_bus.publish("timezone_changed", {"timezone": tz_str}, user_id=_cur())
return jsonify({"ok": True})
return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400
+36 -16
View File
@@ -26,11 +26,16 @@ _TLS = threading.local()
def set_thread_user(user_id):
"""Set the current user for this thread (scheduler per-user iteration)."""
_TLS.user_id = user_id
try:
del _TLS.tz
except AttributeError:
pass
def clear_thread_user():
try:
del _TLS.user_id
del _TLS.tz
except AttributeError:
pass
@@ -78,30 +83,45 @@ def _load_tz() -> "pytz.BaseTzInfo":
def get_tz() -> "pytz.BaseTzInfo":
"""Return the configured timezone object. Cached per user in app config."""
"""Return the configured timezone object.
Cached per-request (Flask ``g``) and per-thread (scheduler TLS), NOT per
process: a timezone change made on one Gunicorn worker must be visible to
the next request on ANY worker immediately (a process-wide cache would
serve stale timezones to other workers).
"""
try:
from flask import current_app
uid = _ctx_user_id()
if hasattr(current_app, "config") and "_timezone_cache" in current_app.config:
cache = current_app.config["_timezone_cache"]
if uid in cache:
return cache[uid]
tz = _load_tz()
if hasattr(current_app, "config"):
cache = current_app.config.setdefault("_timezone_cache", {})
cache[uid] = tz
return tz
from flask import has_request_context, g
if has_request_context():
cached = getattr(g, "_tz_cache", None)
if cached is not None:
return cached
tz = _load_tz()
g._tz_cache = tz
return tz
except Exception:
return pytz.timezone(_DEFAULT_TZ_STR)
pass
# No request context (scheduler threads): cache per thread-user so each
# user's jobs use their own timezone without a DB hit per call.
tz = getattr(_TLS, "tz", None)
if tz is not None:
return tz
tz = _load_tz()
_TLS.tz = tz
return tz
def clear_tz_cache():
"""Force reload timezone on next call. Call after timezone config changes."""
"""Force reload timezone on next call (called after timezone config changes)."""
try:
from flask import current_app
current_app.config.pop("_timezone_cache", None)
from flask import g
g.__dict__.pop("_tz_cache", None)
except Exception:
pass
try:
del _TLS.tz
except AttributeError:
pass
def set_tz(tz_str: str) -> bool:
+35 -17
View File
@@ -13,7 +13,7 @@ Uses the `webauthn` library (v2.x/v3.x).
Requires HTTPS (or localhost) to function.
"""
from dataclasses import asdict
from webauthn.helpers import bytes_to_base64url
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn import (
generate_registration_options,
verify_registration_response,
@@ -64,9 +64,20 @@ RP_NAME = "Cal Display"
RP_ID = None # set dynamically from request.host
ORIGIN = None # set dynamically
# In-memory challenge stores
_CHALLENGES: dict = {} # "login" → challenge
_REGISTRATION_CHALLENGES: dict = {} # "reg_<user_id>" → challenge
# Challenges are persisted in PostgreSQL (backend.models.webauthn_challenges)
# so a ceremony started on one Gunicorn worker can be completed on another.
# Keys: "reg_<user_id>" for registration, "login" for login,
# "reauth_<user_id>" for re-authentication.
from backend.models import save_webauthn_challenge, pop_webauthn_challenge # noqa: E402
def _challenge_db() -> str | None:
"""Resolve the current DB connection string (app config, like the managers)."""
try:
from flask import current_app
return current_app.config.get("DB_PATH")
except Exception:
return None
def _get_rp_id(request) -> str:
@@ -133,8 +144,11 @@ def begin_registration(request, user_id: int, display_name: str = "",
exclude_credentials=exclude_credentials,
)
# Store challenge for verification
_REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge
# Store challenge for verification (DB-backed, cross-worker safe, 5 min TTL)
save_webauthn_challenge(
f"reg_{user_id}", bytes_to_base64url(options.challenge),
db_path=_challenge_db(),
)
return _serialize_options(options)
@@ -150,8 +164,7 @@ def complete_registration(request, credential: dict) -> dict:
user_name = credential.get("user_name", "")
# Derive the target user id from the registration challenge key.
challenge_key = f"reg_{user_name}"
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
expected_challenge = pop_webauthn_challenge(f"reg_{user_name}", db_path=_challenge_db())
if not expected_challenge:
# Self-registration fallback: the browser may not echo the user handle,
# so resolve the target from the authenticated request context.
@@ -159,8 +172,7 @@ def complete_registration(request, credential: dict) -> dict:
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)
expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db())
user_name = f"user_{uid}"
except Exception:
pass
@@ -175,7 +187,7 @@ def complete_registration(request, credential: dict) -> dict:
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_challenge=base64url_to_bytes(expected_challenge),
expected_rp_id=rp_id,
expected_origin=origin,
)
@@ -192,11 +204,15 @@ def complete_registration(request, credential: dict) -> dict:
# ─── Authentication (Login / Re-auth) ────────
def begin_authentication(request, allow_credential_ids: list = None) -> dict:
def begin_authentication(request, allow_credential_ids: list = None,
challenge_key: str = "login") -> dict:
"""Start WebAuthn authentication. Returns options dict for the frontend.
allow_credential_ids: optional list of hex credential ids to restrict the
ceremony to (used for re-authentication of the current user).
challenge_key: DB key under which the challenge is stored; pass a
user-scoped key (e.g. "reauth_<uid>") for re-authentication ceremonies so
concurrent logins/reauths never clobber each other.
"""
rp_id = _get_rp_id(request)
@@ -213,20 +229,22 @@ def begin_authentication(request, allow_credential_ids: list = None) -> dict:
allow_credentials=allow_credentials,
)
# Store challenge for verification
_CHALLENGES["login"] = options.challenge
# Store challenge for verification (DB-backed, cross-worker safe)
save_webauthn_challenge(challenge_key, bytes_to_base64url(options.challenge),
db_path=_challenge_db())
return _serialize_options(options)
def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str) -> dict:
def complete_authentication(request, credential: dict, stored_credential_id: str,
stored_public_key: str, challenge_key: str = "login") -> dict:
"""Complete WebAuthn authentication.
Returns {'ok': True, 'credential_id': str} or raises on failure.
"""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
expected_challenge = _CHALLENGES.pop("login", None)
expected_challenge = pop_webauthn_challenge(challenge_key, db_path=_challenge_db())
if not expected_challenge:
raise ValueError("No authentication challenge found. Please start again.")
@@ -237,7 +255,7 @@ def complete_authentication(request, credential: dict, stored_credential_id: str
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_challenge=base64url_to_bytes(expected_challenge),
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=bytes.fromhex(stored_public_key),