From 04548171bf3f8ddeaf0cff67e45b7a75dc686923 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 1 Nov 2026 20:12:43 +0100 Subject: [PATCH] =?UTF-8?q?Anmeldung=20mit=20Authelia=20f=C3=BCr=20die=20A?= =?UTF-8?q?pps:=20Server=20gibt=20nach=20gepr=C3=BCftem=20Authelia-Login?= =?UTF-8?q?=20den=20App-Zugang=20heraus=20(sw=20CACHE=20v163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android und Desktop melden sich selbst bei Authelia an. Mit dem geprüften Ergebnis bekommen sie von diora den Zugang des verknüpften Kontos, so wie bisher nach der Anmeldung mit Passwort. Den Bücher-Schlüssel öffnen sie selbst, der Server sieht ihn nicht. Details: docs/accounts.md Co-Authored-By: Claude Opus 5.5 --- accounts/oidc.py | 9 +++-- accounts/oidc_views.py | 83 +++++++++++++++++++++++++++++++++++++++++- accounts/test_oidc.py | 61 ++++++++++++++++++++++++++++++- accounts/urls.py | 2 + docs/accounts.md | 15 ++++++-- static/js/sw.js | 2 +- 6 files changed, 163 insertions(+), 9 deletions(-) diff --git a/accounts/oidc.py b/accounts/oidc.py index a79d001..7c4c5a8 100644 --- a/accounts/oidc.py +++ b/accounts/oidc.py @@ -130,7 +130,9 @@ def _public_key(kid: str): raise OidcError('Signaturschlüssel des Anbieters nicht gefunden') -def verify_id_token(token: str, nonce: str, now: float = None) -> dict: +def verify_id_token(token: str, nonce, now: float = None, client_id: str = None) -> dict: + """Check signature and claims. `nonce` is the expected value, or a function that says + whether the token's nonce is good (the apps' signed ones, oidc_views.app_login).""" try: header_b64, payload_b64, sig_b64 = token.split('.') header = json.loads(_b64url_decode(header_b64)) @@ -151,11 +153,12 @@ def verify_id_token(token: str, nonce: str, now: float = None) -> dict: if claims.get('iss', '').rstrip('/') != settings.OIDC_ISSUER.rstrip('/'): raise OidcError('falscher Aussteller') aud = claims.get('aud') - if settings.OIDC_CLIENT_ID not in (aud if isinstance(aud, list) else [aud]): + if (client_id or settings.OIDC_CLIENT_ID) not in (aud if isinstance(aud, list) else [aud]): raise OidcError('id_token ist nicht für diora') if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < now - 60: raise OidcError('id_token abgelaufen') - if not nonce or claims.get('nonce') != nonce: + good = nonce(claims.get('nonce') or '') if callable(nonce) else (nonce and claims.get('nonce') == nonce) + if not good: raise OidcError('nonce passt nicht') if not claims.get('sub'): raise OidcError('id_token ohne sub') diff --git a/accounts/oidc_views.py b/accounts/oidc_views.py index b610988..d45365b 100644 --- a/accounts/oidc_views.py +++ b/accounts/oidc_views.py @@ -1,17 +1,22 @@ """Sign-in with Authelia and linking it to an existing account (accounts/oidc.py).""" +import json import logging +import secrets 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.core import signing +from django.http import JsonResponse 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.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from . import oidc -from .models import OidcIdentity +from .models import ApiToken, OidcIdentity log = logging.getLogger(__name__) @@ -162,3 +167,79 @@ def oidc_key(request): 'next': next_url, } return render(request, 'accounts/oidc_key.html', {'config': config}) + + +# --------------------------------------------------------------------------- +# The apps (Android, desktop): sign in with Authelia as the public client themselves +# --------------------------------------------------------------------------- +# +# 1. GET /accounts/oidc/app/ -> where Authelia is, the client id and a nonce +# signed by this server (good for APP_NONCE_AGE). +# 2. The app runs the code flow with PKCE in the system browser (redirect to its own +# scheme or loopback), trades the code itself and reads `diora_key` from userinfo. +# 3. POST /accounts/oidc/app/login/ {id_token} -> the account's API token, as the +# password login (diora-android Auth.kt) hands it out. +# The book key never passes here: the app opens the vault "authelia-key" itself. + +APP_NONCE_SALT = 'diora.oidc.app' +APP_NONCE_AGE = 600 + + +@require_http_methods(['GET']) +def app_config(request): + if not oidc.key_enabled(): + return JsonResponse({'enabled': False}) + try: + disco = oidc.discovery() + except oidc.OidcError as exc: + log.warning('oidc app config: %s', exc) + return JsonResponse({'enabled': False, 'error': 'provider unreachable'}, status=502) + return JsonResponse({ + 'enabled': True, + 'provider': settings.OIDC_PROVIDER_NAME, + 'issuer': settings.OIDC_ISSUER.rstrip('/'), + 'authorize': disco['authorization_endpoint'], + 'token': disco['token_endpoint'], + 'userinfo': disco.get('userinfo_endpoint', ''), + 'client_id': settings.OIDC_APP_CLIENT_ID, + 'scope': 'openid profile diora_key', + 'nonce': signing.dumps(secrets.token_urlsafe(16), salt=APP_NONCE_SALT), + }) + + +def _app_nonce_ok(value): + try: + signing.loads(value, salt=APP_NONCE_SALT, max_age=APP_NONCE_AGE) + return True + except signing.BadSignature: + return False + + +@csrf_exempt # a native client without cookies; the signed id_token is the proof +@require_http_methods(['POST']) +def app_login(request): + if not oidc.key_enabled(): + return JsonResponse({'error': 'not enabled'}, status=404) + try: + body = json.loads(request.body or b'{}') + id_token = body['id_token'] + if not isinstance(id_token, str): + raise TypeError + except (ValueError, KeyError, TypeError): + return JsonResponse({'error': 'id_token required'}, status=400) + try: + claims = oidc.verify_id_token(id_token, _app_nonce_ok, client_id=settings.OIDC_APP_CLIENT_ID) + except oidc.OidcError as exc: + log.warning('oidc app login: %s', exc) + return JsonResponse({'error': 'Die Anmeldung über Authelia war ungültig.'}, status=401) + identity = OidcIdentity.objects.filter( + issuer=settings.OIDC_ISSUER.rstrip('/'), sub=claims['sub']).select_related('user').first() + if identity is None: + return JsonResponse({'error': 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. ' + 'Verknüpfe es einmal im Browser unter Einstellungen.'}, status=403) + if not identity.user.is_active: + return JsonResponse({'error': 'Dieses diora-Konto ist gesperrt.'}, status=403) + OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now()) + token, _ = ApiToken.objects.get_or_create(user=identity.user) + return JsonResponse({'token': token.token, 'user_id': identity.user.id, + 'username': identity.user.username, 'sub': claims['sub']}) diff --git a/accounts/test_oidc.py b/accounts/test_oidc.py index 2900513..3d0c699 100644 --- a/accounts/test_oidc.py +++ b/accounts/test_oidc.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import User from django.test import TestCase, override_settings from . import oidc -from .models import KeyShare, OidcIdentity +from .models import ApiToken, KeyShare, OidcIdentity ISSUER = 'https://auth.example.org' PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -199,6 +199,65 @@ class OidcFlowTests(TestCase): self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden') +@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret', + OIDC_APP_CLIENT_ID='diora-app') +class OidcAppLoginTests(TestCase): + def setUp(self): + oidc._cache.clear() + self.user = User.objects.create_user(username='crm', password='pw12345678') + OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1') + p = patch('accounts.oidc.requests.get', side_effect=fake_get) + p.start() + self.addCleanup(p.stop) + + def _nonce(self): + cfg = self.client.get('/accounts/oidc/app/').json() + self.assertEqual((cfg['client_id'], cfg['token']), ('diora-app', DISCOVERY['token_endpoint'])) + return cfg['nonce'] + + def _login(self, **claims): + c = {'iss': ISSUER, 'aud': ['diora-app'], 'sub': 'uuid-1', 'exp': time.time() + 300, + 'nonce': self._nonce()} + c.update(claims) + return self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(c)}), + content_type='application/json') + + def test_linked_account_gets_its_existing_token(self): + existing = ApiToken.objects.create(user=self.user) + resp = self._login() + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {'token': existing.token, 'user_id': self.user.id, + 'username': 'crm', 'sub': 'uuid-1'}) + + def test_token_is_made_when_there_is_none(self): + resp = self._login() + self.assertEqual(resp.json()['token'], ApiToken.objects.get(user=self.user).token) + + def test_refused(self): + for name, claims, code in [ + ('unlinked', {'sub': 'other'}, 403), + ('web client token', {'aud': 'diora'}, 401), + ('own nonce', {'nonce': 'made-up'}, 401), + ('expired', {'exp': time.time() - 3600}, 401), + ]: + with self.subTest(name): + self.assertEqual(self._login(**claims).status_code, code) + self.assertEqual(self.client.post('/accounts/oidc/app/login/', '{}', + content_type='application/json').status_code, 400) + + def test_old_nonce_is_refused(self): + nonce = self._nonce() + with patch('django.core.signing.time.time', return_value=time.time() + 3600): + resp = self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token( + {'iss': ISSUER, 'aud': 'diora-app', 'sub': 'uuid-1', 'exp': time.time() + 7200, 'nonce': nonce})}), + content_type='application/json') + self.assertEqual(resp.status_code, 401) + + def test_off_without_app_client(self): + with override_settings(OIDC_APP_CLIENT_ID=''): + self.assertEqual(self.client.get('/accounts/oidc/app/').json(), {'enabled': False}) + + PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode() diff --git a/accounts/urls.py b/accounts/urls.py index aa1c606..1ec9344 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/app/', oidc_views.app_config, name='oidc_app_config'), + path('oidc/app/login/', oidc_views.app_login, name='oidc_app_login'), 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'), diff --git a/docs/accounts.md b/docs/accounts.md index 09dcaa1..38cacaa 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -77,6 +77,15 @@ 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: 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). +API-Token holen). Mit Authelia melden sie sich selbst als öffentlicher Client `diora-app` an: + +1. `GET /accounts/oidc/app/` sagt, wo Authelia ist, gibt die Client-ID und eine vom Server + signierte nonce (10 min gültig). +2. Die App führt den Code-Flow mit PKCE im Systembrowser aus. Redirect: + `xyz.creamfresh.diora:/oauth` bzw. `xyz.creamfresh.diora.reader:/oauth` (Android), + Loopback `http://127.0.0.1:/oauth` (Desktop, jeder Port). Den Code tauscht sie selbst + und liest `diora_key` aus userinfo. +3. `POST /accounts/oidc/app/login/ {id_token}` prüft das Token (`aud` = `diora-app`, nonce) und + gibt den API-Token des verknüpften Kontos heraus (den vorhandenen, kein neuer). +4. Die App holt mit dem Token `/accounts/vault/authelia-key/` und öffnet den Bücher-Schlüssel + wie die Webseite (HKDF, Salt `sub`). Ist dort noch nichts, fragt sie nach dem diora-Passwort. diff --git a/static/js/sw.js b/static/js/sw.js index fe342cd..48403f1 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-v162'; +const CACHE = 'diora-v163'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',