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
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
"""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)
|