Nach dem Authelia-Login sind die Bücher gleich offen, ohne Code und Passwort (sw CACHE v162)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m27s
Test / e2e (push) Failing after 1m30s

Authelia gibt dem Gerät (nicht dem diora-Server) ein geheimes Attribut
heraus. Damit legt das Gerät beim Verknüpfen den Bücher-Schlüssel
verschlüsselt bei diora ab und holt ihn auf neuen Geräten nach dem
Authelia-Login unsichtbar zurück. Code und Passwort bleiben als Notweg, wenn
Authelia nichts liefert. Aktiv, sobald DIORA_OIDC_APP_CLIENT_ID gesetzt ist.

Details und Grenzen: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-01 19:35:21 +01:00
parent 077e6d4543
commit 67f983dd1c
8 changed files with 227 additions and 6 deletions

View file

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

View file

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

View file

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

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/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'),
path('keyshare/', keyshare.create, name='keyshare_create'),
path('keyshare/pending/', keyshare.pending, name='keyshare_pending'),

View file

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

View file

@ -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:<port>/oauth` (jeder Port).

View file

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

View file

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden — diora</title>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font: 15px/1.5 system-ui, sans-serif; background: #111; color: #ccc; }
@media (prefers-color-scheme: light) { body { background: #fafafa; color: #444; } }
</style>
</head>
<body>
<p id="status">Bücher werden entsperrt …</p>
{{ config|json_script:"oidc-key-config" }}
<script>
// The book key with Authelia's help (accounts/oidc_views.py oidc_key, docs/accounts.md).
// Every way out ends on `next`; whatever fails here, the app asks for the key itself
// (static/js/app/33-unlock.js).
(async function () {
const C = JSON.parse(document.getElementById('oidc-key-config').textContent);
const SS = 'diora_oidc_key_flow';
const KEY_NAME = 'diora_enc_key_' + C.user_id;
const status = document.getElementById('status');
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const unb64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const b64url = (buf) => b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const hexToBytes = (h) => Uint8Array.from(h.match(/../g) || [], x => parseInt(x, 16));
const csrf = () => (document.cookie.split('; ').find(r => r.startsWith('csrftoken=')) || '').split('=')[1] || '';
const done = (to) => location.replace(to || C.next || '/');
async function wrapKey(secret, sub) {
const hk = await crypto.subtle.importKey('raw', unb64(secret), 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(sub), info: new TextEncoder().encode('diora authelia key v1')},
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
}
// Is this the key of the books? Tried on one book's metadata (none: nothing to check).
async function fits(raw) {
try {
const books = await (await fetch('/books/', {credentials: 'same-origin'})).json();
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
if (!b) return true;
const k = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
await crypto.subtle.decrypt({name: 'AES-GCM', iv: hexToBytes(b.meta_iv)}, k, unb64(b.meta_ct));
return true;
} catch (e) { return false; }
}
const params = new URLSearchParams(location.search);
const isCallback = location.pathname.endsWith('/callback/');
if (!isCallback) {
// Also a device that has its key goes once round: it leaves the key (again) wrapped with
// the current claim, so a changed claim at Authelia heals with the next login.
const flow = {
state: b64url(crypto.getRandomValues(new Uint8Array(24))),
verifier: b64url(crypto.getRandomValues(new Uint8Array(48))),
next: C.next,
};
sessionStorage.setItem(SS, JSON.stringify(flow));
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(flow.verifier)));
const q = new URLSearchParams({
response_type: 'code', client_id: C.client_id, redirect_uri: C.redirect_uri,
scope: 'openid profile diora_key', state: flow.state, prompt: 'none',
code_challenge: challenge, code_challenge_method: 'S256',
});
location.replace(C.authorize + '?' + q);
return;
}
let flow = null;
try { flow = JSON.parse(sessionStorage.getItem(SS) || 'null'); } catch (e) {}
sessionStorage.removeItem(SS);
const next = flow && flow.next;
try {
if (!flow || params.get('state') !== flow.state || !params.get('code')) return done(next);
const tok = await (await fetch(C.token, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'authorization_code', code: params.get('code'), redirect_uri: C.redirect_uri,
client_id: C.client_id, code_verifier: flow.verifier,
}),
})).json();
if (!tok.access_token) return done(next);
const info = await (await fetch(C.userinfo, {headers: {Authorization: 'Bearer ' + tok.access_token}})).json();
// Only the Authelia account linked to this diora account counts.
if (!info.sub || !C.subs.includes(info.sub) || !info.diora_key) return done(next);
const wrap = await wrapKey(info.diora_key, info.sub);
let have = null;
try { have = localStorage.getItem(KEY_NAME); } catch (e) {}
if (have) {
// This device has the key: leave it with the server, wrapped — only the right one (an
// old browser may hold a stray key of its own).
if (!(await fits(unb64(have)))) return done(next);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrap, unb64(have));
await fetch('/accounts/vault/authelia-key/', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify({ct: b64(ct), iv: b64(iv)}),
});
return done(next);
}
const v = await (await fetch('/accounts/vault/authelia-key/', {credentials: 'same-origin'})).json();
if (!v.ct) return done(next);
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(v.iv)}, wrap, unb64(v.ct)));
if (raw.length === 32 && await fits(raw)) localStorage.setItem(KEY_NAME, b64(raw));
} catch (e) {
status.textContent = 'Entsperren über Authelia ging nicht – du kannst es gleich mit Code oder Passwort tun.';
await new Promise(r => setTimeout(r, 1500));
}
done(next);
})();
</script>
</body>
</html>