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