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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ Uhren zwischen IdP und Tool ggf. `OIDC_BACKCHANNEL_MAX_AGE` erhöhen.

### Template

- **Pins:** basicbar-auth `auth/v0.2.0`, @basicbar/ui `ui/v0.7.0`.
- `config/urls.py` nutzt `include("basicbar_auth.urls")` (OIDC-Routen inkl.
tolerantem Callback, `api/whoami/language/`); `accounts/views.py` enthält
nur noch `whoami` als `{**whoami_payload(request), …Feature-Flags}` —
`logout_view`/`set_language` kommen aus dem Paket. `accounts.User` hat kein
eigenes `language` mehr (geerbt, Migration unverändert).
- Settings brechen produktiv mit `ImproperlyConfigured` ab, wenn
`OIDC_OP_ISSUER` gesetzt ist, die Discovery aber keine Endpunkte lieferte
(sonst scheitert jeder Login später mit unklarem Fehler).
- `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES` nur noch
`SessionAuthentication` — DRFs Default aktiviert `BasicAuthentication`, die
mit dem Break-glass-Superuser (ModelBackend) jeden Endpunkt für
Expand Down
7 changes: 2 additions & 5 deletions template/project/backend/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,11 @@
normal migrations.
"""
from basicbar_auth.models import AbstractBasicUser
from django.db import models


class User(AbstractBasicUser):
"""Application user (``subject``/``claims`` come from AbstractBasicUser)."""

# Preferred UI language ("en"/"de"), set from the SPA; blank = site default.
language = models.CharField(max_length=10, blank=True)
"""Application user (``subject``, ``claims`` and the UI ``language`` come
from AbstractBasicUser)."""

def __str__(self):
return self.get_username()
85 changes: 15 additions & 70 deletions template/project/backend/accounts/views.py
Original file line number Diff line number Diff line change
@@ -1,84 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)

"""Session/identity endpoints for the SPA."""
import json
"""Session/identity endpoints for the SPA.

from basicbar_auth.oidc import provider_logout_url
``logout_view`` and ``set_language`` come from basicbar-auth (wired by
``basicbar_auth.urls``); only ``whoami`` is the tool's own, because it adds
the tool's feature flags to the shared payload.
"""
from basicbar_auth.views import whoami_payload
from basicbar_integrations import ai, translation_service
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


def whoami(request):
"""Return the current session user (for the SPA to check login state).

Also returns the CSRF token in the body: ``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 Caddy)."""
csrf_token = get_token(request)
user = request.user
# Content-i18n config: the default/canonical authoring language and
# whether machine-translation drafts are available, so the SPA can
# decide which language to show/edit without a second round-trip.
common = {
"csrf_token": csrf_token,
"ai_enabled": ai.is_enabled(),
"content_default_language": settings.MODELTRANSLATION_DEFAULT_LANGUAGE,
"content_translation_enabled": translation_service.is_enabled(),
}
if not user.is_authenticated:
return JsonResponse({"authenticated": False, **common})
"""Return the current session user (for the SPA to check login state),
plus the feature flags the SPA needs before its first real request."""
return JsonResponse(
{
"authenticated": True,
"username": user.get_username(),
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"subject": user.subject,
"is_staff": user.is_staff,
"language": user.language,
**common,
**whoami_payload(request),
"ai_enabled": ai.is_enabled(),
# Content-i18n config: the default/canonical authoring language and
# whether machine-translation drafts are available, so the SPA can
# decide which language to show/edit without a second round-trip.
"content_default_language": settings.MODELTRANSLATION_DEFAULT_LANGUAGE,
"content_translation_enabled": translation_service.is_enabled(),
}
)


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.
"""
was_authenticated = request.user.is_authenticated
end_session_url = None
if was_authenticated and 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} — remember the user's UI language.

Plain Django view (matches ``whoami``); the SPA sends its CSRF token from
``whoami`` so the request passes CSRF as elsewhere.
"""
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})
11 changes: 11 additions & 0 deletions template/project/backend/config/settings.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,17 @@ if OIDC_OP_ISSUER and not OIDC_OP_AUTHORIZATION_ENDPOINT:
OIDC_OP_JWKS_ENDPOINT = OIDC_OP_JWKS_ENDPOINT or _discovered.get("jwks_uri", "")
OIDC_OP_LOGOUT_ENDPOINT = OIDC_OP_LOGOUT_ENDPOINT or _discovered.get("end_session_endpoint", "")

# Fail fast in production: with an issuer configured but no endpoints (the
# discovery document was unreachable or incomplete — see the WARNING from
# basicbar_auth.discovery), every login would otherwise fail later with an
# opaque error. In DEBUG the dev stack may simply not have Keycloak up yet.
if OIDC_OP_ISSUER and not DEBUG and not (OIDC_OP_AUTHORIZATION_ENDPOINT and OIDC_OP_TOKEN_ENDPOINT):
raise ImproperlyConfigured(
f"OIDC_OP_ISSUER={OIDC_OP_ISSUER!r} is set, but the OIDC endpoints could not be "
"resolved — the provider's discovery document was unreachable. Set the "
"OIDC_OP_*_ENDPOINT variables explicitly or fix the issuer URL."
)

OIDC_OP_LOGOUT_URL_METHOD = "basicbar_auth.oidc.provider_logout_url"

# Claim mapping (provider-agnostic; defaults are standard OIDC claim names).
Expand Down
14 changes: 3 additions & 11 deletions template/project/backend/config/urls.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,18 @@
# Copyright 2026 Universität Osnabrück (virtUOS)

"""Root URL configuration."""
from basicbar_auth.oidc import SilentLoginView, backchannel_logout
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path

from accounts.views import logout_view, set_language, whoami
from accounts.views import whoami

urlpatterns = [
path("admin/", admin.site.urls),
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",
),
path("oidc/", include("mozilla_django_oidc.urls")),
# OIDC login/logout/silent/back-channel/callback and api/whoami/language/.
path("", include("basicbar_auth.urls")),
path("api/whoami/", whoami),
path("api/whoami/language/", set_language),
]

# Serve uploaded media and static files during local development. (Static
Expand Down
2 changes: 1 addition & 1 deletion template/project/backend/requirements.txt.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ djangorestframework==3.15.2
# Gemeinsame Basis der -bar-Tools; Versionswechsel = Tag in der URL heben,
# Migrationsschritte im CHANGELOG von https://github.com/virtUOS/basicbar.
basicbar-integrations @ https://github.com/virtUOS/basicbar/archive/refs/tags/integrations/v0.2.2.tar.gz#subdirectory=packages/django/basicbar-integrations
basicbar-auth @ https://github.com/virtUOS/basicbar/archive/refs/tags/auth/v0.1.1.tar.gz#subdirectory=packages/django/basicbar-auth
basicbar-auth @ https://github.com/virtUOS/basicbar/archive/refs/tags/auth/v0.2.0.tar.gz#subdirectory=packages/django/basicbar-auth
{% if use_lti -%}
basicbar-lti @ https://github.com/virtUOS/basicbar/archive/refs/tags/lti/v0.1.4.tar.gz#subdirectory=packages/django/basicbar-lti
{% endif -%}
Expand Down
2 changes: 1 addition & 1 deletion template/project/frontend/package.json.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@basicbar/ui": "https://github.com/virtUOS/basicbar/releases/download/ui/v0.6.0/basicbar-ui-0.6.0.tgz",
"@basicbar/ui": "https://github.com/virtUOS/basicbar/releases/download/ui/v0.7.0/basicbar-ui-0.7.0.tgz",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
Expand Down
Loading