Files
2026-08-13 12:50:18 +08:00

283 lines
10 KiB
Python

"""WebAuthn (Passkey) manager — registration and authentication ceremonies.
Multi-user support:
- Registration is bound to a concrete user id (challenge keyed by user id,
user handle = "user_<id>").
- Login ceremonies are credential-driven: after `complete_authentication`,
the caller maps the returned credential id to its owner via
`models.find_user_by_credential_id`.
- `begin_authentication` can be restricted to a single credential for
re-authentication ceremonies.
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, base64url_to_bytes
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
PublicKeyCredentialDescriptor,
)
from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse
# snake_case → camelCase mapping for browser WebAuthn API
_SNAKE_TO_CAMEL = {
"pub_key_cred_params": "pubKeyCredParams",
"exclude_credentials": "excludeCredentials",
"authenticator_selection": "authenticatorSelection",
"allow_credentials": "allowCredentials",
"rp_id": "rpId",
"user_verification": "userVerification",
"resident_key": "residentKey",
"authenticator_attachment": "authenticatorAttachment",
"require_resident_key": "requireResidentKey",
"user_handle": "userHandle",
"credential_id": "credentialId",
"credential_public_key": "credentialPublicKey",
"sign_count": "signCount",
"cred_props": "credProps",
"public_key": "publicKey",
"private_key": "privateKey",
"display_name": "displayName",
}
def _snake_to_camel(name: str) -> str:
"""Convert snake_case key to camelCase, with known overrides."""
if name in _SNAKE_TO_CAMEL:
return _SNAKE_TO_CAMEL[name]
parts = name.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
# Relying Party info
RP_NAME = "Cal Display"
RP_ID = None # set dynamically from request.host
ORIGIN = None # set dynamically
# 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:
"""Extract RP ID from the request host (e.g. 'example.com' or '192.168.1.100')."""
host = request.host.split(":")[0] # strip port
return host
def _get_origin(request) -> str:
"""Extract origin from the request (scheme + host)."""
scheme = request.scheme
return f"{scheme}://{request.host}"
def _serialize_options(options) -> dict:
"""Serialize a webauthn options dataclass to a browser-ready dict.
Converts bytes to base64url strings AND snake_case keys to camelCase.
"""
d = asdict(options)
return _bytes_to_json(d)
def _bytes_to_json(obj):
"""Recursively convert bytes→base64url and snake_case→camelCase."""
if isinstance(obj, bytes):
return bytes_to_base64url(obj)
elif isinstance(obj, dict):
return {_snake_to_camel(k): _bytes_to_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_bytes_to_json(v) for v in obj]
elif isinstance(obj, tuple):
return tuple(_bytes_to_json(v) for v in obj)
return obj
# ─── Registration ────────────────────────────
def begin_registration(request, user_id: int, display_name: str = "",
existing_credential_id: str = None) -> dict:
"""Start WebAuthn registration for a specific user. Returns options dict."""
rp_id = _get_rp_id(request)
user_name = f"user_{user_id}"
user_handle = user_name.encode("utf-8")
# If the user already has a passkey, exclude it so they can't re-register same credential
exclude_credentials = []
if existing_credential_id:
exclude_credentials.append(
PublicKeyCredentialDescriptor(id=bytes.fromhex(existing_credential_id))
)
options = generate_registration_options(
rp_id=rp_id,
rp_name=RP_NAME,
user_id=user_handle,
user_name=user_name,
user_display_name=display_name or user_name,
attestation="none",
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
exclude_credentials=exclude_credentials,
)
# 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)
def complete_registration(request, credential: dict, user_id: int = None) -> dict:
"""Complete WebAuthn registration.
Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str}
or raises an exception on failure.
user_id: explicit target user. Always pass it from the route when known —
admin provisioning runs inside the ADMIN's session (its uid differs from
the target user, so deriving the challenge key from the session would never
match). When omitted, it is derived from credential.user_name ("user_5" → 5),
then from the authenticated request context.
"""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
uid = user_id
if uid is None:
uname = credential.get("user_name", "")
if isinstance(uname, str) and uname.startswith("user_"):
try:
uid = int(uname.split("_")[-1])
except (ValueError, IndexError):
uid = None
if uid is None:
try:
from backend.auth import current_user_id
uid = current_user_id()
except Exception:
uid = None
if uid is None:
raise ValueError("No registration challenge found. Please restart registration.")
# Challenge key must match begin_registration's f"reg_{user_id}" exactly.
expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db())
if not expected_challenge:
raise ValueError("No registration challenge found. Please restart registration.")
user_id = uid
try:
# Library expects rawId field (browser-native camelCase, base64url string)
if "rawId" not in credential:
credential["rawId"] = credential.get("id", "")
verification = verify_registration_response(
credential=credential,
expected_challenge=base64url_to_bytes(expected_challenge),
expected_rp_id=rp_id,
expected_origin=origin,
)
except (InvalidRegistrationResponse, Exception) as e:
raise ValueError(f"Registration verification failed: {e}")
return {
"ok": True,
"user_id": user_id,
"credential_id": verification.credential_id.hex(),
"public_key": verification.credential_public_key.hex(),
}
# ─── Authentication (Login / Re-auth) ────────
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)
allow_credentials = []
if allow_credential_ids:
allow_credentials = [
PublicKeyCredentialDescriptor(id=bytes.fromhex(cid))
for cid in allow_credential_ids
]
options = generate_authentication_options(
rp_id=rp_id,
user_verification=UserVerificationRequirement.PREFERRED,
allow_credentials=allow_credentials,
)
# 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, 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 = pop_webauthn_challenge(challenge_key, db_path=_challenge_db())
if not expected_challenge:
raise ValueError("No authentication challenge found. Please start again.")
try:
# Library expects rawId field (browser-native camelCase, base64url string)
if "rawId" not in credential:
credential["rawId"] = credential.get("id", "")
verification = verify_authentication_response(
credential=credential,
expected_challenge=base64url_to_bytes(expected_challenge),
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=bytes.fromhex(stored_public_key),
credential_current_sign_count=0, # we don't track sign count
require_user_verification=False,
)
except (InvalidAuthenticationResponse, Exception) as e:
raise ValueError(f"Authentication verification failed: {e}")
return {
"ok": True,
"credential_id": verification.credential_id.hex(),
}