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")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Identity-provider adapter: the only code that talks OIDC (AD-1)."""
|
||||
|
||||
from .base import Flow, Identity, Idp, IdpError, IdpUnavailable, LoginRejected
|
||||
from .fake import FakeIdp
|
||||
from .oidc import OidcIdp
|
||||
|
||||
__all__ = [
|
||||
"FakeIdp",
|
||||
"Flow",
|
||||
"Identity",
|
||||
"Idp",
|
||||
"IdpError",
|
||||
"IdpUnavailable",
|
||||
"LoginRejected",
|
||||
"OidcIdp",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The identity-provider interface, its data types and typed errors (AD-1, AD-17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class IdpError(Exception):
|
||||
"""Base class of every error the identity-provider adapter raises."""
|
||||
|
||||
|
||||
class IdpUnavailable(IdpError):
|
||||
"""The provider could not be reached or answered with something unusable."""
|
||||
|
||||
|
||||
class LoginRejected(IdpError):
|
||||
"""The sign-in response is not valid: wrong state, bad token, provider error."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Flow:
|
||||
"""What must survive between the redirect and the callback. Kept in the session."""
|
||||
|
||||
state: str
|
||||
nonce: str
|
||||
code_verifier: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"state": self.state, "nonce": self.nonce, "code_verifier": self.code_verifier}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, str]) -> Flow:
|
||||
return cls(data["state"], data["nonce"], data["code_verifier"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
username: str # the validated preferred_username claim
|
||||
subject: str
|
||||
|
||||
|
||||
class Idp(Protocol):
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
"""The address to send the browser to, and the flow to remember."""
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
"""Validate the callback's query parameters against the flow; return who signed in."""
|
||||
@@ -0,0 +1,41 @@
|
||||
"""In-memory identity provider for tests (AD-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .base import Flow, Identity, IdpUnavailable, LoginRejected
|
||||
|
||||
|
||||
class FakeIdp:
|
||||
"""`begin` hands out a flow; `complete` accepts the one-time code from `callback_params`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.unavailable = False
|
||||
self.identity: Identity | None = Identity("alice", "sub-alice")
|
||||
self._codes: dict[str, Identity | None] = {}
|
||||
|
||||
def sign_in_as(self, username: str | None) -> None:
|
||||
self.identity = Identity(username, f"sub-{username}") if username else None
|
||||
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
if self.unavailable:
|
||||
raise IdpUnavailable("The identity provider is not available")
|
||||
flow = Flow(secrets.token_urlsafe(8), secrets.token_urlsafe(8), secrets.token_urlsafe(8))
|
||||
return f"https://idp.example.test/authorize?state={flow.state}", flow
|
||||
|
||||
def callback_params(self, flow: Flow) -> dict[str, str]:
|
||||
code = secrets.token_urlsafe(8)
|
||||
self._codes[code] = self.identity
|
||||
return {"state": flow.state, "code": code}
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
if self.unavailable:
|
||||
raise IdpUnavailable("The identity provider is not available")
|
||||
if "error" in params or params.get("state") != flow.state:
|
||||
raise LoginRejected("The sign-in response does not match the request")
|
||||
identity = self._codes.pop(params.get("code", ""), None) # a code works once
|
||||
if identity is None:
|
||||
raise LoginRejected("The identity provider refused the code")
|
||||
return identity
|
||||
@@ -0,0 +1,176 @@
|
||||
"""OpenID Connect authorization-code flow with PKCE. The only module that talks OIDC (AD-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from authlib.common.errors import AuthlibBaseError
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from joserfc import jwt
|
||||
from joserfc.errors import InvalidKeyIdError, JoseError
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
from portal.config.env import OidcConfig
|
||||
|
||||
from .base import Flow, Identity, IdpUnavailable, LoginRejected
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 10
|
||||
CACHE_SECONDS = 60 * 60
|
||||
CLOCK_LEEWAY_SECONDS = 30
|
||||
SCOPE = "openid profile email"
|
||||
# Asymmetric algorithms only: a token signed with a shared secret is never accepted.
|
||||
ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
|
||||
|
||||
SessionFactory = Callable[[], OAuth2Session]
|
||||
|
||||
|
||||
class OidcIdp:
|
||||
def __init__(
|
||||
self,
|
||||
config: OidcConfig,
|
||||
session_factory: SessionFactory | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._clock = clock
|
||||
self._factory = session_factory or self._default_session
|
||||
self._lock = threading.Lock()
|
||||
self._metadata: dict[str, Any] | None = None
|
||||
self._metadata_at = 0.0
|
||||
self._keys: KeySet | None = None
|
||||
self._keys_at = 0.0
|
||||
|
||||
def _default_session(self) -> OAuth2Session:
|
||||
return OAuth2Session(
|
||||
client_id=self._config.client_id,
|
||||
client_secret=self._config.client_secret,
|
||||
scope=SCOPE,
|
||||
redirect_uri=self._config.redirect_uri,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
|
||||
# -- provider documents ----------------------------------------------------------
|
||||
|
||||
def _get_json(self, session: OAuth2Session, url: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = session.get(url, timeout=TIMEOUT_SECONDS, withhold_token=True)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.warning("Identity provider request failed: %s", type(exc).__name__)
|
||||
raise IdpUnavailable("The identity provider is not available") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise IdpUnavailable("The identity provider sent an unusable document")
|
||||
return data
|
||||
|
||||
def _discovery(self, session: OAuth2Session) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._metadata is not None and self._clock() - self._metadata_at < CACHE_SECONDS:
|
||||
return self._metadata
|
||||
url = self._config.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
data = self._get_json(session, url)
|
||||
if data.get("issuer") != self._config.issuer:
|
||||
raise IdpUnavailable("The identity provider reports a different issuer")
|
||||
for name in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
if not isinstance(data.get(name), str) or not data[name].startswith("https://"):
|
||||
raise IdpUnavailable(f"The identity provider document lacks a usable {name}")
|
||||
with self._lock:
|
||||
self._metadata, self._metadata_at = data, self._clock()
|
||||
return data
|
||||
|
||||
def _keyset(self, session: OAuth2Session, jwks_uri: str, *, refresh: bool) -> KeySet:
|
||||
with self._lock:
|
||||
if self._keys is not None and not refresh:
|
||||
if self._clock() - self._keys_at < CACHE_SECONDS:
|
||||
return self._keys
|
||||
try:
|
||||
keys = KeySet.import_key_set(self._get_json(session, jwks_uri))
|
||||
except (JoseError, ValueError, KeyError) as exc:
|
||||
raise IdpUnavailable("The identity provider sent unusable keys") from exc
|
||||
with self._lock:
|
||||
self._keys, self._keys_at = keys, self._clock()
|
||||
return keys
|
||||
|
||||
# -- interface ---------------------------------------------------------------------
|
||||
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
session = self._factory()
|
||||
metadata = self._discovery(session)
|
||||
flow = Flow(
|
||||
state=secrets.token_urlsafe(32),
|
||||
nonce=secrets.token_urlsafe(32),
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
)
|
||||
url, _ = session.create_authorization_url(
|
||||
metadata["authorization_endpoint"],
|
||||
state=flow.state,
|
||||
nonce=flow.nonce,
|
||||
code_verifier=flow.code_verifier,
|
||||
)
|
||||
return url, flow
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
if "error" in params:
|
||||
raise LoginRejected("The identity provider refused the sign-in")
|
||||
state, code = params.get("state", ""), params.get("code", "")
|
||||
if not code or not hmac.compare_digest(state.encode(), flow.state.encode()):
|
||||
raise LoginRejected("The sign-in response does not match the request")
|
||||
|
||||
session = self._factory()
|
||||
metadata = self._discovery(session)
|
||||
try:
|
||||
token = session.fetch_token(
|
||||
metadata["token_endpoint"],
|
||||
grant_type="authorization_code",
|
||||
code=code,
|
||||
code_verifier=flow.code_verifier,
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("Identity provider request failed: %s", type(exc).__name__)
|
||||
raise IdpUnavailable("The identity provider is not available") from exc
|
||||
except AuthlibBaseError as exc:
|
||||
raise LoginRejected("The identity provider refused the code") from exc
|
||||
id_token = token.get("id_token") if isinstance(token, dict) else None
|
||||
if not isinstance(id_token, str):
|
||||
raise LoginRejected("The identity provider sent no ID token")
|
||||
return self._identity(session, metadata["jwks_uri"], id_token, flow)
|
||||
|
||||
def _identity(self, session: OAuth2Session, jwks_uri: str, id_token: str, flow: Flow):
|
||||
decoded = None
|
||||
for refresh in (False, True): # the second try covers a key rotation
|
||||
keys = self._keyset(session, jwks_uri, refresh=refresh)
|
||||
try:
|
||||
decoded = jwt.decode(id_token, keys, algorithms=ALGORITHMS)
|
||||
break
|
||||
except InvalidKeyIdError as exc:
|
||||
if refresh:
|
||||
raise LoginRejected("The ID token is not valid") from exc
|
||||
except JoseError as exc:
|
||||
raise LoginRejected("The ID token is not valid") from exc
|
||||
registry = jwt.JWTClaimsRegistry(
|
||||
leeway=CLOCK_LEEWAY_SECONDS,
|
||||
now=int(self._clock()),
|
||||
iss={"essential": True, "value": self._config.issuer},
|
||||
aud={"essential": True, "value": self._config.client_id},
|
||||
exp={"essential": True},
|
||||
sub={"essential": True},
|
||||
nonce={"essential": True, "value": flow.nonce},
|
||||
)
|
||||
try:
|
||||
registry.validate(decoded.claims)
|
||||
except JoseError as exc:
|
||||
raise LoginRejected("The ID token claims are not valid") from exc
|
||||
username = decoded.claims.get("preferred_username")
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
raise LoginRejected("The ID token has no usable user name")
|
||||
return Identity(username=username.strip(), subject=str(decoded.claims["sub"]))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Where the rest of the portal gets its adapters. Tests install fakes with `install`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .directory import Directory, LdapDirectory
|
||||
from .idp import Idp, OidcIdp
|
||||
|
||||
_directory: Directory | None = None
|
||||
_idp: Idp | None = None
|
||||
|
||||
|
||||
def get_directory() -> Directory:
|
||||
global _directory
|
||||
if _directory is None:
|
||||
_directory = LdapDirectory(settings.DIRECTORY)
|
||||
return _directory
|
||||
|
||||
|
||||
def get_idp() -> Idp:
|
||||
global _idp
|
||||
if _idp is None:
|
||||
_idp = OidcIdp(settings.OIDC)
|
||||
return _idp
|
||||
|
||||
|
||||
def install(directory: Directory | None = None, idp: Idp | None = None) -> None:
|
||||
"""Replace the adapters (tests). Passing None for one leaves it to be built on first use."""
|
||||
global _directory, _idp
|
||||
_directory, _idp = directory, idp
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{% load i18n %}<!doctype html>
|
||||
<html lang="{{ LANGUAGE_CODE }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{% trans "Self-service portal" %}{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "No permission" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "No permission" %}</h1>
|
||||
<p>{% trans "You do not have permission to open this page." %}</p>
|
||||
<p><a href="{% url 'home' %}">{% trans "Back to the portal" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Welcome" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}</h1>
|
||||
<p>{{ portal_user.user.sam }}</p>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit">{% trans "Sign out" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Sign-in not possible" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Sign-in not possible" %}</h1>
|
||||
<p>{% trans "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator." %}</p>
|
||||
<p><a href="{% url 'login' %}">{% trans "Try again" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Signed out" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "You have been signed out" %}</h1>
|
||||
<p><a href="{% url 'login' %}">{% trans "Sign in again" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Not available" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Not available" %}</h1>
|
||||
<p>{% trans "This is not possible right now. Please try again later." %}</p>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,11 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.adapters import registry
|
||||
from portal.adapters.directory import FakeDirectory
|
||||
from portal.adapters.idp import FakeIdp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_env(tmp_path):
|
||||
@@ -16,4 +22,17 @@ def valid_env(tmp_path):
|
||||
"LDAP_CA_FILE": str(ca_file),
|
||||
"LDAP_PUBLISHER_GROUP_DN": "CN=Publishers,OU=Users,DC=example,DC=test",
|
||||
"LDAP_ADMIN_GROUP_DN": "CN=Admins,OU=Users,DC=example,DC=test",
|
||||
"OIDC_ISSUER": "https://idp.example.test/application/o/portal/",
|
||||
"OIDC_CLIENT_ID": "portal-client",
|
||||
"OIDC_CLIENT_SECRET": "client-secret",
|
||||
"PUBLIC_BASE_URL": "https://portal.example.test",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def adapters():
|
||||
"""Fakes for every adapter, so no test can reach a real directory or provider."""
|
||||
fakes = SimpleNamespace(directory=FakeDirectory(), idp=FakeIdp())
|
||||
registry.install(fakes.directory, fakes.idp)
|
||||
yield fakes
|
||||
registry.install()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
from portal.accounts.clientip import client_ip, parse_networks
|
||||
from portal.accounts.middleware import ClientIpMiddleware
|
||||
|
||||
PROXY = parse_networks(["192.168.180.150"])
|
||||
|
||||
|
||||
def test_without_a_trusted_proxy_the_peer_is_the_client_and_the_header_is_ignored():
|
||||
assert client_ip("203.0.113.9", "1.2.3.4", []) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_a_peer_that_is_not_a_trusted_proxy_cannot_set_the_address():
|
||||
assert client_ip("203.0.113.9", "1.2.3.4", PROXY) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_behind_a_trusted_proxy_the_last_entry_is_used():
|
||||
assert client_ip("192.168.180.150", "9.9.9.9, 198.51.100.7", PROXY) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_entries_before_the_last_cannot_be_forged_by_the_client():
|
||||
assert client_ip("192.168.180.150", "6.6.6.6, 7.7.7.7, 198.51.100.7", PROXY) == "198.51.100.7"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ["", "not-an-ip", "198.51.100.7, garbage"])
|
||||
def test_a_missing_or_malformed_header_falls_back_to_the_proxy_address(header):
|
||||
assert client_ip("192.168.180.150", header, PROXY) == "192.168.180.150"
|
||||
|
||||
|
||||
def test_networks_work_and_ipv6_is_handled():
|
||||
nets = parse_networks(["10.0.0.0/8", "fd00::/8"])
|
||||
assert client_ip("10.1.2.3", "198.51.100.7", nets) == "198.51.100.7"
|
||||
assert client_ip("fd00::1", "2001:db8::5", nets) == "2001:db8::5"
|
||||
|
||||
|
||||
def test_a_missing_peer_gives_no_address():
|
||||
assert client_ip("", "1.2.3.4", PROXY) == ""
|
||||
|
||||
|
||||
def test_x_real_ip_is_never_used():
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="192.168.180.150", HTTP_X_REAL_IP="6.6.6.6"
|
||||
)
|
||||
with override_settings(TRUSTED_PROXIES=["192.168.180.150"]):
|
||||
middleware = ClientIpMiddleware(lambda r: r)
|
||||
assert middleware(request).client_ip == "192.168.180.150"
|
||||
|
||||
|
||||
def test_the_middleware_applies_the_rule():
|
||||
request = RequestFactory().get(
|
||||
"/", REMOTE_ADDR="192.168.180.150", HTTP_X_FORWARDED_FOR="6.6.6.6, 198.51.100.7"
|
||||
)
|
||||
with override_settings(TRUSTED_PROXIES=["192.168.180.150"]):
|
||||
middleware = ClientIpMiddleware(lambda r: r)
|
||||
assert middleware(request).client_ip == "198.51.100.7"
|
||||
@@ -105,3 +105,38 @@ def test_ldap_server_must_not_be_a_url(valid_env):
|
||||
def test_a_missing_ca_file_is_reported(valid_env, tmp_path):
|
||||
with pytest.raises(ConfigError, match="LDAP_CA_FILE: must be an existing file"):
|
||||
load_config({**valid_env, "LDAP_CA_FILE": str(tmp_path / "missing.pem")})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "PUBLIC_BASE_URL"]
|
||||
)
|
||||
def test_each_oidc_variable_is_required(valid_env, name):
|
||||
del valid_env[name]
|
||||
with pytest.raises(ConfigError, match=name):
|
||||
load_config(valid_env)
|
||||
|
||||
|
||||
def test_oidc_settings_are_loaded_and_the_secret_is_not_printed(valid_env):
|
||||
oidc = load_config({**valid_env, "PUBLIC_BASE_URL": "https://portal.example.test/"}).oidc
|
||||
assert oidc.issuer == "https://idp.example.test/application/o/portal/"
|
||||
assert oidc.redirect_uri == "https://portal.example.test/auth/callback"
|
||||
assert "client-secret" not in repr(oidc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value"),
|
||||
[
|
||||
("OIDC_ISSUER", "http://idp.example.test/"),
|
||||
("PUBLIC_BASE_URL", "http://portal.example.test"),
|
||||
("PUBLIC_BASE_URL", "https://portal.example.test/app"),
|
||||
("PUBLIC_BASE_URL", "portal.example.test"),
|
||||
],
|
||||
)
|
||||
def test_oidc_addresses_must_be_https_origins(valid_env, name, value):
|
||||
with pytest.raises(ConfigError, match=name):
|
||||
load_config({**valid_env, name: value})
|
||||
|
||||
|
||||
def test_a_plain_http_origin_is_allowed_only_in_debug(valid_env):
|
||||
env = {**valid_env, "PUBLIC_BASE_URL": "http://localhost:8000"}
|
||||
assert load_config({**env, "DEBUG": "true"}).oidc.public_base_url == "http://localhost:8000"
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""The OIDC adapter against a stand-in provider: real signed tokens, canned HTTP answers."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import KeySet, OctKey, RSAKey
|
||||
|
||||
from portal.adapters.idp import IdpUnavailable, LoginRejected, OidcIdp
|
||||
from portal.config.env import OidcConfig
|
||||
|
||||
ISSUER = "https://idp.example.test/application/o/portal/"
|
||||
CLIENT_ID = "portal-client"
|
||||
SECRET = "client-secret-value-0123456789abcdef"
|
||||
BASE = "https://idp.example.test/application/o"
|
||||
DISCOVERY_URL = ISSUER + ".well-known/openid-configuration"
|
||||
JWKS_URL = f"{BASE}/jwks/"
|
||||
TOKEN_URL = f"{BASE}/token/"
|
||||
CONFIG = OidcConfig(ISSUER, CLIENT_ID, SECRET, "https://portal.example.test")
|
||||
|
||||
KEY = RSAKey.generate_key(2048, parameters={"kid": "k1", "use": "sig"})
|
||||
OTHER_KEY = RSAKey.generate_key(2048, parameters={"kid": "k1", "use": "sig"})
|
||||
ROTATED_KEY = RSAKey.generate_key(2048, parameters={"kid": "k2", "use": "sig"})
|
||||
|
||||
|
||||
class Provider(requests.adapters.BaseAdapter):
|
||||
"""Answers requests from a table of address -> (status, json) and records them."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = []
|
||||
self.error = None
|
||||
self.routes = {
|
||||
DISCOVERY_URL: (
|
||||
200,
|
||||
{
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{BASE}/authorize/",
|
||||
"token_endpoint": TOKEN_URL,
|
||||
"jwks_uri": JWKS_URL,
|
||||
},
|
||||
),
|
||||
JWKS_URL: (200, KeySet([KEY]).as_dict(private=False)),
|
||||
TOKEN_URL: (200, {"access_token": "at", "token_type": "Bearer", "id_token": ""}),
|
||||
}
|
||||
|
||||
def send(self, request, **kwargs):
|
||||
self.calls.append(request)
|
||||
if self.error:
|
||||
raise self.error
|
||||
status, body = self.routes[request.url.split("?")[0]]
|
||||
response = requests.Response()
|
||||
response.status_code = status
|
||||
response._content = json.dumps(body).encode()
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
response.url = request.url
|
||||
response.request = request
|
||||
response.encoding = "utf-8"
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def count(self, url):
|
||||
return sum(1 for r in self.calls if r.url.split("?")[0] == url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider():
|
||||
return Provider()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def idp(provider):
|
||||
instance = OidcIdp(CONFIG)
|
||||
|
||||
def factory():
|
||||
session = instance._default_session()
|
||||
session.mount("https://", provider)
|
||||
return session
|
||||
|
||||
instance._factory = factory
|
||||
return instance
|
||||
|
||||
|
||||
def mint(flow, *, key=KEY, alg="RS256", header=None, **overrides):
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": CLIENT_ID,
|
||||
"sub": "user-1",
|
||||
"exp": int(time.time()) + 300,
|
||||
"iat": int(time.time()),
|
||||
"nonce": flow.nonce,
|
||||
"preferred_username": "alice",
|
||||
}
|
||||
claims.update(overrides)
|
||||
claims = {k: v for k, v in claims.items() if v is not None}
|
||||
return jwt.encode({"alg": alg, **(header or {"kid": "k1"})}, claims, key)
|
||||
|
||||
|
||||
def sign_in(idp, provider, flow, token, **params):
|
||||
provider.routes[TOKEN_URL] = (
|
||||
200,
|
||||
{"access_token": "at", "token_type": "Bearer", "id_token": token},
|
||||
)
|
||||
return idp.complete({"state": flow.state, "code": "the-code", **params}, flow)
|
||||
|
||||
|
||||
def test_begin_builds_a_pkce_authorization_request(idp):
|
||||
url, flow = idp.begin()
|
||||
parts = urlsplit(url)
|
||||
query = {k: v[0] for k, v in parse_qs(parts.query).items()}
|
||||
assert f"{parts.scheme}://{parts.netloc}{parts.path}" == f"{BASE}/authorize/"
|
||||
assert query["response_type"] == "code"
|
||||
assert query["client_id"] == CLIENT_ID
|
||||
assert query["redirect_uri"] == "https://portal.example.test/auth/callback"
|
||||
assert query["scope"] == "openid profile email"
|
||||
assert query["code_challenge_method"] == "S256"
|
||||
assert query["state"] == flow.state and query["nonce"] == flow.nonce
|
||||
assert "code_challenge" in query
|
||||
assert flow.code_verifier not in url # only its hash is sent
|
||||
|
||||
|
||||
def test_each_flow_has_fresh_random_values(idp):
|
||||
_, first = idp.begin()
|
||||
_, second = idp.begin()
|
||||
assert len({first.state, second.state, first.nonce, second.nonce}) == 4
|
||||
|
||||
|
||||
def test_discovery_is_fetched_once(idp, provider):
|
||||
idp.begin()
|
||||
idp.begin()
|
||||
assert provider.count(DISCOVERY_URL) == 1
|
||||
|
||||
|
||||
def test_a_valid_sign_in_returns_the_identity(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
identity = sign_in(idp, provider, flow, mint(flow))
|
||||
assert (identity.username, identity.subject) == ("alice", "user-1")
|
||||
|
||||
|
||||
def test_the_code_is_exchanged_with_the_verifier_and_client_authentication(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
sign_in(idp, provider, flow, mint(flow))
|
||||
request = [r for r in provider.calls if r.url == TOKEN_URL][0]
|
||||
form = parse_qs(request.body)
|
||||
assert form["grant_type"] == ["authorization_code"]
|
||||
assert form["code"] == ["the-code"]
|
||||
assert form["code_verifier"] == [flow.code_verifier]
|
||||
assert form["redirect_uri"] == ["https://portal.example.test/auth/callback"]
|
||||
assert request.headers["Authorization"].startswith("Basic ")
|
||||
|
||||
|
||||
def test_an_audience_list_that_contains_the_client_is_accepted(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
assert sign_in(idp, provider, flow, mint(flow, aud=[CLIENT_ID, "other"])).username == "alice"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params", [{"state": "wrong"}, {"state": ""}, {"code": ""}, {"error": "access_denied"}]
|
||||
)
|
||||
def test_a_bad_callback_is_rejected_before_any_token_request(idp, provider, params):
|
||||
_, flow = idp.begin()
|
||||
with pytest.raises(LoginRejected):
|
||||
idp.complete({"state": flow.state, "code": "c", **params}, flow)
|
||||
assert provider.count(TOKEN_URL) == 0
|
||||
|
||||
|
||||
def test_a_refused_code_is_rejected(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
provider.routes[TOKEN_URL] = (400, {"error": "invalid_grant"})
|
||||
with pytest.raises(LoginRejected):
|
||||
idp.complete({"state": flow.state, "code": "c"}, flow)
|
||||
|
||||
|
||||
def test_an_unreachable_token_endpoint_is_an_outage(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
provider.error = requests.ConnectionError("down")
|
||||
with pytest.raises(IdpUnavailable):
|
||||
idp.complete({"state": flow.state, "code": "c"}, flow)
|
||||
|
||||
|
||||
def test_a_missing_id_token_is_rejected(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
provider.routes[TOKEN_URL] = (200, {"access_token": "at", "token_type": "Bearer"})
|
||||
with pytest.raises(LoginRejected):
|
||||
idp.complete({"state": flow.state, "code": "c"}, flow)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"nonce": "somebody-elses"},
|
||||
{"nonce": None},
|
||||
{"iss": "https://evil.example.test/"},
|
||||
{"aud": "another-client"},
|
||||
{"exp": int(time.time()) - 3600},
|
||||
{"exp": None},
|
||||
{"sub": None},
|
||||
{"preferred_username": None},
|
||||
{"preferred_username": " "},
|
||||
{"preferred_username": 42},
|
||||
],
|
||||
)
|
||||
def test_invalid_claims_are_rejected(idp, provider, overrides):
|
||||
_, flow = idp.begin()
|
||||
with pytest.raises(LoginRejected):
|
||||
sign_in(idp, provider, flow, mint(flow, **overrides))
|
||||
|
||||
|
||||
def test_a_token_signed_by_another_key_is_rejected(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
with pytest.raises(LoginRejected):
|
||||
sign_in(idp, provider, flow, mint(flow, key=OTHER_KEY))
|
||||
|
||||
|
||||
def test_a_token_signed_with_the_client_secret_is_rejected(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
forged = mint(flow, key=OctKey.import_key(SECRET), alg="HS256", header={})
|
||||
with pytest.raises(LoginRejected):
|
||||
sign_in(idp, provider, flow, forged)
|
||||
|
||||
|
||||
def test_an_unsigned_token_is_rejected(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
unsigned = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0."
|
||||
with pytest.raises(LoginRejected):
|
||||
sign_in(idp, provider, flow, unsigned)
|
||||
|
||||
|
||||
def test_a_rotated_key_is_picked_up_with_one_refetch(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
sign_in(idp, provider, flow, mint(flow)) # caches the key set with k1 only
|
||||
provider.routes[JWKS_URL] = (200, KeySet([KEY, ROTATED_KEY]).as_dict(private=False))
|
||||
_, flow = idp.begin()
|
||||
token = mint(flow, key=ROTATED_KEY, header={"kid": "k2"})
|
||||
assert sign_in(idp, provider, flow, token).username == "alice"
|
||||
assert provider.count(JWKS_URL) == 2
|
||||
|
||||
|
||||
def test_an_unknown_key_is_rejected_after_one_refetch(idp, provider):
|
||||
_, flow = idp.begin()
|
||||
token = mint(flow, key=ROTATED_KEY, header={"kid": "k2"})
|
||||
with pytest.raises(LoginRejected):
|
||||
sign_in(idp, provider, flow, token)
|
||||
assert provider.count(JWKS_URL) == 2
|
||||
|
||||
|
||||
def test_a_different_issuer_in_discovery_is_an_outage(idp, provider):
|
||||
provider.routes[DISCOVERY_URL] = (
|
||||
200,
|
||||
{**provider.routes[DISCOVERY_URL][1], "issuer": "https://evil.example.test/"},
|
||||
)
|
||||
with pytest.raises(IdpUnavailable):
|
||||
idp.begin()
|
||||
|
||||
|
||||
def test_a_discovery_document_with_a_plain_http_endpoint_is_an_outage(idp, provider):
|
||||
document = {**provider.routes[DISCOVERY_URL][1], "token_endpoint": "http://x.test/token"}
|
||||
provider.routes[DISCOVERY_URL] = (200, document)
|
||||
with pytest.raises(IdpUnavailable):
|
||||
idp.begin()
|
||||
|
||||
|
||||
def test_an_unreachable_provider_is_an_outage(idp, provider):
|
||||
provider.error = requests.ConnectionError("down")
|
||||
with pytest.raises(IdpUnavailable):
|
||||
idp.begin()
|
||||
@@ -72,7 +72,8 @@ def test_settings_defaults():
|
||||
assert settings.SESSION_SAVE_EVERY_REQUEST is True
|
||||
|
||||
|
||||
def _client_with_session(created_at):
|
||||
def _client_with_session(created_at, adapters):
|
||||
adapters.directory.add_user("alice")
|
||||
store = SessionStore()
|
||||
request = RequestFactory().get("/")
|
||||
request.session = store
|
||||
@@ -84,16 +85,17 @@ def _client_with_session(created_at):
|
||||
return client, store.session_key
|
||||
|
||||
|
||||
def test_absolute_limit_removes_session_and_clears_cookie():
|
||||
def test_absolute_limit_removes_session_and_clears_cookie(adapters):
|
||||
assert any(m.endswith("AbsoluteSessionLimitMiddleware") for m in settings.MIDDLEWARE)
|
||||
client, key = _client_with_session(time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5)
|
||||
too_old = time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5
|
||||
client, key = _client_with_session(too_old, adapters)
|
||||
response = client.get("/healthz")
|
||||
assert not UserSession.objects.filter(session_key=key).exists()
|
||||
assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
|
||||
|
||||
|
||||
def test_session_within_limits_survives_a_request():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
def test_session_within_limits_survives_a_request(adapters):
|
||||
client, key = _client_with_session(time.time() - 60, adapters)
|
||||
client.get("/healthz")
|
||||
assert UserSession.objects.filter(session_key=key).exists()
|
||||
|
||||
@@ -104,8 +106,8 @@ def test_middleware_callable_directly():
|
||||
assert current_user(request) is not None
|
||||
|
||||
|
||||
def test_request_renews_expire_date_to_one_hour_ahead():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
def test_request_renews_expire_date_to_one_hour_ahead(adapters):
|
||||
client, key = _client_with_session(time.time() - 60, adapters)
|
||||
UserSession.objects.filter(session_key=key).update(
|
||||
expire_date=timezone.now() + timedelta(minutes=5)
|
||||
)
|
||||
@@ -114,8 +116,8 @@ def test_request_renews_expire_date_to_one_hour_ahead():
|
||||
assert timedelta(minutes=59) < remaining <= timedelta(hours=1)
|
||||
|
||||
|
||||
def test_request_with_idle_expired_session_has_no_session_and_clears_cookie():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
def test_request_with_idle_expired_session_has_no_session_and_clears_cookie(adapters):
|
||||
client, key = _client_with_session(time.time() - 60, adapters)
|
||||
UserSession.objects.filter(session_key=key).update(
|
||||
expire_date=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Sign-in, roles, sign-out and account-state handling through the real views (FR-1..FR-3)."""
|
||||
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import Client, RequestFactory
|
||||
|
||||
from portal.accounts import identity
|
||||
from portal.accounts.decorators import admin_required, login_required, publisher_required
|
||||
from portal.accounts.models import UserSession
|
||||
from portal.accounts.session_store import LOGIN_FLOW, ROLES
|
||||
from portal.accounts.sessions import SessionUser
|
||||
from portal.adapters.idp import Flow
|
||||
from portal.audit.models import AuditEntry
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def sign_in(adapters, username="alice", next_path=None, client=None):
|
||||
"""Run the whole flow with the fake provider; returns (client, callback response)."""
|
||||
client = client or Client()
|
||||
adapters.idp.sign_in_as(username)
|
||||
query = f"?next={next_path}" if next_path else ""
|
||||
client.get("/auth/login" + query)
|
||||
flow = Flow.from_dict(client.session[LOGIN_FLOW])
|
||||
return client, client.get("/auth/callback", adapters.idp.callback_params(flow))
|
||||
|
||||
|
||||
def age_the_cache(client):
|
||||
session = client.session
|
||||
session[ROLES] = {**session[ROLES], "checked": time.time() - 3600}
|
||||
session.save()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def people(adapters):
|
||||
d = adapters.directory
|
||||
d.add_user("Alice", given_name="Alice", surname="Anders", display_name="Alice Anders")
|
||||
d.add_user("pub", is_publisher=True)
|
||||
d.add_user("boss", is_admin=True, is_publisher=True)
|
||||
d.add_user("gone", enabled=False)
|
||||
d.add_user("locked", locked=True)
|
||||
d.add_user("expired", password_last_set=None)
|
||||
return d
|
||||
|
||||
|
||||
# -- reaching the sign-in -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unauthenticated_request_redirects_to_sign_in(people):
|
||||
response = Client().get("/")
|
||||
assert response.status_code == 302
|
||||
assert response["Location"] == "/auth/login?next=%2F"
|
||||
|
||||
|
||||
def test_sign_in_redirects_to_the_provider_and_keeps_the_flow_in_the_session(people, adapters):
|
||||
client = Client()
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 302
|
||||
state = parse_qs(urlsplit(response["Location"]).query)["state"][0]
|
||||
assert client.session[LOGIN_FLOW]["state"] == state
|
||||
|
||||
|
||||
def test_the_provider_being_down_shows_an_unavailable_page(people, adapters):
|
||||
adapters.idp.unavailable = True
|
||||
response = Client().get("/auth/login")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
# -- a successful sign-in -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_user_signs_in_and_sees_their_own_name(people, adapters):
|
||||
client, response = sign_in(adapters, "alice")
|
||||
assert response.status_code == 302 and response["Location"] == "/"
|
||||
page = client.get("/")
|
||||
assert page.status_code == 200
|
||||
assert "Alice Anders" in page.content.decode()
|
||||
|
||||
|
||||
def test_the_session_holds_the_exact_directory_name_and_a_lowercase_key(people, adapters):
|
||||
client, _ = sign_in(adapters, "ALICE")
|
||||
row = UserSession.objects.get(session_key=client.session.session_key)
|
||||
assert row.username == "alice"
|
||||
assert client.session["session_user_sam"] == "Alice"
|
||||
|
||||
|
||||
def test_the_requested_page_is_remembered(people, adapters):
|
||||
_, response = sign_in(adapters, "alice", next_path="/auth/signed-out")
|
||||
assert response["Location"] == "/auth/signed-out"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["https://evil.example.test/", "//evil.example.test", "x"])
|
||||
def test_an_outside_address_cannot_be_used_as_the_destination(people, adapters, target):
|
||||
_, response = sign_in(adapters, "alice", next_path=target)
|
||||
assert response["Location"] == "/"
|
||||
|
||||
|
||||
def test_sign_in_is_audited_with_the_client_address(people, adapters):
|
||||
sign_in(adapters, "alice")
|
||||
entry = AuditEntry.objects.get(action="signin.succeeded")
|
||||
assert entry.actor == "alice" and entry.ip == "127.0.0.1"
|
||||
assert "token" not in str(entry.detail).lower()
|
||||
|
||||
|
||||
def test_signing_in_replaces_a_previous_session_key(people, adapters):
|
||||
client = Client()
|
||||
client.get("/auth/login")
|
||||
before = client.session.session_key
|
||||
sign_in(adapters, "alice", client=client)
|
||||
assert client.session.session_key != before
|
||||
assert not UserSession.objects.filter(session_key=before).exists()
|
||||
|
||||
|
||||
def test_an_already_signed_in_user_is_sent_home(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
assert client.get("/auth/login")["Location"] == "/"
|
||||
|
||||
|
||||
# -- refusals -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unknown_disabled_and_locked_accounts_are_refused_with_the_same_page(people, adapters):
|
||||
bodies = set()
|
||||
for name in ("nobody", "gone", "locked"):
|
||||
client, response = sign_in(adapters, name)
|
||||
assert response.status_code == 403
|
||||
assert UserSession.objects.exclude(username="").count() == 0
|
||||
assert client.get("/").status_code == 302
|
||||
bodies.add(response.content)
|
||||
assert len(bodies) == 1
|
||||
|
||||
|
||||
def test_refusals_are_audited_with_the_reason(people, adapters):
|
||||
for name in ("nobody", "gone", "locked"):
|
||||
sign_in(adapters, name)
|
||||
refusals = AuditEntry.objects.filter(action="signin.refused")
|
||||
reasons = {e.actor: e.detail["reason"] for e in refusals}
|
||||
assert reasons == {"nobody": "unknown", "gone": "disabled", "locked": "locked"}
|
||||
|
||||
|
||||
def test_the_refusal_page_does_not_name_the_account(people, adapters):
|
||||
_, response = sign_in(adapters, "gone")
|
||||
assert "gone" not in response.content.decode().lower()
|
||||
|
||||
|
||||
def test_a_user_with_an_expired_password_can_sign_in(people, adapters):
|
||||
client, response = sign_in(adapters, "expired")
|
||||
assert response.status_code == 302
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_a_callback_without_a_started_flow_is_refused(people, adapters):
|
||||
response = Client().get("/auth/callback", {"state": "x", "code": "y"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_a_callback_works_once(people, adapters):
|
||||
adapters.idp.sign_in_as("alice")
|
||||
client = Client()
|
||||
client.get("/auth/login")
|
||||
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
|
||||
assert client.get("/auth/callback", params).status_code == 302
|
||||
assert client.get("/auth/callback", params).status_code == 403
|
||||
|
||||
|
||||
def test_a_wrong_state_is_refused(people, adapters):
|
||||
adapters.idp.sign_in_as("alice")
|
||||
client = Client()
|
||||
client.get("/auth/login")
|
||||
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
|
||||
response = client.get("/auth/callback", {**params, "state": "forged"})
|
||||
assert response.status_code == 403
|
||||
assert client.get("/").status_code == 302
|
||||
|
||||
|
||||
def test_a_directory_outage_blocks_new_sessions(people, adapters):
|
||||
adapters.idp.sign_in_as("alice")
|
||||
client = Client()
|
||||
client.get("/auth/login")
|
||||
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
|
||||
adapters.directory.unavailable = True
|
||||
response = client.get("/auth/callback", params)
|
||||
assert response.status_code == 503
|
||||
assert UserSession.objects.exclude(username="").count() == 0
|
||||
|
||||
|
||||
# -- roles ----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_request(portal_user):
|
||||
request = RequestFactory().get("/")
|
||||
request.portal_user = portal_user
|
||||
return request
|
||||
|
||||
|
||||
def portal_user(publisher=False, admin=False):
|
||||
return identity.PortalUser(SessionUser("u", "U"), "U", publisher, admin)
|
||||
|
||||
|
||||
@publisher_required
|
||||
def publisher_page(request):
|
||||
return HttpResponse("publisher")
|
||||
|
||||
|
||||
@admin_required
|
||||
def admin_page(request):
|
||||
return HttpResponse("admin")
|
||||
|
||||
|
||||
@login_required
|
||||
def any_page(request):
|
||||
return HttpResponse("page")
|
||||
|
||||
|
||||
def test_pages_need_a_signed_in_user():
|
||||
for view in (any_page, publisher_page, admin_page):
|
||||
response = view(make_request(None))
|
||||
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
|
||||
|
||||
|
||||
def test_a_user_in_neither_group_is_refused_on_publisher_and_admin_pages():
|
||||
for view in (publisher_page, admin_page):
|
||||
assert view(make_request(portal_user())).status_code == 403
|
||||
|
||||
|
||||
def test_publisher_and_admin_are_separate_roles():
|
||||
assert publisher_page(make_request(portal_user(publisher=True))).status_code == 200
|
||||
assert admin_page(make_request(portal_user(publisher=True))).status_code == 403
|
||||
assert admin_page(make_request(portal_user(admin=True))).status_code == 200
|
||||
assert publisher_page(make_request(portal_user(admin=True))).status_code == 403
|
||||
|
||||
|
||||
def test_roles_come_from_the_directory_at_sign_in(people, adapters):
|
||||
client, _ = sign_in(adapters, "boss")
|
||||
assert client.session[ROLES]["publisher"] is True
|
||||
assert client.session[ROLES]["admin"] is True
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
assert client.session[ROLES]["publisher"] is False
|
||||
|
||||
|
||||
def session_request(client):
|
||||
request = RequestFactory().get("/")
|
||||
request.session = client.session
|
||||
request.client_ip = "127.0.0.1"
|
||||
return request
|
||||
|
||||
|
||||
def test_roles_are_not_rechecked_within_the_cache_time(people, adapters):
|
||||
client, _ = sign_in(adapters, "pub")
|
||||
adapters.directory.unavailable = True # would fail if the directory were asked
|
||||
assert identity.current(session_request(client)).is_publisher is True
|
||||
|
||||
|
||||
def test_a_role_change_is_seen_after_the_cache_expires(people, adapters):
|
||||
client, _ = sign_in(adapters, "pub")
|
||||
adapters.directory.add_user("pub", is_publisher=False)
|
||||
assert identity.current(session_request(client)).is_publisher is True
|
||||
age_the_cache(client)
|
||||
assert identity.current(session_request(client)).is_publisher is False
|
||||
|
||||
|
||||
def test_a_directory_outage_denies_elevated_roles_but_keeps_the_session(people, adapters):
|
||||
client, _ = sign_in(adapters, "boss")
|
||||
age_the_cache(client)
|
||||
adapters.directory.unavailable = True
|
||||
user = identity.current(session_request(client))
|
||||
assert user is not None and not user.is_admin and not user.is_publisher
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
# -- account state after sign-in -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["disabled", "locked", "deleted"])
|
||||
def test_a_session_ends_at_the_next_request_when_the_account_becomes_unavailable(
|
||||
people, adapters, change
|
||||
):
|
||||
first, _ = sign_in(adapters, "alice")
|
||||
second, _ = sign_in(adapters, "alice")
|
||||
other, _ = sign_in(adapters, "pub")
|
||||
if change == "deleted":
|
||||
del adapters.directory._users["alice"]
|
||||
else:
|
||||
flag = {"disabled": {"enabled": False}, "locked": {"locked": True}}[change]
|
||||
adapters.directory.add_user("Alice", **flag)
|
||||
age_the_cache(first)
|
||||
|
||||
response = first.get("/")
|
||||
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
|
||||
assert UserSession.objects.filter(username="alice").count() == 0 # every session of the user
|
||||
assert second.get("/").status_code == 302
|
||||
assert other.get("/").status_code == 200 # other users are untouched
|
||||
entry = AuditEntry.objects.get(action="session.ended")
|
||||
assert entry.actor == "alice" and entry.detail == {"reason": "account_unavailable"}
|
||||
|
||||
|
||||
# -- sign-out ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sign_out_ends_the_session_on_the_server(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
key = client.session.session_key
|
||||
response = client.post("/auth/logout")
|
||||
assert response["Location"] == "/auth/signed-out"
|
||||
assert not UserSession.objects.filter(session_key=key).exists()
|
||||
assert client.get("/").status_code == 302
|
||||
assert AuditEntry.objects.filter(action="signout", actor="alice").count() == 1
|
||||
|
||||
|
||||
def test_a_replayed_session_cookie_does_not_work_after_sign_out(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
stolen = client.cookies["sessionid"].value
|
||||
client.post("/auth/logout")
|
||||
replay = Client()
|
||||
replay.cookies["sessionid"] = stolen
|
||||
assert replay.get("/").status_code == 302
|
||||
|
||||
|
||||
def test_sign_out_only_ends_this_session(people, adapters):
|
||||
first, _ = sign_in(adapters, "alice")
|
||||
second, _ = sign_in(adapters, "alice")
|
||||
first.post("/auth/logout")
|
||||
assert second.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_sign_out_needs_post_and_a_csrf_token(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
assert client.get("/auth/logout").status_code == 405
|
||||
strict = Client(enforce_csrf_checks=True)
|
||||
strict.cookies = client.cookies
|
||||
assert strict.post("/auth/logout").status_code == 403
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_the_signed_out_page_is_public(people):
|
||||
assert Client().get("/auth/signed-out").status_code == 200
|
||||
Reference in New Issue
Block a user