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
+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),