Skip to content

Commit 97d36ce

Browse files
authored
Merge pull request #16 from virtUOS/feat/auth-0.2
basicbar-auth 0.2.0: Session-Endpunkte im Paket, Admin-Gruppe entzieht nur Gruppenrechte, Session-Index
2 parents a09206e + 26c4970 commit 97d36ce

20 files changed

Lines changed: 734 additions & 55 deletions

‎CHANGELOG.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,54 @@ Changelog ist die Upgrade-Anleitung für die Tools.
77

88
## [Unreleased]
99

10+
### basicbar-auth (→ wird `auth/v0.2.0`)
11+
12+
**Session-Endpunkte und Routen im Paket** (Framework-Review; die vier Tools
13+
trugen identische Kopien): `basicbar_auth.views` mit `whoami_payload` /
14+
`whoami`, `logout_view`, `set_language`; `basicbar_auth.urls` mit
15+
`oidc/logout-redirect/`, `oidc/silent/`, `oidc/backchannel-logout/`,
16+
`oidc/callback/` (neu: `SafeOIDCCallbackView` aus abstimmbar — Browser-
17+
„Zurück“ nach dem Login landet auf der SPA statt auf einer 400-Seite),
18+
mozillas Routen und `api/whoami/language/`. `AbstractBasicUser` hat jetzt
19+
`language` (alle Tools hatten das identische Feld).
20+
21+
**Admin-Gruppe entzieht nur, was sie verliehen hat.** Bisher setzte
22+
`OIDC_ADMIN_GROUP` bei jedem Login `is_staff`/`is_superuser` hart auf die
23+
Gruppenmitgliedschaft — eine Beförderung in der Nutzerverwaltung des Tools
24+
wurde beim nächsten Login still zurückgenommen. Jetzt wird nur entzogen,
25+
wenn der Claims-Snapshot des vorigen Logins die Gruppe enthielt.
26+
27+
**Session-Index statt Komplett-Scan beim Back-Channel-Logout.** Neues Model
28+
`UserSession` (Login-/Logout-Signale), eigene Migration `basicbar_auth
29+
0001`. Der Scan über alle unabgelaufenen Sessions bleibt nur als Fallback
30+
für Sessions aus der Zeit vor dem Upgrade.
31+
32+
Außerdem: `discover_endpoints` loggt Fehler als WARNING statt still `{}` zu
33+
liefern; `filter_users_by_claims` spart das `exists()` ohne aktivierten
34+
Fallback.
35+
36+
**Migration:**
37+
38+
1. `requirements.txt`: Tag `auth/v0.2.0`; `python manage.py migrate`
39+
(legt `basicbar_auth_usersession` an).
40+
2. Optional: Tools, die von `AbstractBasicUser` erben (erkennbar, Template),
41+
können ihr eigenes `language = CharField(max_length=10, blank=True)` aus
42+
`accounts.User` streichen — Django erlaubt das Überschreiben von Feldern
43+
abstrakter Basisklassen, es kollidiert also nichts, und die Definition ist
44+
identisch (keine DB-Migration, `makemigrations --check` bleibt leer;
45+
gegen erkennbar geprüft). Tools auf `AbstractUser` (ausleihbar,
46+
abstimmbar, modulierbar) ändern nichts.
47+
3. Empfohlen: `config/urls.py` auf `path("", include("basicbar_auth.urls"))`
48+
plus eigenes `api/whoami/` umstellen und die lokalen `logout_view` /
49+
`set_language` löschen; `whoami` als `{**whoami_payload(request), …}`
50+
schreiben (Beispiel im README). abstimmbar: `accounts/oidc.py`
51+
(`SafeOIDCCallbackView`) und die eigene `oidc/callback/`-Route entfallen.
52+
ausleihbar: `SetLanguageView` (DRF) kann durch `set_language` ersetzt
53+
werden — gleiche URL, gleiche Antworten.
54+
4. Admin-Semantik prüfen: Wer sich darauf verlassen hat, dass der IdP-
55+
Gruppen-Verlust *jede* Admin-Rolle entzieht, muss lokale Beförderungen
56+
jetzt selbst zurücknehmen (Nutzerverwaltung / Django-Admin).
57+
1058
### @basicbar/ui (→ wird `ui/v0.7.0`)
1159

