Files
ping-mee-self-service-platform/portal/accounts/identity.py
T
ebbe.bassandClaude Sonnet 5.5 3c69c2a156 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
2026-10-17 19:19:51 +02:00

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)