Algú vol ensenyar un projecte a un client que no té compte. La solució òbvia és un codi curt: sis dígits, caduca en cinc minuts, el convidat l'introdueix i entra. Ho vaig implementar així en una eina de notes i fitxers compartits, i va funcionar des del primer dia. El problema no va ser que no funcionés. Va ser que tres mesos després ho vaig haver de refer dues vegades.
Tres iteracions, i la tercera és la que hauria d'haver estat la primera.
Què fa un codi curt i què no
La primera taula era literalment això:
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
);
Dos venciments diferents, i això sí que va estar bé des del principi: code_expires_at és el que triga el codi a morir-se si ningú no el fa servir (cinc minuts), i session_duration_minutes és el que dura la visita un cop a dins. Són coses diferents i barrejar-les és un error clàssic. Un codi que viu vuit hores per WhatsApp és un forat; una sessió que es talla als cinc minuts és inusable.
Amb això ja tens permisos (read o read_write), una taula pont per decidir a quins projectes dona accés el codi, i caducitat. Sembla complet. No ho és: el codi no identifica ningú. Identifica la invitació, no el convidat.
Primer intent: passar l'id de la fila
La primera correcció va ser deixar d'enviar el codi de sis dígits a cada petició i enviar l'id de la fila. El client desava un codeId, el backend el resolia, i les peticions anaven amb Authorization: Bearer <codeId>.
Això arregla una cosa real: el codi de sis dígits deixa de viatjar. Ja no és en un log d'accés ni en una URL. Però introdueix una cosa pitjor. El codeId és un enter autoincremental. És endevinable. És enumerable. Qualsevol amb un id vàlid pot provar el del costat i, si aquella sessió continua viva, entra. Vaig canviar un secret curt per un identificador que no és gens secret.
I continua sense resoldre el problema original, que era aquest: si dues persones fan servir la mateixa invitació, comparteixen el mateix codeId. No en pots fer fora una sense fer fora l'altra. No pots saber quina de les dues va esborrar el fitxer.
Què resol el token de sessió
La tercera versió separa les dues coses que feia tres mesos que anaven juntes. El codi de sis dígits és un bescanvi: es fa servir una vegada, a l'endpoint de connectar, i mai més. El que en surt és un token de sessió:
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));
La resposta ja no retorna codeId, retorna sessionToken. A partir d'aquí tot el frontend l'arrossega: on hi havia guestCodeId va passar a haver-hi guestSessionToken, i el !!guestCodeId que decidia si algú era convidat es va convertir en !!guestSessionToken.
El que canvia de debò:
- Revocar. Posar el
sessionTokena null en una fila fa fora aquella sessió i només aquella. Amb el codi no tenies a qui apuntar. - Auditar. El token és a la fila, així que cada acció es pot atribuir a un bescanvi concret i a una hora concreta.
- No endevinable. Ja no és un enter correlatiu. Quatre bytes aleatoris en hexadecimal no s'enumeren.
- Un sol lloc on caduca. El
sessionExpiresAts'escriu al mateix update que emet el token. No hi ha dos rellotges que es puguin desincronitzar.
El que va trobar l'auditoria, i jo no
Mesos després vaig passar una auditoria al codi. En va sortir de tot — una ruta d'exportació a PDF sense cap guard d'autenticació és el pitjor de la llista — però la que més em va fer pensar la vaig apuntar jo mateix al TODO: encara que el codi de sis es consumeixi, el que retorna és un token de sis més quatre, i aquest és el que autentica.
Aquí hi ha la fallada. El codi curt està protegit per la seva vida de cinc minuts i pel fet que només es pot fer servir una vegada. El token de sessió no té cap d'aquestes dues coses: dura hores i es fa servir a cada petició. Canviar de codi a token treu un vector i n'obre un altre, i només te n'adones si ho mires després amb el cap fred. Quatre bytes són suficients contra l'enumeració casual, no contra algú que tingui vuit hores i un bucle.
L'altra cosa que va sortir, i que és de manual, és que l'endpoint de connectar no embolcallava res en una transacció. Entre llegir que el codi no està fet servir i marcar-lo com a fet servir hi ha una finestra. Dues peticions simultànies amb el mateix codi passaven totes dues.
Bandejar per IP és més fràgil del que sembla
El comptador d'intents fallits necessita saber de quina IP ve la petició, i és aquí on gairebé tothom s'equivoca igual. En un altre projecte tenia bandejament permanent per IP i agafava el primer valor d'x-forwarded-for. Aquesta capçalera és una llista client, proxy1, proxy2: cada proxy afegeix a la dreta, però ningú no neteja el que ja hi havia escrit. Agafar el primer és agafar una cosa que el client es pot inventar.
Ho vaig canviar a comptar des del final segons quants proxies de confiança hi ha al davant:
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];
Amb un proxy al davant, l'últim valor és el que escriu el proxy i el client no pot tocar. Si agafes el primer, qualsevol esquiva el seu bandejament enviant escombraries, o pitjor, en bandeja un altre.
El codi de sis dígits no era la part difícil. La part difícil era acceptar que una invitació i una sessió són dos objectes diferents, i que barrejar-los et deixa sense poder revocar, sense poder auditar i amb un identificador correlatiu fent de credencial.