1260
**TipTap raus aus den Bundles, die keinen Editor rendern** (Framework-Review):

‎CLAUDE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ GitHub-Actions-Runner öffentlicher „-bar“-Tools die Pakete erreichen könne
1313
- `packages/django/basicbar-integrations` — LibreTranslate-/LiteLLM-Clients,
1414
Translate- und Capabilities-Endpoint, HTML-Allowlist (nh3).
1515
- `packages/django/basicbar-auth` — OIDC (mozilla-django-oidc): Backend,
16-
Silent Login, Back-Channel-Logout, Discovery, `AbstractBasicUser`,
17-
optionale Limits (`MAX_USERS`, Drift-Fallback). Betreiber-Doku im README.
16+
Silent Login, Back-Channel-Logout (mit Session-Index `UserSession`, eigene
17+
Migration), Discovery, `AbstractBasicUser`, Session-Endpunkte
18+
(`views.whoami_payload`/`logout_view`/`set_language`, `urls`), optionale
19+
Limits (`MAX_USERS`, Drift-Fallback). Betreiber-Doku im README.
1820
- `packages/django/basicbar-lti` — LTI-1.3-Fundament (PyLTI1p3):
1921
Plattform-Registrierung, Tool-Key, Provisionierung, Middleware.
2022
Launch/Deep-Linking bleiben Tool-Code.

‎packages/django/basicbar-auth/README.md‎

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,75 @@
33
OIDC-Fundament der „-bar“-Tools (auf Basis von mozilla-django-oidc):
44

55
- `oidc.OIDCBackend` — Claim-Mapping aufs Tool-User-Model, Just-in-time-
6-
Provisionierung, IdP-Gruppen → Django-Admin (autoritativ), optionale
7-
Account-Obergrenze und Subject-Drift-Fallback (beides aus per Default).
6+
Provisionierung, IdP-Gruppe → Django-Admin, optionale Account-Obergrenze
7+
und Subject-Drift-Fallback (beides aus per Default).
88
- `oidc.SilentLoginView` — Silent SSO (`prompt=none`, kein Redirect-Loop).
9+
- `oidc.SafeOIDCCallbackView` — mozillas Callback, der einen wiederholten
10+
Callback (Browser-„Zurück“ direkt nach dem Login) auf die SPA umleitet
11+
statt eine 400-Seite zu zeigen; loggt niemanden ein.
912
- `oidc.backchannel_logout` — OIDC Back-Channel Logout 1.0 (Token-Prüfung,
10-
löscht alle Sessions des Subjects).
13+
löscht alle Sessions des Subjects über den Session-Index).
1114
- `oidc.provider_logout_url`, `oidc.is_oidc_admin`, `oidc.claims_in_admin_group`
15+
- `views.whoami_payload` / `views.whoami`, `views.logout_view`,
16+
`views.set_language` — die Session-Endpunkte der SPA; `urls.urlpatterns`
17+
verdrahtet OIDC-Routen und `api/whoami/language/`.
1218
- `discovery.discover_endpoints` — Endpunkte aus `OIDC_OP_ISSUER` ableiten.
1319
- `models.AbstractBasicUser` — Basis fürs konkrete `accounts.User` des Tools
14-
(`subject` + `claims`; ADR-0003: Model und Migrationen bleiben im Tool).
20+
(`subject`, `claims`, `language`; ADR-0003: Model und Migrationen bleiben
21+
im Tool). `models.UserSession` — Index Nutzer → Session (eigene
22+
Migration im Paket).
1523

1624
## Einbinden
1725

