porchlight/src/porchlight/authn/routes.py
Johan Lundberg 6b6ff29b9a
All checks were successful
publish-latest / docker (push) Successful in 22s
Harden forward-auth redirect and header handling
Tighten forward-auth return URL validation to reject unsafe host syntax,
including backslash-based browser/parser mismatches, and normalize
scheme/port matching so bare allowlist entries are HTTPS default-port
only. Support explicit scheme-qualified targets, including IPv6
host:port entries.

Always emit deterministic identity headers with empty values when
attributes are absent, zero unsafe header values, and clear stale
forward-auth return targets when OIDC or registration flows take
precedence.

Add regression coverage for wildcard redirect bypasses, scheme/port
handling, IPv6 allowlist entries, empty identity headers, unsafe header
values, and stale return-target cleanup. Update docs with proxy
header-stripping guidance and the new allowlist semantics.
2026-07-05 00:00:58 +02:00

245 lines
9.7 KiB
Python

from base64 import urlsafe_b64decode
from typing import Annotated
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import AttestedCredentialData, AuthenticationResponse
from porchlight.authn.acr import ACR_PASSWORD, ACR_WEBAUTHN, SESSION_ACR_KEY
from porchlight.forward_auth import (
FORWARD_AUTH_RETURN_TO_SESSION_KEY,
capture_forward_auth_return_to,
clear_forward_auth_return_to,
pop_forward_auth_return_to,
)
from porchlight.models import User
from porchlight.rate_limit import limiter
from porchlight.userid import generate_unique_userid
router = APIRouter(tags=["authn"])
def _login_redirect_target(request: Request) -> str:
"""Determine where to redirect after successful login.
If there's a pending OIDC authorization request, redirect to complete it.
Otherwise, redirect to credential management.
"""
if "oidc_auth_request" in request.session:
clear_forward_auth_return_to(request)
return "/authorization/complete"
forward_auth_return_to = pop_forward_auth_return_to(request)
if forward_auth_return_to is not None:
return forward_auth_return_to
return "/manage/credentials"
def _is_sign_count_rollback(stored: int, presented: int) -> bool:
"""Detect a WebAuthn signature-counter rollback (cloned authenticator/replay).
Authenticators that don't maintain a counter (incl. synced passkeys) report
0; when both stored and presented are 0 the counter is meaningless and no
rollback can be inferred. Otherwise the presented counter must strictly
exceed the stored one.
"""
if stored == 0 and presented == 0:
return False
return presented <= stored
def _establish_authenticated_session(request: Request, user: User, acr: str) -> None:
"""Reset the session before recording the authenticated identity.
Clearing first defeats session fixation: any values an attacker planted in
the pre-auth session are dropped and the session cookie is reissued. A
pending OIDC authorization request is the only pre-auth state worth keeping.
``acr`` records how the user actually authenticated so the OIDC layer can
surface a truthful Authentication Context Class Reference (see acr.py).
"""
pending_oidc = request.session.get("oidc_auth_request")
pending_forward_auth = request.session.get(FORWARD_AUTH_RETURN_TO_SESSION_KEY)
request.session.clear()
if pending_oidc is not None:
request.session["oidc_auth_request"] = pending_oidc
elif pending_forward_auth is not None:
request.session[FORWARD_AUTH_RETURN_TO_SESSION_KEY] = pending_forward_auth
request.session["userid"] = user.userid
request.session["username"] = user.username
request.session[SESSION_ACR_KEY] = acr
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
capture_forward_auth_return_to(request)
templates = request.app.state.templates
return templates.TemplateResponse(request, "login.html")
@router.post("/login/password", response_class=HTMLResponse)
@limiter.limit("5/minute")
async def login_password(
request: Request,
username: Annotated[str, Form()],
password: Annotated[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)
if not user.active:
return HTMLResponse(error_html)
_establish_authenticated_session(request, user, ACR_PASSWORD)
response = Response()
response.headers["HX-Redirect"] = _login_redirect_target(request)
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}", response_class=HTMLResponse)
async def register_magic_link_page(request: Request, token: str) -> Response:
"""Show the registration confirmation page.
GET is side-effect free: it only validates the token (without consuming it)
and renders a form. The token is consumed by the POST below, so that simply
visiting the link (email scanners, prefetchers) cannot create a session.
"""
magic_link_service = request.app.state.magic_link_service
link = await magic_link_service.validate(token)
if link is None:
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
templates = request.app.state.templates
return templates.TemplateResponse(
request,
"register.html",
{"token": token, "username": link.username},
)
@router.post("/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
# Atomically validate and consume the token (single-use, no replay race).
link = await magic_link_service.consume(token)
if link is None:
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
existing_user = await user_repo.get_by_username(link.username)
if existing_user is not None:
if not existing_user.active:
return HTMLResponse("<p>This account has been deactivated.</p>", status_code=400)
# An invite link is for account setup, not authentication. If the
# account already has credentials, refuse to establish a session —
# otherwise a re-invite would be a passwordless login. Account
# recovery must go through a separate, explicitly authenticated flow.
cred_repo = request.app.state.credential_repo
has_password = await cred_repo.get_password_by_user(existing_user.userid) is not None
has_webauthn = bool(await cred_repo.get_webauthn_by_user(existing_user.userid))
if has_password or has_webauthn:
return HTMLResponse(
"<p>This account is already set up. Please sign in instead.</p>",
status_code=400,
)
user = existing_user
else:
userid = await generate_unique_userid(user_repo)
user = User(userid=userid, username=link.username, groups=["users"])
await user_repo.create(user)
# Magic-link registration is single-factor (email possession); mark it as
# such. It normally redirects to credential setup rather than completing an
# OIDC flow, but the session acr governs any later authorization too.
clear_forward_auth_return_to(request)
_establish_authenticated_session(request, user, ACR_PASSWORD)
return RedirectResponse("/manage/credentials?setup=1", status_code=303)
@router.post("/login/webauthn/begin")
async def login_webauthn_begin(request: Request) -> Response:
webauthn_service = request.app.state.webauthn_service
options, state = webauthn_service.begin_authentication()
request.session["webauthn_login_state"] = state
return JSONResponse(options)
@router.post("/login/webauthn/complete")
@limiter.limit("10/minute")
async def login_webauthn_complete(request: Request) -> Response: # noqa: PLR0911
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)
if state is None:
return JSONResponse({"error": "Authentication session expired"}, status_code=400)
body = await request.json()
# Extract user_handle from the assertion to identify the user
user_handle_b64 = body.get("response", {}).get("userHandle")
if not user_handle_b64:
return JSONResponse({"error": "Missing user handle"}, status_code=400)
# Decode base64url user_handle to get userid string
padded = user_handle_b64 + "=" * (-len(user_handle_b64) % 4)
userid = urlsafe_b64decode(padded).decode()
webauthn_creds = await cred_repo.get_webauthn_by_user(userid)
if not webauthn_creds:
return JSONResponse({"error": "Authentication failed"}, status_code=400)
credentials = [AttestedCredentialData(cred.public_key) for cred in webauthn_creds]
try:
webauthn_service.complete_authentication(state, credentials, body)
except Exception:
return JSONResponse({"error": "Authentication failed"}, status_code=400)
# Update sign count
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:
if _is_sign_count_rollback(stored.sign_count, new_counter):
# Counter went backwards — likely a cloned authenticator or replay.
return JSONResponse({"error": "Authentication failed"}, status_code=400)
stored.sign_count = new_counter
await cred_repo.update_webauthn(stored)
user = await user_repo.get_by_userid(userid)
if user is None or not user.active:
return JSONResponse({"error": "Authentication failed"}, status_code=400)
_establish_authenticated_session(request, user, ACR_WEBAUTHN)
return JSONResponse({"redirect": _login_redirect_target(request)})