Clicking Logout returned 405: the base templates linked to the logout view with a GET, which mozilla-django-oidc refuses. Both portals now post a CSRF form instead. A POST alone was not enough. OIDC_OP_LOGOUT_URL_METHOD is now set, so logout builds the realm's RP-initiated end-session URL with the stored id token and a registered post-logout redirect. Without it Keycloak kept its session and silently signed the user back in, so the sign-in screen could never be reached again on that browser. Registered the post-logout redirect URIs on the ixg-platform client and trimmed its redirect URIs to the callback on the hosts that actually serve the platform.
169 lines
7 KiB
Python
169 lines
7 KiB
Python
"""Staff status is derived from the role, never asserted.
|
|
|
|
The bug these pin: ``create_user`` and ``update_user`` used to set
|
|
``is_staff=True`` on every account, so a self-registered buyer came out as staff
|
|
— and, because ``update_user`` runs on every sign-in, a buyer row created
|
|
correctly would still flip to staff the first time they signed in. The second
|
|
case is the one that passes a first test and fails later, so it is covered
|
|
directly.
|
|
"""
|
|
from urllib.parse import quote
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth import get_user_model
|
|
from django.test import RequestFactory, TestCase
|
|
from django.urls import reverse
|
|
|
|
from .auth import KeycloakOIDCBackend, is_staff_for_role
|
|
from .keycloak import _split_name
|
|
from .oidc import provider_logout
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class SplitNameTests(TestCase):
|
|
"""Both Keycloak name fields must be non-empty, or sign-in shows the
|
|
"Update Account Information" (VERIFY_PROFILE) step."""
|
|
|
|
def test_a_two_part_name_splits(self):
|
|
self.assertEqual(_split_name("Ada Greenfield"), ("Ada", "Greenfield"))
|
|
|
|
def test_a_multi_part_name_keeps_the_last_word_as_the_surname(self):
|
|
self.assertEqual(_split_name("Ada Mary Greenfield"), ("Ada Mary", "Greenfield"))
|
|
|
|
def test_a_single_word_name_fills_both_fields(self):
|
|
self.assertEqual(_split_name("Ada"), ("Ada", "Ada"))
|
|
|
|
def test_an_empty_name_yields_empty_fields(self):
|
|
self.assertEqual(_split_name(" "), ("", ""))
|
|
|
|
|
|
class StaffRoleTests(TestCase):
|
|
def setUp(self):
|
|
self.backend = KeycloakOIDCBackend()
|
|
|
|
def test_only_ops_and_admin_are_staff(self):
|
|
self.assertFalse(is_staff_for_role("buyer"))
|
|
self.assertTrue(is_staff_for_role("ops"))
|
|
self.assertTrue(is_staff_for_role("admin"))
|
|
self.assertTrue(is_staff_for_role("buyer", is_superuser=True))
|
|
|
|
def test_create_user_derives_staff_from_the_claims_role(self):
|
|
buyer = self.backend.create_user(
|
|
{"email": "signup-buyer@example.com", "name": "Buyer One", "sub": "sub-buyer", "role": "buyer"}
|
|
)
|
|
self.assertEqual(buyer.role, "buyer")
|
|
self.assertFalse(buyer.is_staff)
|
|
|
|
ops = self.backend.create_user(
|
|
{"email": "signup-ops@example.com", "name": "Ops One", "sub": "sub-ops", "role": "ops"}
|
|
)
|
|
self.assertTrue(ops.is_staff)
|
|
|
|
def test_create_user_defaults_to_buyer_without_a_role_claim(self):
|
|
user = self.backend.create_user(
|
|
{"email": "signup-norole@example.com", "name": "No Role", "sub": "sub-norole"}
|
|
)
|
|
self.assertEqual(user.role, "buyer")
|
|
self.assertFalse(user.is_staff)
|
|
|
|
def test_update_user_does_not_staff_a_buyer(self):
|
|
user = User.objects.create_user(
|
|
email="buyer-stays@example.com", role="buyer", is_staff=False, keycloak_id="sub-1"
|
|
)
|
|
self.backend.update_user(
|
|
user, {"email": "buyer-stays@example.com", "name": "Buyer", "sub": "sub-1", "role": "buyer"}
|
|
)
|
|
user.refresh_from_db()
|
|
self.assertEqual(user.role, "buyer")
|
|
self.assertFalse(user.is_staff)
|
|
|
|
def test_update_user_staffs_ops_and_admin(self):
|
|
for role in ("ops", "admin"):
|
|
user = User.objects.create_user(
|
|
email=f"{role}-signin@example.com", role="buyer", is_staff=False,
|
|
keycloak_id=f"sub-{role}",
|
|
)
|
|
self.backend.update_user(
|
|
user, {"email": user.email, "name": role, "sub": user.keycloak_id, "role": role}
|
|
)
|
|
user.refresh_from_db()
|
|
self.assertEqual(user.role, role)
|
|
self.assertTrue(user.is_staff)
|
|
|
|
def test_update_user_keeps_the_existing_role_when_the_claim_is_absent(self):
|
|
user = User.objects.create_user(
|
|
email="ops-no-role-claim@example.com", role="ops", is_staff=True, keycloak_id="sub-2"
|
|
)
|
|
self.backend.update_user(user, {"email": user.email, "name": "Ops", "sub": "sub-2"})
|
|
user.refresh_from_db()
|
|
self.assertEqual(user.role, "ops")
|
|
self.assertTrue(user.is_staff)
|
|
|
|
def test_update_user_preserves_a_superuser(self):
|
|
user = User.objects.create_superuser(
|
|
email="super@example.com", password="pass1234", role="buyer", keycloak_id="sub-3"
|
|
)
|
|
self.backend.update_user(
|
|
user, {"email": user.email, "name": "Super", "sub": "sub-3", "role": "buyer"}
|
|
)
|
|
user.refresh_from_db()
|
|
self.assertTrue(user.is_superuser)
|
|
self.assertTrue(user.is_staff)
|
|
|
|
|
|
class LogoutTests(TestCase):
|
|
"""Signing out must end the Keycloak session, not just the Django one.
|
|
|
|
The bug: the base templates linked to the logout view with a GET, which
|
|
mozilla-django-oidc refuses (405); and even a successful POST only cleared
|
|
the Django session, so Keycloak silently signed the user back in and the
|
|
sign-in screen could never be reached again. These pin all three parts: GET
|
|
is refused, POST clears the session, and the URL handed to the browser ends
|
|
the Keycloak session and carries a registered return trip.
|
|
"""
|
|
|
|
def test_get_logout_is_refused(self):
|
|
response = self.client.get(reverse("oidc_logout"))
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
def test_post_logout_clears_the_django_session(self):
|
|
user = User.objects.create_user(
|
|
email="logout-ops@example.com", role="ops", is_staff=True
|
|
)
|
|
self.client.force_login(user)
|
|
response = self.client.post(reverse("oidc_logout"))
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertNotIn("_auth_user_id", self.client.session)
|
|
|
|
def test_logout_url_targets_the_realm_end_session_endpoint(self):
|
|
request = RequestFactory().get("/portal/")
|
|
request.session = {"oidc_id_token": "id-token-abc"}
|
|
url = provider_logout(request)
|
|
self.assertTrue(url.startswith(settings.OIDC_OP_LOGOUT_ENDPOINT))
|
|
self.assertIn("id_token_hint=id-token-abc", url)
|
|
self.assertIn("post_logout_redirect_uri=", url)
|
|
self.assertIn(quote("http://testserver/", safe=""), url)
|
|
|
|
def test_logout_url_omits_the_hint_when_no_token_is_stored(self):
|
|
request = RequestFactory().get("/portal/")
|
|
request.session = {}
|
|
self.assertNotIn("id_token_hint", provider_logout(request))
|
|
|
|
def test_the_buyer_portal_renders_a_post_logout_form(self):
|
|
buyer = User.objects.create_user(
|
|
email="logout-buyer@example.com", role="buyer", is_staff=False
|
|
)
|
|
self.client.force_login(buyer)
|
|
response = self.client.get("/portal/")
|
|
self.assertContains(response, 'action="/oidc/logout/"')
|
|
self.assertContains(response, "csrfmiddlewaretoken")
|
|
|
|
def test_the_ops_portal_renders_a_post_logout_form(self):
|
|
ops = User.objects.create_user(
|
|
email="logout-ops2@example.com", role="ops", is_staff=True
|
|
)
|
|
self.client.force_login(ops)
|
|
response = self.client.get("/ops/")
|
|
self.assertContains(response, 'action="/oidc/logout/"')
|
|
self.assertContains(response, "csrfmiddlewaretoken")
|