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:
parent
f7ed2cf54d
commit
e15dcc4745
23 changed files with 1440 additions and 2 deletions
154
src/fastapi_oidc_op/authn/routes.py
Normal file
154
src/fastapi_oidc_op/authn/routes.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue