El model Usuari d'Escena Viva fa des de la lliçó 07-02 que porta el seu correu, el seu nom i el seu rol —assistent, organitzador, administrador— i cap camp de contrasenya. No va ser un descuit: era un buit reservat. En aquesta lliçó l'omplim amb el rigor que mereix, perquè l'emmagatzematge de contrasenyes és el punt on una fallada no es nota fins que és un titular de premsa.

Veurem per què un hash ràpid no serveix, què fa exactament bcrypt, com se'n tria el factor de cost mesurant, i com s'escriuen POST /auth/registre i POST /auth/entrada sense filtrar quins correus existeixen ni quant triga cada branca del codi. Acabarem amb allò que més es fa malament: els tokens de verificació i de recuperació.

Contingut

  1. Per què mai no es desen contrasenyes en clar
  2. Què és un hash de contrasenya
  3. bcrypt, scrypt i Argon2
  4. bcrypt a la pràctica
  5. Alternativa nativa: crypto.scrypt
  6. Completar el model Usuari
  7. Política de contrasenyes i esquema zod
  8. POST /auth/registre i enumeració d'usuaris
  9. POST /auth/entrada i temps constants
  10. Límit d'intents i registres
  11. Verificació de correu i recuperació
  12. Errors comuns, exercicis i conclusió

  1. Per què mai no es desen contrasenyes en clar

Imagina't que la base escena_viva es filtra: una còpia de seguretat amb permisos mal posats, una injecció SQL, un empleat descontent. Si existeix una columna contrasenya amb el text tal qual, passen tres coses: tots els comptes queden compromesos, inclosos els d'organitzadors i administradors; es comprometen comptes d'altres serveis, perquè la gent reutilitza contrasenyes (el correu de la Lucía i la seva clau es proven automàticament a desenes de llocs: això és el farciment de credencials); i les contrasenyes continuen sent vàlides fins que cada persona la canviï una per una.

I xifrar-les amb una clau? Tampoc. El xifratge és reversible per disseny: existeix una clau que retorna l'original, aquesta clau viu al servidor, i qui roba la base sol poder robar també el servidor. A més, no hi ha cap raó legítima per recuperar una contrasenya: el servidor només necessita comprovar si la que li acaben de donar és la correcta. Un hash com MD5 o SHA-256? És irreversible, sí, però massa ràpid:

Algorisme Intents/segon (GPU) Diccionari de 10^10 candidats
MD5 / SHA-256 ~10^11 / ~10^10 segons / minuts
bcrypt cost 12 ~10^3 milers d'anys

La velocitat és virtut per verificar fitxers i desastre per a contrasenyes: allò que fa ràpid el servidor fa ràpid l'atacant, i l'atacant té GPUs. Afegeix-hi que un hash sense sal d'una contrasenya comuna està precalculat en taules arc de Sant Martí públiques (5f4dcc3b5aa765d61d8327deb882cf99 és password en qualsevol cercador), i que dos usuaris amb la mateixa contrasenya produirien hashos idèntics, cosa que ja és una fuita en si mateixa.

  1. Què és un hash de contrasenya

Un hash de contrasenya (o funció de derivació de clau) es dissenya amb tres propietats deliberades:

  1. És lenta a propòsit: de l'ordre de 100–500 ms. A l'usuari legítim tant li fa; a l'atacant que prova milions de combinacions l'arruïna.
  2. Fa servir una sal única per usuari: una cadena aleatòria desada al costat del hash. Anul·la les taules arc de Sant Martí i fa que contrasenyes iguals donin hashos diferents. La sal no és secreta: la seva funció és la unicitat.
  3. Té un factor de cost ajustable: el maquinari millora cada any, el paràmetre es puja i l'algorisme continua servint una dècada.
  4. Els millors hi afegeixen cost en memòria, perquè no n'hi hagi prou de posar molts nuclis de GPU en paral·lel.

  1. bcrypt, scrypt i Argon2

Algorisme Cost CPU Cost memòria Paràmetres Notes
bcrypt (1999) Sí Baix i fix (4 KB) cost logarítmic Molt madur. Límit de 72 bytes a l'entrada
scrypt (2009) Sí Sí, ajustable N, r, p A node:crypto, sense dependències
Argon2id (2015) Sí Sí, ajustable temps, memòria, paral·lelisme Guanyador del Password Hashing Competition; la millor opció actual

