feat: add OIDC sign-in, roles, sessions and client address rule
Authorization-code flow with PKCE through an idp adapter (with a fake), ID token validation, identity and roles resolved from the directory with a short cache, refusal of unknown, disabled and locked accounts with one generic page, sessions that end when the account becomes unavailable, sign-out that ends the server-side session, role decorators and the trusted-proxy client address rule. Adds the OIDC_* and PUBLIC_BASE_URL configuration. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Sign-in with OIDC, sign-out and the signed-in landing page (FR-1, FR-3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from portal.adapters.directory import DirectoryUnavailable
|
||||
from portal.adapters.idp import Flow, IdpUnavailable, LoginRejected
|
||||
from portal.adapters.registry import get_idp
|
||||
from portal.audit import services as audit
|
||||
|
||||
from . import identity
|
||||
from .decorators import login_required
|
||||
from .session_store import LOGIN_FLOW
|
||||
from .sessions import start_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _unavailable(request):
|
||||
return render(request, "accounts/unavailable.html", status=503)
|
||||
|
||||
|
||||
def _refused(request, reason: str, username: str = ""):
|
||||
"""One generic page for every refusal, so it never says whether the name exists."""
|
||||
audit.record_failure(
|
||||
username,
|
||||
"signin.refused",
|
||||
ip=request.client_ip,
|
||||
detail={"reason": reason},
|
||||
)
|
||||
return render(request, "accounts/refused.html", status=403)
|
||||
|
||||
|
||||
def _safe_next(value: str) -> str:
|
||||
if value.startswith("/") and url_has_allowed_host_and_scheme(value, allowed_hosts=None):
|
||||
return value
|
||||
return "/"
|
||||
|
||||
|
||||
@require_GET
|
||||
def login(request):
|
||||
if request.portal_user is not None:
|
||||
return redirect("home")
|
||||
try:
|
||||
url, flow = get_idp().begin()
|
||||
except IdpUnavailable:
|
||||
return _unavailable(request)
|
||||
target = _safe_next(request.GET.get("next", ""))
|
||||
request.session[LOGIN_FLOW] = {**flow.as_dict(), "next": target}
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@require_GET
|
||||
def callback(request):
|
||||
data = request.session.pop(LOGIN_FLOW, None) # a flow works once
|
||||
try:
|
||||
flow = Flow.from_dict(data) if data else None
|
||||
except KeyError:
|
||||
flow = None
|
||||
if flow is None:
|
||||
return _refused(request, "no_flow")
|
||||
try:
|
||||
found = get_idp().complete(request.GET, flow)
|
||||
except IdpUnavailable:
|
||||
return _unavailable(request)
|
||||
except LoginRejected:
|
||||
return _refused(request, "provider_response")
|
||||
try:
|
||||
user = identity.admit(found.username)
|
||||
except identity.SignInRefused as refusal:
|
||||
return _refused(request, refusal.reason, found.username)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
|
||||
start_session(request, user.sam)
|
||||
identity.remember(request, user)
|
||||
with transaction.atomic():
|
||||
audit.record(user.sam, "signin.succeeded", ip=request.client_ip)
|
||||
return redirect(_safe_next(data.get("next", "")))
|
||||
|
||||
|
||||
@login_required
|
||||
def home(request):
|
||||
return render(request, "accounts/home.html", {"portal_user": request.portal_user})
|
||||
|
||||
|
||||
@require_POST
|
||||
def logout(request):
|
||||
portal_user = request.portal_user
|
||||
if portal_user is not None:
|
||||
with transaction.atomic():
|
||||
audit.record(portal_user.user.key, "signout", ip=request.client_ip)
|
||||
request.session.flush() # deletes the session row on the server
|
||||
return redirect("signed-out")
|
||||
|
||||
|
||||
@require_GET
|
||||
def signed_out(request):
|
||||
return render(request, "accounts/signed_out.html")
|
||||
Reference in New Issue
Block a user