All checks were successful
publish-latest / docker (push) Successful in 22s
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.
245 lines
9.7 KiB
Python
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)})
|