From 97ce26fb510c87cd2d26b377b652d9af8ec01808 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Wed, 25 Feb 2026 01:33:20 +0100 Subject: [PATCH 1/8] Testing with local keycloak --- Keycloak-installation.md | 176 +++++++++++++++++++++++++++++++++ labhelper/auth_backend.py | 54 ++++++++++ labhelper/settings.py | 42 +++++++- labhelper/templates/base.html | 2 +- labhelper/templates/login.html | 72 +++----------- labhelper/urls.py | 8 +- requirements.txt | 1 + 7 files changed, 289 insertions(+), 66 deletions(-) create mode 100644 Keycloak-installation.md create mode 100644 labhelper/auth_backend.py diff --git a/Keycloak-installation.md b/Keycloak-installation.md new file mode 100644 index 0000000..4f8b508 --- /dev/null +++ b/Keycloak-installation.md @@ -0,0 +1,176 @@ +# Keycloak SSO Integration + +This document describes how Keycloak SSO was integrated into labhelper, replacing the built-in Django username/password authentication. + +## Overview + +Authentication is handled via OpenID Connect (OIDC) using the `mozilla-django-oidc` library. When a user visits any protected page, they are redirected to Keycloak to authenticate. On return, Keycloak group memberships are synced to Django groups, controlling what the user can do in the app. + +--- + +## Keycloak Setup + +### 1. Create a client + +In the Keycloak admin console, go to **Clients → Create client**. + +| Field | Value | Why | +|---|---|---| +| Client type | OpenID Connect | The protocol mozilla-django-oidc speaks | +| Client ID | `labhelper` | Must match `OIDC_RP_CLIENT_ID` in the app | +| Client authentication | On (confidential) | Server-side apps use a client secret; this is more secure than a public client | +| Authentication flow | Standard flow only | labhelper uses the standard authorisation code flow | + +After saving, go to the **Credentials** tab and copy the **Client secret** — this is `OIDC_RP_CLIENT_SECRET`. + +### 2. Set the redirect URI + +In the client **Settings** tab: + +| Field | Value | Why | +|---|---|---| +| Valid redirect URIs | `https://your-app/oidc/callback/` | Keycloak will only redirect back to whitelisted URIs after authentication. The trailing slash is required — without it Keycloak rejects the request. For local dev also add `http://127.0.0.1:8000/oidc/callback/` | + +### 3. Set the PKCE code challenge method + +In the client **Advanced** tab, find **"Proof Key for Code Exchange Code Challenge Method"** and set it to **S256**. + +Why: Keycloak 26 configures new clients with PKCE enforced. `mozilla-django-oidc` sends `S256` as the challenge method (the more secure option). If Keycloak is set to `plain`, the two sides don't agree and authentication fails with `code challenge method is not matching the configured one`. + +### 4. Add a Groups mapper + +This makes Keycloak include the user's group memberships in the token so the app can sync them to Django groups. + +Go to **Clients → labhelper → Client scopes** tab → click the dedicated scope (named `labhelper-dedicated`) → **Add mapper → By configuration → Group Membership**. + +| Field | Value | Why | +|---|---|---| +| Name | `groups` | Label for this mapper | +| Token Claim Name | `groups` | The claim name the app reads from the token | +| Full group path | Off | Sends `Lab Administrators` instead of `/Lab Administrators`. The app strips leading slashes anyway, but this is cleaner | +| Add to ID token | On | | +| Add to access token | On | | +| Add to userinfo | On | The app fetches userinfo after the token exchange | + +### 5. Create groups + +Go to **Groups** (left sidebar) and create these three groups with exactly these names — they map to the existing Django groups: + +- `Lab Administrators` — gets `is_staff=True` in Django (admin access) +- `Lab Staff` +- `Lab Viewers` + +### 6. Ensure users have an email address + +`mozilla-django-oidc` requires the `email` claim to be present in the token. Every Keycloak user who will log into labhelper must have: + +- An **Email** address set (Users → select user → Details tab) +- **Email verified** ticked + +Without an email, authentication fails silently with `Claims verification failed` in the Django logs. + +--- + +## App Configuration + +The app is configured entirely via environment variables. + +### Required variables + +```bash +OIDC_OP_BASE_URL=https://keycloak.example.com/realms/your-realm +OIDC_RP_CLIENT_ID=labhelper +OIDC_RP_CLIENT_SECRET= +``` + +`OIDC_OP_BASE_URL` is the realm URL. All OIDC endpoints (authorisation, token, userinfo, JWKS, logout) are derived from it automatically in `settings.py`. + +### Other relevant variables + +```bash +ALLOWED_HOSTS=your-app-hostname +CSRF_TRUSTED_ORIGINS=https://your-app +``` + +`CSRF_TRUSTED_ORIGINS` must include the app's origin. The OIDC callback goes through Django's CSRF middleware, and requests from untrusted origins are rejected. + +--- + +## How the App Side Works + +### Library + +`mozilla-django-oidc` handles the full OIDC flow: redirecting to Keycloak, validating the returned token (RS256 signature verified against Keycloak's JWKS endpoint), exchanging the authorisation code, and fetching userinfo. + +### Key settings + +| Setting | Value | Why | +|---|---|---| +| `OIDC_RP_SIGN_ALGO` | `RS256` | Keycloak signs tokens with RS256 by default | +| `OIDC_RP_SCOPES` | `openid email profile` | `profile` is needed to get `preferred_username`, `given_name`, `family_name` from Keycloak | +| `OIDC_USE_PKCE` | `True` | Required because Keycloak enforces PKCE on this client | +| `OIDC_STORE_ID_TOKEN` | `True` | The ID token is stored in the session and passed as `id_token_hint` when logging out, so Keycloak also ends its own session | +| `OIDC_EXEMPT_URLS` | `['search_api']` | The search endpoint is called via AJAX. The `SessionRefresh` middleware would return a redirect instead of JSON for unauthenticated AJAX calls, breaking the UI | +| `LOGIN_URL` | `oidc_authentication_init` | When `@login_required` intercepts an unauthenticated request, it redirects directly to the OIDC flow rather than a local login form | + +### Authentication backend (`labhelper/auth_backend.py`) + +Overrides `OIDCAuthenticationBackend` to: + +- Use `preferred_username` from Keycloak as the Django username +- Set `first_name` and `last_name` from `given_name` / `family_name` claims +- Sync group memberships on every login — if a user is added to or removed from a Keycloak group, it takes effect at their next login +- Set `is_staff=True` for members of `Lab Administrators` (grants Django admin access) + +`django.contrib.auth.backends.ModelBackend` is kept as a fallback so the Django admin login form still works with a local username/password (useful for emergency superuser access without Keycloak). + +### Session refresh middleware + +`mozilla_django_oidc.middleware.SessionRefresh` is added after `AuthenticationMiddleware`. It periodically checks whether the user's OIDC session is still valid and forces re-authentication if the token has expired, rather than keeping a stale Django session alive indefinitely. + +### URLs + +All OIDC routes are mounted under `/oidc/`: + +| URL | Purpose | +|---|---| +| `/oidc/authenticate/` | Initiates the OIDC flow, redirects to Keycloak | +| `/oidc/callback/` | Keycloak redirects here after authentication | +| `/oidc/logout/` | Logs out of Django and ends the Keycloak session | + +`/login/` is kept as a static landing page with a "Login with SSO" button, used when users navigate to it manually or are redirected there after logout. + +--- + +## Auth Flow + +``` +User visits protected page + ↓ +@login_required → redirect to /oidc/authenticate/?next=/original/url/ + ↓ +Redirect to Keycloak (with code_challenge for PKCE) + ↓ +User authenticates in Keycloak + ↓ +Keycloak redirects to /oidc/callback/?code=... + ↓ +App exchanges code for tokens, verifies RS256 signature + ↓ +App fetches userinfo, syncs groups and attributes + ↓ +User redirected to original URL +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `Invalid parameter: redirect_uri` | Redirect URI not in Keycloak whitelist, or trailing slash missing | Add exact URI including trailing slash to Keycloak client Valid Redirect URIs | +| `Missing parameter: code_challenge_method` | Keycloak requires PKCE but app wasn't sending it | Set `OIDC_USE_PKCE = True` in settings | +| `code challenge method is not matching the configured one` | Keycloak client set to `plain`, app sends `S256` | Set PKCE method to `S256` in Keycloak client Advanced settings | +| `Claims verification failed` | User has no email set in Keycloak | Set email address and tick Email Verified on the Keycloak user | +| `NoReverseMatch` for `OIDC_EXEMPT_URLS` | Regex pattern used instead of URL name | Use the Django URL name (`'search_api'`), not a regex | +| Login loops without showing Keycloak | Existing Keycloak session auto-authenticates | Expected behaviour — Keycloak reuses its session. Log out of Keycloak admin console to test a clean login | diff --git a/labhelper/auth_backend.py b/labhelper/auth_backend.py new file mode 100644 index 0000000..f72c422 --- /dev/null +++ b/labhelper/auth_backend.py @@ -0,0 +1,54 @@ +from mozilla_django_oidc.auth import OIDCAuthenticationBackend +from django.contrib.auth.models import Group + +# Keycloak group name → Django group name mapping. +# Keycloak may send group paths with a leading slash (e.g. "/Lab Administrators"); +# these are stripped before comparison. +KEYCLOAK_GROUP_MAP = { + 'Lab Administrators': 'Lab Administrators', + 'Lab Staff': 'Lab Staff', + 'Lab Viewers': 'Lab Viewers', +} + +# Members of these groups receive is_staff=True (Django admin access) +STAFF_GROUPS = {'Lab Administrators'} + + +class KeycloakOIDCBackend(OIDCAuthenticationBackend): + """OIDC backend that maps Keycloak groups to Django groups on every login.""" + + def get_username(self, claims): + return claims.get('preferred_username') or super().get_username(claims) + + def create_user(self, claims): + user = super().create_user(claims) + self._sync_from_claims(user, claims) + return user + + def update_user(self, user, claims): + user = super().update_user(user, claims) + self._sync_from_claims(user, claims) + return user + + def _sync_from_claims(self, user, claims): + """Sync user attributes and group memberships from Keycloak token claims.""" + user.first_name = claims.get('given_name', user.first_name) + user.last_name = claims.get('family_name', user.last_name) + + # Keycloak sends group paths like "/Lab Administrators"; normalise them. + raw_groups = claims.get('groups', []) + keycloak_groups = {g.lstrip('/') for g in raw_groups} + + user.is_staff = bool(keycloak_groups & STAFF_GROUPS) + user.save() + + # Add/remove the user from each managed Django group to match Keycloak. + for kc_group, django_group_name in KEYCLOAK_GROUP_MAP.items(): + try: + group = Group.objects.get(name=django_group_name) + except Group.DoesNotExist: + continue + if kc_group in keycloak_groups: + user.groups.add(group) + else: + user.groups.remove(group) diff --git a/labhelper/settings.py b/labhelper/settings.py index 8ba9855..3ccd0ff 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -39,6 +39,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'mozilla_django_oidc', 'mptt', 'django_mptt_admin', 'sorl.thumbnail', @@ -52,6 +53,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'mozilla_django_oidc.middleware.SessionRefresh', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -137,8 +139,44 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com').split(',') +CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',') -LOGIN_URL = os.environ.get('LOGIN_URL', 'login') +LOGIN_URL = os.environ.get('LOGIN_URL', 'oidc_authentication_init') LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index') LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login') + +AUTHENTICATION_BACKENDS = [ + 'labhelper.auth_backend.KeycloakOIDCBackend', + # ModelBackend kept as fallback for Django admin emergency access + 'django.contrib.auth.backends.ModelBackend', +] + +# --------------------------------------------------------------------------- +# Keycloak / OIDC configuration +# +# Set OIDC_OP_BASE_URL to your realm URL, e.g.: +# https://keycloak.example.com/realms/myrealm +# +# All individual endpoints are derived from OIDC_OP_BASE_URL automatically. +# You can override any individual endpoint with its own env var. +# --------------------------------------------------------------------------- +_oidc_base = "http://127.0.0.1:8080/realms/master" +_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else '' + +OIDC_RP_CLIENT_ID = "labhelper" +OIDC_RP_CLIENT_SECRET = "NnDDaJfbQlBSHV1z1H2cCiaubLyuQcgY" +OIDC_RP_SIGN_ALGO = 'RS256' +OIDC_RP_SCOPES = 'openid email profile' +OIDC_USE_PKCE = True + +OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth') +OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token') +OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', f'{_oidc_connect}/userinfo') +OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs') +OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout') + +# Store the ID token in the session so Keycloak logout can use id_token_hint +OIDC_STORE_ID_TOKEN = True + +# Exempt AJAX endpoints from the session-refresh middleware redirect +OIDC_EXEMPT_URLS = ['search_api'] diff --git a/labhelper/templates/base.html b/labhelper/templates/base.html index 52425fa..4c02860 100644 --- a/labhelper/templates/base.html +++ b/labhelper/templates/base.html @@ -33,7 +33,7 @@ Resources Fixme Admin -
+ {% csrf_token %}
diff --git a/labhelper/templates/login.html b/labhelper/templates/login.html index 011dc9d..c19eae8 100644 --- a/labhelper/templates/login.html +++ b/labhelper/templates/login.html @@ -9,70 +9,24 @@ {% endblock %} {% block content %} -
- {% if form.errors %} -
- Your username and password didn't match. Please try again. +
+ {% if request.GET.next and user.is_authenticated %} +
+ Your account doesn't have access to this page.
- {% endif %} - - {% if next %} - {% if user.is_authenticated %} -
- Your account doesn't have access to this page. To proceed, - please login with an account that has access. -
- {% else %} -
{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/labhelper/urls.py b/labhelper/urls.py index ed99266..f9c6ecc 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -17,8 +17,8 @@ Including another URLconf from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import path -from django.contrib.auth import views as auth_views +from django.urls import include, path +from django.views.generic import TemplateView from boxes.views import ( add_box, @@ -40,8 +40,8 @@ from boxes.views import ( ) urlpatterns = [ - path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'), - path('logout/', auth_views.LogoutView.as_view(), name='logout'), + path('oidc/', include('mozilla_django_oidc.urls')), + path('login/', TemplateView.as_view(template_name='login.html'), name='login'), path('', index, name='index'), path('box-management/', box_management, name='box_management'), path('box-type/add/', add_box_type, name='add_box_type'), diff --git a/requirements.txt b/requirements.txt index 16deb09..02f606a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,4 @@ sorl-thumbnail==12.11.0 bleach==6.1.0 coverage==7.6.1 whitenoise==6.8.2 +mozilla-django-oidc==4.0.1 From 450ff488ea575d7d0ece515fe43ed288dc10e611 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Wed, 25 Feb 2026 01:43:23 +0100 Subject: [PATCH 2/8] Hardcoded bits removed, Documentation updated. All hail the muse of epic commits! --- Keycloak-installation.md | 31 +++++++++++++++++++++++++++++++ labhelper/settings.py | 6 +++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Keycloak-installation.md b/Keycloak-installation.md index 4f8b508..7a44509 100644 --- a/Keycloak-installation.md +++ b/Keycloak-installation.md @@ -174,3 +174,34 @@ User redirected to original URL | `Claims verification failed` | User has no email set in Keycloak | Set email address and tick Email Verified on the Keycloak user | | `NoReverseMatch` for `OIDC_EXEMPT_URLS` | Regex pattern used instead of URL name | Use the Django URL name (`'search_api'`), not a regex | | Login loops without showing Keycloak | Existing Keycloak session auto-authenticates | Expected behaviour — Keycloak reuses its session. Log out of Keycloak admin console to test a clean login | + +--- + +## Kubernetes Deployment + +Split the configuration across a ConfigMap and a Secret. The client secret must not go in a ConfigMap as the contents are visible in plain text to anyone with cluster access. + +**ConfigMap** +```yaml +data: + OIDC_OP_BASE_URL: https://keycloak.example.com/realms/your-realm + OIDC_RP_CLIENT_ID: labhelper + CSRF_TRUSTED_ORIGINS: https://labhelper.adebaumann.com + ALLOWED_HOSTS: labhelper.adebaumann.com +``` + +**Secret** +```yaml +stringData: + OIDC_RP_CLIENT_SECRET: + DJANGO_SECRET_KEY: +``` + +Reference both in the deployment: +```yaml +envFrom: + - configMapRef: + name: labhelper-config + - secretRef: + name: labhelper-secret +``` diff --git a/labhelper/settings.py b/labhelper/settings.py index 3ccd0ff..4e4c46c 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -160,11 +160,11 @@ AUTHENTICATION_BACKENDS = [ # All individual endpoints are derived from OIDC_OP_BASE_URL automatically. # You can override any individual endpoint with its own env var. # --------------------------------------------------------------------------- -_oidc_base = "http://127.0.0.1:8080/realms/master" +_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/') _oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else '' -OIDC_RP_CLIENT_ID = "labhelper" -OIDC_RP_CLIENT_SECRET = "NnDDaJfbQlBSHV1z1H2cCiaubLyuQcgY" +OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', '') +OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '') OIDC_RP_SIGN_ALGO = 'RS256' OIDC_RP_SCOPES = 'openid email profile' OIDC_USE_PKCE = True From 88ff6ddae5c58e115c0d93491ce5f7399e369458 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Wed, 25 Feb 2026 21:31:52 +0100 Subject: [PATCH 3/8] Testing with real Keycloak --- argocd/configmap.yaml | 6 ++++-- argocd/deployment.yaml | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/argocd/configmap.yaml b/argocd/configmap.yaml index 0d6c54e..1f8ebc2 100644 --- a/argocd/configmap.yaml +++ b/argocd/configmap.yaml @@ -14,8 +14,10 @@ data: STATIC_URL: "/static/" MEDIA_URL: "/media/" CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com" - LOGIN_URL: "login" + LOGIN_URL: "oidc_authentication_init" + OIDC_OP_BASE_URL: "https://sso.baumann.gr/realms/homelab" + OIDC_RP_CLIENT_ID: "labhelper" LOGIN_REDIRECT_URL: "index" LOGOUT_REDIRECT_URL: "login" GUNICORN_OPTS: "--access-logfile -" - IMAGE_TAG: "0.076" + IMAGE_TAG: "0.077" diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index b6f6f3c..f9643eb 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.076 + image: git.baumann.gr/adebaumann/labhelper:0.077 imagePullPolicy: Always ports: - containerPort: 8000 @@ -92,6 +92,21 @@ spec: configMapKeyRef: name: django-config key: LOGIN_URL + - name: OIDC_OP_BASE_URL + valueFrom: + configMapKeyRef: + name: django-config + key: OIDC_OP_BASE_URL + - name: OIDC_RP_CLIENT_ID + valueFrom: + configMapKeyRef: + name: django-config + key: OIDC_RP_CLIENT_ID + - name: OIDC_RP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: django-secret + key: oidc-client-secret - name: LOGIN_REDIRECT_URL valueFrom: configMapKeyRef: From 4ad03403aa303a36874d101023a4cb103a7fb409 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Wed, 25 Feb 2026 22:24:44 +0100 Subject: [PATCH 4/8] Keycloak shenanigans --- Keycloak-installation.md | 10 +++++----- argocd/configmap.yaml | 5 +++-- argocd/deployment.yaml | 7 ++++++- labhelper/auth_backend.py | 12 ++++++------ .../management/commands/create_default_users.py | 12 ++++++------ labhelper/settings.py | 3 +++ 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Keycloak-installation.md b/Keycloak-installation.md index 7a44509..d47277f 100644 --- a/Keycloak-installation.md +++ b/Keycloak-installation.md @@ -47,7 +47,7 @@ Go to **Clients → labhelper → Client scopes** tab → click the dedicated sc |---|---|---| | Name | `groups` | Label for this mapper | | Token Claim Name | `groups` | The claim name the app reads from the token | -| Full group path | Off | Sends `Lab Administrators` instead of `/Lab Administrators`. The app strips leading slashes anyway, but this is cleaner | +| Full group path | Off | Sends `LabHelper Administrators` instead of `/LabHelper Administrators`. The app strips leading slashes anyway, but this is cleaner | | Add to ID token | On | | | Add to access token | On | | | Add to userinfo | On | The app fetches userinfo after the token exchange | @@ -56,9 +56,9 @@ Go to **Clients → labhelper → Client scopes** tab → click the dedicated sc Go to **Groups** (left sidebar) and create these three groups with exactly these names — they map to the existing Django groups: -- `Lab Administrators` — gets `is_staff=True` in Django (admin access) -- `Lab Staff` -- `Lab Viewers` +- `LabHelper Administrators` — gets `is_staff=True` in Django (admin access) +- `LabHelper Staff` +- `LabHelper Viewers` ### 6. Ensure users have an email address @@ -120,7 +120,7 @@ Overrides `OIDCAuthenticationBackend` to: - Use `preferred_username` from Keycloak as the Django username - Set `first_name` and `last_name` from `given_name` / `family_name` claims - Sync group memberships on every login — if a user is added to or removed from a Keycloak group, it takes effect at their next login -- Set `is_staff=True` for members of `Lab Administrators` (grants Django admin access) +- Set `is_staff=True` for members of `LabHelper Administrators` (grants Django admin access) `django.contrib.auth.backends.ModelBackend` is kept as a fallback so the Django admin login form still works with a local username/password (useful for emergency superuser access without Keycloak). diff --git a/argocd/configmap.yaml b/argocd/configmap.yaml index 1f8ebc2..7f2eff0 100644 --- a/argocd/configmap.yaml +++ b/argocd/configmap.yaml @@ -18,6 +18,7 @@ data: OIDC_OP_BASE_URL: "https://sso.baumann.gr/realms/homelab" OIDC_RP_CLIENT_ID: "labhelper" LOGIN_REDIRECT_URL: "index" - LOGOUT_REDIRECT_URL: "login" + LOGOUT_REDIRECT_URL: "/login/" + OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/" GUNICORN_OPTS: "--access-logfile -" - IMAGE_TAG: "0.077" + IMAGE_TAG: "0.078" diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index f9643eb..01be0f2 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.077 + image: git.baumann.gr/adebaumann/labhelper:0.078 imagePullPolicy: Always ports: - containerPort: 8000 @@ -117,6 +117,11 @@ spec: configMapKeyRef: name: django-config key: LOGOUT_REDIRECT_URL + - name: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL + valueFrom: + configMapKeyRef: + name: django-config + key: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL - name: GUNICORN_OPTS valueFrom: configMapKeyRef: diff --git a/labhelper/auth_backend.py b/labhelper/auth_backend.py index f72c422..14376a7 100644 --- a/labhelper/auth_backend.py +++ b/labhelper/auth_backend.py @@ -2,16 +2,16 @@ from mozilla_django_oidc.auth import OIDCAuthenticationBackend from django.contrib.auth.models import Group # Keycloak group name → Django group name mapping. -# Keycloak may send group paths with a leading slash (e.g. "/Lab Administrators"); +# Keycloak may send group paths with a leading slash (e.g. "/LabHelper Administrators"); # these are stripped before comparison. KEYCLOAK_GROUP_MAP = { - 'Lab Administrators': 'Lab Administrators', - 'Lab Staff': 'Lab Staff', - 'Lab Viewers': 'Lab Viewers', + 'LabHelper Administrators': 'LabHelper Administrators', + 'LabHelper Staff': 'LabHelper Staff', + 'LabHelper Viewers': 'LabHelper Viewers', } # Members of these groups receive is_staff=True (Django admin access) -STAFF_GROUPS = {'Lab Administrators'} +STAFF_GROUPS = {'LabHelper Administrators'} class KeycloakOIDCBackend(OIDCAuthenticationBackend): @@ -35,7 +35,7 @@ class KeycloakOIDCBackend(OIDCAuthenticationBackend): user.first_name = claims.get('given_name', user.first_name) user.last_name = claims.get('family_name', user.last_name) - # Keycloak sends group paths like "/Lab Administrators"; normalise them. + # Keycloak sends group paths like "/LabHelper Administrators"; normalise them. raw_groups = claims.get('groups', []) keycloak_groups = {g.lstrip('/') for g in raw_groups} diff --git a/labhelper/management/commands/create_default_users.py b/labhelper/management/commands/create_default_users.py index eedd780..d68e2d5 100644 --- a/labhelper/management/commands/create_default_users.py +++ b/labhelper/management/commands/create_default_users.py @@ -9,9 +9,9 @@ class Command(BaseCommand): self.stdout.write('Creating default users and groups...') groups = { - 'Lab Administrators': 'Full access to all lab functions', - 'Lab Staff': 'Can view and search items, add things to boxes', - 'Lab Viewers': 'Read-only access to view and search', + 'LabHelper Administrators': 'Full access to all lab functions', + 'LabHelper Staff': 'Can view and search items, add things to boxes', + 'LabHelper Viewers': 'Read-only access to view and search', } for group_name, description in groups.items(): @@ -22,9 +22,9 @@ class Command(BaseCommand): self.stdout.write(f'Group already exists: {group_name}') users = { - 'admin': ('Lab Administrators', True), - 'staff': ('Lab Staff', False), - 'viewer': ('Lab Viewers', False), + 'admin': ('LabHelper Administrators', True), + 'staff': ('LabHelper Staff', False), + 'viewer': ('LabHelper Viewers', False), } for username, (group_name, is_superuser) in users.items(): diff --git a/labhelper/settings.py b/labhelper/settings.py index 4e4c46c..2260525 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -178,5 +178,8 @@ OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_con # Store the ID token in the session so Keycloak logout can use id_token_hint OIDC_STORE_ID_TOKEN = True +# Redirect to the static login page on auth failure instead of looping back into OIDC +OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/') + # Exempt AJAX endpoints from the session-refresh middleware redirect OIDC_EXEMPT_URLS = ['search_api'] From da6a73e357ea9bc93c23ec5d66efb2262eb4fa3b Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Wed, 25 Feb 2026 22:39:53 +0100 Subject: [PATCH 5/8] Delete things implemented (can't believe I forgot that...) --- argocd/configmap.yaml | 2 +- argocd/deployment.yaml | 2 +- boxes/templates/boxes/edit_thing.html | 10 ++++++++++ boxes/views.py | 15 +++++++++++++++ labhelper/urls.py | 2 ++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/argocd/configmap.yaml b/argocd/configmap.yaml index 7f2eff0..0a81378 100644 --- a/argocd/configmap.yaml +++ b/argocd/configmap.yaml @@ -21,4 +21,4 @@ data: LOGOUT_REDIRECT_URL: "/login/" OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/" GUNICORN_OPTS: "--access-logfile -" - IMAGE_TAG: "0.078" + IMAGE_TAG: "0.079" diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index 01be0f2..ceb8851 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.078 + image: git.baumann.gr/adebaumann/labhelper:0.079 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/templates/boxes/edit_thing.html b/boxes/templates/boxes/edit_thing.html index 7418648..3f9cebb 100644 --- a/boxes/templates/boxes/edit_thing.html +++ b/boxes/templates/boxes/edit_thing.html @@ -284,6 +284,16 @@
+
+
+ {% csrf_token %} + +
+
+ {% endblock %} {% block extra_css %} diff --git a/boxes/views.py b/boxes/views.py index 2acac18..ae981ae 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -373,6 +373,21 @@ def delete_box(request, box_id): return redirect('box_management') +@login_required +def delete_thing(request, thing_id): + """Delete a thing and its associated files.""" + thing = get_object_or_404(Thing, pk=thing_id) + if request.method == 'POST': + box_id = thing.box.id + if thing.picture: + thing.picture.delete(save=False) + for thing_file in thing.files.all(): + thing_file.file.delete(save=False) + thing.delete() + return redirect('box_detail', box_id=box_id) + return redirect('edit_thing', thing_id=thing_id) + + @login_required def resources_list(request): """List all links and files from things that have them.""" diff --git a/labhelper/urls.py b/labhelper/urls.py index f9c6ecc..e4e4a50 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -29,6 +29,7 @@ from boxes.views import ( boxes_list, delete_box, delete_box_type, + delete_thing, edit_box, edit_box_type, edit_thing, @@ -53,6 +54,7 @@ urlpatterns = [ path('box//', box_detail, name='box_detail'), path('thing//', thing_detail, name='thing_detail'), path('thing//edit/', edit_thing, name='edit_thing'), + path('thing//delete/', delete_thing, name='delete_thing'), path('box//add/', add_things, name='add_things'), path('boxes/', boxes_list, name='boxes_list'), path('inventory/', boxes_list, name='inventory'), From 41ec7bdc0858cf635fc5c99bed02a3cfd7d74ccf Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Mon, 2 Mar 2026 12:36:54 +0100 Subject: [PATCH 6/8] Removed some more unnecessary files from container --- Dockerfile | 3 +++ argocd/deployment.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9e5c7f9..8dc653f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,9 @@ RUN rm -rvf /app/Dockerfile* \ /app/requirements.txt \ /app/node_modules \ /app/*.json \ + /app/AGENTS* \ + /app/*.md \ + /app/k8s-templates \ /app/test_*.py && \ python3 /app/manage.py collectstatic --noinput CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 $GUNICORN_OPTS labhelper.wsgi:application"] diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index ceb8851..c84d5be 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.079 + image: git.baumann.gr/adebaumann/labhelper:0.080 imagePullPolicy: Always ports: - containerPort: 8000 From b507f961cb8ff8ab8f42a5b4ffbb900665aeb4be Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Mon, 2 Mar 2026 13:05:51 +0100 Subject: [PATCH 7/8] New health check added --- argocd/deployment.yaml | 6 +++--- boxes/views.py | 5 +++++ labhelper/urls.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index c84d5be..bf3c759 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.080 + image: git.baumann.gr/adebaumann/labhelper:0.081 imagePullPolicy: Always ports: - containerPort: 8000 @@ -137,7 +137,7 @@ spec: mountPath: /app/data readinessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 5 periodSeconds: 10 @@ -145,7 +145,7 @@ spec: failureThreshold: 6 livenessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 20 periodSeconds: 20 diff --git a/boxes/views.py b/boxes/views.py index ae981ae..d793e25 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -18,6 +18,11 @@ from .forms import ( from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink +def health_check(request): + """Health check endpoint for Kubernetes liveness/readiness probes.""" + return HttpResponse('OK', status=200) + + def _strip_markdown(text, max_length=100): """Convert Markdown to plain text and truncate.""" if not text: diff --git a/labhelper/urls.py b/labhelper/urls.py index e4e4a50..2f708df 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -34,6 +34,7 @@ from boxes.views import ( edit_box_type, edit_thing, fixme, + health_check, index, resources_list, search_api, @@ -43,6 +44,7 @@ from boxes.views import ( urlpatterns = [ path('oidc/', include('mozilla_django_oidc.urls')), path('login/', TemplateView.as_view(template_name='login.html'), name='login'), + path('health/', health_check, name='health_check'), path('', index, name='index'), path('box-management/', box_management, name='box_management'), path('box-type/add/', add_box_type, name='add_box_type'), From 4569fec82c48be0bebcad84dec0c721936449474 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Fri, 13 Mar 2026 21:22:14 +0100 Subject: [PATCH 8/8] Image links -> lightbox instead of download --- argocd/deployment.yaml | 2 +- boxes/templates/boxes/thing_detail.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index bf3c759..40058fe 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.081 + image: git.baumann.gr/adebaumann/labhelper:0.082 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/templates/boxes/thing_detail.html b/boxes/templates/boxes/thing_detail.html index ddf7218..a9e47e2 100644 --- a/boxes/templates/boxes/thing_detail.html +++ b/boxes/templates/boxes/thing_detail.html @@ -98,7 +98,7 @@
{{ file.title }}
- {{ file.title }} + {{ file.title }} ({{ file.filename }})