Files
ping-mee-self-service-platform/portal/tests/test_signin.py
T
ebbe.bassandClaude Sonnet 5.5 3c69c2a156 feat: add OIDC sign-in, roles, sessions and client address rule
Authorization-code flow with PKCE through an idp adapter (with a fake), ID
token validation, identity and roles resolved from the directory with a short
cache, refusal of unknown, disabled and locked accounts with one generic page,
sessions that end when the account becomes unavailable, sign-out that ends the
server-side session, role decorators and the trusted-proxy client address rule.
Adds the OIDC_* and PUBLIC_BASE_URL configuration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-17 19:19:51 +02:00

340 lines
12 KiB
Python

"""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