diora-web/templates/accounts/oidc_key.html
mrwnslz 67f983dd1c
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
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>
2026-11-01 19:35:21 +01:00

120 lines
5.7 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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