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:
2026-10-17 19:19:51 +02:00
co-authored by Claude Sonnet 5.5
parent 7458a2a815
commit 3c69c2a156
32 changed files with 1692 additions and 14 deletions
+19
View File
@@ -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()
+56
View File
@@ -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"
+35
View File
@@ -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"
+271
View File
@@ -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()
+11 -9
View File
@@ -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)
)
+339
View File
@@ -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