bug fix
This commit is contained in:
@@ -257,7 +257,7 @@ deploy:
|
|||||||
|
|
||||||
## 数据库迁移(版本化,升级无需重写数据)
|
## 数据库迁移(版本化,升级无需重写数据)
|
||||||
|
|
||||||
数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 **2**)。每次启动时比对:
|
数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 **3**)。每次启动时比对:
|
||||||
|
|
||||||
| 情况 | 行为 |
|
| 情况 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
+119
-1
@@ -2,14 +2,29 @@
|
|||||||
|
|
||||||
Events are scoped by user_id so that each user's display only receives
|
Events are scoped by user_id so that each user's display only receives
|
||||||
updates about their own data (alarms, calendar, pomodoro, ...).
|
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 json
|
||||||
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
_NOTIFY_CHANNEL = "iot_events"
|
||||||
|
_NOTIFY_MAX_LEN = 7000 # stay well under PostgreSQL's 8KB NOTIFY payload limit
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
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
|
publish(event_type, data, user_id=None): when user_id is given, only
|
||||||
subscribers registered for that user receive the event.
|
subscribers registered for that user receive the event.
|
||||||
@@ -18,6 +33,27 @@ class EventBus:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._subscribers: list[tuple[int | None, queue.Queue]] = []
|
self._subscribers: list[tuple[int | None, queue.Queue]] = []
|
||||||
self._lock = threading.Lock()
|
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:
|
def subscribe(self, user_id: int | None = None) -> queue.Queue:
|
||||||
"""Subscribe to events. user_id=None receives ALL events (broadcast)."""
|
"""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]
|
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):
|
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})
|
msg = json.dumps({"type": event_type, "data": data})
|
||||||
with self._lock:
|
with self._lock:
|
||||||
dead = []
|
dead = []
|
||||||
@@ -44,3 +88,77 @@ class EventBus:
|
|||||||
for item in dead:
|
for item in dead:
|
||||||
self._subscribers.remove(item)
|
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
@@ -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);
|
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
|
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 ─────────────────────
|
# ─── DB init / migration ─────────────────────
|
||||||
|
|
||||||
# Stores passwords that were just generated, for the startup script to print.
|
# Stores passwords that were just generated, for the startup script to print.
|
||||||
_EMITTED_PASSWORDS: list = []
|
_EMITTED_PASSWORDS: list = []
|
||||||
|
|
||||||
# Current schema version supported by this program. Bump it whenever the
|
# Current schema version supported by this program. Bump it whenever the
|
||||||
# SCHEMA changes so existing databases get upgraded on next start.
|
# SCHEMA changes (new table, column, index, constraint, ...) so existing
|
||||||
DB_VERSION = 2
|
# databases get upgraded on next start. Rule: ANY database change ⇒ bump.
|
||||||
|
DB_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
def _ensure_defaults(conn):
|
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:
|
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)
|
_ensure_defaults(conn)
|
||||||
_fix_timestamp_defaults(conn)
|
_fix_timestamp_defaults(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
+7
-1
@@ -323,7 +323,10 @@ def api_reauth_webauthn_begin():
|
|||||||
if not stored_id:
|
if not stored_id:
|
||||||
return jsonify({"error": "未注册 Passkey"}), 400
|
return jsonify({"error": "未注册 Passkey"}), 400
|
||||||
try:
|
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)
|
return jsonify(options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -347,6 +350,7 @@ def api_reauth_webauthn_complete():
|
|||||||
request, credential,
|
request, credential,
|
||||||
stored_credential_id=stored_id,
|
stored_credential_id=stored_id,
|
||||||
stored_public_key=stored_key,
|
stored_public_key=stored_key,
|
||||||
|
challenge_key=f"reauth_{_cur()}",
|
||||||
)
|
)
|
||||||
if result.get("credential_id") != stored_id:
|
if result.get("credential_id") != stored_id:
|
||||||
return jsonify({"error": "Passkey 凭证不匹配"}), 401
|
return jsonify({"error": "Passkey 凭证不匹配"}), 401
|
||||||
@@ -1644,6 +1648,8 @@ def set_time_config():
|
|||||||
if not tz_str:
|
if not tz_str:
|
||||||
return jsonify({"error": "timezone is required"}), 400
|
return jsonify({"error": "timezone is required"}), 400
|
||||||
if set_tz(tz_str):
|
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({"ok": True})
|
||||||
return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400
|
return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400
|
||||||
|
|
||||||
|
|||||||
+34
-14
@@ -26,11 +26,16 @@ _TLS = threading.local()
|
|||||||
def set_thread_user(user_id):
|
def set_thread_user(user_id):
|
||||||
"""Set the current user for this thread (scheduler per-user iteration)."""
|
"""Set the current user for this thread (scheduler per-user iteration)."""
|
||||||
_TLS.user_id = user_id
|
_TLS.user_id = user_id
|
||||||
|
try:
|
||||||
|
del _TLS.tz
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def clear_thread_user():
|
def clear_thread_user():
|
||||||
try:
|
try:
|
||||||
del _TLS.user_id
|
del _TLS.user_id
|
||||||
|
del _TLS.tz
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -78,30 +83,45 @@ def _load_tz() -> "pytz.BaseTzInfo":
|
|||||||
|
|
||||||
|
|
||||||
def get_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:
|
try:
|
||||||
from flask import current_app
|
from flask import has_request_context, g
|
||||||
uid = _ctx_user_id()
|
if has_request_context():
|
||||||
if hasattr(current_app, "config") and "_timezone_cache" in current_app.config:
|
cached = getattr(g, "_tz_cache", None)
|
||||||
cache = current_app.config["_timezone_cache"]
|
if cached is not None:
|
||||||
if uid in cache:
|
return cached
|
||||||
return cache[uid]
|
|
||||||
tz = _load_tz()
|
tz = _load_tz()
|
||||||
if hasattr(current_app, "config"):
|
g._tz_cache = tz
|
||||||
cache = current_app.config.setdefault("_timezone_cache", {})
|
|
||||||
cache[uid] = tz
|
|
||||||
return tz
|
return tz
|
||||||
except Exception:
|
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():
|
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:
|
try:
|
||||||
from flask import current_app
|
from flask import g
|
||||||
current_app.config.pop("_timezone_cache", None)
|
g.__dict__.pop("_tz_cache", None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
del _TLS.tz
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def set_tz(tz_str: str) -> bool:
|
def set_tz(tz_str: str) -> bool:
|
||||||
|
|||||||
+35
-17
@@ -13,7 +13,7 @@ Uses the `webauthn` library (v2.x/v3.x).
|
|||||||
Requires HTTPS (or localhost) to function.
|
Requires HTTPS (or localhost) to function.
|
||||||
"""
|
"""
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from webauthn.helpers import bytes_to_base64url
|
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||||
from webauthn import (
|
from webauthn import (
|
||||||
generate_registration_options,
|
generate_registration_options,
|
||||||
verify_registration_response,
|
verify_registration_response,
|
||||||
@@ -64,9 +64,20 @@ RP_NAME = "Cal Display"
|
|||||||
RP_ID = None # set dynamically from request.host
|
RP_ID = None # set dynamically from request.host
|
||||||
ORIGIN = None # set dynamically
|
ORIGIN = None # set dynamically
|
||||||
|
|
||||||
# In-memory challenge stores
|
# Challenges are persisted in PostgreSQL (backend.models.webauthn_challenges)
|
||||||
_CHALLENGES: dict = {} # "login" → challenge
|
# so a ceremony started on one Gunicorn worker can be completed on another.
|
||||||
_REGISTRATION_CHALLENGES: dict = {} # "reg_<user_id>" → challenge
|
# 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:
|
def _get_rp_id(request) -> str:
|
||||||
@@ -133,8 +144,11 @@ def begin_registration(request, user_id: int, display_name: str = "",
|
|||||||
exclude_credentials=exclude_credentials,
|
exclude_credentials=exclude_credentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store challenge for verification
|
# Store challenge for verification (DB-backed, cross-worker safe, 5 min TTL)
|
||||||
_REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge
|
save_webauthn_challenge(
|
||||||
|
f"reg_{user_id}", bytes_to_base64url(options.challenge),
|
||||||
|
db_path=_challenge_db(),
|
||||||
|
)
|
||||||
|
|
||||||
return _serialize_options(options)
|
return _serialize_options(options)
|
||||||
|
|
||||||
@@ -150,8 +164,7 @@ def complete_registration(request, credential: dict) -> dict:
|
|||||||
|
|
||||||
user_name = credential.get("user_name", "")
|
user_name = credential.get("user_name", "")
|
||||||
# Derive the target user id from the registration challenge key.
|
# Derive the target user id from the registration challenge key.
|
||||||
challenge_key = f"reg_{user_name}"
|
expected_challenge = pop_webauthn_challenge(f"reg_{user_name}", db_path=_challenge_db())
|
||||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
|
||||||
if not expected_challenge:
|
if not expected_challenge:
|
||||||
# Self-registration fallback: the browser may not echo the user handle,
|
# Self-registration fallback: the browser may not echo the user handle,
|
||||||
# so resolve the target from the authenticated request context.
|
# 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
|
from backend.auth import current_user_id
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
if uid is not None:
|
if uid is not None:
|
||||||
challenge_key = f"reg_{uid}"
|
expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db())
|
||||||
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
|
|
||||||
user_name = f"user_{uid}"
|
user_name = f"user_{uid}"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -175,7 +187,7 @@ def complete_registration(request, credential: dict) -> dict:
|
|||||||
|
|
||||||
verification = verify_registration_response(
|
verification = verify_registration_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=base64url_to_bytes(expected_challenge),
|
||||||
expected_rp_id=rp_id,
|
expected_rp_id=rp_id,
|
||||||
expected_origin=origin,
|
expected_origin=origin,
|
||||||
)
|
)
|
||||||
@@ -192,11 +204,15 @@ def complete_registration(request, credential: dict) -> dict:
|
|||||||
|
|
||||||
# ─── Authentication (Login / Re-auth) ────────
|
# ─── 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.
|
"""Start WebAuthn authentication. Returns options dict for the frontend.
|
||||||
|
|
||||||
allow_credential_ids: optional list of hex credential ids to restrict the
|
allow_credential_ids: optional list of hex credential ids to restrict the
|
||||||
ceremony to (used for re-authentication of the current user).
|
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)
|
rp_id = _get_rp_id(request)
|
||||||
|
|
||||||
@@ -213,20 +229,22 @@ def begin_authentication(request, allow_credential_ids: list = None) -> dict:
|
|||||||
allow_credentials=allow_credentials,
|
allow_credentials=allow_credentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store challenge for verification
|
# Store challenge for verification (DB-backed, cross-worker safe)
|
||||||
_CHALLENGES["login"] = options.challenge
|
save_webauthn_challenge(challenge_key, bytes_to_base64url(options.challenge),
|
||||||
|
db_path=_challenge_db())
|
||||||
|
|
||||||
return _serialize_options(options)
|
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.
|
"""Complete WebAuthn authentication.
|
||||||
Returns {'ok': True, 'credential_id': str} or raises on failure.
|
Returns {'ok': True, 'credential_id': str} or raises on failure.
|
||||||
"""
|
"""
|
||||||
rp_id = _get_rp_id(request)
|
rp_id = _get_rp_id(request)
|
||||||
origin = _get_origin(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:
|
if not expected_challenge:
|
||||||
raise ValueError("No authentication challenge found. Please start again.")
|
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(
|
verification = verify_authentication_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=base64url_to_bytes(expected_challenge),
|
||||||
expected_rp_id=rp_id,
|
expected_rp_id=rp_id,
|
||||||
expected_origin=origin,
|
expected_origin=origin,
|
||||||
credential_public_key=bytes.fromhex(stored_public_key),
|
credential_public_key=bytes.fromhex(stored_public_key),
|
||||||
|
|||||||
@@ -288,6 +288,12 @@
|
|||||||
showAlarmOverlay(msg.data); // reuse alarm overlay UI
|
showAlarmOverlay(msg.data); // reuse alarm overlay UI
|
||||||
switchPanel('countdown');
|
switchPanel('countdown');
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === 'timezone_changed') {
|
||||||
|
// 控制端改了账户时区 → 立即重拉状态(state.timezone 更新后时钟/闹钟兜底随之生效)
|
||||||
|
fetchState();
|
||||||
|
} else if (msg.type === 'db_changed') {
|
||||||
|
// 超大事件(NOTIFY 放不下)→ 从数据库重拉完整状态
|
||||||
|
fetchState();
|
||||||
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
|
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else if (msg.type === 'theme_changed') {
|
} else if (msg.type === 'theme_changed') {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ if __name__ == "__main__":
|
|||||||
if try_acquire_scheduler_lock(db_url):
|
if try_acquire_scheduler_lock(db_url):
|
||||||
start_scheduler(app)
|
start_scheduler(app)
|
||||||
|
|
||||||
|
# 跨进程 SSE 广播(PG LISTEN/NOTIFY)
|
||||||
|
app.event_bus.attach(db_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
bind_host = os.environ.get("FLASK_BIND", "127.0.0.1")
|
||||||
app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5001")),
|
app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5001")),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ _maybe_start_scheduler()
|
|||||||
if os.environ.get("FLASK_DISABLE_SCHEDULER", "").lower() not in ("1", "true", "yes"):
|
if os.environ.get("FLASK_DISABLE_SCHEDULER", "").lower() not in ("1", "true", "yes"):
|
||||||
start_watchdog(app, db_url)
|
start_watchdog(app, db_url)
|
||||||
|
|
||||||
|
# 跨 worker SSE 广播(PG LISTEN/NOTIFY):控制端的事件能到达任意 worker 的显示端
|
||||||
|
app.event_bus.attach(db_url)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import atexit
|
import atexit
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import shutil
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -658,7 +659,7 @@ routes_mod.complete_registration = lambda request, credential: {
|
|||||||
"ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]),
|
"ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]),
|
||||||
"credential_id": CRED_HEX, "public_key": "abcdef",
|
"credential_id": CRED_HEX, "public_key": "abcdef",
|
||||||
}
|
}
|
||||||
routes_mod.begin_authentication = lambda request, allow_credential_ids=None: {"challenge": "BB"}
|
routes_mod.begin_authentication = lambda request, allow_credential_ids=None, challenge_key="login": {"challenge": "BB"}
|
||||||
def _cred_hex(credential):
|
def _cred_hex(credential):
|
||||||
import base64
|
import base64
|
||||||
cid = credential.get("id", "")
|
cid = credential.get("id", "")
|
||||||
@@ -667,7 +668,7 @@ def _cred_hex(credential):
|
|||||||
return base64.urlsafe_b64decode(padded.encode("ascii")).hex()
|
return base64.urlsafe_b64decode(padded.encode("ascii")).hex()
|
||||||
except Exception:
|
except Exception:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="": {
|
routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="", challenge_key="login": {
|
||||||
"ok": True, "credential_id": _cred_hex(credential),
|
"ok": True, "credential_id": _cred_hex(credential),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1074,90 @@ n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='OK Group'").f
|
|||||||
conn.close()
|
conn.close()
|
||||||
check("失败导入整体回滚(原子性)", n == 0)
|
check("失败导入整体回滚(原子性)", n == 0)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 21: WebAuthn challenge DB 存储(一次性/覆盖/并发原子)")
|
||||||
|
print("=" * 60)
|
||||||
|
from backend.models import save_webauthn_challenge, pop_webauthn_challenge
|
||||||
|
save_webauthn_challenge("reg_0", "ch1", db_path=db21)
|
||||||
|
check("challenge 一次性消费成功", pop_webauthn_challenge("reg_0", db_path=db21) == "ch1")
|
||||||
|
check("challenge 二次消费为 None", pop_webauthn_challenge("reg_0", db_path=db21) is None)
|
||||||
|
save_webauthn_challenge("reg_0", "a", db_path=db21)
|
||||||
|
save_webauthn_challenge("reg_0", "b", db_path=db21)
|
||||||
|
check("challenge 覆盖为最新值", pop_webauthn_challenge("reg_0", db_path=db21) == "b")
|
||||||
|
save_webauthn_challenge("race", "tok", db_path=db21)
|
||||||
|
race_results = []
|
||||||
|
race_lock = threading.Lock()
|
||||||
|
def _consume():
|
||||||
|
r = pop_webauthn_challenge("race", db_path=db21)
|
||||||
|
with race_lock:
|
||||||
|
race_results.append(r)
|
||||||
|
ths = [threading.Thread(target=_consume) for _ in range(8)]
|
||||||
|
[t.start() for t in ths]
|
||||||
|
[t.join() for t in ths]
|
||||||
|
check("并发消费仅 1 次成功(DELETE RETURNING 原子)",
|
||||||
|
race_results.count("tok") == 1 and race_results.count(None) == 7)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 22: EventBus 跨进程去重 + 时区即时更新")
|
||||||
|
print("=" * 60)
|
||||||
|
from backend.events import EventBus
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
q = bus.subscribe(user_id=0)
|
||||||
|
bus.publish("theme_changed", {"mode": "light"}, user_id=0)
|
||||||
|
n_local = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
q.get(timeout=0.3)
|
||||||
|
n_local += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
check("本地 publish 恰好投递 1 次", n_local == 1)
|
||||||
|
# 模拟本进程 NOTIFY echo(pid 相同)→ 必须被去重
|
||||||
|
echo_payload = json.dumps({"pid": _os.getpid(), "type": "theme_changed",
|
||||||
|
"data": {"mode": "light"}, "user_id": 0})
|
||||||
|
bus.handle_remote(echo_payload)
|
||||||
|
try:
|
||||||
|
q.get(timeout=0.3)
|
||||||
|
check("本进程 echo 被去重", False)
|
||||||
|
except Exception:
|
||||||
|
check("本进程 echo 被去重", True)
|
||||||
|
# 模拟其他 worker 的事件 → 应分发
|
||||||
|
remote_payload = json.dumps({"pid": _os.getpid() + 1, "type": "force_refresh",
|
||||||
|
"data": {}, "user_id": 0})
|
||||||
|
bus.handle_remote(remote_payload)
|
||||||
|
try:
|
||||||
|
ev = q.get(timeout=0.3)
|
||||||
|
check("远端事件正确分发", json.loads(ev)["type"] == "force_refresh")
|
||||||
|
except Exception:
|
||||||
|
check("远端事件正确分发", False)
|
||||||
|
# oversized → db_changed 信号
|
||||||
|
oversized_payload = json.dumps({"pid": _os.getpid() + 2, "type": "custom_notification",
|
||||||
|
"oversized": True, "user_id": 0})
|
||||||
|
bus.handle_remote(oversized_payload)
|
||||||
|
try:
|
||||||
|
ev = q.get(timeout=0.3)
|
||||||
|
check("oversized → db_changed 信号", json.loads(ev)["type"] == "db_changed")
|
||||||
|
except Exception:
|
||||||
|
check("oversized → db_changed 信号", False)
|
||||||
|
|
||||||
|
# 时区即时更新(多 worker 场景:新请求立即读到新时区,无进程级缓存)
|
||||||
|
app22, db22 = fresh_app("t22")
|
||||||
|
pw22 = get_temp_password(db22, 0)
|
||||||
|
c22, _ = do_login(app22.test_client(), pw22)
|
||||||
|
setup_totp(c22)
|
||||||
|
r = c22.get("/api/time/config")
|
||||||
|
check("默认时区 Asia/Shanghai", r.get_json()["timezone"] == "Asia/Shanghai")
|
||||||
|
r = c22.put("/api/time/config", json={"timezone": "America/New_York"})
|
||||||
|
check("设置时区成功", r.status_code == 200 and r.get_json().get("ok"))
|
||||||
|
r = c22.get("/api/time/config")
|
||||||
|
check("下一个请求立即读到新时区(无陈旧缓存)", r.get_json()["timezone"] == "America/New_York")
|
||||||
|
st = c22.get("/api/display/state").get_json()
|
||||||
|
check("display/state 返回新时区", st.get("timezone") == "America/New_York")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"RESULT: {PASS} passed, {FAIL} failed")
|
print(f"RESULT: {PASS} passed, {FAIL} failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user