feat(security): make WebAuthn user verification configurable
User verification was hardcoded to PREFERRED. Add a webauthn_user_verification setting (default "preferred") wired into WebAuthnService for both registration and authentication, so identity-provider deployments can require it. Refs: porchlight-is8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
baef5e0e2e
commit
0f04a7daf9
4 changed files with 30 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ from urllib.parse import urlparse
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fido2.webauthn import UserVerificationRequirement
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.requests import Request
|
||||
|
|
@ -55,6 +56,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
rp_id=rp_id,
|
||||
rp_name=app.title,
|
||||
origin=settings.issuer,
|
||||
user_verification=UserVerificationRequirement(settings.webauthn_user_verification),
|
||||
)
|
||||
|
||||
app.state.magic_link_service = MagicLinkService(
|
||||
|
|
|
|||
|
|
@ -18,9 +18,16 @@ from fido2.webauthn import (
|
|||
class WebAuthnService:
|
||||
"""FIDO2 WebAuthn registration and authentication."""
|
||||
|
||||
def __init__(self, rp_id: str, rp_name: str, origin: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
rp_id: str,
|
||||
rp_name: str,
|
||||
origin: str,
|
||||
user_verification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
|
||||
) -> None:
|
||||
rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id)
|
||||
self._server = Fido2Server(rp, verify_origin=lambda o: o == origin)
|
||||
self._user_verification = user_verification
|
||||
|
||||
def begin_registration(
|
||||
self,
|
||||
|
|
@ -39,7 +46,7 @@ class WebAuthnService:
|
|||
user=user,
|
||||
credentials=existing_credentials,
|
||||
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
user_verification=self._user_verification,
|
||||
)
|
||||
return dict(options), state
|
||||
|
||||
|
|
@ -64,7 +71,7 @@ class WebAuthnService:
|
|||
"""
|
||||
options, state = self._server.authenticate_begin(
|
||||
credentials=credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
user_verification=self._user_verification,
|
||||
)
|
||||
return dict(options), state
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ class Settings(BaseSettings):
|
|||
session_secret: str | None = None # If None, a random secret is generated per process
|
||||
session_https_only: bool = True
|
||||
|
||||
# WebAuthn user verification requirement: "preferred" (default), "required",
|
||||
# or "discouraged". Identity providers may want "required".
|
||||
webauthn_user_verification: str = "preferred"
|
||||
|
||||
# Magic links
|
||||
invite_ttl: int = 86400 # seconds
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from fido2.webauthn import (
|
|||
PublicKeyCredentialDescriptor,
|
||||
PublicKeyCredentialType,
|
||||
RegistrationResponse,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from porchlight.authn.webauthn import WebAuthnService
|
||||
|
|
@ -194,6 +195,19 @@ def test_begin_authentication_prefers_user_verification() -> None:
|
|||
assert pub_key["userVerification"] == "preferred"
|
||||
|
||||
|
||||
def test_user_verification_is_configurable() -> None:
|
||||
service = WebAuthnService(
|
||||
rp_id=RP_ID,
|
||||
rp_name=RP_NAME,
|
||||
origin=ORIGIN,
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
reg, _ = service.begin_registration(user_id=b"user-123", username="alice")
|
||||
assert reg["publicKey"]["authenticatorSelection"]["userVerification"] == "required"
|
||||
auth, _ = service.begin_authentication()
|
||||
assert auth["publicKey"]["userVerification"] == "required"
|
||||
|
||||
|
||||
def test_begin_authentication_returns_options_and_state() -> None:
|
||||
service = _make_service()
|
||||
_, cred_id, _attested = _generate_credential()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue