source: Klonkt/src/services/PostAccessService.js

main
Last change on this file was af2cc73, checked in by Bart <bart@…>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 5.2 KB
Line 
1/**
2 * Wat mag DEZE bezoeker van DIT bericht zien?
3 *
4 * De vraag stond tot nu toe alleen in de postroute, en daar werd hij beantwoord
5 * door een ANDERE PAGINA te renderen: paid-gate of fan-gate in plaats van het
6 * bericht. Dat werkt zolang een bericht een pagina is. Zodra de tijdlijn hele
7 * berichten toont, moet hetzelfde besluit een STUK opleveren dat tussen de
8 * andere berichten past -- en twee plekken die allebei zelf beslissen wie wat
9 * mag zien, zijn een lek dat wacht op een gelegenheid.
10 *
11 * Dus één functie, en de route en de tijdlijn lezen er allebei uit. Deze module
12 * bepaalt alleen WAT er mag; hoe het eruitziet is aan de sjablonen.
13 */
14import PermissionsService from './PermissionsService.js';
15
16/**
17 * De teaser van een betaald bericht: nooit meer dan de eerste alinea.
18 *
19 * Stond als paidTeaser() in routes/posts.js. Hier neergezet omdat de tijdlijn
20 * hem ook nodig heeft, en een tweede kopie vroeg of laat meer prijsgeeft dan
21 * deze -- precies de fout die je bij een betaalmuur niet wilt maken.
22 */
23export function paidTeaser(post, max = 280) {
24 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
25 const html = String((post && post.content) || '');
26 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
27 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
28 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
29}
30
31/**
32 * Het besluit, als één waarde.
33 *
34 * 'full' → de hele inhoud
35 * 'paid' → betaalmuur: teaser + ontgrendelknop
36 * 'fan' → alleen voor fans: teaser + inloggen
37 * 'forbidden' → niet gepubliceerd en jij mag het niet bewerken
38 *
39 * De VOLGORDE is niet vrijblijvend. Betaald wordt vóór fan-only getoetst, want
40 * een bericht dat allebei is gaat open met een passkey en niet met een
41 * Klonkt-login; andersom belandt een anonieme supporter op het inlogscherm en
42 * ziet hij de ontgrendelknop nooit. Die regel stond als commentaar in de route
43 * en verhuist hier mee, want hij hoort bij het besluit en niet bij de pagina.
44 *
45 * @param post de rij uit `posts`
46 * @param viewer { user, site, unlockedSlug, isFollower } -- unlockedSlug is de
47 * slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd
48 * door de aanroeper; isFollower komt uit OpenWebAuthService en
49 * zegt dat een bewezen fedi-actor deze site volgt. Deze module
50 * doet geen crypto en raakt de database niet aan: ophalen doet
51 * de aanroeper, beslissen doet deze.
52 */
53export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
54 if (!post) return 'forbidden';
55
56 const canEdit = !!(user && PermissionsService.canEditPost(user, post, site));
57
58 // Een concept is van de maker. Dit stond vóór beide poorten in de route en
59 // hoort dat te blijven: over een ongepubliceerd bericht valt niets te kopen
60 // en niets te ontgrendelen.
61 if (post.status !== 'published' && !canEdit) return 'forbidden';
62
63 // De eigenaar/redacteur ziet altijd zijn eigen werk, betaald of niet.
64 if (canEdit) return 'full';
65
66 if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
67 // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort
68 // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen
69 // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de
70 // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn
71 // en die deze site volgt. Wie dat bewijs levert, komt binnen.
72 //
73 // Het lokale account blijft er ook door, zoals het altijd deed: dat is een
74 // andere manier om te weten wie iemand is, niet een mindere.
75 if (post.fan_only && !user && !isFollower) return 'fan';
76 return 'full';
77}
78
79/** Handig voor sjablonen: mag de bezoeker de echte inhoud zien? */
80export function canReadBody(post, viewer) { return postAccess(post, viewer) === 'full'; }
81
82/**
83 * Eén bericht, klaar voor de tijdlijn.
84 *
85 * DE HELE REDEN DAT DIT EEN FUNCTIE IS: het lijf wordt alleen GERENDERD als het
86 * ook getoond mag worden. Een sjabloon dat zelf `<% if (access === 'full') %>`
87 * doet krijgt de volledige tekst tóch mee in het model, en dan is het één
88 * vergeten conditie -- of één `?partial=1` die net iets anders samenstelt --
89 * tussen een betaalmuur en de tekst erachter. Hier komt hij niet eens mee.
90 *
91 * `renderBody` wordt ingespoten (posts.js levert renderPostBodyHtml), zodat
92 * deze module niets van routes of sjablonen hoeft te weten.
93 */
94export function postEntry(post, viewer, { renderBody } = {}) {
95 const access = postAccess(post, viewer);
96 const full = access === 'full';
97 return {
98 slug: post && post.slug,
99 title: (post && post.title) || '',
100 access,
101 // Alleen bij een gesloten poort een teaser, en nooit allebei: de teaser is
102 // de vervanging van het lijf, niet een voorproefje ernaast.
103 teaser: full || access === 'forbidden' ? null : paidTeaser(post),
104 content_html: full && typeof renderBody === 'function' ? renderBody(post) : null,
105 };
106}
107
108export default { postAccess, canReadBody, paidTeaser, postEntry };
Note: See TracBrowser for help on using the repository browser.