Nach dem Authelia-Login sind die Bücher gleich offen, ohne Code und Passwort (sw CACHE v162)
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:
parent
077e6d4543
commit
67f983dd1c
8 changed files with 227 additions and 6 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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=''):
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
120
templates/accounts/oidc_key.html
Normal file
120
templates/accounts/oidc_key.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue