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,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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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