1826
```python
19-
INSTALLED_APPS = [..., "basicbar_auth"]
27+
INSTALLED_APPS = [..., "basicbar_auth"] # hat seit 0.2 eine eigene Migration
2028
AUTHENTICATION_BACKENDS = [
2129
"basicbar_auth.oidc.OIDCBackend",
2230
"django.contrib.auth.backends.ModelBackend",
2331
]
2432
OIDC_OP_LOGOUT_URL_METHOD = "basicbar_auth.oidc.provider_logout_url"
2533

26-
# urls.py
27-
from basicbar_auth.oidc import SilentLoginView, backchannel_logout
28-
path("oidc/silent/", SilentLoginView.as_view(), name="oidc-silent"),
29-
path("oidc/backchannel-logout/", backchannel_logout, name="oidc-backchannel-logout"),
30-
path("oidc/", include("mozilla_django_oidc.urls")),
34+
# urls.py — logout-redirect, silent, backchannel-logout, callback, mozilla,
35+
# api/whoami/language/ in einem Rutsch; api/whoami/ bleibt Sache des Tools:
36+
path("", include("basicbar_auth.urls")),
37+
path("api/whoami/", whoami),
38+
39+
# accounts/models.py
40+
class User(AbstractBasicUser): # subject, claims, language kommen mit
41+
... # (ein eigenes, gleiches `language` darf bleiben)
42+
43+
# accounts/views.py — whoami mit Tool-Feldern:
44+
from basicbar_auth.views import whoami_payload
45+
def whoami(request):
46+
return JsonResponse({**whoami_payload(request), "ai_enabled": ai.is_enabled()})
3147
```
3248

49+
`whoami_payload` liefert `authenticated`, `csrf_token` und — angemeldet —
50+
`username`, `first_name`, `last_name`, `email`, `subject`, `is_staff`,
51+
`language`. Ohne eigene Felder reicht `basicbar_auth.views.whoami` direkt.
52+
3353
Claim-Namen (`OIDC_CLAIM_USERNAME`, …), `OIDC_GROUPS_CLAIM`/`OIDC_ADMIN_GROUP`
3454
und die optionalen Verhalten kommen aus Settings mit Paket-Defaults
35-
(`conf.py`). Bestehende Tools adoptieren ohne Umbau ihres User-Models.
55+
(`conf.py`). Bestehende Tools adoptieren ohne Umbau ihres User-Models
56+
(`AbstractUser` + identische Felder funktioniert weiter).
57+
58+
**Admin-Gruppe:** Mit `OIDC_ADMIN_GROUP` verleiht die Gruppenmitgliedschaft
59+
`is_staff`/`is_superuser` und ihr Verlust entzieht sie — aber nur Rechte,
60+
die aus der Gruppe kamen (erkennbar am Claims-Snapshot des letzten Logins).
61+
Eine Beförderung im Tool (Nutzerverwaltung, Django-Admin) überlebt den
62+
nächsten Login; `is_oidc_admin(user)` sagt dem Tool, welche Admins der IdP
63+
verwaltet (die darf das Tool lokal nicht entziehen).
64+
65+
**Session-Index:** `user_logged_in`/`user_logged_out` pflegen `UserSession`;
66+
der Back-Channel-Logout löscht darüber gezielt, statt jede unabgelaufene
67+
Session zu dekodieren (relevant bei vielen anonymen Besucher-Sessions).
68+
Sessions aus der Zeit vor 0.2 kennt der Index nicht — für sie bleibt der
69+
Scan als Fallback, bis sie ablaufen. Voraussetzung ist der DB-Session-Store.
70+
71+
**Discovery:** `discover_endpoints` loggt Fehler (WARNING) und liefert `{}`,
72+
damit der Settings-Import nicht abstürzt. Produktiv sollten die Settings bei
73+
leeren Endpunkten mit `ImproperlyConfigured` abbrechen (so das Template),
74+
sonst scheitert jeder Login später mit einem unklaren Fehler.
3675

3776
## Betreiber-Hinweise (Fristen, Kennungs-Wiedervergabe)
3877

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright 2026 Universität Osnabrück (virtUOS)
33

4-
__version__ = "0.1.1"
4+
__version__ = "0.2.0"

‎packages/django/basicbar-auth/basicbar_auth/apps.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@
77
class BasicbarAuthConfig(AppConfig):
88
name = "basicbar_auth"
99
verbose_name = "Basicbar Auth"
10+
# Pinned here so the package's own migration is the same in every tool,
11+
# whatever the project's DEFAULT_AUTO_FIELD.
12+
default_auto_field = "django.db.models.BigAutoField"
13+
14+
def ready(self):
15+
from . import signals # noqa: F401 — connects the session-index receivers