Recomanació raonada: si comences avui i pots afegir la dependència argon2, fes servir Argon2id, que resisteix millor el maquinari especialitzat gràcies al cost en memòria; si necessites zero dependències natives, crypto.scrypt ja ve amb Node i és perfectament defensable. A Escena Viva fem servir bcrypt perquè és el que trobaràs a la immensa majoria del codi existent, la seva API s'explica sense soroll i la seva seguretat és adequada amb un cost ben triat. L'important és el model; canviar d'algorisme serà un canvi localitzat en un únic mòdul, i així ho escriurem. Mai no és acceptable: MD5, SHA-1, «SHA-256 amb sal» o qualsevol invenció pròpia a base de concatenar i tornar a fer el hash.

  1. bcrypt a la pràctica

npm install bcrypt

bcrypt compila un mòdul natiu; si el teu desplegament ho complica (contenidors mínims, funcions sense servidor), bcryptjs és la implementació en JavaScript pur, compatible en API i unes tres vegades més lenta.

// src/serveis/contrasenyes.js
'use strict';
const bcrypt = require('bcrypt');
const { configuracio } = require('../config/index.js');
const { ErrorDeValidacio } = require('../errors.js');
// Limit util de bcrypt: 72 bytes. Rebutgem abans de fer el hash per no
// truncar en silenci (dues contrasenyes llargues amb els mateixos primers
// 72 bytes es considerarien iguals).
const MAX_BYTES = 72;
async function generarHashContrasenya(contrasenyaEnClar) {
  if (Buffer.byteLength(contrasenyaEnClar, 'utf8') > MAX_BYTES) {
    throw new ErrorDeValidacio('La contrasenya supera el maxim admes');
  }
  // El cost viu a la configuracio: es puja sense tocar aquest fitxer.
  return bcrypt.hash(contrasenyaEnClar, configuracio.bcryptCost);
}
async function verificarContrasenya(contrasenyaEnClar, hashDesat) {
  if (!hashDesat) { return false; }
  // compare extreu la sal i el cost del hash mateix, i compara en temps
  // constant: no s'atura al primer byte diferent.
  return bcrypt.compare(contrasenyaEnClar, hashDesat);
}
module.exports = { generarHashContrasenya, verificarContrasenya, MAX_BYTES };

Un hash bcrypt s'explica sol:

$2b$12$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └──────── sal (22 car.) ──────┘└──── hash (31 car.) ────┘
 │   └── factor de cost: 12            └── variant de l'algorisme

Per això bcrypt.compare no necessita que li passis la sal ni el cost: són dins del hash. I per això pujar el cost no invalida els hashos antics: cadascun es verifica amb el seu.

El factor de cost és logarítmic: 12 significa 2^12 = 4096 iteracions i cada punt duplica el temps. Regla pràctica: el valor més alt que mantingui l'entrada per sota d'uns 250 ms al teu maquinari de producció. Mesura'l, no el copiïs:

// scripts/medir-bcrypt.js
'use strict';
const bcrypt = require('bcrypt');
(async () => {
  for (let cost = 10; cost <= 15; cost += 1) {
    const inici = process.hrtime.bigint();
    await bcrypt.hash('contrasenya-de-prova-escena-viva', cost);
    console.log(`cost ${cost}: ${(Number(process.hrtime.bigint() - inici) / 1e6).toFixed(0)} ms`);
  }
})();
// node scripts/medir-bcrypt.js
// cost 10: 62 ms | cost 11: 124 ms | cost 12: 248 ms (triat) | cost 13: 495 ms

Per què importa la comparació en temps constant. Al mòdul 3, amb Buffers, vam veure crypto.timingSafeEqual: una comparació normal (===) s'atura al primer byte diferent, així que el temps revela quants bytes coincidien, i repetint la mesura milers de vegades es reconstrueix un secret byte a byte. bcrypt.compare compara sempre els 60 caràcters sencers; la mateixa lògica s'aplicarà als tokens de la secció 11.

  1. Alternativa nativa: crypto.scrypt

