| 1 | /**
|
|---|
| 2 | * OpenWebAuth (FEP-61cf) — de TARGET-kant.
|
|---|
| 3 | *
|
|---|
| 4 | * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
|
|---|
| 5 | * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
|
|---|
| 6 | * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
|
|---|
| 7 | * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
|
|---|
| 8 | * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
|
|---|
| 9 | * wachtwoord of een cookie van een derde partij.
|
|---|
| 10 | *
|
|---|
| 11 | * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
|
|---|
| 12 | * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
|
|---|
| 13 | * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
|
|---|
| 14 | * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
|
|---|
| 15 | * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
|
|---|
| 16 | * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
|
|---|
| 17 | *
|
|---|
| 18 | * De stroom, met de FEP-stappen erbij:
|
|---|
| 19 | * 1. bezoeker geeft zijn adres -> wij webfingeren hem, vinden zijn
|
|---|
| 20 | * redirect-endpoint, sturen hem daarheen
|
|---|
| 21 | * 2. zijn server controleert hem -> en vraagt ONS om een token
|
|---|
| 22 | * 3. wij verifieren die ondertekende -> token terug, versleuteld met ZIJN
|
|---|
| 23 | * aanvraag publieke sleutel
|
|---|
| 24 | * 4. zijn server ontsleutelt -> stuurt hem terug met ?owt=<token>
|
|---|
| 25 | * 5. wij wisselen het token in -> nu weten we wie hij is
|
|---|
| 26 | *
|
|---|
| 27 | * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
|
|---|
| 28 | * anders precies de fout worden die je niet ziet:
|
|---|
| 29 | *
|
|---|
| 30 | * - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
|
|---|
| 31 | * ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
|
|---|
| 32 | * en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
|
|---|
| 33 | * Mallory als Bob binnen.
|
|---|
| 34 | * - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
|
|---|
| 35 | * dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
|
|---|
| 36 | * wij bezoekers naar waar een vreemde maar wil.
|
|---|
| 37 | * - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
|
|---|
| 38 | */
|
|---|
| 39 | import crypto from 'crypto';
|
|---|
| 40 | import db from '../config/database.js';
|
|---|
| 41 |
|
|---|
| 42 | /** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
|
|---|
| 43 | export const TOKEN_TTL_MS = 3 * 60 * 1000;
|
|---|
| 44 |
|
|---|
| 45 | /** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
|
|---|
| 46 | export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
|
|---|
| 47 | export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
|
|---|
| 48 |
|
|---|
| 49 | // ── tokens ────────────────────────────────────────────────────────────────
|
|---|
| 50 |
|
|---|
| 51 | /** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
|
|---|
| 52 | export function sweepTokens(now = Date.now()) {
|
|---|
| 53 | db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | /**
|
|---|
| 57 | * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
|
|---|
| 58 | * URL-veilig, want hij reist als query-parameter terug.
|
|---|
| 59 | */
|
|---|
| 60 | export function issueToken(actorUri, now = Date.now()) {
|
|---|
| 61 | sweepTokens(now);
|
|---|
| 62 | const token = crypto.randomBytes(32).toString('base64url');
|
|---|
| 63 | db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
|
|---|
| 64 | .run(token, String(actorUri), now);
|
|---|
| 65 | return token;
|
|---|
| 66 | }
|
|---|
| 67 |
|
|---|
| 68 | /**
|
|---|
| 69 | * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
|
|---|
| 70 | *
|
|---|
| 71 | * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
|
|---|
| 72 | * eenmaal is aangeboden mag nooit een tweede kans krijgen.
|
|---|
| 73 | */
|
|---|
| 74 | export function redeemToken(token, now = Date.now()) {
|
|---|
| 75 | const t = String(token || '');
|
|---|
| 76 | if (!t) return null;
|
|---|
| 77 | const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
|
|---|
| 78 | if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
|
|---|
| 79 | sweepTokens(now);
|
|---|
| 80 | if (!row) return null;
|
|---|
| 81 | if (now - row.created_at > TOKEN_TTL_MS) return null;
|
|---|
| 82 | return row.actor_uri;
|
|---|
| 83 | }
|
|---|
| 84 |
|
|---|
| 85 | /**
|
|---|
| 86 | * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
|
|---|
| 87 | * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
|
|---|
| 88 | * dat is geen smaak maar interop met Hubzilla en (streams).
|
|---|
| 89 | */
|
|---|
| 90 | export function encryptTokenFor(token, publicKeyPem) {
|
|---|
| 91 | const buf = crypto.publicEncrypt(
|
|---|
| 92 | { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|---|
| 93 | Buffer.from(String(token), 'utf8'),
|
|---|
| 94 | );
|
|---|
| 95 | return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | // ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
|
|---|
| 99 |
|
|---|
| 100 | /** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
|
|---|
| 101 | export function parseHandle(input) {
|
|---|
| 102 | const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
|
|---|
| 103 | .match(/^([^@\s/]+)@([^@\s/]+)$/);
|
|---|
| 104 | if (!m) return null;
|
|---|
| 105 | const host = m[2].toLowerCase();
|
|---|
| 106 | if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
|
|---|
| 107 | return { user: m[1], host, acct: `${m[1]}@${host}` };
|
|---|
| 108 | }
|
|---|
| 109 |
|
|---|
| 110 | /**
|
|---|
| 111 | * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
|
|---|
| 112 | *
|
|---|
| 113 | * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
|
|---|
| 114 | * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
|
|---|
| 115 | * omdat hij per constructie op DEZELFDE host ligt.
|
|---|
| 116 | *
|
|---|
| 117 | * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
|
|---|
| 118 | * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
|
|---|
| 119 | * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
|
|---|
| 120 | */
|
|---|
| 121 | export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
|
|---|
| 122 | const h = parseHandle(handle);
|
|---|
| 123 | if (!h) return null;
|
|---|
| 124 | const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
|
|---|
| 125 | let href = null;
|
|---|
| 126 | try {
|
|---|
| 127 | const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
|
|---|
| 128 | if (r.ok) {
|
|---|
| 129 | const jrd = await r.json();
|
|---|
| 130 | const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
|
|---|
| 131 | if (link) href = link.href;
|
|---|
| 132 | }
|
|---|
| 133 | } catch { /* geen webfinger: hieronder de terugval */ }
|
|---|
| 134 | if (!href) href = `https://${h.host}/magic`;
|
|---|
| 135 | try {
|
|---|
| 136 | if (new URL(href).host.toLowerCase() !== h.host) return null; // open redirect
|
|---|
| 137 | } catch { return null; }
|
|---|
| 138 | return { endpoint: href, handle: h };
|
|---|
| 139 | }
|
|---|
| 140 |
|
|---|
| 141 | /** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
|
|---|
| 142 | export function toBdest(url) {
|
|---|
| 143 | return Buffer.from(String(url), 'utf8').toString('hex');
|
|---|
| 144 | }
|
|---|
| 145 |
|
|---|
| 146 | /**
|
|---|
| 147 | * De URL waar we de bezoeker heen sturen.
|
|---|
| 148 | *
|
|---|
| 149 | * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
|
|---|
| 150 | * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
|
|---|
| 151 | * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
|
|---|
| 152 | * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
|
|---|
| 153 | */
|
|---|
| 154 | export function buildRedirect(endpoint, returnUrl) {
|
|---|
| 155 | const u = new URL(endpoint);
|
|---|
| 156 | u.searchParams.set('owa', '1');
|
|---|
| 157 | u.searchParams.set('bdest', toBdest(returnUrl));
|
|---|
| 158 | return u.toString();
|
|---|
| 159 | }
|
|---|
| 160 |
|
|---|
| 161 | // ── wie is er binnen ──────────────────────────────────────────────────────
|
|---|
| 162 |
|
|---|
| 163 | /** De actor die deze sessie bewees te zijn, of null. */
|
|---|
| 164 | export function guestActor(req) {
|
|---|
| 165 | const g = req && req.session && req.session.owa;
|
|---|
| 166 | return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
|
|---|
| 167 | }
|
|---|
| 168 |
|
|---|
| 169 | /** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
|
|---|
| 170 | export function isFollowerOf(slug, actorUri) {
|
|---|
| 171 | if (!slug || !actorUri) return false;
|
|---|
| 172 | const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
|
|---|
| 173 | .get(String(slug), String(actorUri));
|
|---|
| 174 | return !!row;
|
|---|
| 175 | }
|
|---|
| 176 |
|
|---|
| 177 | /**
|
|---|
| 178 | * Alles wat een poort over deze bezoeker moet weten, op één plek.
|
|---|
| 179 | *
|
|---|
| 180 | * Bewust hier en niet in PostAccessService: die module beslist en raakt de
|
|---|
| 181 | * database niet aan. Deze haalt op, die beslist.
|
|---|
| 182 | */
|
|---|
| 183 | export function viewerFor(req, site, extra = {}) {
|
|---|
| 184 | const actor = guestActor(req);
|
|---|
| 185 | return {
|
|---|
| 186 | user: (req && req.session && req.session.user) || null,
|
|---|
| 187 | site: site || null,
|
|---|
| 188 | fediActor: actor,
|
|---|
| 189 | isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
|
|---|
| 190 | ...extra,
|
|---|
| 191 | };
|
|---|
| 192 | }
|
|---|
| 193 |
|
|---|
| 194 | export default {
|
|---|
| 195 | TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
|
|---|
| 196 | sweepTokens, issueToken, redeemToken, encryptTokenFor,
|
|---|
| 197 | parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
|
|---|
| 198 | guestActor, isFollowerOf, viewerFor,
|
|---|
| 199 | };
|
|---|