‎packages/django/basicbar-auth/basicbar_auth/discovery.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,29 @@
77
talk to any compliant OIDC provider — no per-endpoint configuration needed.
88
"""
99
import json
10+
import logging
1011
import urllib.request
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
def discover_endpoints(issuer, timeout=5):
1417
"""Fetch ``{issuer}/.well-known/openid-configuration``.
1518
16-
Returns the parsed document, or an empty dict on any failure (so settings
17-
import never crashes if the provider is temporarily unreachable).
19+
Returns the parsed document, or an empty dict on any failure, so a
20+
settings import never crashes because the provider is temporarily
21+
unreachable — the failure is logged, and the settings should refuse to
22+
start without ``DEBUG`` when the endpoints end up empty (the template
23+
does), otherwise every login fails with an opaque error later.
1824
"""
1925
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
2026
try:
2127
with urllib.request.urlopen(url, timeout=timeout) as response:
22-
return json.load(response)
23-
except Exception:
28+
document = json.load(response)
29+
except Exception as exc: # noqa: BLE001 — network, HTTP, JSON: all "no document"
30+
logger.warning("OIDC discovery failed for %s: %s", url, exc)
31+
return {}
32+
if not isinstance(document, dict):
33+
logger.warning("OIDC discovery for %s returned no JSON object", url)
2434
return {}
35+
return document
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Universität Osnabrück (virtUOS)
3+
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="UserSession",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
24+
),
25+
),
26+
("session_key", models.CharField(max_length=40)),
27+
("created_at", models.DateTimeField(auto_now_add=True)),
28+
(
29+
"user",
30+
models.ForeignKey(
31+
on_delete=django.db.models.deletion.CASCADE,
32+
related_name="basicbar_sessions",
33+
to=settings.AUTH_USER_MODEL,
34+
),
35+
),
36+
],
37+
options={
38+
"constraints": [
39+
models.UniqueConstraint(
40+
fields=("session_key",), name="basicbar_auth_usersession_key_uniq"
41+
)
42+
],
43+
},
44+
),
45+
]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Universität Osnabrück (virtUOS)

‎packages/django/basicbar-auth/basicbar_auth/models.py‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright 2026 Universität Osnabrück (virtUOS)
33

4+
from django.conf import settings
45
from django.contrib.auth.models import AbstractUser
56
from django.db import models
67

78

89
class AbstractBasicUser(AbstractUser):
9-
"""Base for the tools' user models: Django's ``AbstractUser`` plus the two
10-
fields the shared OIDC machinery relies on.
10+
"""Base for the tools' user models: Django's ``AbstractUser`` plus the
11+
fields the shared OIDC machinery and session endpoints rely on.
1112
1213
Each tool defines its own concrete ``accounts.User(AbstractBasicUser)``
1314
with its own fields and migrations (see basicbar ADR-0003) — tool-specific
@@ -22,5 +23,42 @@ class AbstractBasicUser(AbstractUser):
2223
# mappings and claim-based access rules.
2324
claims = models.JSONField(default=dict, blank=True)
2425

26+
# Preferred UI (and e-mail) language, set from the SPA via
27+
# ``basicbar_auth.views.set_language``; blank = site default.
28+
language = models.CharField(max_length=10, blank=True)
29+
2530
class Meta(AbstractUser.Meta):
2631
abstract = True
32+
33+
34+
class UserSession(models.Model):
35+
"""Index from user to live Django session, maintained by the
36+
``user_logged_in`` / ``user_logged_out`` signals (see ``signals.py``).
37+
38+
Django's DB session store has no such index — the only way to find a
39+
user's sessions is to decode every unexpired session row. The back-channel
40+
logout needs exactly that lookup, and a tool with many anonymous visitor
41+
sessions (participants, public catalogue) would pay for all of them on
42+
every SSO logout. Rows are dropped when the user logs out, when the
43+
back-channel logout acts on them, and — for sessions that expired
44+
silently — lazily on the next lookup for that user.
45+
"""
46+
47+
user = models.ForeignKey(
48+
settings.AUTH_USER_MODEL,
49+
on_delete=models.CASCADE,
50+
related_name="basicbar_sessions",
51+
)
52+
session_key = models.CharField(max_length=40)
53+
created_at = models.DateTimeField(auto_now_add=True)
54+
55+
class Meta:
56+
constraints = [
57+
# Package-prefixed name: Postgres constraint names are schema-wide.
58+
models.UniqueConstraint(
59+
fields=["session_key"], name="basicbar_auth_usersession_key_uniq"
60+
),
61+
]
62+
63+
def __str__(self):
64+
return f"{self.user_id}:{self.session_key[:8]}…"

0 commit comments

Comments
 (0)