diora-web/templates/accounts/oidc_key.html

121 lines
5.7 KiB
HTML
Raw Normal View History

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