coulomb-social/coulomb_social/apps/identity/views.py
tegwick 29a9ff735e Complete identity smoke path: id_token claims, registration entry, cutover docs
Prefer verified KeyCape id_token claims when /userinfo returns 401; soft-fail
userinfo. Add CSOC-WP-0003 registration entry (disabled until NetKingdom URL),
AAL step-up hooks, smoke/cutover evidence for tegwick OIDC without MFA.
2026-08-09 22:42:51 +02:00

189 lines
7.1 KiB
Python

"""OIDC login/callback/logout + DEBUG dev-login. Sole auth entrypoints."""
from __future__ import annotations
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.http import require_GET, require_http_methods
from . import oidc
from .services import establish_session
from .user_engine import IdentityClaims
logger = logging.getLogger(__name__)
SESSION_OIDC_STATE = "oidc_state"
SESSION_OIDC_VERIFIER = "oidc_code_verifier"
SESSION_OIDC_REQUIRED_ACR = "oidc_required_acr"
@require_GET
def login_start(request: HttpRequest) -> HttpResponse:
if request.user.is_authenticated:
return redirect(settings.LOGIN_REDIRECT_URL)
if oidc.oidc_configured():
state, verifier = oidc.new_pkce_pair()
request.session[SESSION_OIDC_STATE] = state
request.session[SESSION_OIDC_VERIFIER] = verifier
requested_acr = (
settings.OIDC_STEP_UP_ACR
if request.GET.get("assurance") == "aal2"
else None
)
if requested_acr:
request.session[SESSION_OIDC_REQUIRED_ACR] = requested_acr
else:
request.session.pop(SESSION_OIDC_REQUIRED_ACR, None)
try:
url = oidc.build_authorization_url(
state=state, code_verifier=verifier, acr_values=requested_acr
)
except Exception:
logger.exception("OIDC authorization URL build failed")
messages.error(request, "Identity provider is unavailable. Try again later.")
return redirect("core:landing")
return redirect(url)
if settings.DEBUG:
return redirect("identity:dev_login")
messages.error(request, "Sign-in is not configured (OIDC_ENABLED=false).")
return redirect("core:landing")
@require_GET
def oidc_callback(request: HttpRequest) -> HttpResponse:
if not oidc.oidc_configured():
return HttpResponseBadRequest("OIDC is not enabled")
error = request.GET.get("error")
if error:
messages.error(request, f"Sign-in failed: {error}")
return redirect("core:landing")
code = request.GET.get("code")
state = request.GET.get("state")
expected_state = request.session.pop(SESSION_OIDC_STATE, None)
verifier = request.session.pop(SESSION_OIDC_VERIFIER, None)
required_acr = request.session.pop(SESSION_OIDC_REQUIRED_ACR, None)
if not code or not state or state != expected_state or not verifier:
return HttpResponseBadRequest("Invalid OIDC callback state")
try:
token = oidc.exchange_code(code, code_verifier=verifier)
userinfo = oidc.fetch_userinfo(token.get("access_token", ""))
raw = oidc.claims_from_token_response(token, userinfo)
except Exception:
logger.exception("OIDC token exchange failed")
messages.error(request, "Could not complete sign-in with the identity provider.")
return redirect("core:landing")
sub = raw.get("sub")
if not sub:
return HttpResponseBadRequest("Token missing subject")
issuer = raw.get("iss") or settings.OIDC_ISSUER
if required_acr and not _claims_satisfy_step_up(raw, required_acr):
logger.warning("OIDC response did not satisfy requested assurance")
return HttpResponseBadRequest("Requested sign-in assurance was not satisfied")
claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub))
establish_session(request, claims)
return redirect(settings.LOGIN_REDIRECT_URL)
@require_GET
def registration_start(request: HttpRequest) -> HttpResponse:
"""Send applicants only to the operator-configured registration service."""
url = settings.NETKINGDOM_REGISTRATION_URL
if not url:
messages.error(request, "Account registration is not available yet.")
return redirect("core:landing")
return redirect(url)
def _claims_satisfy_step_up(raw: dict, required_acr: str) -> bool:
assurance = raw.get("assurance") if isinstance(raw.get("assurance"), dict) else {}
acr = str(raw.get("acr") or assurance.get("aal") or "").lower()
if required_acr.lower() in {"aal2", "urn:netkingdom:aal2", "mfa"}:
return acr in {"aal2", "urn:netkingdom:aal2", "mfa"} or bool(
assurance.get("mfa")
)
return acr == required_acr.lower()
@require_http_methods(["GET", "POST"])
def dev_login(request: HttpRequest) -> HttpResponse:
"""Local-only claims form when OIDC is off. Never enable outside DEBUG."""
if not settings.DEBUG or settings.OIDC_ENABLED:
return HttpResponseBadRequest("Dev login only when DEBUG and OIDC disabled")
if request.method == "POST":
subject = (request.POST.get("subject") or "").strip()
if not subject:
messages.error(request, "Subject is required")
return render(request, "identity/dev_login.html")
claims = IdentityClaims(
issuer=request.POST.get("issuer") or "https://local.dev/issuer",
subject=subject,
email=(request.POST.get("email") or "").strip(),
name=(request.POST.get("name") or "").strip(),
preferred_username=(request.POST.get("preferred_username") or "").strip(),
tenant=(request.POST.get("tenant") or settings.DEFAULT_TENANT_ID).strip(),
principal_type="human",
roles=("user",),
authorized_party=settings.USER_ENGINE_APPLICATION_ID,
)
establish_session(request, claims)
return redirect(settings.LOGIN_REDIRECT_URL)
return render(
request,
"identity/dev_login.html",
{"default_tenant": settings.DEFAULT_TENANT_ID},
)
def _claims_from_oidc_payload(
raw: dict, *, issuer: str, subject: str
) -> IdentityClaims:
aud = raw.get("aud", ())
if isinstance(aud, str):
audience = (aud,)
else:
audience = tuple(str(a) for a in (aud or ()))
groups = raw.get("groups") or ()
if isinstance(groups, str):
groups = (groups,)
roles = raw.get("roles") or raw.get("tenant_roles") or ()
if isinstance(roles, str):
roles = (roles,)
assurance = raw.get("assurance") if isinstance(raw.get("assurance"), dict) else {}
return IdentityClaims(
issuer=issuer,
subject=subject,
email=str(raw.get("email") or ""),
name=str(raw.get("name") or ""),
preferred_username=str(raw.get("preferred_username") or ""),
tenant=str(raw.get("tenant") or settings.DEFAULT_TENANT_ID),
principal_type=str(raw.get("principal_type") or "human"),
groups=tuple(str(g) for g in groups),
roles=tuple(str(r) for r in roles) or ("user",),
assurance=dict(assurance),
audience=audience,
authorized_party=str(
raw.get("azp") or raw.get("client_id") or settings.OIDC_CLIENT_ID or ""
),
)
@require_http_methods(["GET", "POST"])
def logout_view(request: HttpRequest) -> HttpResponse:
logout(request)
return redirect(settings.LOGOUT_REDIRECT_URL)