Iemand wil een project laten zien aan een klant die geen account heeft. De voor de hand liggende oplossing is een korte code: zes cijfers, vervalt na vijf minuten, de gast voert hem in en is binnen. Zo heb ik het gebouwd in een tool voor gedeelde notities en bestanden, en het werkte vanaf dag één. Het probleem was niet dat het niet werkte. Het probleem was dat ik het drie maanden later twee keer opnieuw moest doen.
Drie iteraties, en de derde had de eerste moeten zijn.
Wat een korte code wel doet en wat niet
De eerste tabel was letterlijk dit:
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
Twee verschillende vervaltijden, en dat was vanaf het begin wel goed: code_expires_at is hoe lang het duurt voordat de code sterft als niemand hem gebruikt (vijf minuten), en session_duration_minutes is hoe lang het bezoek duurt zodra je binnen bent. Dat zijn verschillende dingen en ze door elkaar halen is een klassieke fout. Een code die acht uur leeft in een WhatsApp-gesprek is een gat; een sessie die na vijf minuten wordt afgekapt is onbruikbaar.
Hiermee heb je al rechten (read of read_write), een koppeltabel om te bepalen tot welke projecten de code toegang geeft, en een vervaltijd. Het lijkt compleet. Dat is het niet: de code identificeert niemand. Hij identificeert de uitnodiging, niet de gast.
Eerste poging: het id van de rij doorgeven
De eerste correctie was om de code van zes cijfers niet meer bij elk verzoek mee te sturen, maar het id van de rij. De client bewaarde een codeId, de backend loste die op, en de verzoeken gingen met Authorization: Bearer <codeId>.
Dit lost één echt probleem op: de code van zes cijfers reist niet meer mee. Hij staat niet meer in een toegangslog of in een URL. Maar het introduceert iets ergers. De codeId is een auto-increment integer. Hij is te raden. Hij is opsombaar. Iedereen met een geldig id kan het id ernaast proberen en, als die sessie nog leeft, komt hij binnen. Ik ruilde een kort geheim in voor een identifier die helemaal geen geheim is.
En het lost het oorspronkelijke probleem nog steeds niet op, namelijk dit: als twee mensen dezelfde uitnodiging gebruiken, delen ze dezelfde codeId. Je kunt de één er niet uit gooien zonder de ander. Je kunt niet weten wie van de twee het bestand heeft verwijderd.
Wat het sessietoken wel oplost
De derde versie scheidt de twee dingen die drie maanden lang samen zaten. De code van zes cijfers is een inwisseling: hij wordt één keer gebruikt, op het verbindingsendpoint, en daarna nooit meer. Wat daaruit komt is een sessietoken:
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
Het antwoord geeft geen codeId meer terug, maar een sessionToken. Vanaf daar sleept de hele frontend die mee: waar guestCodeId stond, kwam guestSessionToken, en de !!guestCodeId die bepaalde of iemand gast was, werd !!guestSessionToken.
Wat er echt verandert:
- Intrekken. Het
sessionTokenop null zetten in één rij gooit die sessie eruit en alleen die. Met de code had je niets om op te richten. - Auditen. Het token staat in de rij, dus elke actie is toe te schrijven aan een concrete inwisseling en een concreet tijdstip.
- Niet te raden. Het is geen opeenvolgend geheel getal meer. Vier willekeurige bytes in hexadecimaal som je niet op.
- Eén plek waar het vervalt. De
sessionExpiresAtwordt geschreven in dezelfde update die het token uitgeeft. Er zijn geen twee klokken die uit de pas kunnen lopen.
Wat de audit vond, en ik niet
Maanden later heb ik de code een audit gegeven. Er kwam van alles uit — een PDF-exportroute zonder enige authenticatieguard is het ergste van de lijst — maar het punt waar ik het meest over nadacht, noteerde ik zelf in de TODO: ook al wordt de code van zes verbruikt, wat hij teruggeeft is een token van zes plus vier, en dát is degene die authenticeert.
Daar zit de fout. De korte code wordt beschermd door zijn leven van vijf minuten en door het feit dat hij maar één keer te gebruiken is. Het sessietoken heeft geen van die twee dingen: het leeft uren en wordt bij elk verzoek gebruikt. Van code naar token gaan haalt één vector weg en opent een andere, en dat merk je alleen als je er achteraf met een koel hoofd naar kijkt. Vier bytes zijn genoeg tegen toevallige enumeratie, niet tegen iemand met acht uur en een loop.
Het andere wat eruit kwam, en dat is uit het boekje, is dat het verbindingsendpoint niets in een transactie verpakte. Tussen het lezen dat de code niet gebruikt is en het markeren als gebruikt zit een venster. Twee gelijktijdige verzoeken met dezelfde code kwamen allebei door.
Bannen op IP is fragieler dan het lijkt
De teller van mislukte pogingen moet weten van welk IP het verzoek komt, en daar gaat bijna iedereen op dezelfde manier de fout in. In een ander project had ik een permanente ban op IP en pakte ik de eerste waarde uit x-forwarded-for. Die header is een lijst client, proxy1, proxy2: elke proxy voegt rechts toe, maar niemand ruimt op wat er al stond. De eerste pakken is iets pakken dat de client zelf kan verzinnen.
Ik heb het veranderd naar tellen vanaf het einde, op basis van hoeveel vertrouwde proxy's ervoor staan:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Met één proxy ervoor is de laatste waarde degene die de proxy schrijft en waar de client niet aan kan komen. Als je de eerste pakt, ontwijkt iedereen zijn ban door rommel te sturen, of erger nog, bant hij iemand anders.
De code van zes cijfers was niet het moeilijke deel. Het moeilijke deel was accepteren dat een uitnodiging en een sessie twee verschillende objecten zijn, en dat je door ze te vermengen niets kunt intrekken, niets kunt auditen en een opeenvolgende identifier als credential gebruikt.
