diff --git a/CHANGELOG.md b/CHANGELOG.md index f580a50..8805a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### basicbar-auth (→ wird `auth/v0.2.0`) + +**Session-Endpunkte und Routen im Paket** (Framework-Review; die vier Tools +trugen identische Kopien): `basicbar_auth.views` mit `whoami_payload` / +`whoami`, `logout_view`, `set_language`; `basicbar_auth.urls` mit +`oidc/logout-redirect/`, `oidc/silent/`, `oidc/backchannel-logout/`, +`oidc/callback/` (neu: `SafeOIDCCallbackView` aus abstimmbar — Browser- +„Zurück“ nach dem Login landet auf der SPA statt auf einer 400-Seite), +mozillas Routen und `api/whoami/language/`. `AbstractBasicUser` hat jetzt +`language` (alle Tools hatten das identische Feld). + +**Admin-Gruppe entzieht nur, was sie verliehen hat.** Bisher setzte +`OIDC_ADMIN_GROUP` bei jedem Login `is_staff`/`is_superuser` hart auf die +Gruppenmitgliedschaft — eine Beförderung in der Nutzerverwaltung des Tools +wurde beim nächsten Login still zurückgenommen. Jetzt wird nur entzogen, +wenn der Claims-Snapshot des vorigen Logins die Gruppe enthielt. + +**Session-Index statt Komplett-Scan beim Back-Channel-Logout.** Neues Model +`UserSession` (Login-/Logout-Signale), eigene Migration `basicbar_auth +0001`. Der Scan über alle unabgelaufenen Sessions bleibt nur als Fallback +für Sessions aus der Zeit vor dem Upgrade. + +Außerdem: `discover_endpoints` loggt Fehler als WARNING statt still `{}` zu +liefern; `filter_users_by_claims` spart das `exists()` ohne aktivierten +Fallback. + +**Migration:** + +1. `requirements.txt`: Tag `auth/v0.2.0`; `python manage.py migrate` + (legt `basicbar_auth_usersession` an). +2. Optional: Tools, die von `AbstractBasicUser` erben (erkennbar, Template), + können ihr eigenes `language = CharField(max_length=10, blank=True)` aus + `accounts.User` streichen — Django erlaubt das Überschreiben von Feldern + abstrakter Basisklassen, es kollidiert also nichts, und die Definition ist + identisch (keine DB-Migration, `makemigrations --check` bleibt leer; + gegen erkennbar geprüft). Tools auf `AbstractUser` (ausleihbar, + abstimmbar, modulierbar) ändern nichts. +3. Empfohlen: `config/urls.py` auf `path("", include("basicbar_auth.urls"))` + plus eigenes `api/whoami/` umstellen und die lokalen `logout_view` / + `set_language` löschen; `whoami` als `{**whoami_payload(request), …}` + schreiben (Beispiel im README). abstimmbar: `accounts/oidc.py` + (`SafeOIDCCallbackView`) und die eigene `oidc/callback/`-Route entfallen. + ausleihbar: `SetLanguageView` (DRF) kann durch `set_language` ersetzt + werden — gleiche URL, gleiche Antworten. +4. Admin-Semantik prüfen: Wer sich darauf verlassen hat, dass der IdP- + Gruppen-Verlust *jede* Admin-Rolle entzieht, muss lokale Beförderungen + jetzt selbst zurücknehmen (Nutzerverwaltung / Django-Admin). + ### @basicbar/ui (→ wird `ui/v0.7.0`) **TipTap raus aus den Bundles, die keinen Editor rendern** (Framework-Review): diff --git a/CLAUDE.md b/CLAUDE.md index 28863eb..05ed2bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,10 @@ GitHub-Actions-Runner öffentlicher „-bar“-Tools die Pakete erreichen könne - `packages/django/basicbar-integrations` — LibreTranslate-/LiteLLM-Clients, Translate- und Capabilities-Endpoint, HTML-Allowlist (nh3). - `packages/django/basicbar-auth` — OIDC (mozilla-django-oidc): Backend, - Silent Login, Back-Channel-Logout, Discovery, `AbstractBasicUser`, - optionale Limits (`MAX_USERS`, Drift-Fallback). Betreiber-Doku im README. + Silent Login, Back-Channel-Logout (mit Session-Index `UserSession`, eigene + Migration), Discovery, `AbstractBasicUser`, Session-Endpunkte + (`views.whoami_payload`/`logout_view`/`set_language`, `urls`), optionale + Limits (`MAX_USERS`, Drift-Fallback). Betreiber-Doku im README. - `packages/django/basicbar-lti` — LTI-1.3-Fundament (PyLTI1p3): Plattform-Registrierung, Tool-Key, Provisionierung, Middleware. Launch/Deep-Linking bleiben Tool-Code. diff --git a/packages/django/basicbar-auth/README.md b/packages/django/basicbar-auth/README.md index 481f01b..1fc6c3f 100644 --- a/packages/django/basicbar-auth/README.md +++ b/packages/django/basicbar-auth/README.md @@ -3,36 +3,75 @@ OIDC-Fundament der „-bar“-Tools (auf Basis von mozilla-django-oidc): - `oidc.OIDCBackend` — Claim-Mapping aufs Tool-User-Model, Just-in-time- - Provisionierung, IdP-Gruppen → Django-Admin (autoritativ), optionale - Account-Obergrenze und Subject-Drift-Fallback (beides aus per Default). + Provisionierung, IdP-Gruppe → Django-Admin, optionale Account-Obergrenze + und Subject-Drift-Fallback (beides aus per Default). - `oidc.SilentLoginView` — Silent SSO (`prompt=none`, kein Redirect-Loop). +- `oidc.SafeOIDCCallbackView` — mozillas Callback, der einen wiederholten + Callback (Browser-„Zurück“ direkt nach dem Login) auf die SPA umleitet + statt eine 400-Seite zu zeigen; loggt niemanden ein. - `oidc.backchannel_logout` — OIDC Back-Channel Logout 1.0 (Token-Prüfung, - löscht alle Sessions des Subjects). + löscht alle Sessions des Subjects über den Session-Index). - `oidc.provider_logout_url`, `oidc.is_oidc_admin`, `oidc.claims_in_admin_group` +- `views.whoami_payload` / `views.whoami`, `views.logout_view`, + `views.set_language` — die Session-Endpunkte der SPA; `urls.urlpatterns` + verdrahtet OIDC-Routen und `api/whoami/language/`. - `discovery.discover_endpoints` — Endpunkte aus `OIDC_OP_ISSUER` ableiten. - `models.AbstractBasicUser` — Basis fürs konkrete `accounts.User` des Tools - (`subject` + `claims`; ADR-0003: Model und Migrationen bleiben im Tool). + (`subject`, `claims`, `language`; ADR-0003: Model und Migrationen bleiben + im Tool). `models.UserSession` — Index Nutzer → Session (eigene + Migration im Paket). ## Einbinden ```python -INSTALLED_APPS = [..., "basicbar_auth"] +INSTALLED_APPS = [..., "basicbar_auth"] # hat seit 0.2 eine eigene Migration AUTHENTICATION_BACKENDS = [ "basicbar_auth.oidc.OIDCBackend", "django.contrib.auth.backends.ModelBackend", ] OIDC_OP_LOGOUT_URL_METHOD = "basicbar_auth.oidc.provider_logout_url" -# urls.py -from basicbar_auth.oidc import SilentLoginView, backchannel_logout -path("oidc/silent/", SilentLoginView.as_view(), name="oidc-silent"), -path("oidc/backchannel-logout/", backchannel_logout, name="oidc-backchannel-logout"), -path("oidc/", include("mozilla_django_oidc.urls")), +# urls.py — logout-redirect, silent, backchannel-logout, callback, mozilla, +# api/whoami/language/ in einem Rutsch; api/whoami/ bleibt Sache des Tools: +path("", include("basicbar_auth.urls")), +path("api/whoami/", whoami), + +# accounts/models.py +class User(AbstractBasicUser): # subject, claims, language kommen mit + ... # (ein eigenes, gleiches `language` darf bleiben) + +# accounts/views.py — whoami mit Tool-Feldern: +from basicbar_auth.views import whoami_payload +def whoami(request): + return JsonResponse({**whoami_payload(request), "ai_enabled": ai.is_enabled()}) ``` +`whoami_payload` liefert `authenticated`, `csrf_token` und — angemeldet — +`username`, `first_name`, `last_name`, `email`, `subject`, `is_staff`, +`language`. Ohne eigene Felder reicht `basicbar_auth.views.whoami` direkt. + Claim-Namen (`OIDC_CLAIM_USERNAME`, …), `OIDC_GROUPS_CLAIM`/`OIDC_ADMIN_GROUP` und die optionalen Verhalten kommen aus Settings mit Paket-Defaults -(`conf.py`). Bestehende Tools adoptieren ohne Umbau ihres User-Models. +(`conf.py`). Bestehende Tools adoptieren ohne Umbau ihres User-Models +(`AbstractUser` + identische Felder funktioniert weiter). + +**Admin-Gruppe:** Mit `OIDC_ADMIN_GROUP` verleiht die Gruppenmitgliedschaft +`is_staff`/`is_superuser` und ihr Verlust entzieht sie — aber nur Rechte, +die aus der Gruppe kamen (erkennbar am Claims-Snapshot des letzten Logins). +Eine Beförderung im Tool (Nutzerverwaltung, Django-Admin) überlebt den +nächsten Login; `is_oidc_admin(user)` sagt dem Tool, welche Admins der IdP +verwaltet (die darf das Tool lokal nicht entziehen). + +**Session-Index:** `user_logged_in`/`user_logged_out` pflegen `UserSession`; +der Back-Channel-Logout löscht darüber gezielt, statt jede unabgelaufene +Session zu dekodieren (relevant bei vielen anonymen Besucher-Sessions). +Sessions aus der Zeit vor 0.2 kennt der Index nicht — für sie bleibt der +Scan als Fallback, bis sie ablaufen. Voraussetzung ist der DB-Session-Store. + +**Discovery:** `discover_endpoints` loggt Fehler (WARNING) und liefert `{}`, +damit der Settings-Import nicht abstürzt. Produktiv sollten die Settings bei +leeren Endpunkten mit `ImproperlyConfigured` abbrechen (so das Template), +sonst scheitert jeder Login später mit einem unklaren Fehler. ## Betreiber-Hinweise (Fristen, Kennungs-Wiedervergabe) diff --git a/packages/django/basicbar-auth/basicbar_auth/__init__.py b/packages/django/basicbar-auth/basicbar_auth/__init__.py index a999b74..ab95dfd 100644 --- a/packages/django/basicbar-auth/basicbar_auth/__init__.py +++ b/packages/django/basicbar-auth/basicbar_auth/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/packages/django/basicbar-auth/basicbar_auth/apps.py b/packages/django/basicbar-auth/basicbar_auth/apps.py index 1bd86a4..c447fd5 100644 --- a/packages/django/basicbar-auth/basicbar_auth/apps.py +++ b/packages/django/basicbar-auth/basicbar_auth/apps.py @@ -7,3 +7,9 @@ class BasicbarAuthConfig(AppConfig): name = "basicbar_auth" verbose_name = "Basicbar Auth" + # Pinned here so the package's own migration is the same in every tool, + # whatever the project's DEFAULT_AUTO_FIELD. + default_auto_field = "django.db.models.BigAutoField" + + def ready(self): + from . import signals # noqa: F401 — connects the session-index receivers diff --git a/packages/django/basicbar-auth/basicbar_auth/discovery.py b/packages/django/basicbar-auth/basicbar_auth/discovery.py index 36ca3e3..385a879 100644 --- a/packages/django/basicbar-auth/basicbar_auth/discovery.py +++ b/packages/django/basicbar-auth/basicbar_auth/discovery.py @@ -7,18 +7,29 @@ talk to any compliant OIDC provider — no per-endpoint configuration needed. """ import json +import logging import urllib.request +logger = logging.getLogger(__name__) + def discover_endpoints(issuer, timeout=5): """Fetch ``{issuer}/.well-known/openid-configuration``. - Returns the parsed document, or an empty dict on any failure (so settings - import never crashes if the provider is temporarily unreachable). + Returns the parsed document, or an empty dict on any failure, so a + settings import never crashes because the provider is temporarily + unreachable — the failure is logged, and the settings should refuse to + start without ``DEBUG`` when the endpoints end up empty (the template + does), otherwise every login fails with an opaque error later. """ url = issuer.rstrip("/") + "/.well-known/openid-configuration" try: with urllib.request.urlopen(url, timeout=timeout) as response: - return json.load(response) - except Exception: + document = json.load(response) + except Exception as exc: # noqa: BLE001 — network, HTTP, JSON: all "no document" + logger.warning("OIDC discovery failed for %s: %s", url, exc) + return {} + if not isinstance(document, dict): + logger.warning("OIDC discovery for %s returned no JSON object", url) return {} + return document diff --git a/packages/django/basicbar-auth/basicbar_auth/migrations/0001_initial.py b/packages/django/basicbar-auth/basicbar_auth/migrations/0001_initial.py new file mode 100644 index 0000000..74dbb45 --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserSession", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("session_key", models.CharField(max_length=40)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="basicbar_sessions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("session_key",), name="basicbar_auth_usersession_key_uniq" + ) + ], + }, + ), + ] diff --git a/packages/django/basicbar-auth/basicbar_auth/migrations/__init__.py b/packages/django/basicbar-auth/basicbar_auth/migrations/__init__.py new file mode 100644 index 0000000..aed940e --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/migrations/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) diff --git a/packages/django/basicbar-auth/basicbar_auth/models.py b/packages/django/basicbar-auth/basicbar_auth/models.py index 14f9765..2bb9a1a 100644 --- a/packages/django/basicbar-auth/basicbar_auth/models.py +++ b/packages/django/basicbar-auth/basicbar_auth/models.py @@ -1,13 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models class AbstractBasicUser(AbstractUser): - """Base for the tools' user models: Django's ``AbstractUser`` plus the two - fields the shared OIDC machinery relies on. + """Base for the tools' user models: Django's ``AbstractUser`` plus the + fields the shared OIDC machinery and session endpoints rely on. Each tool defines its own concrete ``accounts.User(AbstractBasicUser)`` with its own fields and migrations (see basicbar ADR-0003) — tool-specific @@ -22,5 +23,42 @@ class AbstractBasicUser(AbstractUser): # mappings and claim-based access rules. claims = models.JSONField(default=dict, blank=True) + # Preferred UI (and e-mail) language, set from the SPA via + # ``basicbar_auth.views.set_language``; blank = site default. + language = models.CharField(max_length=10, blank=True) + class Meta(AbstractUser.Meta): abstract = True + + +class UserSession(models.Model): + """Index from user to live Django session, maintained by the + ``user_logged_in`` / ``user_logged_out`` signals (see ``signals.py``). + + Django's DB session store has no such index — the only way to find a + user's sessions is to decode every unexpired session row. The back-channel + logout needs exactly that lookup, and a tool with many anonymous visitor + sessions (participants, public catalogue) would pay for all of them on + every SSO logout. Rows are dropped when the user logs out, when the + back-channel logout acts on them, and — for sessions that expired + silently — lazily on the next lookup for that user. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="basicbar_sessions", + ) + session_key = models.CharField(max_length=40) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + # Package-prefixed name: Postgres constraint names are schema-wide. + models.UniqueConstraint( + fields=["session_key"], name="basicbar_auth_usersession_key_uniq" + ), + ] + + def __str__(self): + return f"{self.user_id}:{self.session_key[:8]}…" diff --git a/packages/django/basicbar-auth/basicbar_auth/oidc.py b/packages/django/basicbar-auth/basicbar_auth/oidc.py index 9572f7f..1c1d95f 100644 --- a/packages/django/basicbar-auth/basicbar_auth/oidc.py +++ b/packages/django/basicbar-auth/basicbar_auth/oidc.py @@ -16,15 +16,19 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.sessions.models import Session -from django.core.exceptions import FieldDoesNotExist, PermissionDenied -from django.http import HttpResponse, HttpResponseBadRequest +from django.core.exceptions import FieldDoesNotExist, PermissionDenied, SuspiciousOperation +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from mozilla_django_oidc.auth import OIDCAuthenticationBackend -from mozilla_django_oidc.views import OIDCAuthenticationRequestView +from mozilla_django_oidc.views import ( + OIDCAuthenticationCallbackView, + OIDCAuthenticationRequestView, +) from . import conf +from .models import UserSession logger = logging.getLogger(__name__) @@ -85,6 +89,32 @@ def get_extra_params(self, request): return params +class SafeOIDCCallbackView(OIDCAuthenticationCallbackView): + """mozilla-django-oidc's callback, tolerant of a replayed/stale callback. + + The parent raises ``SuspiciousOperation`` when it receives a + ``code``/``state`` whose ``state`` is no longer in the session's + ``oidc_states`` (already consumed). That is exactly what a browser *Back* + press right after login does — it re-requests ``/oidc/callback/?code=…`` + with the now-spent state — and it surfaced as a 400 error page. + + We catch that one case and redirect to the SPA instead. The parent raises + *before* authenticating, so the replay never logs anyone in; this only + turns an ugly error page into a friendly redirect and does not weaken the + state check. Every other outcome (provider ``error``, unusable token) + already goes through the parent's ``login_failure`` redirect. Wired in + ``basicbar_auth.urls`` ahead of mozilla's own route. (From abstimmbar.) + """ + + def get(self, request): + try: + return super().get(request) + except SuspiciousOperation: + # A still-valid session lands logged in; otherwise the SPA shows + # the landing page. + return HttpResponseRedirect(settings.LOGIN_REDIRECT_URL) + + def _users_counting_toward_cap(UserModel): """Accounts that occupy a ``MAX_USERS`` slot. Anonymized (deleted) users don't count — removing one frees a slot — recognised by the retention @@ -108,7 +138,7 @@ def filter_users_by_claims(self, claims): if not subject: return self.UserModel.objects.none() users = self.UserModel.objects.filter(subject=subject) - if users.exists() or not conf.get("OIDC_MATCH_BY_USERNAME_FALLBACK"): + if not conf.get("OIDC_MATCH_BY_USERNAME_FALLBACK") or users.exists(): return users # Subject drift (opt-in): the IdP re-issued its user IDs (re-imported # dev realm, realm/IdP migration). The username comes from the same @@ -155,25 +185,34 @@ def update_user(self, user, claims): # username-fallback match — see filter_users_by_claims. if claims.get("sub") and user.subject != claims.get("sub"): user.subject = claims.get("sub") + previous_claims = user.claims user.claims = claims or {} user.last_login = timezone.now() - self._apply_admin_group(user, claims) + self._apply_admin_group(user, claims, previous_claims) user.save() return user - def _apply_admin_group(self, user, claims): + def _apply_admin_group(self, user, claims, previous_claims=None): """Sync Django admin flags with an IdP group claim, if configured. When ``OIDC_ADMIN_GROUP`` is set, the IdP group is authoritative for - OIDC users: membership grants admin, absence revokes it. The local - ``createsuperuser`` account is a separate identity and is unaffected, - serving as a break-glass fallback. + the rights it granted: membership grants admin, and losing the + membership revokes it. Rights that did *not* come from the group — + a promotion made inside the tool (its user management, the Django + admin) — are left alone, so an app-level admin without the IdP group + does not lose the role on the next login. Whether the rights came + from the group is read off the previous claims snapshot + (``user.claims`` before this login). The local ``createsuperuser`` + account is a separate identity and never passes through here. """ if not conf.get("OIDC_ADMIN_GROUP"): return - is_admin = claims_in_admin_group(claims) - user.is_staff = is_admin - user.is_superuser = is_admin + if claims_in_admin_group(claims): + user.is_staff = True + user.is_superuser = True + elif claims_in_admin_group(previous_claims): + user.is_staff = False + user.is_superuser = False def provider_logout_url(request): @@ -191,23 +230,28 @@ def provider_logout_url(request): def _delete_sessions_for_subject(subject): """Delete every active Django session belonging to the OIDC ``subject``. - Django's DB session store has no index from user to session, so we scan the - unexpired sessions and match the decoded ``_auth_user_id``. There are only - ever a handful of live sessions per user, so this stays cheap. Logging out - by subject (not ``sid``) drops all of the user's sessions, which is exactly - what a remote SSO logout should do. + Logging out by subject (not ``sid``) drops all of the user's sessions, + which is exactly what a remote SSO logout should do. The sessions come + from the ``UserSession`` index that the login signal maintains; the index + rows go with them. Only when the index knows nothing about the user do we + fall back to decoding every unexpired session row (sessions that were + created before the index existed, i.e. before basicbar-auth 0.2). """ - user_ids = { - str(pk) - for pk in get_user_model().objects.filter(subject=subject).values_list( - "pk", flat=True - ) - } + user_ids = list( + get_user_model().objects.filter(subject=subject).values_list("pk", flat=True) + ) if not user_ids: return 0 + indexed = UserSession.objects.filter(user_id__in=user_ids) + keys = list(indexed.values_list("session_key", flat=True)) + if keys: + deleted, _ = Session.objects.filter(session_key__in=keys).delete() + indexed.delete() + return deleted + wanted = {str(pk) for pk in user_ids} deleted = 0 - for session in Session.objects.filter(expire_date__gte=timezone.now()): - if session.get_decoded().get("_auth_user_id") in user_ids: + for session in Session.objects.filter(expire_date__gte=timezone.now()).iterator(): + if session.get_decoded().get("_auth_user_id") in wanted: session.delete() deleted += 1 return deleted diff --git a/packages/django/basicbar-auth/basicbar_auth/signals.py b/packages/django/basicbar-auth/basicbar_auth/signals.py new file mode 100644 index 0000000..d07fef2 --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/signals.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Keep the user → session index (``UserSession``) in sync with Django's +login/logout. Connected in ``apps.py``; both handlers are best-effort so a +hiccup in the index can never break a login.""" +import logging + +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.dispatch import receiver + +from .models import UserSession + +logger = logging.getLogger(__name__) + + +@receiver(user_logged_in, dispatch_uid="basicbar_auth.record_session") +def record_session(sender, request, user, **kwargs): + # ``login()`` has already cycled the key, so this is the key the browser + # will carry from now on. + key = getattr(getattr(request, "session", None), "session_key", None) + if not key: + return + try: + UserSession.objects.update_or_create(session_key=key, defaults={"user": user}) + except Exception: # noqa: BLE001 — index maintenance must never break login + logger.exception("Could not record session for user %s", user.pk) + + +@receiver(user_logged_out, dispatch_uid="basicbar_auth.forget_session") +def forget_session(sender, request, user, **kwargs): + # Sent before ``logout()`` flushes the session, so the key is still there. + key = getattr(getattr(request, "session", None), "session_key", None) + if not key: + return + try: + UserSession.objects.filter(session_key=key).delete() + except Exception: # noqa: BLE001 + logger.exception("Could not forget session %s", key[:8]) diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/settings.py b/packages/django/basicbar-auth/basicbar_auth/tests/settings.py index 3b5bb64..df517f4 100644 --- a/packages/django/basicbar-auth/basicbar_auth/tests/settings.py +++ b/packages/django/basicbar-auth/basicbar_auth/tests/settings.py @@ -43,5 +43,6 @@ OIDC_OP_JWKS_ENDPOINT = "https://idp.test/jwks" OIDC_OP_LOGOUT_ENDPOINT = "https://idp.test/logout" OIDC_RP_SIGN_ALGO = "RS256" -LOGIN_REDIRECT_URL = "/" -LOGOUT_REDIRECT_URL = "/" +LOGIN_REDIRECT_URL = "http://spa.test/" +LOGOUT_REDIRECT_URL = "http://spa.test/" +LANGUAGES = [("en", "English"), ("de", "German")] diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/test_admin_group.py b/packages/django/basicbar-auth/basicbar_auth/tests/test_admin_group.py index 8b04f14..684eb04 100644 --- a/packages/django/basicbar-auth/basicbar_auth/tests/test_admin_group.py +++ b/packages/django/basicbar-auth/basicbar_auth/tests/test_admin_group.py @@ -50,6 +50,31 @@ def test_group_membership_is_authoritative_on_update(self): self.backend.update_user(user, {"sub": "a3", "groups": []}) user.refresh_from_db() self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + self.assertFalse(is_oidc_admin(user)) + + def test_local_promotion_survives_a_login_without_the_group(self): + # Promoted inside the tool (user management / Django admin), never a + # member of the IdP group: the next login must not demote them. + user = self.backend.create_user( + {"sub": "a6", "preferred_username": "local", "groups": ["students"]} + ) + user.is_staff = user.is_superuser = True + user.save() + self.backend.update_user(user, {"sub": "a6", "groups": ["students"]}) + user.refresh_from_db() + self.assertTrue(user.is_staff) + self.assertTrue(user.is_superuser) + # … and the tool may still revoke it locally: it is not an IdP admin. + self.assertFalse(is_oidc_admin(user)) + + def test_joining_the_group_later_grants_admin(self): + user = self.backend.create_user({"sub": "a7", "preferred_username": "y", "groups": []}) + self.assertFalse(user.is_staff) + self.backend.update_user(user, {"sub": "a7", "groups": ["tool-admins"]}) + user.refresh_from_db() + self.assertTrue(user.is_superuser) + self.assertTrue(is_oidc_admin(user)) class NoAdminGroupConfiguredTests(TestCase): diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/test_discovery.py b/packages/django/basicbar-auth/basicbar_auth/tests/test_discovery.py new file mode 100644 index 0000000..ab1d52a --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/tests/test_discovery.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +import io +import json +from unittest.mock import patch + +from django.test import SimpleTestCase + +from basicbar_auth.discovery import discover_endpoints + + +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class DiscoveryTests(SimpleTestCase): + @patch("basicbar_auth.discovery.urllib.request.urlopen") + def test_returns_the_document(self, mock_open): + doc = {"authorization_endpoint": "https://idp.test/auth", "jwks_uri": "https://idp.test/jwks"} + mock_open.return_value = _Response(json.dumps(doc).encode()) + self.assertEqual(discover_endpoints("https://idp.test/realms/x/"), doc) + url = mock_open.call_args.args[0] + self.assertEqual(url, "https://idp.test/realms/x/.well-known/openid-configuration") + + @patch("basicbar_auth.discovery.urllib.request.urlopen", side_effect=OSError("unreachable")) + def test_failure_is_logged_and_returns_empty(self, _mock_open): + with self.assertLogs("basicbar_auth.discovery", level="WARNING") as logs: + self.assertEqual(discover_endpoints("https://idp.test"), {}) + self.assertIn("unreachable", logs.output[0]) + + @patch("basicbar_auth.discovery.urllib.request.urlopen") + def test_non_object_document_is_rejected(self, mock_open): + mock_open.return_value = _Response(b"[1, 2]") + with self.assertLogs("basicbar_auth.discovery", level="WARNING"): + self.assertEqual(discover_endpoints("https://idp.test"), {}) diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/test_sessions.py b/packages/django/basicbar-auth/basicbar_auth/tests/test_sessions.py new file mode 100644 index 0000000..c846015 --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/tests/test_sessions.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""The user → session index and its use by the back-channel logout.""" +import time +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.sessions.models import Session +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from basicbar_auth.models import UserSession +from basicbar_auth.oidc import BACKCHANNEL_LOGOUT_EVENT, _delete_sessions_for_subject + +User = get_user_model() + + +class SessionIndexTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="demo", subject="sub-123") + + def test_login_records_the_session(self): + client = Client() + client.force_login(self.user) + key = client.session.session_key + self.assertTrue(UserSession.objects.filter(user=self.user, session_key=key).exists()) + + def test_logout_forgets_the_session(self): + client = Client() + client.force_login(self.user) + self.assertEqual(UserSession.objects.filter(user=self.user).count(), 1) + client.logout() + self.assertEqual(UserSession.objects.filter(user=self.user).count(), 0) + + def test_two_browsers_two_rows(self): + a, b = Client(), Client() + a.force_login(self.user) + b.force_login(self.user) + self.assertEqual(UserSession.objects.filter(user=self.user).count(), 2) + + def test_delete_by_subject_uses_the_index(self): + a, b = Client(), Client() + a.force_login(self.user) + b.force_login(self.user) + keys = {a.session.session_key, b.session.session_key} + + with patch("basicbar_auth.oidc.Session.objects.filter", wraps=Session.objects.filter) as spy: + deleted = _delete_sessions_for_subject("sub-123") + + self.assertEqual(deleted, 2) + self.assertFalse(Session.objects.filter(session_key__in=keys).exists()) + self.assertEqual(UserSession.objects.filter(user=self.user).count(), 0) + # One indexed lookup by key — no scan over the unexpired session table. + self.assertEqual(spy.call_count, 1) + self.assertEqual(set(spy.call_args.kwargs["session_key__in"]), keys) + + def test_falls_back_to_a_scan_for_unindexed_sessions(self): + # A session from before the index existed: logged in, index row gone. + client = Client() + client.force_login(self.user) + key = client.session.session_key + UserSession.objects.all().delete() + + deleted = _delete_sessions_for_subject("sub-123") + + self.assertEqual(deleted, 1) + self.assertFalse(Session.objects.filter(session_key=key).exists()) + + def test_unknown_subject_deletes_nothing(self): + Client().force_login(self.user) + self.assertEqual(_delete_sessions_for_subject("nobody"), 0) + self.assertEqual(Session.objects.count(), 1) + + +@override_settings(OIDC_RP_CLIENT_ID="test-client", OIDC_OP_ISSUER="") +class BackChannelUsesIndexTests(TestCase): + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") + def test_backchannel_logout_drops_indexed_sessions_only_for_that_user(self, mock_verify): + me = User.objects.create_user(username="me", subject="sub-me") + other = User.objects.create_user(username="other", subject="sub-other") + mine, theirs = Client(), Client() + mine.force_login(me) + theirs.force_login(other) + mock_verify.return_value = { + "iss": "https://idp.test/realms/x", + "aud": "test-client", + "sub": "sub-me", + "iat": int(time.time()), + "events": {BACKCHANNEL_LOGOUT_EVENT: {}}, + } + + res = self.client.post(reverse("oidc-backchannel-logout"), {"logout_token": "tok"}) + + self.assertEqual(res.status_code, 200) + self.assertFalse(mine.get("/api/whoami/").json()["authenticated"]) + self.assertTrue(theirs.get("/api/whoami/").json()["authenticated"]) + self.assertEqual(UserSession.objects.filter(user=me).count(), 0) + self.assertEqual(UserSession.objects.filter(user=other).count(), 1) diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/test_views.py b/packages/django/basicbar-auth/basicbar_auth/tests/test_views.py new file mode 100644 index 0000000..889e1ff --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/tests/test_views.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Session endpoints and the tolerant OIDC callback (ported from the tools).""" +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings + +User = get_user_model() + + +class WhoamiTests(TestCase): + def test_anonymous(self): + payload = self.client.get("/api/whoami/").json() + self.assertFalse(payload["authenticated"]) + # An authoritative CSRF token is always returned so the SPA can send + # unsafe requests reliably (esp. cross-origin in dev). + self.assertTrue(payload["csrf_token"]) + self.assertNotIn("username", payload) + + def test_authenticated(self): + user = User.objects.create_user( + username="frank", subject="abc-123", first_name="Frank", language="de" + ) + self.client.force_login(user) + payload = self.client.get("/api/whoami/").json() + self.assertTrue(payload["authenticated"]) + self.assertEqual(payload["username"], "frank") + self.assertEqual(payload["first_name"], "Frank") + self.assertEqual(payload["subject"], "abc-123") + self.assertEqual(payload["language"], "de") + self.assertFalse(payload["is_staff"]) + self.assertTrue(payload["csrf_token"]) + + +class SetLanguageTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="frank") + + def _post(self, body): + return self.client.post( + "/api/whoami/language/", body, content_type="application/json" + ) + + def test_sets_supported_language(self): + self.client.force_login(self.user) + response = self._post({"language": "de"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"language": "de"}) + self.user.refresh_from_db() + self.assertEqual(self.user.language, "de") + + def test_rejects_unknown_language(self): + self.client.force_login(self.user) + self.assertEqual(self._post({"language": "xx"}).status_code, 400) + self.assertEqual(self._post({"language": ""}).status_code, 400) + self.assertEqual(self._post([1, 2]).status_code, 400) + + def test_tolerates_broken_json(self): + self.client.force_login(self.user) + response = self.client.post( + "/api/whoami/language/", "{not json", content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + + def test_requires_authentication(self): + self.assertEqual(self._post({"language": "de"}).status_code, 403) + + def test_requires_post(self): + self.client.force_login(self.user) + self.assertEqual(self.client.get("/api/whoami/language/").status_code, 405) + + +class LogoutViewTests(TestCase): + def test_anonymous_goes_straight_to_the_spa(self): + response = self.client.get("/oidc/logout-redirect/") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "http://spa.test/") + + def test_authenticated_is_sent_to_the_provider_end_session(self): + user = User.objects.create_user(username="frank") + self.client.force_login(user) + session = self.client.session + session["oidc_id_token"] = "id-token" + session.save() + + response = self.client.get("/oidc/logout-redirect/") + + self.assertEqual(response.status_code, 302) + location = response["Location"] + self.assertTrue(location.startswith("https://idp.test/logout?")) + self.assertIn("id_token_hint=id-token", location) + self.assertIn("client_id=test-client", location) + self.assertIn("post_logout_redirect_uri=http%3A%2F%2Fspa.test%2F", location) + # The Django session is gone either way. + self.assertFalse(self.client.get("/api/whoami/").json()["authenticated"]) + + @override_settings(OIDC_OP_LOGOUT_ENDPOINT="") + def test_without_end_session_endpoint_logs_out_locally(self): + user = User.objects.create_user(username="frank") + self.client.force_login(user) + response = self.client.get("/oidc/logout-redirect/") + self.assertEqual(response["Location"], "http://spa.test/") + self.assertFalse(self.client.get("/api/whoami/").json()["authenticated"]) + + +class SafeOIDCCallbackViewTests(TestCase): + def test_stale_callback_redirects_instead_of_400(self): + # A replayed callback (Back after login): the session still has its + # ``oidc_states`` dict, but this state was consumed by the first + # callback — mozilla raises SuspiciousOperation for exactly that. + session = self.client.session + session["oidc_states"] = {"other": {"nonce": "n"}} + session.save() + + response = self.client.get("/oidc/callback/?code=abc&state=spent") + + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "http://spa.test/") + self.assertFalse(self.client.get("/api/whoami/").json()["authenticated"]) + + def test_callback_without_any_pending_login_goes_to_login_failure(self): + # No ``oidc_states`` at all (fresh browser): mozilla's own graceful path. + response = self.client.get("/oidc/callback/?code=abc&state=spent") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/") + + def test_provider_error_still_goes_to_login_failure(self): + response = self.client.get("/oidc/callback/?error=login_required") + self.assertEqual(response.status_code, 302) + # mozilla's own failure redirect (LOGIN_REDIRECT_URL_FAILURE, default "/"). + self.assertEqual(response["Location"], "/") diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/urls.py b/packages/django/basicbar-auth/basicbar_auth/tests/urls.py index 14d93c3..b63425f 100644 --- a/packages/django/basicbar-auth/basicbar_auth/tests/urls.py +++ b/packages/django/basicbar-auth/basicbar_auth/tests/urls.py @@ -1,14 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) -from django.urls import path +from django.urls import include, path -from basicbar_auth.oidc import backchannel_logout +from basicbar_auth.views import whoami urlpatterns = [ - path( - "oidc/backchannel-logout/", - backchannel_logout, - name="oidc-backchannel-logout", - ), + path("", include("basicbar_auth.urls")), + path("api/whoami/", whoami), ] diff --git a/packages/django/basicbar-auth/basicbar_auth/urls.py b/packages/django/basicbar-auth/basicbar_auth/urls.py new file mode 100644 index 0000000..e86e986 --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/urls.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""The OIDC and session routes every tool wires the same way:: + + path("", include("basicbar_auth.urls")), + path("api/whoami/", whoami), # the tool's own (or basicbar_auth.views.whoami) + +``api/whoami/`` is deliberately not included: most tools add fields to it. +""" +from django.urls import include, path + +from .oidc import SafeOIDCCallbackView, SilentLoginView, backchannel_logout +from .views import logout_view, set_language + +urlpatterns = [ + path("oidc/logout-redirect/", logout_view, name="spa-logout"), + path("oidc/silent/", SilentLoginView.as_view(), name="oidc-silent"), + path("oidc/backchannel-logout/", backchannel_logout, name="oidc-backchannel-logout"), + # Must precede mozilla's include: same URL, and the first match wins. + path( + "oidc/callback/", + SafeOIDCCallbackView.as_view(), + name="oidc_authentication_callback", + ), + path("oidc/", include("mozilla_django_oidc.urls")), + path("api/whoami/language/", set_language, name="whoami-language"), +] diff --git a/packages/django/basicbar-auth/basicbar_auth/views.py b/packages/django/basicbar-auth/basicbar_auth/views.py new file mode 100644 index 0000000..6193844 --- /dev/null +++ b/packages/django/basicbar-auth/basicbar_auth/views.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Session/identity endpoints for a tool's SPA. + +Plain Django views (no DRF): the SPA calls them with the session cookie and +the CSRF token it got from ``whoami``. A tool with extra fields in its +``whoami`` composes ``whoami_payload`` into its own view — see the README. +""" +import json + +from django.conf import settings +from django.contrib.auth import logout as django_logout +from django.http import JsonResponse +from django.middleware.csrf import get_token +from django.shortcuts import redirect +from django.views.decorators.http import require_POST + +from .oidc import provider_logout_url + + +def whoami_payload(request) -> dict: + """The session state the SPA needs at load time. + + Always includes the CSRF token: ``get_token`` sets the cookie *and* hands + the SPA an authoritative token, so unsafe requests work even cross-origin + in dev, where reading the cookie from JavaScript can be unreliable + (production is same-origin behind the reverse proxy). + """ + payload = {"authenticated": False, "csrf_token": get_token(request)} + user = request.user + if not user.is_authenticated: + return payload + payload.update( + { + "authenticated": True, + "username": user.get_username(), + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "subject": getattr(user, "subject", None), + "is_staff": user.is_staff, + "language": getattr(user, "language", ""), + } + ) + return payload + + +def whoami(request): + """``GET /api/whoami/`` — the plain payload; tools with extra fields wrap + ``whoami_payload`` instead of using this view.""" + return JsonResponse(whoami_payload(request)) + + +def logout_view(request): + """Log out of Django and (if logged in via OIDC) the identity provider. + + GET-friendly so the SPA can trigger it with a plain redirect. + """ + end_session_url = None + if request.user.is_authenticated and getattr(settings, "OIDC_OP_LOGOUT_ENDPOINT", ""): + end_session_url = provider_logout_url(request) + django_logout(request) + return redirect(end_session_url or settings.LOGOUT_REDIRECT_URL) + + +@require_POST +def set_language(request): + """``POST /api/whoami/language/`` ``{"language": "de"}`` — remember the + user's UI language (``AbstractBasicUser.language``). Accepts only codes + from ``settings.LANGUAGES``.""" + if not request.user.is_authenticated: + return JsonResponse({"detail": "Not authenticated."}, status=403) + try: + data = json.loads(request.body or b"{}") + except ValueError: + data = {} + raw = data.get("language") if isinstance(data, dict) else "" + language = (str(raw) if raw else "").strip() + if language not in dict(settings.LANGUAGES): + return JsonResponse({"detail": "Unsupported language."}, status=400) + request.user.language = language + request.user.save(update_fields=["language"]) + return JsonResponse({"language": language}) diff --git a/packages/django/basicbar-auth/pyproject.toml b/packages/django/basicbar-auth/pyproject.toml index 2a73ae4..a864419 100644 --- a/packages/django/basicbar-auth/pyproject.toml +++ b/packages/django/basicbar-auth/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basicbar-auth" -version = "0.1.1" +version = "0.2.0" description = "OIDC-Fundament der virtUOS -bar-Tools: Backend, Silent Login, Back-Channel-Logout, Discovery, AbstractBasicUser, optionale Account-Limits" readme = "README.md" requires-python = ">=3.12"