// src/serveis/contrasenyes-scrypt.js  (alternativa sense dependencies)
'use strict';
const { randomBytes, scrypt, timingSafeEqual } = require('node:crypto');
const scryptAsync = require('node:util').promisify(scrypt);
// N=2^15 exigeix uns 32 MB per hash: aixo encareix l'atac amb GPU,
// que te molt calcul i poca memoria.
const P = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
async function generarHashContrasenya(contrasenyaEnClar) {
  const sal = randomBytes(16);
  const derivada = await scryptAsync(contrasenyaEnClar, sal, 64, P);
  // Desem parametres + sal + hash: el format s'ha d'autodescriure,
  // aixi els hashos antics continuen verificant-se si dema pugem N.
  return `scrypt$${P.N}$${P.r}$${P.p}$${sal.toString('base64')}$${derivada.toString('base64')}`;
}
async function verificarContrasenya(contrasenyaEnClar, hashDesat) {
  const [etiqueta, n, r, p, salB64, hashB64] = String(hashDesat).split('$');
  if (etiqueta !== 'scrypt') { return false; }
  const esperat = Buffer.from(hashB64, 'base64');
  const derivada = await scryptAsync(contrasenyaEnClar, Buffer.from(salB64, 'base64'),
    esperat.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: P.maxmem });
  return timingSafeEqual(derivada, esperat); // obligatori: comparem a ma (M3)
}
module.exports = { generarHashContrasenya, verificarContrasenya };

Fixa't en el detall que gairebé ningú inclou: desar els paràmetres al costat del hash, que és exactament el que bcrypt fa per tu. Sense ells, pujar N invalidaria tots els hashos anteriors.

  1. Completar el model Usuari

// src/models/usuari.js
'use strict';
const mongoose = require('mongoose');
const ROLS = Object.freeze(['assistent', 'organitzador', 'administrador']);
const esquemaUsuari = new mongoose.Schema({
  // Normalitzem sempre: "[email protected]" i "[email protected]"
  // han de ser el MATEIX compte, o l'index unic no serveix de res.
  correu: { type: String, required: true, unique: true, lowercase: true, trim: true, index: true },
  nom: { type: String, required: true, trim: true, maxlength: 120 },
  // select: false => MAI surt en una consulta llevat que es demani amb
  // .select("+hashContrasenya"): blinda contra "vam retornar l'usuari sencer".
  hashContrasenya: { type: String, required: true, select: false },
  rol: { type: String, enum: ROLS, default: 'assistent', required: true },
  verificat: { type: Boolean, default: false },
  salaAssignada: { type: String, default: null }, // nomes per a organitzadors
  creatEl: { type: Date, default: () => new Date() },
});
// Cinturo i tirants: encara que algu demani +hashContrasenya, en serialitzar desapareix.
esquemaUsuari.set('toJSON', {
  transform: (doc, pla) => { delete pla.hashContrasenya; delete pla.__v; return pla; },
});
const Usuari = mongoose.model('Usuari', esquemaUsuari);
module.exports = { Usuari, ROLS };

select: false converteix «no filtrar el hash» en el comportament per defecte en comptes d'una disciplina que cal recordar; l'únic lloc que el demanarà explícitament serà l'entrada. lowercase: true evita la fallada subtil de tenir dos comptes per al mateix correu: tècnicament la part local distingeix majúscules, però cap proveïdor real no ho aprofita i normalitzar és el que esperen els usuaris.

  1. Política de contrasenyes i esquema zod

Durant vint anys es va exigir «8 caràcters amb majúscula, número i símbol». El NIST va canviar d'opinió (SP 800-63B) perquè les dades van mostrar que aquestes regles produeixen Estiu2024! a tot arreu: contrasenyes curtes, previsibles i difícils de recordar, que la gent acaba apuntant.

Regla moderna Regla antiga descartada
Mínim 12 caràcters, màxim generós; espais i Unicode permesos (frases de pas) Mínim 8 amb regles de composició; prohibir caràcters «rars»
Comprovar contra llistes de contrasenyes filtrades Caducitat obligatòria cada 90 dies
Canvi obligatori només si hi ha sospita Prohibir repetir les 5 últimes
// src/esquemes/autenticacio.js
'use strict';
const { z } = require('zod');
const { MAX_BYTES } = require('../serveis/contrasenyes.js');
const esquemaCorreu = z.string().trim().toLowerCase().email().max(254);
// El maxim protegeix de dues coses: el limit de 72 bytes de bcrypt i la
// denegacio de servei per fer el hash d'entrades enormes.
const esquemaContrasenya = z.string().min(12, 'Minim 12 caracters').max(MAX_BYTES);
// .strict() rebutja camps extra: ningu s'autoassigna rol.
const esquemaRegistre = z.object({ correu: esquemaCorreu, contrasenya: esquemaContrasenya,
  nom: z.string().trim().min(2).max(120) }).strict();
