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