diff --git a/accounts/oidc.py b/accounts/oidc.py index 833b94c..a79d001 100644 --- a/accounts/oidc.py +++ b/accounts/oidc.py @@ -39,6 +39,10 @@ def enabled() -> bool: return bool(settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET) +def key_enabled() -> bool: + return enabled() and bool(settings.OIDC_APP_CLIENT_ID) + + def _b64url_decode(s: str) -> bytes: return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) diff --git a/accounts/oidc_views.py b/accounts/oidc_views.py index 1e655ad..b610988 100644 --- a/accounts/oidc_views.py +++ b/accounts/oidc_views.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect +from django.shortcuts import redirect, render from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_http_methods @@ -100,6 +100,8 @@ def oidc_callback(request): OidcIdentity.objects.filter(pk=identity.pk).update(**shown) messages.success(request, f'Authelia-Konto „{shown["username"] or sub}“ verknüpft. ' 'Du kannst dich jetzt auch darüber anmelden.') + if oidc.key_enabled(): # leave the book key with Authelia's help right away + return redirect('/accounts/oidc/key/?next=/accounts/settings/') return redirect('settings') if identity is None: @@ -111,9 +113,13 @@ def oidc_callback(request): return redirect('login') OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now(), **shown) login(request, identity.user, backend='django.contrib.auth.backends.ModelBackend') - # The page asks for the book key (static/js/app/33-unlock.js); a password login - # brings it along, this one cannot. - return redirect(flow.get('next') or '/') + # A password login brings the book key along, this one cannot: the device fetches it + # (oidc_key below), else the page asks (static/js/app/33-unlock.js). + next_url = flow.get('next') or '/' + if oidc.key_enabled(): + from urllib.parse import urlencode + return redirect('/accounts/oidc/key/?' + urlencode({'next': next_url})) + return redirect(next_url) @login_required @@ -122,3 +128,37 @@ def oidc_unlink(request, pk): OidcIdentity.objects.filter(user=request.user, pk=pk).delete() messages.success(request, 'Verknüpfung mit Authelia gelöst.') return redirect('settings') + + +@login_required +@require_http_methods(['GET']) +def oidc_key(request): + """The book key with the provider's help, without asking (docs/accounts.md). + + Runs entirely on the device: it signs in once more as the public client + (OIDC_APP_CLIENT_ID, PKCE, prompt=none — the provider session from just before makes it + invisible), reads its `diora_key` claim from userinfo and with it opens the book key the + server keeps wrapped (vault "authelia-key") — or, when the device has the key, wraps and + leaves it there. The server never sees the claim; this view only hands out the page. + The same page is the redirect target (`/accounts/oidc/key/callback/`).""" + next_url = _safe_next(request, request.GET.get('next', '')) + if not oidc.key_enabled(): + return redirect(next_url) + try: + disco = oidc.discovery() + except oidc.OidcError as exc: + log.warning('oidc key: %s', exc) + return redirect(next_url) + config = { + 'authorize': disco['authorization_endpoint'], + 'token': disco['token_endpoint'], + 'userinfo': disco.get('userinfo_endpoint', ''), + 'client_id': settings.OIDC_APP_CLIENT_ID, + 'redirect_uri': request.build_absolute_uri('/accounts/oidc/key/callback/') + if not settings.OIDC_REDIRECT_URI + else settings.OIDC_REDIRECT_URI.replace('/oidc/callback/', '/oidc/key/callback/'), + 'subs': [i.sub for i in request.user.oidc_identities.all()], + 'user_id': request.user.id, + 'next': next_url, + } + return render(request, 'accounts/oidc_key.html', {'config': config}) diff --git a/accounts/test_oidc.py b/accounts/test_oidc.py index 4f08c3e..2900513 100644 --- a/accounts/test_oidc.py +++ b/accounts/test_oidc.py @@ -168,6 +168,31 @@ class OidcFlowTests(TestCase): self.client.post(f'/accounts/oidc/{ident.pk}/unlink/') self.assertEqual(list(OidcIdentity.objects.all()), [foreign]) + def test_with_app_client_login_and_link_go_by_the_key_page(self): + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + with override_settings(OIDC_APP_CLIENT_ID='diora-app'): + state, nonce = self._start('?next=/books/') + resp, _ = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=%2Fbooks%2F') + page = self.client.get(resp['Location']) + self.assertContains(page, 'oidc-key-config') + cfg = page.context['config'] + self.assertEqual(cfg['client_id'], 'diora-app') + self.assertEqual(cfg['redirect_uri'], 'https://diora.test/accounts/oidc/key/callback/') + self.assertEqual(cfg['subs'], ['uuid-1']) + self.assertEqual(cfg['next'], '/books/') + self.assertEqual(cfg['token'], DISCOVERY['token_endpoint']) + self.assertEqual(self.client.get('/accounts/oidc/key/callback/?code=x&state=y').status_code, 200) + + state, nonce = self._start('?link=1') + resp, _ = self._callback(state, self._claims(nonce)) + self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=/accounts/settings/') + + def test_key_page_without_app_client_goes_straight_on(self): + self.client.force_login(self.user) + resp = self.client.get('/accounts/oidc/key/?next=/books/') + self.assertEqual(resp['Location'], '/books/') + def test_login_page_shows_button_only_when_configured(self): self.assertContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden') with override_settings(OIDC_CLIENT_SECRET=''): diff --git a/accounts/urls.py b/accounts/urls.py index 1cac810..aa1c606 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -23,6 +23,8 @@ urlpatterns = [ path('vault//', views.vault, name='vault'), path('oidc/start/', oidc_views.oidc_start, name='oidc_start'), path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'), + path('oidc/key/', oidc_views.oidc_key, name='oidc_key'), + path('oidc/key/callback/', oidc_views.oidc_key, name='oidc_key_callback'), path('oidc//unlink/', oidc_views.oidc_unlink, name='oidc_unlink'), path('keyshare/', keyshare.create, name='keyshare_create'), path('keyshare/pending/', keyshare.pending, name='keyshare_pending'), diff --git a/diora/settings.py b/diora/settings.py index 75bed42..a171dd1 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -146,6 +146,9 @@ OIDC_CLIENT_SECRET = os.environ.get('DIORA_OIDC_CLIENT_SECRET', '') # Fixed callback address; empty = built from the request (needs the real https host). OIDC_REDIRECT_URI = os.environ.get('DIORA_OIDC_REDIRECT_URI', '') OIDC_PROVIDER_NAME = os.environ.get('DIORA_OIDC_PROVIDER_NAME', 'Authelia') +# Public client the devices use to fetch their `diora_key` claim from the provider themselves +# (docs/accounts.md, "Schlüssel über Authelia"); empty = off. +OIDC_APP_CLIENT_ID = os.environ.get('DIORA_OIDC_APP_CLIENT_ID', '') LOGOUT_REDIRECT_URL = '/' # Last.fm diff --git a/docs/accounts.md b/docs/accounts.md index 777744a..09dcaa1 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -28,6 +28,31 @@ die JWKS des Anbieters, `iss`, `aud`, `exp`, `nonce`), dafür reicht `cryptograp ## Der Schlüssel nach einer Authelia-Anmeldung +### Normalfall: über Authelia, ohne Nachfrage + +Authelia hält je Nutzer ein geheimes Attribut `diora_key` (32 Byte, base64). Es geht nur an den +öffentlichen Client `diora-app` (`DIORA_OIDC_APP_CLIENT_ID`, PKCE, Scope `diora_key`) und nur +über userinfo, nicht im id_token. Nach jedem Authelia-Login (und nach dem Verknüpfen) führt +diora auf `/accounts/oidc/key/` (`templates/accounts/oidc_key.html`). Die Seite meldet sich +**im Browser** noch einmal mit `prompt=none` als `diora-app` an, unsichtbar, weil die +Authelia-Sitzung noch besteht, und holt per CORS Token und userinfo. Daraus leitet sie mit +HKDF-SHA256 (Salt = `sub`, info `diora authelia key v1`) einen AES-GCM-Schlüssel ab: + +- Hat das Gerät den Bücher-Schlüssel (und passt er zu einem Buch), wird er damit verschlüsselt + und im Vault `authelia-key` abgelegt, bei jedem Authelia-Login neu. So heilt ein geänderter + `diora_key` von selbst. +- Hat es keinen, öffnet sie den Vault-Eintrag damit und legt den Schlüssel ab. +- Es zählt nur das Authelia-Konto, das mit diesem diora-Konto verknüpft ist (`sub`). + +Der diora-Server sieht `diora_key` nie. **Grenze, ehrlich:** Wer Authelia (Hetzner-Root) oder +dein Authelia-Konto (Passwort, Mail-Reset) übernimmt, kommt damit an die Bücher. Wer Strato +übernimmt, kann im Browser ohnehin manipuliertes JS ausliefern. Geschützt ist gegen geleakte +Daten **einer** Seite (diora-DB/Backup ohne Authelia-Nutzerdatei oder umgekehrt). Geht +`diora_key` verloren, sind die Bücher nicht weg: Der Schlüssel kommt weiter aus dem Passwort. +Entschieden 2026-11-01 vom Nutzer (Komfort vor stärkerer Trennung). + +### Notweg: Code oder Passwort + Ohne Passwort gibt es keinen Schlüssel. Deshalb erzeugt `getOrCreateEncKey()` **nie mehr selbst einen neuen Schlüssel**: Früher machte es einen zufälligen, wenn keiner da war, und damit waren die vorhandenen Bücher auf diesem Gerät verloren. Jetzt fragt es `dioraUnlock()` @@ -52,4 +77,6 @@ Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr. Android und Desktop melden sich bisher nur mit Passwort an (eigene Login-Seiten in `diora-android/web-native/login.html` und `diora-desktop/src/web/login.js`, die einen -API-Token holen). Authelia für die Apps folgt als eigener Schritt. +API-Token holen). Authelia für die Apps folgt als eigener Schritt: Login im Systembrowser +als `diora-app` mit PKCE, Redirect `xyz.creamfresh.diora:/oauth`, +`xyz.creamfresh.diora.reader:/oauth` bzw. Loopback `http://127.0.0.1:/oauth` (jeder Port). diff --git a/static/js/sw.js b/static/js/sw.js index 1cd9709..fe342cd 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v161'; +const CACHE = 'diora-v162'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/templates/accounts/oidc_key.html b/templates/accounts/oidc_key.html new file mode 100644 index 0000000..e5cf532 --- /dev/null +++ b/templates/accounts/oidc_key.html @@ -0,0 +1,120 @@ + + + + + +Anmelden — diora + + + +

Bücher werden entsperrt …

+{{ config|json_script:"oidc-key-config" }} + + +