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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fido2.webauthn import UserVerificationRequirement
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
@ -55,6 +56,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
rp_id=rp_id,
|
rp_id=rp_id,
|
||||||
rp_name=app.title,
|
rp_name=app.title,
|
||||||
origin=settings.issuer,
|
origin=settings.issuer,
|
||||||
|
user_verification=UserVerificationRequirement(settings.webauthn_user_verification),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.state.magic_link_service = MagicLinkService(
|
app.state.magic_link_service = MagicLinkService(
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,16 @@ from fido2.webauthn import (
|
||||||
class WebAuthnService:
|
class WebAuthnService:
|
||||||
"""FIDO2 WebAuthn registration and authentication."""
|
"""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)
|
rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id)
|
||||||
self._server = Fido2Server(rp, verify_origin=lambda o: o == origin)
|
self._server = Fido2Server(rp, verify_origin=lambda o: o == origin)
|
||||||
|
self._user_verification = user_verification
|
||||||
|
|
||||||
def begin_registration(
|
def begin_registration(
|
||||||
self,
|
self,
|
||||||
|
|
@ -39,7 +46,7 @@ class WebAuthnService:
|
||||||
user=user,
|
user=user,
|
||||||
credentials=existing_credentials,
|
credentials=existing_credentials,
|
||||||
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
user_verification=self._user_verification,
|
||||||
)
|
)
|
||||||
return dict(options), state
|
return dict(options), state
|
||||||
|
|
||||||
|
|
@ -64,7 +71,7 @@ class WebAuthnService:
|
||||||
"""
|
"""
|
||||||
options, state = self._server.authenticate_begin(
|
options, state = self._server.authenticate_begin(
|
||||||
credentials=credentials,
|
credentials=credentials,
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
user_verification=self._user_verification,
|
||||||
)
|
)
|
||||||
return dict(options), state
|
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_secret: str | None = None # If None, a random secret is generated per process
|
||||||
session_https_only: bool = True
|
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
|
# Magic links
|
||||||
invite_ttl: int = 86400 # seconds
|
invite_ttl: int = 86400 # seconds
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from fido2.webauthn import (
|
||||||
PublicKeyCredentialDescriptor,
|
PublicKeyCredentialDescriptor,
|
||||||
PublicKeyCredentialType,
|
PublicKeyCredentialType,
|
||||||
RegistrationResponse,
|
RegistrationResponse,
|
||||||
|
UserVerificationRequirement,
|
||||||
)
|
)
|
||||||
|
|
||||||
from porchlight.authn.webauthn import WebAuthnService
|
from porchlight.authn.webauthn import WebAuthnService
|
||||||
|
|
@ -194,6 +195,19 @@ def test_begin_authentication_prefers_user_verification() -> None:
|
||||||
assert pub_key["userVerification"] == "preferred"
|
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:
|
def test_begin_authentication_returns_options_and_state() -> None:
|
||||||
service = _make_service()
|
service = _make_service()
|
||||||
_, cred_id, _attested = _generate_credential()
|
_, cred_id, _attested = _generate_credential()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue