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,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)
|
||||
Reference in New Issue
Block a user