Anmeldung mit Authelia für die Apps: Server gibt nach geprüftem Authelia-Login den App-Zugang heraus (sw CACHE v163)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m35s
Test / e2e (push) Failing after 1m29s

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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-01 20:12:43 +01:00
parent 67f983dd1c
commit 04548171bf
6 changed files with 163 additions and 9 deletions

View file

@ -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')

View file

@ -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']})

View file

@ -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()

View file

@ -23,6 +23,8 @@ urlpatterns = [
path('vault/<slug:name>/', 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/<int:pk>/unlink/', oidc_views.oidc_unlink, name='oidc_unlink'),

View file

@ -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:<port>/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:<port>/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.

View file

@ -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',