| 1 | /**
|
|---|
| 2 | * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
|
|---|
| 3 | *
|
|---|
| 4 | * POST /owa/login de bezoeker geeft zijn adres, wij sturen hem naar huis
|
|---|
| 5 | * ALL /owa/token zijn server vraagt ondertekend om een token (stap 2/3)
|
|---|
| 6 | * GET /owa/logout weer anoniem
|
|---|
| 7 | *
|
|---|
| 8 | * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
|
|---|
| 9 | *
|
|---|
| 10 | * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
|
|---|
| 11 | * die de FEP beschrijft.
|
|---|
| 12 | */
|
|---|
| 13 | import express from 'express';
|
|---|
| 14 | import * as AP from '../services/ActivityPubService.js';
|
|---|
| 15 | import * as OWA from '../services/OpenWebAuthService.js';
|
|---|
| 16 | import db from '../config/database.js';
|
|---|
| 17 | import { renderPage } from '../middleware/render.js';
|
|---|
| 18 | import { owaMagicLimiter } from '../middleware/rate-limit.js';
|
|---|
| 19 |
|
|---|
| 20 | const router = express.Router();
|
|---|
| 21 |
|
|---|
| 22 | /** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
|
|---|
| 23 | function returnUrlFor(req, path) {
|
|---|
| 24 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 25 | // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
|
|---|
| 26 | // hoe een aanmeldformulier een open redirect wordt.
|
|---|
| 27 | let p = String(path || '/');
|
|---|
| 28 | if (!p.startsWith('/') || p.startsWith('//')) p = '/';
|
|---|
| 29 | return base + p;
|
|---|
| 30 | }
|
|---|
| 31 |
|
|---|
| 32 | /**
|
|---|
| 33 | * De handtekening staat hier in `Authorization`, niet in `Signature`.
|
|---|
| 34 | *
|
|---|
| 35 | * De FEP is er stellig over: "An OpenWebAuth signed request must have an
|
|---|
| 36 | * Authorization header starting with the word Signature". De rest van de
|
|---|
| 37 | * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals
|
|---|
| 38 | * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding.
|
|---|
| 39 | *
|
|---|
| 40 | * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte --
|
|---|
| 41 | * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste
|
|---|
| 42 | * interop-proef dat aan het licht brengen.
|
|---|
| 43 | *
|
|---|
| 44 | * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een
|
|---|
| 45 | * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd.
|
|---|
| 46 | */
|
|---|
| 47 | function metSignatureHeader(req) {
|
|---|
| 48 | const auth = String((req.headers && req.headers.authorization) || '');
|
|---|
| 49 | if (req.headers && req.headers.signature) return req; // al in de gewone vorm
|
|---|
| 50 | if (!/^signature\s+/i.test(auth)) return req; // niets te vertalen
|
|---|
| 51 | return {
|
|---|
| 52 | method: req.method,
|
|---|
| 53 | originalUrl: req.originalUrl,
|
|---|
| 54 | rawBody: req.rawBody,
|
|---|
| 55 | headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') },
|
|---|
| 56 | };
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| 59 | /**
|
|---|
| 60 | * Stap 2/3: de home instance vraagt ondertekend om een token.
|
|---|
| 61 | *
|
|---|
| 62 | * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
|
|---|
| 63 | * willekeurig lijf sturen.
|
|---|
| 64 | *
|
|---|
| 65 | * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
|
|---|
| 66 | * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
|
|---|
| 67 | * (anders host je een document met andermans id naast je eigen sleutel), een
|
|---|
| 68 | * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
|
|---|
| 69 | * implementatie van "is deze aanvraag echt van wie hij zegt".
|
|---|
| 70 | */
|
|---|
| 71 | router.all('/owa/token', async (req, res) => {
|
|---|
| 72 | let verified = null;
|
|---|
| 73 | try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
|
|---|
| 74 | if (!verified || !verified.id) {
|
|---|
| 75 | return res.status(401).json({ success: false });
|
|---|
| 76 | }
|
|---|
| 77 | const pem = verified.publicKey && verified.publicKey.publicKeyPem;
|
|---|
| 78 | if (!pem) return res.status(400).json({ success: false });
|
|---|
| 79 |
|
|---|
| 80 | try {
|
|---|
| 81 | const token = OWA.issueToken(verified.id);
|
|---|
| 82 | // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
|
|---|
| 83 | // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
|
|---|
| 84 | // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
|
|---|
| 85 | return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
|
|---|
| 86 | } catch (e) {
|
|---|
| 87 | console.warn('[owa] token uitgeven mislukte:', e && e.message);
|
|---|
| 88 | return res.status(500).json({ success: false });
|
|---|
| 89 | }
|
|---|
| 90 | });
|
|---|
| 91 |
|
|---|
| 92 | /**
|
|---|
| 93 | * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
|
|---|
| 94 | *
|
|---|
| 95 | * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
|
|---|
| 96 | * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
|
|---|
| 97 | * FEP). Een `zid`/handle is niet meer dan een routeringshint.
|
|---|
| 98 | */
|
|---|
| 99 | router.post('/owa/login', async (req, res) => {
|
|---|
| 100 | const terug = returnUrlFor(req, req.body && req.body.next);
|
|---|
| 101 | const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
|
|---|
| 102 | if (!found) {
|
|---|
| 103 | const u = new URL(terug);
|
|---|
| 104 | u.searchParams.set('owa_error', '1');
|
|---|
| 105 | return res.redirect(u.toString());
|
|---|
| 106 | }
|
|---|
| 107 | return res.redirect(OWA.buildRedirect(found.endpoint, terug));
|
|---|
| 108 | });
|
|---|
| 109 |
|
|---|
| 110 | /**
|
|---|
| 111 | * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site.
|
|---|
| 112 | *
|
|---|
| 113 | * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker
|
|---|
| 114 | * hierheen; wij halen daar ondertekend een token op, ontsleutelen het met onze
|
|---|
| 115 | * eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar
|
|---|
| 116 | * die sleutel nodig is -- en meteen de reden dat alleen een echte instance deze
|
|---|
| 117 | * kant kan spelen.
|
|---|
| 118 | *
|
|---|
| 119 | * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het
|
|---|
| 120 | * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een,
|
|---|
| 121 | * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook
|
|---|
| 122 | * echt beheert.
|
|---|
| 123 | *
|
|---|
| 124 | * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information
|
|---|
| 125 | * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die
|
|---|
| 126 | * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen
|
|---|
| 127 | * server is het enige moment waarop je kunt zeggen: deze site niet.
|
|---|
| 128 | */
|
|---|
| 129 | function eigenSites(user) {
|
|---|
| 130 | if (!user || !user.id) return [];
|
|---|
| 131 | return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC')
|
|---|
| 132 | .all(user.id);
|
|---|
| 133 | }
|
|---|
| 134 |
|
|---|
| 135 | /** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */
|
|---|
| 136 | function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
|
|---|
| 137 |
|
|---|
| 138 | router.get('/magic', (req, res) => {
|
|---|
| 139 | const bdest = OWA.fromBdest(req.query && req.query.bdest);
|
|---|
| 140 | if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
|
|---|
| 141 |
|
|---|
| 142 | // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm --
|
|---|
| 143 | // met de bdest nog intact, anders is de hele stroom weg.
|
|---|
| 144 | if (!(req.session && req.session.user)) {
|
|---|
| 145 | const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest));
|
|---|
| 146 | return res.redirect('/auth/login?next=' + encodeURIComponent(terug));
|
|---|
| 147 | }
|
|---|
| 148 |
|
|---|
| 149 | const sites = eigenSites(req.session.user);
|
|---|
| 150 | if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen');
|
|---|
| 151 |
|
|---|
| 152 | return renderPage(req, res, 'pages/owa-consent', {
|
|---|
| 153 | pageTitle: 'Aanmelden bij ' + doelHost(bdest.href),
|
|---|
| 154 | bodyClass: 'on-special',
|
|---|
| 155 | owaHost: doelHost(bdest.href),
|
|---|
| 156 | owaBdest: String(req.query.bdest),
|
|---|
| 157 | owaSites: sites,
|
|---|
| 158 | });
|
|---|
| 159 | });
|
|---|
| 160 |
|
|---|
| 161 | /**
|
|---|
| 162 | * De gebruiker zei ja. Nu pas gaan we tekenen.
|
|---|
| 163 | *
|
|---|
| 164 | * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de
|
|---|
| 165 | * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen,
|
|---|
| 166 | * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een
|
|---|
| 167 | * foutpagina geeft en geen redirect.
|
|---|
| 168 | */
|
|---|
| 169 | router.post('/magic', owaMagicLimiter, async (req, res) => {
|
|---|
| 170 | const bdest = OWA.fromBdest(req.body && req.body.bdest);
|
|---|
| 171 | if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
|
|---|
| 172 | if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd');
|
|---|
| 173 |
|
|---|
| 174 | // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans
|
|---|
| 175 | // sleutel, en dat is precies het gat dat je hier niet wilt.
|
|---|
| 176 | const sites = eigenSites(req.session.user);
|
|---|
| 177 | const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0];
|
|---|
| 178 | if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor');
|
|---|
| 179 |
|
|---|
| 180 | const endpoint = await OWA.discoverTokenEndpoint(bdest.href);
|
|---|
| 181 | if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan');
|
|---|
| 182 |
|
|---|
| 183 | try {
|
|---|
| 184 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 185 | const keys = AP.getOrCreateKeys(gekozen.slug);
|
|---|
| 186 | const keyId = AP.actorId(base, gekozen.slug) + '#main-key';
|
|---|
| 187 | const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem });
|
|---|
| 188 | if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen');
|
|---|
| 189 | const token = OWA.decryptToken(versleuteld, keys.private_pem);
|
|---|
| 190 | if (!token) return res.status(502).type('text/plain').send('token onleesbaar');
|
|---|
| 191 | const terug = new URL(bdest.href);
|
|---|
| 192 | terug.searchParams.set('owt', token);
|
|---|
| 193 | return res.redirect(terug.toString());
|
|---|
| 194 | } catch (e) {
|
|---|
| 195 | console.warn('[owa] /magic mislukte:', e && e.message);
|
|---|
| 196 | return res.status(502).type('text/plain').send('aanmelden mislukte');
|
|---|
| 197 | }
|
|---|
| 198 | });
|
|---|
| 199 |
|
|---|
| 200 | /** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
|
|---|
| 201 | router.get('/owa/logout', (req, res) => {
|
|---|
| 202 | if (req.session) delete req.session.owa;
|
|---|
| 203 | res.redirect(returnUrlFor(req, req.query && req.query.next));
|
|---|
| 204 | });
|
|---|
| 205 |
|
|---|
| 206 | /**
|
|---|
| 207 | * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
|
|---|
| 208 | *
|
|---|
| 209 | * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
|
|---|
| 210 | * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
|
|---|
| 211 | */
|
|---|
| 212 | export function owaMiddleware(req, res, next) {
|
|---|
| 213 | if (req.method !== 'GET' || !req.query) return next();
|
|---|
| 214 |
|
|---|
| 215 | if (req.query.owt) {
|
|---|
| 216 | let actor = null;
|
|---|
| 217 | try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
|
|---|
| 218 | if (actor) req.session.owa = { actor, at: Date.now() };
|
|---|
| 219 | // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
|
|---|
| 220 | // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
|
|---|
| 221 | const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
|
|---|
| 222 | u.searchParams.delete('owt');
|
|---|
| 223 | u.searchParams.delete('zid');
|
|---|
| 224 | return res.redirect(u.pathname + (u.search || ''));
|
|---|
| 225 | }
|
|---|
| 226 |
|
|---|
| 227 | if (req.query.zid && !OWA.guestActor(req)) {
|
|---|
| 228 | const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
|
|---|
| 229 | u.searchParams.delete('zid');
|
|---|
| 230 | const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
|
|---|
| 231 | // Geen await in een middleware-keten die verder synchroon is: bij een fout
|
|---|
| 232 | // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
|
|---|
| 233 | OWA.discoverRedirectEndpoint(req.query.zid)
|
|---|
| 234 | .then((found) => {
|
|---|
| 235 | if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
|
|---|
| 236 | return res.redirect(terug);
|
|---|
| 237 | })
|
|---|
| 238 | .catch(() => res.redirect(terug));
|
|---|
| 239 | return;
|
|---|
| 240 | }
|
|---|
| 241 |
|
|---|
| 242 | return next();
|
|---|
| 243 | }
|
|---|
| 244 |
|
|---|
| 245 | export default router;
|
|---|