Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 50 additions & 11 deletions packages/django/basicbar-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion packages/django/basicbar-auth/basicbar_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions packages/django/basicbar-auth/basicbar_auth/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 15 additions & 4 deletions packages/django/basicbar-auth/basicbar_auth/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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"
)
],
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)
42 changes: 40 additions & 2 deletions packages/django/basicbar-auth/basicbar_auth/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]}…"
Loading
Loading