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>
120 lines
5.7 KiB
HTML
120 lines
5.7 KiB
HTML
<!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>
|