// A l'entrada la contrasenya es valida amb min(1), NO amb la politica:
// un usuari antic amb 9 caracters quedaria fora del seu compte.
const esquemaEntrada = z.object({ correu: esquemaCorreu,
  contrasenya: z.string().min(1).max(MAX_BYTES) }).strict();
module.exports = { esquemaRegistre, esquemaEntrada, esquemaCorreu, esquemaContrasenya };

Sense .strict(), un cos amb "rol":"administrador" podria acabar creant un administrador si algú escriu new Usuari(req.body): és assignació massiva, i hi tornarem a 08-06. La comprovació contra llistes de contrasenyes filtrades (Have I Been Pwned) es fa amb k-anonimat —s'envien els 5 primers caràcters del SHA-1 i es filtra localment, sense revelar la contrasenya—; no la implementem aquí, però en producció té de les millors relacions cost/benefici.

  1. POST /auth/registre i enumeració d'usuaris

// src/controladors/autenticacio.js  (fragment: registre)
'use strict';
const { Usuari } = require('../models/usuari.js');
const { generarHashContrasenya } = require('../serveis/contrasenyes.js');
async function registrar(req, res, next) {
  // req.dadesValidades el deixa el middleware validar() del modul 6.
  const { correu, contrasenya, nom } = req.dadesValidades.cos;
  if (!await Usuari.exists({ correu })) {
    const hashContrasenya = await generarHashContrasenya(contrasenya);
    // Camps explicits, MAI ...req.body: el rol el fixa el servidor.
    await Usuari.create({ correu, nom, hashContrasenya, rol: 'assistent' });
    // Aqui hi aniria l'enviament del correu de verificacio (seccio 11).
  }
  // Mateixa resposta existeixi o no el compte: no filtrem quins correus hi ha registrats.
  res.status(202).json({ missatge: 'Si el correu es valid, rebras un missatge per activar el compte' });
}

Repassa el que no fa: no retorna l'usuari creat, no retorna el hash, no diu si el correu ja existia i no accepta el cos sencer. Quatre omissions deliberades. Les rutes, a src/rutes/autenticacio.js → { crearRutesAutenticacio }, encadenen el límit i el validar(esquema, ORIGENS_VALIDS.COS) del mòdul 6 abans de cada controlador.

Respondre «aquest correu ja està registrat» sembla amable i és una fuita d'informació: permet confirmar quins correus tenen compte abans de llançar farciment de credencials, i en serveis sensibles revela pertinença. Es filtra per quatre canals i cal tancar-los tots:

Canal Fuita i tancament
Missatge d'error «Correu ja registrat» → missatge idèntic sempre
Codi HTTP 409 si existeix, 201 si no → mateix codi (202) sempre
Temps de resposta Ràpid si no existeix → fer sempre el hash, encara que sigui en va
Recuperació «No hi ha cap compte amb aquest correu» → «si existeix, t'hem enviat un missatge»

El compromís honest: això empitjora la usabilitat, i qui es va registrar fa un any no rep cap avís clar. La solució correcta és que el correu ho aclareixi: si el compte ja existeix, s'envia un missatge que diu «algú ha intentat registrar-se amb el teu correu; si has estat tu, aquí tens l'enllaç per recuperar-lo». L'atacant que no controla la bústia no veu res; el legítim sí. Decidir és un judici de valor: en un fòrum de receptes, un 409 clar és defensable; en una plataforma amb dades de compra, no.

  1. POST /auth/entrada i temps constants

