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:
@@ -6,6 +6,7 @@ import ipaddress
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
@@ -42,6 +43,18 @@ class DirectoryConfig:
|
||||
admin_group_dn: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OidcConfig:
|
||||
issuer: str # exactly as the provider reports it, including any trailing slash
|
||||
client_id: str
|
||||
client_secret: str = field(repr=False)
|
||||
public_base_url: str # origin the users reach the portal at; no trailing slash
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"{self.public_base_url}/auth/callback"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
secret_key: str
|
||||
@@ -52,6 +65,7 @@ class Config:
|
||||
log_level: str
|
||||
default_timezone: str
|
||||
directory: DirectoryConfig
|
||||
oidc: OidcConfig
|
||||
|
||||
|
||||
def load_config(environ: Mapping[str, str]) -> Config:
|
||||
@@ -135,6 +149,19 @@ def load_config(environ: Mapping[str, str]) -> Config:
|
||||
if raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
|
||||
problems.append("LDAP_CA_FILE: must be an existing file")
|
||||
|
||||
issuer = required("OIDC_ISSUER", "issuer address from the provider's OpenID configuration")
|
||||
client_id = required("OIDC_CLIENT_ID", "client ID of the provider's application")
|
||||
client_secret = required("OIDC_CLIENT_SECRET", "client secret of the provider's application")
|
||||
base_url = required("PUBLIC_BASE_URL", "address users reach the portal at, e.g. https://host")
|
||||
base_url = base_url.rstrip("/")
|
||||
if issuer and urlsplit(issuer).scheme != "https":
|
||||
problems.append("OIDC_ISSUER: must be an https address")
|
||||
if base_url:
|
||||
parts = urlsplit(base_url)
|
||||
allowed = ("https", "http") if debug else ("https",)
|
||||
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
|
||||
problems.append("PUBLIC_BASE_URL: must be an https origin such as https://host")
|
||||
|
||||
if problems:
|
||||
raise ConfigError(problems)
|
||||
|
||||
@@ -155,4 +182,10 @@ def load_config(environ: Mapping[str, str]) -> Config:
|
||||
publisher_group_dn=publisher_group_dn,
|
||||
admin_group_dn=admin_group_dn,
|
||||
),
|
||||
oidc=OidcConfig(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
public_base_url=base_url,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,11 +19,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
SECRET_KEY = _config.secret_key
|
||||
DEBUG = _config.debug
|
||||
ALLOWED_HOSTS = _config.allowed_hosts
|
||||
# Reserved for the client-IP rule in a later entry; currently has no effect.
|
||||
# The client-IP rule (AD-7) trusts X-Forwarded-For only from these peers.
|
||||
TRUSTED_PROXIES = _config.trusted_proxies
|
||||
DATA_DIR = _config.data_dir
|
||||
DISPLAY_TIME_ZONE = _config.default_timezone
|
||||
DIRECTORY = _config.directory
|
||||
OIDC = _config.oidc
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
@@ -36,11 +37,13 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
"portal.config.middleware.RequestIdMiddleware",
|
||||
"portal.accounts.middleware.ClientIpMiddleware",
|
||||
"portal.config.middleware.SecurityHeadersMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
|
||||
"portal.accounts.middleware.CurrentUserMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -99,10 +102,13 @@ SESSION_ENGINE = "portal.accounts.session_store"
|
||||
SESSION_COOKIE_AGE = 60 * 60 # idle limit: 1 hour, renewed on every request
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
SESSION_ABSOLUTE_SECONDS = 4 * 60 * 60 # absolute limit: 4 hours
|
||||
# How long roles and account state are trusted before the directory is asked again (AD-5).
|
||||
ROLE_CACHE_SECONDS = 60
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
CSRF_TRUSTED_ORIGINS = [OIDC.public_base_url]
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from .health import healthz
|
||||
|
||||
urlpatterns = [
|
||||
path("healthz", healthz, name="healthz"),
|
||||
path("", include("portal.accounts.urls")),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user