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:
2026-10-17 19:19:51 +02:00
co-authored by Claude Sonnet 5.5
parent 7458a2a815
commit 3c69c2a156
32 changed files with 1692 additions and 14 deletions
+35
View File
@@ -0,0 +1,35 @@
"""The client address, trusting X-Forwarded-For only from configured proxies (AD-7)."""
from __future__ import annotations
import ipaddress
from collections.abc import Iterable
Network = ipaddress.IPv4Network | ipaddress.IPv6Network
def parse_networks(items: Iterable[str]) -> list[Network]:
return [ipaddress.ip_network(item, strict=False) for item in items]
def _address(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def client_ip(peer: str, forwarded_for: str, trusted: list[Network]) -> str:
"""The TCP peer, or the last X-Forwarded-For entry when the peer is a trusted proxy.
The last entry is the one the proxy appended itself; earlier ones come from the client and
can be forged. X-Real-IP is never used.
"""
peer_address = _address(peer)
if peer_address is None:
return ""
if not any(peer_address in network for network in trusted):
return str(peer_address)
last = forwarded_for.rsplit(",", 1)[-1] if forwarded_for else ""
forwarded = _address(last)
return str(forwarded) if forwarded is not None else str(peer_address)
+39
View File
@@ -0,0 +1,39 @@
"""Access control for views: signed in, Publisher, Admin (FR-1, FR-2, FR-25)."""
from __future__ import annotations
from functools import wraps
from urllib.parse import urlencode
from django.shortcuts import redirect, render
from django.urls import reverse
def login_required(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
if getattr(request, "portal_user", None) is None:
target = reverse("login")
if request.method in ("GET", "HEAD"):
target += "?" + urlencode({"next": request.get_full_path()})
return redirect(target)
return view(request, *args, **kwargs)
return wrapper
def _role_required(attribute: str):
def decorator(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
if not getattr(request.portal_user, attribute):
return render(request, "accounts/forbidden.html", status=403)
return view(request, *args, **kwargs)
return login_required(wrapper)
return decorator
publisher_required = _role_required("is_publisher")
admin_required = _role_required("is_admin")
+101
View File
@@ -0,0 +1,101 @@
"""Who the signed-in user is and what they may do: identity, roles and account state (AD-5).
The directory is the source of truth. Roles and account state are cached in the session for a
short time; a directory outage denies elevated roles, and a disabled or locked account ends all
of its sessions at its next request.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from django.conf import settings
from django.db import transaction
from portal.adapters.directory import DirectoryUnavailable, DirectoryUser
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from .session_store import DISPLAY_NAME, ROLES
from .sessions import SessionUser, current_user, end_sessions
logger = logging.getLogger(__name__)
class SignInRefused(Exception):
"""The directory does not allow this account to start a session; `reason` is for the audit."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(f"Sign-in refused: {reason}")
@dataclass(frozen=True)
class PortalUser:
user: SessionUser
display_name: str
is_publisher: bool
is_admin: bool
def _display_name(user: DirectoryUser) -> str:
full_name = " ".join(p for p in (user.given_name, user.surname) if p)
return user.display_name or full_name or user.sam
def admit(username: str) -> DirectoryUser:
"""The directory user for a verified sign-in name, or SignInRefused.
Raises DirectoryUnavailable when the directory cannot answer; the caller denies the sign-in.
"""
user = get_directory().find_user(username)
if user is None:
raise SignInRefused("unknown")
if not user.enabled:
raise SignInRefused("disabled")
if user.locked:
raise SignInRefused("locked")
return user
def remember(request, user: DirectoryUser) -> None:
"""Store what the portal shows and decides with, with the time it was read."""
request.session[DISPLAY_NAME] = _display_name(user)
request.session[ROLES] = {
"publisher": user.is_publisher,
"admin": user.is_admin,
"checked": time.time(),
}
def current(request) -> PortalUser | None:
"""The signed-in user for this request, refreshing roles and state when the cache is old."""
user = current_user(request)
if user is None:
return None
session = request.session
cached = session.get(ROLES)
name = session.get(DISPLAY_NAME) or user.sam
age = time.time() - cached.get("checked", 0) if isinstance(cached, dict) else None
if age is not None and age < settings.ROLE_CACHE_SECONDS:
return PortalUser(user, name, bool(cached.get("publisher")), bool(cached.get("admin")))
try:
fresh = get_directory().find_user(user.sam)
except DirectoryUnavailable:
logger.warning("Roles could not be refreshed; elevated roles are denied")
return PortalUser(user, name, False, False) # the old cache stays expired
if fresh is None or not fresh.enabled or fresh.locked:
with transaction.atomic():
end_sessions(user)
audit.record(
user.key,
"session.ended",
ip=getattr(request, "client_ip", ""),
detail={"reason": "account_unavailable"},
)
session.flush()
return None
remember(request, fresh)
return PortalUser(user, _display_name(fresh), fresh.is_publisher, fresh.is_admin)
+33 -1
View File
@@ -1,9 +1,14 @@
"""Absolute session limit; the idle limit is SESSION_COOKIE_AGE with save-every-request."""
"""Session limits, client address and the signed-in user.
The idle limit is SESSION_COOKIE_AGE with save-every-request; the absolute limit is below.
"""
import time
from django.conf import settings
from . import identity
from .clientip import client_ip, parse_networks
from .session_store import CREATED_AT
@@ -18,3 +23,30 @@ class AbsoluteSessionLimitMiddleware:
# flush() deletes the row; the empty session makes SessionMiddleware clear the cookie.
session.flush()
return self.get_response(request)
class ClientIpMiddleware:
"""Set request.client_ip from the peer and, behind a trusted proxy, X-Forwarded-For (AD-7)."""
def __init__(self, get_response):
self.get_response = get_response
self.trusted = parse_networks(settings.TRUSTED_PROXIES)
def __call__(self, request):
request.client_ip = client_ip(
request.META.get("REMOTE_ADDR", ""),
request.META.get("HTTP_X_FORWARDED_FOR", ""),
self.trusted,
)
return self.get_response(request)
class CurrentUserMiddleware:
"""Set request.portal_user: the signed-in user with roles, or None (AD-5)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.portal_user = identity.current(request)
return self.get_response(request)
+3
View File
@@ -12,6 +12,9 @@ from .models import UserSession
USER_KEY = "session_user_key"
USER_SAM = "session_user_sam"
CREATED_AT = "session_created_at" # epoch seconds, UTC
DISPLAY_NAME = "session_display_name"
ROLES = "session_roles" # {"publisher": bool, "admin": bool, "checked": epoch seconds}
LOGIN_FLOW = "session_login_flow" # state, nonce and PKCE verifier between redirect and callback
class SessionStore(DBSessionStore):
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.home, name="home"),
path("auth/login", views.login, name="login"),
path("auth/callback", views.callback, name="callback"),
path("auth/logout", views.logout, name="logout"),
path("auth/signed-out", views.signed_out, name="signed-out"),
]
+105
View File
@@ -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")