// src/controladors/autenticacio.js  (fragment: entrada)
// Hash esquer: un bcrypt valid d'una contrasenya que ningu no coneix.
const HASH_ESQUER = '$2b$12$C6UzMDM.H6dfI/f/IKcEeO6iVQ9Lm2ZaZ8Xk1lF4a2iSg9WbLh9nK';
async function iniciarSessio(req, res, next) {
  const { correu, contrasenya } = req.dadesValidades.cos;
  // +hashContrasenya: l'unic lloc del codi que el demana.
  const usuari = await Usuari.findOne({ correu }).select('+hashContrasenya');
  // Es verifica SEMPRE, existeixi o no l'usuari. Sense esquer, un
  // "usuari inexistent" respondria en 2 ms i una "contrasenya dolenta"
  // en 250 ms: mesurar el temps revelaria quins correus tenen compte.
  const coincideix = await verificarContrasenya(contrasenya, usuari ? usuari.hashContrasenya : HASH_ESQUER);
  if (!usuari || !coincideix) { // missatge i codi IDENTICS en tots dos casos
    return next(new ErrorDAutenticacio('Credencials incorrectes'));
  }
  if (!usuari.verificat) {
    return next(new ErrorDAutenticacio('El compte encara no esta verificat'));
  }
  // A partir d'aqui, emetre la sessio (08-03) o els tokens (08-04).
  req.usuariAutenticat = { id: String(usuari._id), nom: usuari.nom, rol: usuari.rol };
  next();
}

Afegim els errors a src/errors.js, seguint el patró del mòdul 6:

class ErrorDAutenticacio extends ErrorDAplicacio {
  constructor(missatge = 'Credencials incorrectes', detalls) {
    super(missatge, { codi: 'NO_AUTENTICAT', esOperatiu: true, detalls });
  }
}
class ErrorDAutoritzacio extends ErrorDAplicacio {
  constructor(missatge = 'No tens permis per a aquesta operacio', detalls) {
    super(missatge, { codi: 'SENSE_PERMIS', esOperatiu: true, detalls });
  }
}

Ampliem la taula ESTAT_PER_CODI de src/middleware/errors.js amb NO_AUTENTICAT: 401, SENSE_PERMIS: 403 i MASSA_PETICIONS: 429. Així una entrada fallida retorna el format de sempre:

{ "error": { "codi": "NO_AUTENTICAT", "missatge": "Credencials incorrectes", "estat": 401 } }

  1. Límit d'intents i registres

El hash lent encareix l'atac per contrasenya provada; el límit de peticions l'encareix per intent. Calen tots dos:

// src/middleware/limits.js  (addicions)
const limitEntrada = rateLimit({
  windowMs: 15 * 60 * 1000, limit: 10,
  standardHeaders: 'draft-7', legacyHeaders: false, // inclou Retry-After
  // Clau IP + correu: frena tambe l'atac distribuit que reparteix
  // els intents contra UN compte des de moltes IPs.
  keyGenerator: (req) => `${req.ip}:${String(req.body?.correu || '').toLowerCase()}`,
});
const limitRegistre = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5 });
module.exports = { limitGeneral, limitCompra, limitEntrada, limitRegistre };

Compte amb el bloqueig de compte: si bloqueges després de N errors, has creat una denegació de servei contra qualsevol usuari de qui es conegui el correu; és preferible un retard creixent, CAPTCHA a partir d'un llindar i avís per correu. A 08-06 afinem els límits per tipus de ruta i expliquem per què el magatzem en memòria no val amb diversos processos.

Què es registra: marca de temps, idPeticio (M6), correu o id d'usuari, resultat i motiu genèric, IP i agent d'usuari. Què mai: la contrasenya —ni tan sols amb el hash fet—, el hash desat, tokens de sessió, accés o recuperació, i el cos sencer de la petició. La fallada real més habitual no és escriure console.log(contrasenya), sinó abocar la petició sencera en un middleware de depuració o en un capturador d'errors; per això convé una llista centralitzada de camps censurats: contrasenya, contrasenyaActual, token, authorization, cookie.

  1. Verificació de correu i recuperació

