L'API de la Botiga Aroma funciona, persisteix i valida, però ara mateix qualsevol amb curl pot apujar el preu d'un cafè, esborrar el catàleg o llegir les comandes de tots els clients. Avui tanquem aquella porta amb les dues preguntes que tota API ha de saber respondre: qui ets i què pots fer. Són preguntes diferents, tenen codis HTTP diferents —401 i 403, que el contracte separa des de 02-04— i es resolen amb mecanismes diferents. Implementarem el registre de clients amb la contrasenya protegida amb bcrypt, l'inici de sessió que emet un JWT signat, el middleware que el verifica i distingeix un token absent d'un de caducat, els rols de la Botiga Aroma amb la seva matriu de permisos, i l'autorització a nivell de recurs, que és la que de debò impedeix que la Marta llegeixi les comandes d'un altre client.
Contingut
- Autenticació i autorització no són el mateix
- Panoràmica de mecanismes d'autenticació
- Per què una API REST sense estat encaixa amb tokens
- El model de clients i el seu repositori
- Contrasenyes: per què bcrypt i mai text pla
- Registre:
POST /v1/clients - Inici de sessió:
POST /v1/sessions - Anatomia d'un JWT
- Els claims i què no ficar mai al payload
- El middleware d'autenticació
401ben fet:WWW-Authenticatei els dos codis del catàleg- Autorització per rol:
exigirRol - Autorització a nivell de recurs
403o404: quan ocultar l'existència- La matriu de permisos de la Botiga Aroma
- Caducitat, tokens de refresc i revocació
- On desa el token el client
- Autenticació i autorització no són el mateix
| Autenticació | Autorització | |
|---|---|---|
| Pregunta | Qui ets? | Pots fer això? |
| Moment | Primer | Després |
| Codi HTTP | 401 Unauthorized | 403 Forbidden |
| Capçalera obligatòria | WWW-Authenticate |
Cap |
| Solució per al client | Autentica't o renova el token | Cap: no insisteixis |
| Codis del catàleg | no_autenticat, token_caducat |
permisos_insuficients |
La confusió més estesa a les APIs reals és retornar 401 quan toca 403. La diferència és operativa, no acadèmica:
401significa "no sé qui ets, o ja no m'ho crec". El client ho pot arreglar: renova el token, torna a iniciar sessió i ho reintenta. Reintentar-ho té sentit.403significa "sé perfectament qui ets i no tens permís". Reintentar-ho amb el mateix token donarà sempre el mateix. Reintentar-ho no té sentit.
Un client ben escrit automatitza la reacció al 401 —renovar i repetir— i mostra un missatge a l'usuari davant d'un 403. Si barreges els codis, la SPA de la Botiga Aroma entrarà en un bucle de renovació infinit davant d'un permís denegat.
Un apunt històric que despista: el nom oficial del 401 al RFC és Unauthorized, quan hauria de ser Unauthenticated. És un error de nomenclatura del 1997 que ja no es pot corregir. Fia't de la semàntica, no del nom.
- Panoràmica de mecanismes d'autenticació
| Mecanisme | Com viatja | A favor | En contra | Ús típic |
|---|---|---|---|---|
| Basic | Authorization: Basic base64(usuari:clau) |
Trivial d'implementar | Envia la contrasenya a cada petició; només acceptable sobre HTTPS i ni així | Eines internes, prototips |
| Clau d'API | Capçalera pròpia o Authorization |
Simple, bona per a servidor a servidor | No identifica una persona, no caduca sola, difícil de rotar | Integracions de socis |
| Sessió amb galeta | Cookie: sessio=abc, estat al servidor |
Revocació immediata, el navegador la gestiona | Amb estat, complica l'escalat, exposada a CSRF | Aplicacions web clàssiques |
| JWT (Bearer) | Authorization: Bearer <token> |
Sense estat, verificable sense consultar la BD, porta claims | No es pot revocar de manera senzilla, el payload és llegible | APIs REST modernes |
| OAuth 2.0 / OIDC | Bearer emès per un tercer | Delegació d'accés, "entrar amb Google" | Complexitat notable | Accés de tercers, SSO |
La Botiga Aroma fa servir JWT per als seus consumidors propis (SPA, Aroma Mòbil, panell intern) i claus d'API per al soci RàpidEnviaments, que és una màquina. OAuth 2.0 i OpenID Connect —la delegació d'accés a tercers i l'"entrar amb…"— es desenvolupen sencers a 04-03; aquí només cal situar-los: OAuth no substitueix el que construirem, sinó que hi afegeix a sobre un protocol perquè un altre emeti els tokens.
- Per què una API REST sense estat encaixa amb tokens
A 01-04 vam fixar la restricció d'absència d'estat: cada petició conté tot el que cal per ser atesa, i el servidor no desa context de sessió entre peticions.
Una sessió amb galeta trenca això: el servidor desa sessio_abc → client cli_842 en memòria o a Redis, i tota petició depèn d'aquell magatzem. Conseqüències: si hi ha tres instàncies darrere d'un balancejador, o comparteixen magatzem de sessions o cal afinitat de sessió; i aquell magatzem és un punt únic de fallada.
Un token signat inverteix el model: la informació viatja amb la petició i el servidor només comprova la signatura. Qualsevol instància la pot atendre sense consultar res.
| Sessió amb galeta | Token signat | |
|---|---|---|
| On viu la identitat | Al servidor | Al token, al client |
| Escalat horitzontal | Requereix magatzem compartit | Immediat |
| Cost per petició | Consulta al magatzem | Verificació de signatura (microsegons) |
| Revocació | Immediata: s'esborra | Difícil: el token continua sent vàlid |
| Encaixa amb REST | Regular | Sí |
Aquest "difícil" de la revocació és la contrapartida real i la tractarem a la secció 16. No hi ha menjar de franc.
- El model de clients i el seu repositori
La taula clients ja existeix des de la migració 001-inicial.sql de 03-05, amb email UNIQUE, hash_contrasenya i rol. Ens falta el seu repositori:
// src/repositoris/clients-sqlite.js
import { baseDades } from '../config/base-dades.js';
function aModel(fila) {
if (!fila) return undefined;
return {
id: fila.id,
nom: fila.nom,
email: fila.email,
hashContrasenya: fila.hash_contrasenya,
rol: fila.rol,
dataCreacio: fila.data_creacio,
};
}
const sentencies = {
perId: baseDades.prepare('SELECT * FROM clients WHERE id = ?'),
perEmail: baseDades.prepare('SELECT * FROM clients WHERE email = ?'),
inserir: baseDades.prepare(`
INSERT INTO clients (id, nom, email, hash_contrasenya, rol, data_creacio)
VALUES (@id, @nom, @email, @hashContrasenya, @rol, @dataCreacio)
`),
seguentNumero: baseDades.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 840) + 1 AS seguent FROM clients"
),
};
export const repositoriClients = {
buscarPerId(id) {
return aModel(sentencies.perId.get(id));
},
buscarPerEmail(email) {
// L'email es normalitza a minúscules SEMPRE, en desar i en cercar:
// '[email protected]' i '[email protected]' són la mateixa persona.
return aModel(sentencies.perEmail.get(email.toLowerCase()));
},
crear({ nom, email, hashContrasenya, rol = 'client' }) {
const id = `cli_${sentencies.seguentNumero.get().seguent}`;
sentencies.inserir.run({
id,
nom,
email: email.toLowerCase(),
hashContrasenya,
rol,
dataCreacio: new Date().toISOString(),
});
return this.buscarPerId(id);
},
};I el mapejador guanya una funció. Fixa't en el que no hi apareix:
// src/serveis/mapejadors.js (afegit)
/**
* Client → representació pública.
* hashContrasenya NO hi apareix. Ni al registre, ni al detall, ni a
* cap col·lecció. Un hash filtrat es pot atacar sense límit fora de
* línia, i a més ningú no té cap motiu per veure'l.
*/
export function clientARepresentacio(client) {
return {
id: client.id,
nom: client.nom,
email: client.email,
rol: client.rol,
dataCreacio: client.dataCreacio,
_links: {
self: { href: `/v1/clients/${client.id}` },
comandes: { href: `/v1/clients/${client.id}/comandes` },
preferencies: { href: `/v1/clients/${client.id}/preferencies` },
},
};
}Construir la representació camp a camp, en comptes de fer { ...client, hashContrasenya: undefined }, és el que garanteix que un camp sensible afegit demà no es filtri per descuit. Llista d'inclusió, mai d'exclusió.
- Contrasenyes: per què bcrypt i mai text pla
Regles, per ordre d'importància:
- Mai no es desa la contrasenya. Ni xifrada: xifrar és reversible, i qui tingui la clau les té totes.
- Es desa un hash, resultat d'una funció irreversible.
- No val qualsevol hash. MD5 i SHA-1 estan trencats. SHA-256 no ho està, però és massa ràpid: una GPU en calcula milers de milions per segon i prova un diccionari sencer en minuts.
- Es fa servir una funció de hash de contrasenyes, dissenyada per ser lenta i amb cost ajustable: bcrypt, scrypt o Argon2.
bcrypt fa dues coses que el defineixen:
- Sal: genera un valor aleatori per contrasenya i l'incorpora al hash. Dos usuaris amb la mateixa contrasenya obtenen hashos diferents, cosa que anul·la les taules precalculades (rainbow tables). La sal va dins del hash resultant; no cal desar-la a part.
- Cost: un paràmetre (per defecte 10, és a dir 2¹⁰ = 1024 iteracions) que es pot apujar amb el temps, a mesura que el maquinari millora.
$2b$10$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhW │ │ │ │ │ │ └── sal (22 car.) └── hash (31 car.) │ └── cost: 10 └── algorisme: 2b (bcrypt)
// src/serveis/autenticacio.js
import bcrypt from 'bcrypt';
/**
* Cost de bcrypt. 10 ≈ 60-100 ms per hash en maquinari normal del 2026.
* Compromís: prou lent per frenar un atac per força bruta,
* prou ràpid per no bloquejar l'inici de sessió. Cada +1 DUPLICA el temps.
*/
const COST_BCRYPT = 10;
/** Genera el hash d'una contrasenya. Asíncron: no bloqueja el bucle. */
export async function hashejarContrasenya(contrasenya) {
return bcrypt.hash(contrasenya, COST_BCRYPT);
}
/**
* Comprova una contrasenya contra el seu hash.
* bcrypt extreu la sal i el cost del mateix hash, així que continua
* funcionant encara que demà apugem COST_BCRYPT a 12.
*/
export async function verificarContrasenya(contrasenya, hash) {
return bcrypt.compare(contrasenya, hash);
}Dos matisos de pes. bcrypt.compare és de temps constant: triga el mateix tant si encerta com si falla, per no filtrar informació pel temps de resposta. I el hash és asíncron a propòsit: 80 ms de càlcul al fil principal bloquejarien totes les altres peticions; bcrypt ho fa al pool de fils de Node.
Com a nota de futur: Argon2 va guanyar la competició de funcions de hash de contrasenyes el 2015 i és la recomanació actual de l'OWASP per a projectes nous, perquè a més de temps consumeix memòria, cosa que penalitza els atacants amb GPU. bcrypt continua sent perfectament acceptable i és l'opció més estesa i provada; fem servir bcrypt per això.
- Registre:
POST /v1/clients
POST /v1/clients// src/esquemes/clients.js
import { z } from 'zod';
export const esquemaRegistre = z
.object({
nom: z.string().trim().min(2, 'El nom necessita almenys 2 caràcters').max(120),
email: z.string().trim().toLowerCase().email('El correu no té un format vàlid'),
contrasenya: z
.string()
.min(10, 'La contrasenya necessita almenys 10 caràcters')
.max(200, 'La contrasenya no pot superar els 200 caràcters'),
})
.strict();
export const esquemaCrearSessio = z
.object({
email: z.string().trim().toLowerCase().email(),
contrasenya: z.string().min(1),
})
.strict();Sobre el màxim de 200 caràcters: bcrypt trunca a 72 bytes, així que un límit generós però explícit evita sorpreses i, sobretot, impedeix que algú enviï una contrasenya de 10 MB per consumir CPU. I sobre el mínim de 10 en comptes de regles del tipus "una majúscula, un número i un símbol": la recomanació moderna del NIST és premiar la longitud i no imposar composicions, que només produeixen Password1! i contrasenyes apuntades en un pòsit.
// src/serveis/autenticacio.js (continuació)
import { repositoriClients } from '../repositoris/clients-sqlite.js';
export const serveiAutenticacio = {
/** Registra un client. Retorna {client} o {error}. */
async registrar({ nom, email, contrasenya }) {
if (repositoriClients.buscarPerEmail(email)) {
// 409: el conflicte és amb l'estat actual, no amb el format.
return { error: 'email_ja_registrat' };
}
const hashContrasenya = await hashejarContrasenya(contrasenya);
const client = repositoriClients.crear({
nom,
email,
hashContrasenya,
rol: 'client', // el rol MAI no el tria qui es registra
});
return { client };
},
};Com va passar amb ruta_no_trobada a 03-02, email_ja_registrat és un codi nou que no era al catàleg de 02-04. Afegir-lo és legítim —el catàleg només creix— i és obligatori documentar-lo a openapi.yaml. El 409 és el codi correcte: el cos era vàlid, el que xoca és l'estat actual del sistema.
La línia del rol és de les més importants de la lliçó. esquemaRegistre és .strict() i no inclou rol, així que un cos amb "rol": "administrador" rep 400 camp_desconegut. I encara que l'esquema fos lax, el servei fixa 'client' literalment. Dues barreres independents contra l'escalada de privilegis, perquè una de sola sempre acaba fallant.
// src/controladors/clients.js
import { serveiAutenticacio } from '../serveis/autenticacio.js';
import { clientARepresentacio } from '../serveis/mapejadors.js';
export const controladorClients = {
/** POST /v1/clients */
async registrar(req, res) {
const { client, error } = await serveiAutenticacio.registrar(req.body);
if (error === 'email_ja_registrat') {
return res.status(409).json({
error: {
codi: 'email_ja_registrat',
missatge: 'Ja existeix un compte amb aquell correu electrònic.',
detalls: [],
},
});
}
res.set('Location', `/v1/clients/${client.id}`);
res.status(201).json(clientARepresentacio(client));
},
};// src/rutes/clients.js
import { Router } from 'express';
import { controladorClients } from '../controladors/clients.js';
import { validar } from '../middleware/validacio.js';
import { esquemaRegistre } from '../esquemes/clients.js';
export const rutesClients = Router();
// Ruta PÚBLICA: per registrar-se no es pot exigir estar registrat.
rutesClients.post('/', validar(esquemaRegistre), controladorClients.registrar);curl -i -s -X POST http://localhost:3000/v1/clients \
-H "Content-Type: application/json" \
-d '{"nom":"Llúcia Ferrer","email":"[email protected]","contrasenya":"torrefaccio-clara-2026"}'HTTP/1.1 201 Created
Location: /v1/clients/cli_843
{"id":"cli_843","nom":"Llúcia Ferrer","email":"[email protected]","rol":"client","dataCreacio":"2026-03-14T10:28:00.000Z","_links":{...}}Ni rastre de la contrasenya ni del hash a la resposta, com ha de ser.
Actualitza la sembra. El
cli_842de la Marta es va sembrar a 03-05 amb el text'pendent-de-03-06'ahash_contrasenya, que no és un hash vàlid i per tant no deixarà entrar mai ningú. Substitueix-lo amigracions/sembrar.jsper un hash real generat ambawait hashejarContrasenya('cafe-especialitat-2026'), i aprofita per sembrar també uncli_001amb rolempleati uncli_002amb roladministrador: els necessitaràs per provar la matriu de permisos i per a les proves de 03-08.
Nota sobre async: aquest controlador és asíncron perquè bcrypt ho és. Si llancés una excepció, Express 4 no la capturaria i la petició quedaria penjada, tal com vam advertir a 03-03. Avui funciona perquè el servei retorna {error} en comptes de llançar; a 03-07 ho resoldrem bé amb l'embolcall asincron().
- Inici de sessió:
POST /v1/sessions
POST /v1/sessionsFixa't en la URI: POST /v1/sessions, no /login. És coherent amb 02-02: iniciar sessió és crear un recurs sessió, no invocar un verb. Un DELETE /v1/sessions/actual seria el tancament de sessió, amb els matisos de la secció 16.
// src/serveis/autenticacio.js (continuació)
import jwt from 'jsonwebtoken';
import { entorn } from '../config/entorn.js';
/** Signa un JWT per a un client. */
export function emetreToken(client) {
return jwt.sign(
{
// Claims propis: el mínim per autoritzar sense consultar la BD.
rol: client.rol,
},
entorn.jwtSecret,
{
subject: client.id, // sub: a qui identifica
expiresIn: entorn.jwtCaducitat, // exp: '1h' des de .env
issuer: 'api.botigaaroma.example', // iss: qui l'ha emès
algorithm: 'HS256', // signatura simètrica
}
);
}
/** Verifica les credencials i retorna el token, o null si no són vàlides. */
export async function iniciarSessio({ email, contrasenya }) {
const client = repositoriClients.buscarPerEmail(email);
if (!client) {
// Es gasta el mateix temps que si existís, per no revelar pel
// rellotge quins correus estan registrats (atac d'enumeració).
await verificarContrasenya(contrasenya, '$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin');
return null;
}
const correcta = await verificarContrasenya(contrasenya, client.hashContrasenya);
if (!correcta) return null;
return {
token: emetreToken(client),
caducaEn: entorn.jwtCaducitat,
client,
};
}// src/controladors/sessions.js
import { iniciarSessio } from '../serveis/autenticacio.js';
import { clientARepresentacio } from '../serveis/mapejadors.js';
export const controladorSessions = {
/** POST /v1/sessions */
async crear(req, res) {
const sessio = await iniciarSessio(req.body);
if (!sessio) {
// MATEIX missatge per a "no existeix el correu" i "la contrasenya falla".
res.set('WWW-Authenticate', 'Bearer realm="api.botigaaroma.example"');
return res.status(401).json({
error: {
codi: 'no_autenticat',
missatge: 'Les credencials no són correctes.',
detalls: [],
},
});
}
res.status(201).json({
token: sessio.token,
tipus: 'Bearer',
caducaEn: sessio.caducaEn,
client: clientARepresentacio(sessio.client),
});
},
};// src/rutes/sessions.js
import { Router } from 'express';
import { controladorSessions } from '../controladors/sessions.js';
import { validar } from '../middleware/validacio.js';
import { esquemaCrearSessio } from '../esquemes/clients.js';
export const rutesSessions = Router();
rutesSessions.post('/', validar(esquemaCrearSessio), controladorSessions.crear);I a src/rutes/index.js:
Per què el mateix missatge en els dos casos de fallada. Si responguéssim "aquell correu no està registrat" en un cas i "contrasenya incorrecta" en l'altre, qualsevol podria esbrinar quins correus tenen compte a la Botiga Aroma provant-los un a un. Això és una fuita de dades personals amb valor real per al frau i el phishing. La resposta genèrica costa una mica de comoditat i ho compensa de sobres. Per la mateixa raó el servei gasta temps comparant contra un hash fals quan el correu no existeix: sense això, una fallada en 3 ms davant d'una en 90 ms delataria el mateix per una altra via.
curl -s -X POST http://localhost:3000/v1/sessions \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","contrasenya":"cafe-especialitat-2026"}' | jq{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50IiwiaXNzIjoiYXBpLmJvdGlnYWFyb21hLmV4YW1wbGUiLCJpYXQiOjE3NzM0ODQyMDAsImV4cCI6MTc3MzQ4NzgwMH0.k3vQ2xR7fW1sPmT9aZbN4cE8hJdL0gYuXi6oV5rSqBw",
"tipus": "Bearer",
"caducaEn": "1h",
"client": { "id": "cli_842", "nom": "Marta Garcia", "rol": "client", "...": "..." }
}
- Anatomia d'un JWT
Un JWT són tres parts separades per punts, cadascuna codificada en base64url:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJjbGlfODQyIiwicm9sIjoi... . k3vQ2xR7fW1sPmT9aZbN4c... └────────── capçalera ─────────────┘ └────────── payload ──────────┘ └──── signatura ────────┘
Capçalera, descodificada:
Payload (els claims), descodificat:
{
"sub": "cli_842",
"rol": "client",
"iss": "api.botigaaroma.example",
"iat": 1773484200,
"exp": 1773487800
}Signatura: HMAC-SHA256(base64url(capçalera) + "." + base64url(payload), secret).
Ho pots comprovar tu mateix ara:
# La segona part del token, descodificada. NO cal el secret.
echo "eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50IiwiaXNzIjoiYXBpLmJvdGlnYWFyb21hLmV4YW1wbGUiLCJpYXQiOjE3NzM0ODQyMDAsImV4cCI6MTc3MzQ4NzgwMH0=" | base64 -dAquesta és la propietat més malinterpretada del JWT: el payload NO està xifrat, només codificat. Base64 no és seguretat; és una manera d'escriure bytes amb caràcters imprimibles. Qualsevol que intercepti el token en llegeix el contingut.
Aleshores, què aporta la signatura? Integritat i autenticitat: garanteix que el contingut no ha estat modificat i que l'ha emès qui té el secret. Si un atacant canvia "rol":"client" per "rol":"administrador", la signatura deixa de quadrar i jwt.verify rebutja el token. No en pot recalcular la signatura perquè no coneix el secret.
Sobre HS256: és simètric, un únic secret serveix per signar i per verificar. Perfecte quan el mateix sistema fa totes dues coses, com aquí. L'alternativa és RS256, asimètric: se signa amb la clau privada i es verifica amb la pública, de manera que altres serveis poden validar tokens sense poder-los emetre. És el que fan servir els proveïdors d'OAuth (04-03).
I un advertiment de seguretat clàssic: va existir una vulnerabilitat històrica en diverses llibreries que acceptaven "alg": "none" —un token sense signatura— perquè confiaven en l'algorisme declarat a la capçalera del mateix token. La defensa és fixar l'algorisme esperat en verificar, i per això el nostre codi passarà algorithms: ['HS256'] explícitament.
- Els claims i què no ficar mai al payload
Claims estàndard del RFC 7519:
| Claim | Nom | Significat | El fem servir? |
|---|---|---|---|
sub |
Subject | A qui identifica el token | Sí: cli_842 |
iat |
Issued At | Quan es va emetre | Sí, automàtic |
exp |
Expiration | Quan caduca | Sí: obligatori |
iss |
Issuer | Qui l'ha emès | Sí |
aud |
Audience | Per a qui és | No (una sola API) |
nbf |
Not Before | No vàlid abans de | No |
jti |
JWT ID | Identificador únic del token | No (útil per revocar) |
I el nostre, rol, que és un claim privat.
La regla d'or del payload: és públic i és immutable. D'aquí se'n dedueixen les dues llistes.
Què NO hi has de ficar mai:
| No hi fiquis | Per què |
|---|---|
| Contrasenyes o el seu hash | Qualsevol les llegeix |
| Números de targeta, DNI, adreça | Dades personals llegibles per qui intercepti el token |
| Claus d'API o secrets | Ídem |
| Dades que canvien sovint | El token no s'actualitza: queden obsoletes fins que caduqui |
| Llistes llargues de permisos | El token viatja a cada petició; infla totes les capçaleres |
Aquest quart punt té una conseqüència operativa important i poc intuïtiva: si un administrador degrada un empleat a client, el seu token continua dient rol: empleat fins que caduqui. Amb una hora de caducitat, hi ha fins a una hora de finestra. Per a operacions crítiques, la solució és no fiar-se només del claim i consultar el rol real a la base de dades; hi tornarem en parlar de revocació.
Què sí que hi has de ficar: el mínim, estable i no sensible. sub, exp, iss i, com a molt, un rol. Un token ben dissenyat ocupa 150-250 bytes.
- El middleware d'autenticació
// src/middleware/autenticacio.js
import jwt from 'jsonwebtoken';
import { entorn } from '../config/entorn.js';
const REPTE = 'Bearer realm="api.botigaaroma.example"';
/** Resposta 401 uniforme, amb la capçalera que exigeix el RFC 9110. */
function noAutenticat(res, codi, missatge) {
res.set('WWW-Authenticate', REPTE);
return res.status(401).json({ error: { codi, missatge, detalls: [] } });
}
/**
* Exigeix un JWT vàlid. Si n'hi ha, deixa la identitat a req.usuari i cedeix
* el torn; si no, respon 401 i talla la cadena.
*/
export function autenticar(req, res, next) {
const capcalera = req.get('Authorization');
// --- 1. Token absent o mal format ---
if (!capcalera || !capcalera.startsWith('Bearer ')) {
return noAutenticat(
res,
'no_autenticat',
'Falta la capçalera Authorization amb un token Bearer.'
);
}
const token = capcalera.slice('Bearer '.length).trim();
try {
// --- 2. Verificació de la signatura i de la caducitat ---
const contingut = jwt.verify(token, entorn.jwtSecret, {
algorithms: ['HS256'], // MAI confiar en l''alg' del token
issuer: 'api.botigaaroma.example',
});
// --- 3. Identitat disponible per a la resta de la cadena ---
req.usuari = {
id: contingut.sub,
rol: contingut.rol ?? 'client',
};
next();
} catch (error) {
// --- 4. Caducat i invàlid són casos DIFERENTS del catàleg ---
if (error.name === 'TokenExpiredError') {
return noAutenticat(
res,
'token_caducat',
'El token ha caducat. Torna a iniciar sessió per obtenir-ne un de nou.'
);
}
return noAutenticat(res, 'no_autenticat', 'El token no és vàlid.');
}
}
/**
* Variant opcional: si hi ha token vàlid, omple req.usuari; si no hi ha
* token, deixa passar igualment. Útil a GET /v1/cafes, que és públic però
* es pot personalitzar si sabem qui pregunta.
*/
export function autenticarOpcional(req, res, next) {
const capcalera = req.get('Authorization');
if (!capcalera) return next();
return autenticar(req, res, next);
}
401 ben fet: WWW-Authenticate i els dos codis del catàleg
401 ben fet: WWW-Authenticate i els dos codis del catàlegUn 401 ha de portar la capçalera WWW-Authenticate; ho exigeix el RFC 9110. La seva funció és dir-li al client com autenticar-se, i ometre-la és un incompliment del protocol que a més deixa l'integrador sense pistes.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.botigaaroma.example"
Content-Type: application/json; charset=utf-8
{"error":{"codi":"no_autenticat","missatge":"Falta la capçalera Authorization amb un token Bearer.","detalls":[]}}# Amb un token caducat
curl -s -H "Authorization: Bearer $TOKEN_VELL" http://localhost:3000/v1/comandes | jq .error.codiPer què el catàleg distingeix no_autenticat de token_caducat. Tots dos són 401, però la reacció del client és diferent: davant de token_caducat, la SPA renova el token en silenci i repeteix la petició sense molestar l'usuari; davant de no_autenticat, el porta a la pantalla d'accés. Amb un sol codi, el client hauria d'endevinar-ho. És un exemple perfecte de per a què serveix tenir un codi de negoci a més del codi HTTP (02-04).
Ara protegim les rutes. A src/rutes/comandes.js:
import { autenticar } from '../middleware/autenticacio.js';
// Totes les rutes d'aquest router exigeixen identitat. Aquest app.use() sense
// ruta s'aplica a TOT el que es declari a continuació al router.
rutesComandes.use(autenticar);
rutesComandes.get('/', validar(esquemaConsultaComandes, 'query'), controladorComandes.llistar);
rutesComandes.get('/:id', controladorComandes.obtenir);
- Autorització per rol:
exigirRol
exigirRolLa Botiga Aroma té quatre rols:
| Rol | Qui | Què fa |
|---|---|---|
client |
Compradors | Comprar, veure les seves comandes, escriure ressenyes |
empleat |
Atenció al client | Veure totes les comandes, moderar ressenyes, gestionar enviaments |
administrador |
Responsables | Tot l'anterior més gestionar el catàleg |
soci |
RàpidEnviaments | Només actualitzar l'enviament de les comandes que li corresponen |
// src/middleware/autenticacio.js (continuació)
/**
* Exigeix que req.usuari tingui un dels rols indicats.
* Es registra SEMPRE després d'autenticar: sense identitat no hi ha permisos.
*/
export function exigirRol(...rolsPermesos) {
return (req, res, next) => {
// Salvaguarda: si falta req.usuari és que l'ordre és incorrecte.
if (!req.usuari) {
return noAutenticat(res, 'no_autenticat', 'Aquesta operació requereix autenticació.');
}
if (!rolsPermesos.includes(req.usuari.rol)) {
// 403, no 401: sabem qui és; simplement no pot.
return res.status(403).json({
error: {
codi: 'permisos_insuficients',
missatge: `Aquesta operació requereix un d'aquests rols: ${rolsPermesos.join(', ')}.`,
detalls: [],
},
});
}
next();
};
}I les rutes de cafès queden amb la seva declaració de permisos a la vista:
// src/rutes/cafes.js (versió final del mòdul)
import { autenticar, exigirRol } from '../middleware/autenticacio.js';
// Lectura PÚBLICA: el catàleg és l'aparador de la botiga.
rutesCafes.get('/', validar(esquemaConsultaCafes, 'query'), controladorCafes.llistar);
rutesCafes.get('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.obtenir);
// Escriptura: només empleats i administradors.
rutesCafes.post(
'/',
autenticar,
exigirRol('empleat', 'administrador'),
validar(esquemaCrearCafe),
controladorCafes.crear
);
rutesCafes.put(
'/:id',
autenticar,
exigirRol('empleat', 'administrador'),
validar(esquemaIdCafe, 'params'),
validar(esquemaReemplacarCafe),
controladorCafes.reemplacar
);
rutesCafes.patch(
'/:id',
autenticar,
exigirRol('empleat', 'administrador'),
validar(esquemaIdCafe, 'params'),
validar(esquemaModificarCafe),
controladorCafes.modificar
);
// Esborrar del catàleg: només administradors.
rutesCafes.delete(
'/:id',
autenticar,
exigirRol('administrador'),
validar(esquemaIdCafe, 'params'),
controladorCafes.esborrar
);L'ordre de la cadena és obligatori: autenticar → exigirRol → validar → controlador. Autenticar abans d'autoritzar és evident; validar després de comprovar permisos és menys obvi i també deliberat: no té sentit gastar cicles analitzant el cos d'algú que no té dret a enviar-lo, i a més evita filtrar informació sobre la forma esperada del recurs a qui no l'hauria de conèixer.
- Autorització a nivell de recurs
El rol no n'hi ha prou. La Marta (cli_842) té rol client i pot consultar comandes… però només les seves. Cap middleware genèric no pot saber això, perquè depèn de les dades:
// src/serveis/comandes.js (afegit)
export const serveiComandes = {
/**
* Obté una comanda comprovant que qui la demana la pot veure.
* La comprovació viu al SERVEI perquè necessita la comanda carregada:
* només consultant la base de dades se sap de qui és.
*/
obtenirPer(id, usuari) {
const comanda = repositoriComandes.buscarPerId(id);
if (!comanda) return { noTrobat: true };
const esPropietari = comanda.clientId === usuari.id;
const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);
if (!esPropietari && !esPersonal) {
return { noTrobat: true }; // vegeu la secció 14
}
return { comanda };
},
/** Llista comandes, restringint-les al client mateix si no és personal. */
llistarPer(criteris, usuari) {
const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);
// Un client NO pot consultar les comandes d'un altre encara que ho demani
// per paràmetre de consulta: s'ignora el que enviï i es força el seu id.
const clientId = esPersonal ? criteris.clientId : usuari.id;
return repositoriComandes.buscar({ ...criteris, clientId });
},
};Aquesta darrera funció mereix atenció. Si ens limitéssim a passar req.query.clientId al repositori, qualsevol client autenticat llegiria les comandes de qualsevol altre amb ?clientId=cli_999. És la vulnerabilitat coneguda com a IDOR (Insecure Direct Object Reference), i encapçala des de fa anys la llista de riscos específics d'API de l'OWASP precisament perquè és invisible: les proves funcionals passen, el 401 funciona, el rol és correcte… i tot i així es filtren dades alienes.
La regla, que convé escriure a la guia d'estil de l'equip: l'identificador del propietari no es pren mai de l'entrada del client; es pren del token.
403 o 404: quan ocultar l'existència
403 o 404: quan ocultar l'existènciaQuan la Marta demana GET /v1/comandes/com_9999, una comanda que existeix però és d'una altra persona, hi ha dues respostes defensables:
| Resposta | Avantatge | Inconvenient |
|---|---|---|
403 permisos_insuficients |
Honesta i més fàcil de depurar | Confirma que aquella comanda existeix |
404 comanda_no_trobada |
No filtra res | Pot desconcertar un integrador legítim |
Que un 403 filtri informació no és un detall teòric: permet enumerar recursos. Provant com_5001, com_5002, com_5003… es distingeix "existeix però no és teva" (403) de "no existeix" (404), i així s'esbrina quantes comandes té la botiga i a quin ritme creix. És intel·ligència competitiva servida en safata, i en altres dominis (històries clíniques, expedients) el fet mateix que un recurs existeixi ja és informació sensible.
Criteri de la Botiga Aroma:
404quan el recurs pertany a una altra persona i qui el demana no té cap motiu legítim per saber que existeix. És el cas de les comandes alienes.403quan el recurs és clarament compartit o públic i el que falta és un permís d'operació. Exemple: unclientque intentaDELETE /v1/cafes/caf_001rep403, perquè el cafè és públic i ningú no dubta que existeixi.
L'important és triar un criteri i documentar-lo. Una API que retorna 403 unes vegades i 404 unes altres per al mateix tipus de situació és impossible d'integrar.
- La matriu de permisos de la Botiga Aroma
| Endpoint | Públic | client |
empleat |
administrador |
soci |
|---|---|---|---|---|---|
GET /v1/cafes |
✅ | ✅ | ✅ | ✅ | ✅ |
GET /v1/cafes/{id} |
✅ | ✅ | ✅ | ✅ | ✅ |
POST /v1/cafes |
❌ | ❌ | ✅ | ✅ | ❌ |
PUT/PATCH /v1/cafes/{id} |
❌ | ❌ | ✅ | ✅ | ❌ |
DELETE /v1/cafes/{id} |
❌ | ❌ | ❌ | ✅ | ❌ |
POST /v1/clients |
✅ | — | — | — | — |
POST /v1/sessions |
✅ | — | — | — | — |
GET /v1/clients/{id} |
❌ | Només el seu | ✅ | ✅ | ❌ |
GET /v1/comandes |
❌ | Només les seves | ✅ | ✅ | Només assignades |
GET /v1/comandes/{id} |
❌ | Només la seva | ✅ | ✅ | Només assignades |
POST /v1/comandes |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/comandes/{id}/pagament |
❌ | Només la seva | ❌ | ✅ | ❌ |
PUT /v1/comandes/{id}/enviament |
❌ | ❌ | ✅ | ✅ | ✅ |
POST /v1/cafes/{id}/ressenyes |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/ressenyes/{id}/aprovacio |
❌ | ❌ | ✅ | ✅ | ❌ |
Dues files mereixen comentari. POST /v1/comandes/{id}/pagament no el pot fer un empleat: ningú no ha de poder pagar en nom d'un altre, i limitar-ho és tant una mesura antifrau com una protecció per al mateix empleat. I PUT /v1/comandes/{id}/enviament és l'única cosa que pot tocar el soci, que és una màquina de RàpidEnviaments: el principi de mínim privilegi portat a la pràctica.
Aquesta taula no és documentació decorativa: és l'especificació dels exigirRol del codi, forma part d'openapi.yaml (02-08) i a 03-08 es convertirà en proves que comproven que cada cel·la ❌ retorna efectivament 403.
- Caducitat, tokens de refresc i revocació
El problema, dit sense embuts: un JWT vàlid no es pot invalidar. No hi ha estat al servidor per esborrar; mentre la signatura quadri i exp no hagi passat, el token val. Si a algú li roben el token, l'atacant entra fins que caduqui. I "tancar sessió" al client només esborra el token d'aquell dispositiu: la còpia robada continua funcionant.
Les estratègies, i els seus costos:
| Estratègia | Com funciona | Cost |
|---|---|---|
| Vida curta | exp de 15 min a 1 h |
Finestra d'exposició petita; obliga a renovar sovint |
| Token de refresc | Token llarg (dies) que només serveix per demanar-ne un de nou | S'ha d'emmagatzemar i poder revocar |
| Llista de revocació | Taula de jti invalidats, consultada a cada petició |
Reintrodueix l'estat que volíem evitar |
| Canvi de secret | Rotar JWT_SECRET |
Invalida tots els tokens de cop |
El patró habitual combina les dues primeres:
sequenceDiagram participant C as Client participant API C->>API: POST /v1/sessions (email + contrasenya) API-->>C: token d'accés (1 h) + refresc (30 dies) C->>API: GET /v1/comandes amb Bearer API-->>C: 200 OK Note over C,API: passa una hora C->>API: GET /v1/comandes amb Bearer API-->>C: 401 token_caducat C->>API: POST /v1/sessions/renovacio (refresc) API-->>C: token d'accés nou C->>API: GET /v1/comandes (reintent) API-->>C: 200 OK
La clau del repartiment: el token d'accés és curt i sense estat, així que la majoria de peticions no consulten res; el token de refresc és llarg però es desa a la base de dades, així que sí que es pot revocar —i es fa servir un cop cada hora, no a cada petició—. Es concentra l'estat allà on gairebé no costa.
Per a la Botiga Aroma, el compromís raonable és: accés d'1 hora, refresc de 30 dies desat a la base de dades i revocable, revocació immediata en tres situacions (canvi de contrasenya, tancament de sessió explícit, sospita de robatori) i, per a les operacions crítiques —pagar, canviar la contrasenya—, consultar el rol real a la base de dades en lloc de fiar-se del claim del token.
Un avís de disseny: no caiguis en la temptació de consultar la base de dades a cada petició "per seguretat". Si fas això, has reconstruït les sessions amb estat pagant a més el cost dels JWT. Si el teu cas exigeix revocació immediata universal, les sessions amb galeta són una opció legítima i més simple; tria-les conscientment.
- On desa el token el client
| Lloc | Risc XSS | Risc CSRF | Nota |
|---|---|---|---|
localStorage |
Alt: qualsevol script el llegeix | Cap | El més còmode i el més comú |
sessionStorage |
Alt | Cap | Es perd en tancar la pestanya |
| Variable en memòria | Baix | Cap | Es perd en recarregar |
Galeta HttpOnly + Secure + SameSite |
Baix: JavaScript no la llegeix | Requereix defensa | L'opció més segura per a navegadors |
Recomanació per a la SPA de la Botiga Aroma: galeta HttpOnly; Secure; SameSite=Strict per al token de refresc, i el token d'accés en memòria. Així, un atac XSS no pot robar la sessió de llarga durada, que és el que és veritablement valuós.
Per a l'Aroma Mòbil, que no és un navegador, es fa servir el magatzem segur del sistema (Keychain a iOS, Keystore a Android), mai un fitxer de preferències en clar.
I tres regles que valen per a qualsevol client: el token viatja només per HTTPS (en clar, qualsevol de la xarxa el copia); mai a la URL (?token=... acaba als logs del servidor, a l'històric del navegador i a la capçalera Referer); i mai s'escriu en un log. Tot això s'amplia a 04-02.
Errors Comuns i Consells
1. Retornar 403 quan toca 401, o a l'inrevés. El client no sap si ha de renovar el token o rendir-se. Autenticació és 401; permisos és 403.
2. Ometre WWW-Authenticate al 401. Ho exigeix el RFC i sense ella l'integrador no sap quin esquema fer servir.
3. Desar contrasenyes amb SHA-256. Massa ràpid. Fes servir bcrypt, scrypt o Argon2.
4. Confiar en l'alg de la capçalera del token. Passa sempre algorithms: ['HS256'] a jwt.verify.
5. Ficar dades personals o canviants al payload. És llegible per qualsevol i no s'actualitza fins que caduca.
6. Acceptar el rol al cos del registre. Escalada de privilegis immediata. El rol el fixa el servidor.
7. Filtrar el hashContrasenya en una resposta. Construeix la representació camp a camp; no retornis mai el model intern tal qual.
8. Comprovar només el rol i oblidar la propietat. Un client autenticat amb ?clientId=cli_999 no ha de veure comandes alienes. L'id del propietari surt del token.
9. Missatges d'inici de sessió diferents segons la fallada. Permet enumerar els correus registrats. Un únic missatge genèric.
10. Un JWT sense exp. Un token etern és una clau que no es pot canviar mai.
Consell: escriu la matriu de permisos abans de programar els middleware, revisa-la amb qui conegui el negoci i converteix-la en proves (03-08). Els forats d'autorització apareixen gairebé sempre als endpoints que ningú no va pensar a revisar.
Exercicis
Exercici 1
Implementa GET /v1/clients/:id amb aquestes regles: un client només pot veure la seva pròpia fitxa; empleat i administrador en poden veure qualsevol; el soci no en pot veure cap. Decideix si un client que demana la fitxa d'un altre rep 403 o 404, justifica l'elecció amb el criteri de la secció 14 i escriu la ruta amb la seva cadena de middleware.
Exercici 2
Un desenvolupador proposa incloure al payload del JWT el nom complet, el correu, l'adreça d'enviament i la llista de les últimes cinc comandes, "perquè la SPA no els hagi de demanar". Enumera quatre problemes concrets d'aquella proposta i ofereix una alternativa que resolgui la seva necessitat real.
Exercici 3
Escriu el middleware exigirPropietatOPersonal(obtenirPropietari) que generalitzi la comprovació de la secció 13: rep una funció que, donat el req, retorna l'id del propietari del recurs, i deixa passar si qui ho demana és el propietari o té rol empleat/administrador. Després explica per què, tot i ser possible, no és la millor solució per a les comandes i què es fa al seu lloc.
Solucions
Solució 1
// src/controladors/clients.js (afegit)
obtenir(req, res) {
const { id } = req.params;
const peticionari = req.usuari;
const esPersonal = ['empleat', 'administrador'].includes(peticionari.rol);
const esElMateix = peticionari.id === id;
if (!esPersonal && !esElMateix) {
// 404, no 403: no confirmem que aquell client existeixi.
return res.status(404).json({
error: {
codi: 'client_no_trobat',
missatge: `No existeix cap client amb l'identificador '${id}'.`,
detalls: [],
},
});
}
const client = serveiClients.obtenir(id);
if (!client) {
return res.status(404).json({
error: {
codi: 'client_no_trobat',
missatge: `No existeix cap client amb l'identificador '${id}'.`,
detalls: [],
},
});
}
res.status(200).json(clientARepresentacio(client));
},// src/rutes/clients.js
rutesClients.get(
'/:id',
autenticar,
exigirRol('client', 'empleat', 'administrador'), // exclou el 'soci'
controladorClients.obtenir
);Per què 404 i no 403: la fitxa d'un client conté dades personals, i aquí la mateixa existència ja és informació. Amb 403, qualsevol podria provar cli_800, cli_801, cli_802… i esbrinar quants comptes té la Botiga Aroma, a quin ritme creix i en quin rang són els identificadors. Amb un 404 uniforme, un client aliè és indistingible d'un d'inexistent. Fixa't que totes dues branques retornen exactament el mateix cos: si el missatge diferís, la fuita tornaria per la porta del darrere.
El soci queda fora per exigirRol, i rep 403: és una màquina amb un contracte clar que no inclou dades de clients, així que aquí l'honestedat no filtra res d'útil.
Solució 2
Quatre problemes concrets:
- Fuita de dades personals. El payload és base64, no xifrat. Qualsevol que capturi el token —un proxy corporatiu, un registre mal configurat, una extensió del navegador— llegeix el correu i l'adreça d'enviament de la Marta. Amb el token a
localStorage, un XSS ho obté tot de cop. - Dades obsoletes. Si la Marta canvia la seva adreça, el token continua dient l'antiga fins que caduqui. I si el token durés un mes, la SPA mostraria durant un mes dades incorrectes que a més semblen autoritzades pel servidor.
- Mida. Cinc comandes amb les seves línies poden ser 2 KB. Aquell token viatja a cada petició, incloses les d'imatges si estan protegides. Molts servidors i proxys limiten les capçaleres a 8 KB, i superar-ho produeix un
431desconcertant. És trànsit malbaratat a cada crida. - Responsabilitat equivocada. El token és una credencial d'identitat, no una memòria cau de dades. Barrejar-les fa que la SPA depengui de l'estructura interna del token i que qualsevol canvi a les dades del client obligui a tocar l'emissió de credencials.
Alternativa: el payload es queda amb sub, rol, exp i iss, i la SPA obté les dades amb una crida a GET /v1/clients/{id} just després d'iniciar sessió, desant-les a la seva memòria cau d'estat. La necessitat real —evitar peticions repetides— es resol amb memòria cau HTTP al client (04-06), no ficant dades a la credencial. A més, la resposta del POST /v1/sessions ja retorna client amb la representació pública, així que a la pràctica ni tan sols cal aquella crida extra.
Solució 3
// src/middleware/autenticacio.js (afegit)
/**
* Deixa passar si qui ho demana és el propietari del recurs o és personal
* de la botiga. 'obtenirPropietari' rep req i retorna l'id del
* propietari, o undefined si el recurs no existeix.
*/
export function exigirPropietatOPersonal(obtenirPropietari, codiNoTrobat) {
return (req, res, next) => {
if (!req.usuari) {
return noAutenticat(res, 'no_autenticat', 'Aquesta operació requereix autenticació.');
}
if (['empleat', 'administrador'].includes(req.usuari.rol)) return next();
const propietariId = obtenirPropietari(req);
// Recurs inexistent i recurs aliè donen la MATEIXA resposta.
if (propietariId === undefined || propietariId !== req.usuari.id) {
// El codi concret del catàleg l'aporta qui fa servir el middleware:
// així no cal inventar un 'recurs_no_trobat' genèric.
return res.status(404).json({
error: {
codi: codiNoTrobat,
missatge: 'No existeix el recurs sol·licitat.',
detalls: [],
},
});
}
next();
};
}
// Ús:
rutesComandes.get(
'/:id',
autenticar,
exigirPropietatOPersonal(
(req) => repositoriComandes.buscarPerId(req.params.id)?.clientId,
'comanda_no_trobada'
),
controladorComandes.obtenir
);Per què no és la millor solució per a les comandes, amb tres raons:
- Consulta duplicada. El middleware carrega la comanda per saber de qui és, i tot seguit el controlador la torna a carregar per respondre. Són dues consultes per a una petició, i el patró es repeteix a cada endpoint protegit així.
- No serveix per a les col·leccions.
GET /v1/comandesno té un propietari únic: cal filtrar els resultats, no acceptar o rebutjar la petició sencera. Un middleware que només sap dir sí o no no pot expressar "només les teves", i aquesta és justament l'operació més freqüent. - La regla es parteix en dos llocs. Una part de "qui pot veure una comanda" queda a la ruta i una altra al servei, i quan la política canviï —per exemple, permetre que un
socivegi les comandes que té assignades— caldrà recordar tocar tots dos.
Què es fa al seu lloc: la comprovació viu al servei, amb obtenirPer(id, usuari) i llistarPer(criteris, usuari), com vam escriure a la secció 13. El servei ja té la comanda carregada, així que no hi ha consulta extra; sap filtrar a més de rebutjar; i tota la política d'accés a comandes és en un únic fitxer que es pot provar sense HTTP (03-08). El middleware exigirRol continua sent útil per al que sí que depèn únicament del rol —qui pot tocar el catàleg—, que és una decisió que no necessita mirar les dades.
Conclusió
L'API ja sap qui truca i què pot fer, i ho expressa amb la precisió que exigeix el contracte. L'autenticació es resol amb JWT signats amb HS256: registre amb la contrasenya protegida per bcrypt amb sal i cost ajustable, inici de sessió a POST /v1/sessions —un recurs, no un verb— amb missatge genèric per no revelar quins correus existeixen, i un middleware que verifica la signatura fixant l'algorisme, omple req.usuari i distingeix els dos casos que el catàleg separa: no_autenticat quan no hi ha token o no és vàlid, token_caducat quan simplement ha vençut, tots dos amb WWW-Authenticate com mana el RFC. L'autorització funciona en dos nivells: exigirRol per al que depèn només del rol, i la comprovació de propietat al servei per al que depèn de les dades, amb l'identificador del propietari pres sempre del token i mai de l'entrada del client.
I has vist el que un JWT no és: el payload va codificat, no xifrat, així que és llegible per qualsevol i no admet dades personals ni secrets; els seus claims no s'actualitzen fins que el token caduca; i revocar-lo exigeix reintroduir estat, que és exactament el que el model sense estat volia evitar. D'aquí ve el compromís: tokens d'accés curts i sense estat, tokens de refresc llargs, desats i revocables, i consulta a la base de dades només a les operacions crítiques.
Queda un deute tècnic que ja no es pot continuar ajornant i que has vist créixer a cada lliçó: els res.status(404).json({error: {...}}) escrits a mà estan repartits per controladors, middleware i serveis, amb el mateix cos copiat un cop i un altre; els serveis retornen {error: 'email_ja_registrat'} o {noTrobat: true} en lloc de fallar netament; i ara que hi ha controladors async per culpa de bcrypt, una excepció inesperada deixa la petició penjada sense resposta. A 03-07, Gestió d'errors, ho unifiquem tot: la classe ErrorApi amb les seves fàbriques, llançada des dels serveis sense que aquests sàpiguen d'HTTP; el middleware d'errors de quatre arguments, registrat l'últim, que distingeix un error del catàleg d'un d'inesperat i emet 500 error_intern amb tracaId i sense filtrar la pila; l'embolcall asincron() que per fi resol les promeses rebutjades; i la traducció dels errors de Zod, de SQLite i del JSON mal format a l'únic format d'error que coneix el consumidor.
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
