feat: add authentication routes with session login, WebAuthn, and credential management

Implement Phase 4 auth routes: password login/logout, WebAuthn
registration and authentication, magic link registration, and
credential management pages with HTMX. Includes session middleware,
Jinja2 templates, vendored HTMX, and last-credential guardrails.

120 tests passing.
This commit is contained in:
Johan Lundberg 2026-02-16 11:39:50 +01:00
parent f7ed2cf54d
commit e15dcc4745
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
23 changed files with 1440 additions and 2 deletions

View file

@ -0,0 +1,154 @@
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import (
AttestedCredentialData,
AuthenticationResponse,
PublicKeyCredentialDescriptor,
PublicKeyCredentialType,
)
from fastapi_oidc_op.models import User
from fastapi_oidc_op.userid import generate_unique_userid
router = APIRouter(tags=["authn"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
templates = request.app.state.templates
return templates.TemplateResponse(request, "login.html")
@router.post("/login/password", response_class=HTMLResponse)
async def login_password(
request: Request,
username: str = Form(),
password: str = Form(),
) -> Response:
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
password_service = request.app.state.password_service
error_html = '<div role="alert">Invalid username or password</div>'
user = await user_repo.get_by_username(username)
if user is None:
return HTMLResponse(error_html)
credential = await cred_repo.get_password_by_user(user.userid)
if credential is None:
return HTMLResponse(error_html)
if not password_service.verify(credential.password_hash, password):
return HTMLResponse(error_html)
request.session["userid"] = user.userid
request.session["username"] = user.username
response = Response()
response.headers["HX-Redirect"] = "/manage/credentials"
return response
@router.post("/logout")
async def logout(request: Request) -> Response:
request.session.clear()
response = Response()
response.headers["HX-Redirect"] = "/login"
return response
@router.get("/register/{token}")
async def register_magic_link(request: Request, token: str) -> Response:
magic_link_service = request.app.state.magic_link_service
user_repo = request.app.state.user_repo
link = await magic_link_service.validate(token)
if link is None:
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
userid = await generate_unique_userid(user_repo)
user = User(userid=userid, username=link.username, groups=["users"])
await user_repo.create(user)
await magic_link_service.mark_used(token)
request.session["userid"] = user.userid
request.session["username"] = user.username
return RedirectResponse("/manage/credentials?setup=1", status_code=303)
@router.post("/login/webauthn/begin")
async def login_webauthn_begin(
request: Request,
username: str = Form(),
) -> Response:
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
webauthn_service = request.app.state.webauthn_service
error_html = '<div role="alert">Invalid username or password</div>'
user = await user_repo.get_by_username(username)
if user is None:
return HTMLResponse(error_html)
webauthn_creds = await cred_repo.get_webauthn_by_user(user.userid)
if not webauthn_creds:
return HTMLResponse(error_html)
descriptors = [
PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id)
for cred in webauthn_creds
]
options, state = webauthn_service.begin_authentication(credentials=descriptors)
request.session["webauthn_login_state"] = state
request.session["webauthn_login_userid"] = user.userid
return JSONResponse(options)
@router.post("/login/webauthn/complete")
async def login_webauthn_complete(request: Request) -> Response:
webauthn_service = request.app.state.webauthn_service
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
state = request.session.pop("webauthn_login_state", None)
userid = request.session.pop("webauthn_login_userid", None)
if state is None or userid is None:
return HTMLResponse('<div role="alert">Authentication session expired</div>', status_code=400)
webauthn_creds = await cred_repo.get_webauthn_by_user(userid)
credentials = [AttestedCredentialData(cred.public_key) for cred in webauthn_creds]
body = await request.json()
try:
webauthn_service.complete_authentication(state, credentials, body)
except Exception:
return HTMLResponse('<div role="alert">Authentication failed</div>')
# Extract sign count from the response and update
auth_response = AuthenticationResponse.from_dict(body)
new_counter = auth_response.response.authenticator_data.counter
matched_credential_id = auth_response.raw_id
stored = await cred_repo.get_webauthn_by_credential_id(matched_credential_id)
if stored is not None:
stored.sign_count = new_counter
await cred_repo.update_webauthn(stored)
user = await user_repo.get_by_userid(userid)
if user is None:
return HTMLResponse('<div role="alert">User not found</div>', status_code=400)
request.session["userid"] = user.userid
request.session["username"] = user.username
response = Response()
response.headers["HX-Redirect"] = "/manage/credentials"
return response