Tots dos fluxos fan servir la mateixa primitiva: un token d'un sol ús enviat per correu. Quatre regles, sense excepció:

  1. Aleatori criptogràfic, no Math.random() ni un UUID versió 1: randomBytes(32).
  2. Es desa amb el hash fet (SHA-256 basta: el token ja té 256 bits d'entropia, no cal lentitud), així qui robi la base no pot fer servir els tokens pendents.
  3. Caduca aviat: 1 hora per a recuperació, 24 h per a verificació.
  4. Un sol ús: s'esborra en consumir-lo. I en fer-lo servir s'invaliden les sessions i els refrescos existents: si algú havia entrat amb la contrasenya robada, el canvi l'ha de fer fora.
// src/serveis/tokens-correu.js
'use strict';
const { randomBytes, createHash, timingSafeEqual } = require('node:crypto');
const { TokenCorreu } = require('../models/token-correu.js');
const CADUCITAT = Object.freeze({ verificacio: 24 * 60 * 60 * 1000, recuperacio: 60 * 60 * 1000 });
// SHA-256 es correcte AQUI (i no per a contrasenyes) perque el token
// te 256 bits d'entropia: no hi ha diccionari que hi arribi.
const generarHashToken = (token) => createHash('sha256').update(token).digest('hex');
async function emetreToken(usuariId, tipus) {
  // 32 bytes = 256 bits. base64url perque viatgi net dins d'una URL (M3).
  const token = randomBytes(32).toString('base64url');
  await TokenCorreu.deleteMany({ usuariId, tipus }); // invalida els anteriors
  await TokenCorreu.create({ usuariId, tipus, hashToken: generarHashToken(token),
    expiraEl: new Date(Date.now() + CADUCITAT[tipus]) });
  return token; // en clar UNA vegada, per construir l'enllac del correu
}
async function consumirToken(token, tipus) {
  const hash = generarHashToken(token);
  const registre = await TokenCorreu.findOne({ hashToken: hash, tipus });
  if (!registre || registre.expiraEl.getTime() < Date.now()) { return null; }
  // Comparacio en temps constant sobre el hash (M3).
  if (!timingSafeEqual(Buffer.from(registre.hashToken), Buffer.from(hash))) { return null; }
  await registre.deleteOne(); // un sol us: es consumeix aqui
  return String(registre.usuariId);
}
module.exports = { emetreToken, consumirToken, CADUCITAT };

El model TokenCorreu desa usuariId, tipus (verificacio o recuperacio), hashToken (únic) i expiraEl amb un índex { expireAfterSeconds: 0 }, perquè MongoDB esborri tot sol els documents caducats sense necessitat d'una tasca de neteja.

Fallada habitual Com l'evitem
Token = id o correu codificat: s'endevina i es pren qualsevol compte 256 bits aleatoris
Token desat en clar: filtrar la base = prendre qualsevol compte Se'n desa el hash
Sense caducitat: un correu antic serveix anys després expiraEl + índex TTL
Reutilitzable: l'enllaç de l'historial torna a funcionar Esborrat en consumir-lo
No tancar sessions en canviar la contrasenya: l'atacant continua dins Revocar sessions i refrescos

I el canvi de contrasenya estant a dins, que sempre exigeix l'actual:

async function canviarContrasenya(req, res, next) {
  const { contrasenyaActual, contrasenyaNova } = req.dadesValidades.cos;
  const usuari = await Usuari.findById(req.usuari.id).select('+hashContrasenya');
  // Exigir l'actual bloqueja el segrest des d'una sessio oberta i
  // oblidada en un ordinador compartit.
  if (!await verificarContrasenya(contrasenyaActual, usuari.hashContrasenya)) {
    return next(new ErrorDAutenticacio('Credencials incorrectes'));
  }
  usuari.hashContrasenya = await generarHashContrasenya(contrasenyaNova);
  await usuari.save();
  await revocarTotsElsRefrescos(usuari._id); // nomes sobreviu la sessio actual
  res.status(204).end();
}

Errors Comuns i Consells

  • Fer el hash al client i enviar el hash. No serveix: el hash es converteix en la contrasenya, i qui robi la base s'hi autentica directament. La contrasenya viatja en clar dins del TLS i el hash es fa al servidor.
  • Sal global compartida. La sal és única per usuari. Un secret addicional d'aplicació (pepper) és una capa vàlida, però mai no substitueix la sal i complica la rotació.
  • Desar la contrasenya «temporalment» en un camp, que acaba en una còpia de seguretat i en un log; o oblidar select: false i retornar l'usuari sencer en un JSON, la manera més silenciosa de filtrar hashos.
  • Truncar sense avisar pel límit de 72 bytes. Dues frases de pas llargues amb el mateix començament passarien a ser equivalents.
  • Consell: que generarHashContrasenya/verificarContrasenya siguin l'únic punt que coneix l'algorisme —migrar a Argon2 serà canviar un fitxer—, i en pujar el cost aprofita l'entrada per regenerar el hash quan la contrasenya es verifica bé: migració transparent, sense demanar res a l'usuari.

Exercicis

Exercici 1: detectar les fallades

Aquest controlador té cinc fallades de seguretat. Troba-les i explica cadascuna.

async function registrar(req, res) {
  const usuari = await Usuari.create(req.body);
  const hash = require('node:crypto').createHash('sha256').update(req.body.contrasenya).digest('hex');
  usuari.hashContrasenya = hash;
  await usuari.save();
  console.log('Registrat', req.body.correu, req.body.contrasenya);
  res.status(201).json(usuari);
}

Exercici 2: refer el hash de manera transparent

Escriu verificarIActualitzar(usuari, contrasenyaEnClar): verifica la contrasenya i, si és correcta però el hash fa servir un cost menor que configuracio.bcryptCost, el regenera i el desa. Pista: el cost és dins del hash mateix, entre el segon i el tercer $.

Exercici 3: recuperació de contrasenya

Enumera en ordre els passos de POST /auth/recuperar/:token, que fixa una contrasenya nova, indicant a cadascun quin atac evita.

Solucions

Exercici 1

  1. Usuari.create(req.body): assignació massiva; un cos amb "rol":"administrador" crea un administrador. Cal extreure els camps un a un des de req.dadesValidades.
  2. SHA-256 com a hash de contrasenya: ràpid i sense sal, vulnerable a GPU i a taules arc de Sant Martí. Ha de ser bcrypt/scrypt/Argon2.
  3. console.log de la contrasenya en clar: queda als registres per sempre.
  4. res.json(usuari): retorna el document sencer; sense select: false ni toJSON, exposa el hash i camps interns.
  5. Sense validació prèvia: ni política de longitud, ni normalització del correu, ni control de duplicats; a més respon 201 revelant si el correu ja existia (enumeració). Fallada extra: l'usuari es crea sense hashContrasenya i es desa en dos passos; si el segon save() falla, queda un compte sense credencial.

Exercici 2. L'entrada és l'únic moment en què el servidor coneix la contrasenya en clar legítimament, així que és l'únic moment possible per refer el hash.

async function verificarIActualitzar(usuari, contrasenyaEnClar) {
  if (!await verificarContrasenya(contrasenyaEnClar, usuari.hashContrasenya)) { return false; }
  // Format $2b$12$sal+hash: el cost es el tercer segment.
  if (Number(usuari.hashContrasenya.split('$')[2]) < configuracio.bcryptCost) {
    usuari.hashContrasenya = await generarHashContrasenya(contrasenyaEnClar);
    await usuari.save();
  }
  return true;
}

Exercici 3

Pas Atac que evita
1. Validar el cos amb zod Entrades enormes que provoquen un hash costós
2. consumirToken(token, 'recuperacio'): comprova hash i caducitat, i l'esborra Reutilització i endevinació del token
3. Error genèric si no és vàlid Distingir «inexistent» de «caducat» ajuda a calibrar l'atac
4. Fer el hash amb bcrypt i desar-lo, marcant verificat: true Emmagatzematge en clar; qui controla la bústia ja ha provat la propietat del correu
5. Revocar totes les sessions i refrescos L'atacant que ja hi fos continua dins
6. Avisar per correu i registrar l'esdeveniment (sense token ni contrasenya) Presa de control silenciosa; falta de rastre per a l'auditoria (08-05, 08-06)

Conclusió

Ja existeix una credencial de debò a Escena Viva. Sabem per què una contrasenya mai no es desa en clar, xifrada ni amb SHA-256; què fa un hash de contrasenya —lent, salat, amb cost ajustable— i per què bcrypt desa variant, cost i sal dins del hash mateix. Hem mesurat el factor de cost en lloc de copiar-lo, completat el model Usuari amb hashContrasenya i select: false, escrit un esquema zod .strict() amb política moderna, i construït POST /auth/registre i POST /auth/entrada que no filtren quins correus existeixen ni pel missatge, ni pel codi, ni pel temps. I hem fet bé la part que més falla: tokens d'un sol ús, aleatoris, amb el hash desat a la base i amb caducitat.

Però fixa't en com acaba avui iniciarSessio: sabem que és la Lucía… i no fem res amb aquesta informació. La petició següent torna a arribar anònima. A la lliçó següent, Sessions i Galetes amb Passport.js, ho resolem de la manera clàssica: un identificador de sessió aleatori en una galeta HttpOnly, l'estat al servidor, express-session amb les opcions que de debò importen, la regeneració que impedeix la fixació de sessió, Passport amb la seva estratègia local, i la protecció CSRF que les galetes exigeixen a canvi de la seva comoditat.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats