📚 Unidad ISS-09 · Auth base (seguridad y modelos) — capa 🧠 APRENDER
🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación
Capa Página Para qué 🧠 Aprender esta página comprender, explicar y relacionar 🛠 Construir Auth base (seguridad y modelos) ejecutar, programar y verificar ✅ GATE Cierre de la unidad condición para pasar al bloque siguiente
Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).
| Tema | 🧠 Aprender (esta página) | 🛠 Construir (ISS técnico) |
|---|---|---|
| Ruta y ficha de la unidad | Ruta de aprendizaje · Ficha del ISS | Contenido de la unidad |
| Mapas y estructura | Mapa mental · Mapa del backend · Árbol de archivos | Contenido de la unidad |
| Recorrido y comandos | Comandos explicados · Recorrido paso a paso | Contenido de la unidad |
| Diagnóstico | Diagnóstico | Criterios de aceptación |
| Evaluación y cierre | Criterios · Evaluación · GATE | Condiciones de cierre · Cierre |
🖥 Presentación del ISS
Presentación de la unidad. Diapositivas de ISS-09 — Auth base (seguridad y modelos) (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.
🎬 Video explicativo
Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, las primitivas de autenticación del ISS-09: hash, comparación de contraseña, token opaco y el contrato que el criterio llama
verifyPasswordy el archivo exporta comocomparePassword.
8:31 · narración en español · subtítulos activables desde el reproductor.
ISS-09 — Cuaderno de aprendizaje visual
Tema
Base de seguridad compartida y modelos Auth: construir las primitivas transversales (hash de contraseña con bcrypt, firma y verificación de JWT HS256, matcher de rutas, identidad en Request, sendError) y las seis tablas del modelo RBAC con sus asociaciones, antes de escribir el primer endpoint protegido.
Fuente técnica autoritativa
| Archivo fuente | ../manual/11-ISS-09-auth-base.md |
| Nombre literal | 11-ISS-09-auth-base.md |
| Estado | Solo lectura — este cuaderno no modifica el ISS |
| Fase | Fase II — Auth con RBAC (primer ISS de la fase) |
| Alcance | 1.377 líneas, 12 bloques de código, 10 criterios de aceptación (14.1 … 14.10) |
Este cuaderno es una capa pedagógica sobre ese archivo: todo su contenido técnico aparece aquí íntegro y verbatim. El ISS manda; el cuaderno explica. Las secciones ## El access token, por dentro, ## Anatomía del código, ## Comandos explicados, ## Flujos, ## Diagnóstico y ## Glosario son aportación didáctica; nada de lo que afirman contradice al ISS.
Pregunta que responde: ¿qué piezas de seguridad debo construir antes de poder hablar de «usuarios» y «permisos»?
Regla del ISS
Objetivo: dejar listas las primitivas de seguridad (hash de contraseña, hashes de tokens opacos, firma y verificación de JWT, matcher de rutas) y las seis tablas del modelo RBAC con sus asociaciones, de modo que los ISS posteriores solo construyan capas sobre esta base. Bloqueado por: Fase I cerrada (el
App,Routes,SeedersRunnerySwaggerya existen y se extienden, no se reescriben).
La condición fundamental que el propio ISS exige para darse por terminado combina dos ideas:
- Compila y arranca:
npx tsc --noEmitsin errores ynpm run devlevanta el servidor. - Sin endpoints nuevos y sin protección todavía: la API de Fase I sigue funcionando SIN AUTH. Las rutas de negocio no se tocan aquí; la protección real llega en ISS-13.
Es decir: este ISS crea infraestructura y modelos vacíos. No hay ni un GET /api/usuarios funcional, y eso es correcto. Si al terminar intentas llamar a un endpoint de auth y recibes un 404, no has fallado: todavía no existe.
Cómo leer este cuaderno
Cada concepto se presenta tres veces, desde tres ángulos distintos:
CONCEPTO
│
┌───────────┼───────────┐
▼ ▼ ▼
EXPLICACIÓN CÓDIGO VISUAL
│ │ │
¿qué es? ¿dónde está? ¿cómo lo
¿por qué? ¿qué hace? visualizo?
¿para qué? ¿cómo opera? ¿con qué
se relaciona?
Pregunta que responde: ¿cómo está organizado este cuaderno y qué espero encontrar en cada parte?
El recorrido de lectura es siempre el mismo:
Y cada cuaderno contiene los mismos seis componentes:
| Componente | Dónde vive | Para qué sirve |
|---|---|---|
| Texto | todas las secciones | entender el por qué |
| Código | Recorrido del ISS, paso a paso |
ver el qué exacto, verbatim |
| Diagramas | Mapa mental, Mapa del backend, El access token, por dentro, Flujos |
ver el cómo se conecta |
| Preguntas | Evaluación |
comprobar que entendiste |
| Evaluación | Evaluación |
practicar y autoevaluarte |
| GATE | GATE |
saber si puedes pasar al siguiente ISS |
En este ISS el código es casi todo el contenido: son 12 bloques de un solo uso (seed de archivos con
cat >> … << 'EOF'). Léelos como «recetas de creación», no como código que se ejecuta en caliente.
Ruta de aprendizaje
Esta ruta es específica de este ISS: sigue el orden real de sus 10 apartados (14.1 … 14.10).
Fase I cerrada (5 features business, SIN AUTH)
↓
14.1 Instalar jsonwebtoken + nuevas variables .env
↓
14.2 password.ts → bcrypt 12, sha256Hex, generateOpaqueToken
↓
14.3 jwt.ts → firma y verificación HS256
↓
14.4 resource-match.ts → normalizePath, pathMatches, isOperationGranted
↓
14.5 auth-user.ts → Request.auth y requireAuthUser
↓
14.6 error-response.ts + PARCHE de BaseController
↓
14.7 swagger-security.ts → bearerAuth, 401, 403
↓
14.8 Los 6 modelos Sequelize (users, roles, resources, role_users,
resource_roles, refresh_tokens)
↓
14.9 rbac.associations.ts → el grafo completo
↓
14.10 Cableado en config/index.ts y seeders/index.ts
↓
Verificación: tsc + db:seed + dev
Fíjate en lo que no aparece: no hay «feature users», ni «services», ni «middlewares», ni «login». Todo eso son ISS-10 … ISS-15. Aquí solo se prepara el terreno sobre el que se apoyarán.
Pregunta que responde: ¿en qué orden se construye la base de seguridad y por qué ese orden?
Índice
- Ficha del ISS
- Mapa mental del ISS
- Mapa del backend
- El access token, por dentro
- Árbol de archivos
- Anatomía del código
- Comandos explicados
- Flujos
- Recorrido del ISS, paso a paso
- Diagnóstico
- Conexión con el resto del curso
- Glosario
- Criterios de aceptación
- Evaluación
- GATE
Ficha del ISS
| Campo | Valor |
|---|---|
| ISS | ISS-09 |
| Título | Base de seguridad compartida y modelos Auth |
| Objetivo | Dejar listas las primitivas de seguridad (bcrypt 12, SHA-256 de tokens, JWT HS256, matcher de rutas) y las 6 tablas RBAC con sus asociaciones |
| Fase | Fase II — Auth con RBAC (primer ISS) |
| Tecnología principal | jsonwebtoken@^9.0.3 (HS256), bcryptjs (12 rondas), Sequelize |
| Depende de | Cierre de Fase I (00…ISS-08); el App, Routes, SeedersRunner y Swagger ya existen |
| Habilita | ISS-10 — Feature Users |
| Archivos creados | src/shared/auth/{password,jwt,resource-match,auth-user}.ts, src/shared/http/{error-response,swagger-security}.ts, 6 modelos en src/features/auth/…, src/features/auth/rbac.associations.ts |
| Archivos parcheados | src/shared/http/base-controller.ts, src/config/index.ts, src/database/seeders/index.ts, .env, package.json |
| Componentes incorporados | Hash bcrypt, hash SHA-256, token opaco, firma/verificación JWT, matcher de rutas, sendError, requireAuthUser, 6 modelos + asociaciones |
| Verificación principal | npx tsc --noEmit + npm run db:seed + npm run dev |
| Resultado esperado | Las 6 tablas existen vacías; el servidor arranca; sin endpoints nuevos (la API de Fase I sigue SIN AUTH) |
| GATE | DoD del ISS-09: criterios 14.1 … 14.10 + tsc OK + db:seed crea las 6 tablas + dev arranca |
Qué implementamos AHORA
- Primitivas de seguridad en
src/shared/auth/: password.ts→hashPassword,comparePassword(bcrypt 12),sha256Hex,generateOpaqueToken.jwt.ts→signAccessToken/verifyAccessToken/extractBearerToken(HS256 coniss,aud,exp,jti).resource-match.ts→normalizePath,pathMatches,isOperationGranted.auth-user.ts→ tipoAuthUser,Request.auth,requireAuthUser.- HTTP compartido en
src/shared/http/: error-response.ts→sendError(único mapeoerror → HTTP).- Parche de
base-controller.tspara quehandleErrordelegue ensendError. swagger-security.ts→bearerSecurityScheme,unauthorizedResponse,forbiddenResponse.- Los seis modelos en
src/features/auth/:User,Role,Resource,RoleUser,ResourceRole,RefreshToken, con sus UK e índices. - El grafo de asociaciones en
rbac.associations.ts. - El cableado de modelos y asociaciones en
config/index.tsyseeders/index.ts(primero modelos, después asociaciones).
Qué todavía NO implementamos
| No se implementa aquí | Llega en |
|---|---|
Feature users (DTOs, repository, service, controller, rutas) |
ISS-10 |
Features roles y resources (+ catálogo de 58 recursos) |
ISS-11 |
Pivotes role_users y resource_roles + consulta de permisos efectivos |
ISS-12 |
Middlewares authenticate y authorize; protección real de rutas |
ISS-13 |
refresh_tokens funcional (rotación, detección de reúso) |
ISS-14 |
Login, refresh, logout, perfil, /api/permisos |
ISS-15 |
| Protección de las rutas de negocio de Fase I | ISS-13 |
Trampa habitual: ver
src/features/auth/refresh-tokens/refresh-token.model.tsy creer que el refresh token ya funciona. No: aquí solo nace la tabla (refresh_tokens). La lógica de rotación vive en ISS-14. Lo mismo vale parausers: la tabla se crea en ISS-09, pero el CRUD llega en ISS-10.
Mapa mental del ISS
mindmap
root((ISS-09<br/>Base de seguridad))
Primitivas shared auth
password
jwt
resource match
auth user
HTTP compartido
error response
parche BaseController
swagger security
Seis modelos
User
Role
Resource
RoleUser
ResourceRole
RefreshToken
Asociaciones
rbac associations
Cableado
config index
seeders index
Resultado
tablas vacias
sin endpoints nuevos
Pregunta que responde: ¿de qué trata este ISS y qué piezas lo componen?
Mapa del backend
Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos?
IMPLEMENTADO HASTA ESTE ISS
───────────────────────────
HTTP
↓
Routes (5 features business — todavía SIN AUTH) ✅ Fase I
↓
Controller → Service → Repository → Model → Sequelize → BD ✅ Fase I
── Capa transversal compartida (NUEVA en ISS-09) ──────────────
shared/auth/ password.ts bcrypt 12 ✅
jwt.ts firma/verificación HS256 ✅
resource-match.ts normalizador + matcher ✅
auth-user.ts Request.auth ✅
shared/http/ error-response.ts sendError ✅
swagger-security.ts bearerAuth, 401, 403 ✅
── Modelos Auth (NUEVOS en ISS-09, tablas VACÍAS) ───────────────
features/auth/ User Role Resource RoleUser
ResourceRole RefreshToken ✅
rbac.associations.ts ✅
OBJETIVO DE ARQUITECTURA
────────────────────────
HTTP
↓
Routes
↓
authenticate → authorize 🎯 ISS-13
↓
Controller → Service → Repository → Model → Sequelize → BD
↑ ↑
feature users features roles / resources / pivotes
🎯 ISS-10 🎯 ISS-11 / ISS-12
login · refresh · logout · perfil · permisos 🎯 ISS-15
protección de las 5 rutas de negocio 🎯 ISS-13
Pregunta que responde: ¿qué capas del backend existen ya y cuáles siguen siendo objetivo?
Lo importante de este mapa: la caja «capa de seguridad» ya está construida (✅), pero todavía no está enchufada a ninguna ruta. Es como tener las cerraduras montadas pero aún sin colocar en las puertas: las puertas de negocio siguen abiertas hasta ISS-13.
El access token, por dentro
Un access token es un JWT (JSON Web Token) firmado: un texto con tres partes separadas por puntos (header.payload.signature). El servidor lo firma con JWT_SECRET (HMAC SHA-256) y cualquiera puede leer su contenido, pero nadie puede modificarlo sin invalidar la firma. Esto es lo que permite autenticar sin tocar la base de datos.
flowchart TD
U["Usuario autenticado"] --> S["signAccessToken user"]
S --> H["jwt.sign con HS256"]
H --> P["Payload del token"]
P --> C1["sub id del usuario"]
P --> C2["username nombre"]
P --> C3["jti identificador unico del token"]
P --> C4["iss app-storelab-express"]
P --> C5["aud app-storelab-api"]
P --> C6["iat fecha de emision"]
P --> C7["exp caducidad 15 min por defecto"]
P --> NO["NO viajan roles ni permisos"]
H --> F["Firma HMAC SHA-256 con JWT_SECRET"]
F --> T["access_token entregado al cliente"]
Pregunta que responde: ¿qué información viaja dentro del access token y qué información NO viaja?
Traducción de cada claim (declaración):
| Claim | Qué significa | Para qué sirve aquí |
|---|---|---|
sub |
subject: a quién representa el token | Identifica al usuario (su id); se valida como entero positivo |
username |
Nombre del usuario | Comodidad de lectura / trazabilidad |
jti |
JWT ID: identificador único del token | Trazabilidad y correlación (RFC 8725) |
iss |
issuer: quién lo emitió | Rechaza tokens de otro servicio |
aud |
audience: para quién es | Rechaza tokens destinados a otra API |
iat |
issued at: cuándo se emitió | Trazabilidad |
exp |
expiration: cuándo caduca | El token deja de valer solo |
¿Por qué el token NO lleva roles ni permisos? Porque un token es inmutable durante su vida: lo que viaja dentro no se puede cambiar hasta que caduque. Si los permisos fueran dentro del token, revocar un permiso no tendría efecto hasta que el token expirara: el usuario seguiría pudiendo hacer lo prohibido durante, como mínimo, los 15 minutos de vida del token. La decisión de este diseño es la contraria: el token solo dice quién eres; qué puedes hacer se consulta contra la matriz en cada petición (ISS-13). Así la revocación es inmediata.
Este concepto es crítico y reaparece en ISS-13 (
authorizereconstruye los permisos efectivos por petición) y en ISS-14/15 (los tokens de sesión se renuevan y se revocan). Si te llevas una sola idea de este ISS, que sea esta.
Árbol de archivos
Estructura antes
Estado al cerrar Fase I (referencia: 10-cierre-business.md). Solo se muestran las ramas relevantes.
app-storelab-express-ii/
├── src/
│ ├── config/
│ │ └── index.ts △ (se parchea en 14.10)
│ ├── database/
│ │ ├── db.ts
│ │ └── seeders/
│ │ ├── counts.ts
│ │ └── index.ts △ (se parchea en 14.10)
│ ├── features/
│ │ └── business/ (5 features de Fase I)
│ ├── routes/
│ │ └── index.ts
│ ├── shared/
│ │ ├── database/with-transaction.ts
│ │ ├── errors/app-error.ts
│ │ └── http/
│ │ └── base-controller.ts △ (se parchea en 14.6)
│ ├── swagger/index.ts
│ └── server.ts
└── .env △ (se parchea en 14.1)
Archivos creados / modificados en este ISS
★ src/shared/auth/password.ts (14.2)
★ src/shared/auth/jwt.ts (14.3)
★ src/shared/auth/resource-match.ts (14.4)
★ src/shared/auth/auth-user.ts (14.5)
★ src/shared/http/error-response.ts (14.6)
△ src/shared/http/base-controller.ts (14.6, parche: delega en sendError)
★ src/shared/http/swagger-security.ts (14.7)
★ src/features/auth/users/user.model.ts (14.8)
★ src/features/auth/roles/role.model.ts (14.8)
★ src/features/auth/resources/resource.model.ts (14.8)
★ src/features/auth/role-users/role-user.model.ts (14.8)
★ src/features/auth/resource-roles/resource-role.model.ts (14.8)
★ src/features/auth/refresh-tokens/refresh-token.model.ts (14.8)
★ src/features/auth/rbac.associations.ts (14.9)
△ src/config/index.ts (14.10, parche: imports + rutas)
△ src/database/seeders/index.ts (14.10, parche: imports)
△ .env (14.1, parche: 3 variables)
△ package.json (14.1, jsonwebtoken + @types)
Estructura después
app-storelab-express-ii/
├── src/
│ ├── config/
│ │ └── index.ts △ (cablea modelos + asociaciones)
│ ├── database/
│ │ ├── db.ts
│ │ └── seeders/
│ │ ├── counts.ts
│ │ └── index.ts △ (importa modelos + asociaciones)
│ ├── features/
│ │ ├── business/ (sin cambios de Fase I)
│ │ └── auth/
│ │ ├── users/user.model.ts ★
│ │ ├── roles/role.model.ts ★
│ │ ├── resources/resource.model.ts ★
│ │ ├── role-users/role-user.model.ts ★
│ │ ├── resource-roles/resource-role.model.ts ★
│ │ ├── refresh-tokens/refresh-token.model.ts ★
│ │ └── rbac.associations.ts ★
│ ├── shared/
│ │ ├── auth/ ★ (nueva carpeta)
│ │ │ ├── password.ts
│ │ │ ├── jwt.ts
│ │ │ ├── resource-match.ts
│ │ │ └── auth-user.ts
│ │ ├── database/with-transaction.ts
│ │ ├── errors/app-error.ts
│ │ └── http/
│ │ ├── base-controller.ts △
│ │ ├── error-response.ts ★
│ │ └── swagger-security.ts ★
│ ├── routes/index.ts
│ ├── swagger/index.ts
│ └── server.ts
└── .env △
Leyenda: ★ = creado en este ISS · △ = existente parcheado.
Pregunta que responde: ¿este ISS modifica la estructura del proyecto y en qué partes?
Anatomía del código
Por cada archivo importante, qué responsabilidad tiene y con quién se conecta. El código completo está en ## Recorrido del ISS, paso a paso (verbatim); aquí se explica el porqué y el cómo encaja.
Archivo: src/shared/auth/password.ts
Propósito
Concentrar en un único punto toda la criptografía de contraseñas y de tokens de sesión, para que los tres sitios que los usan no puedan divergir.
Explicación
hashPassword/comparePasswordenvuelvenbcryptjscon una constanteSALT_ROUNDS = 12. Al vivir aquí, si mañana se sube o baja el coste se cambia en un solo lugar.sha256Hexes para los refresh tokens, no para contraseñas: un token opaco de alta entropía no es adivinable, así que no necesita un algoritmo lento; solo hay que evitar guardarlo en claro y poder buscarlo por índice.generateOpaqueTokenproduce un valor aleatorio URL-safe (randomBytes(64)enbase64url), es decir, un token que no es un JWT y que por tanto no se puede autovalidar: hay que consultarlo en la BD.
Se conecta con
- Entrada: el hook
beforeCreate/beforeUpdatedeUser, el service de usuarios (ISS-10) y el login (ISS-15). - Salida:
bcryptjsynode:crypto. La BD recibe siempre el hash, nunca el valor en claro.
Archivo: src/shared/auth/jwt.ts
Propósito
Emitir y verificar el access token (JWT firmado con HS256), fijando en el código el algoritmo, el emisor y la audiencia.
Explicación
signAccessTokenfirma conalgorithm: "HS256",subject,issuer,audience,expiresInyjwtidaleatorio (randomUUID). Devuelve el token y su TTL, porque el service lo necesita para responder cuándo caduca.getSecretexige unJWT_SECRETde mínimo 32 caracteres; si falta, lanzaAppError(500). Es un fallo de configuración, no de usuario.verifyAccessTokenpasa opciones explícitas (algorithms,issuer,audience,clockTolerance) y después comprueba a manosub(entero positivo) yjti. Este detalle es importante:jsonwebtokenno tiene la opciónrequire(esa es dejose); confiar en ella sería confiar en que valida algo cuando no lo hace.- Cualquier fallo de verificación se traduce a
AppError(401): «no autenticado», nunca un 500. extractBearerTokenimplementa la lectura deAuthorization: Bearer <token>(RFC 6750).
Se conecta con
- Entrada: el futuro middleware
authenticate(ISS-13) y el service de sesión (ISS-15). - Salida:
jsonwebtoken,node:cryptoyAppError.
Archivo: src/shared/auth/resource-match.ts
Propósito
Traducir la petición real (GET /api/productos/42) a la concesión almacenada (GET /api/productos/:id). Es el corazón lógico del RBAC.
Explicación
normalizePathquita query string, hash, barra final y barras duplicadas. Normalizar antes de comparar evita que/api/productos/y/api/productosse traten como cosas distintas.pathMatchesdivide ambos caminos por/y exige el mismo número de segmentos; cada segmento:paramdel patrón casa con un segmento cualquiera, y el resto debe ser igual. Es deliberadamente estricto: no hay comodines*.- Por esa estrictez,
GET /api/productos/42no casa conGET /api/productos. Así, un permiso de «listar» no autoriza por accidente «leer uno concreto». isOperationGrantedcompara el verbo (en mayúsculas) y el camino contra todas las concesiones; devuelvefalsesi ninguna casa. Es la regla deny by default hecha código.
Se conecta con
- Entrada: el futuro middleware
authorizey el modeloResource(que normaliza supathcon esta misma función). - Salida:
true/falsepuro, sin efectos secundarios. No conocereq/resni Sequelize.
Archivo: src/shared/auth/auth-user.ts
Propósito
Definir qué es la identidad de una petición y cómo se lee de forma segura.
Explicación
- La interfaz
AuthUserdescribe lo que los middlewares de acceso dejan en la petición:id,username,email?ytokenId?(eljti, útil para cerrar la sesión actual). requireAuthUser(req)devuelvereq.autho lanzaAppError(401). Cierra el caso de las rutas JWT (sinauthorize): el middleware ya garantizó la identidad, pero el tipo es opcional, así que esta función evita los!(non-null assertions) dispersos.- El bloque
declare globalamplíaExpress.Requestconauth?: AuthUser. Elundefinedsignifica «ruta OPEN»: es la forma de que el tipo refleje las tres modalidades de acceso.
Se conecta con
- Entrada: el middleware
authenticate(ISS-13) escribereq.auth. - Salida: los controllers que necesitan saber quién llama (
/api/sesion/perfil, ISS-15).
Archivo: src/shared/http/error-response.ts
Propósito
Ser el único punto del proyecto donde se decide cómo un error se convierte en una respuesta HTTP.
Explicación
sendErrordistingue dos casos: si el error es unAppError, responde con sustatusCodey sumessage; cualquier otra cosa se convierte en 500 con undetailde texto.- Se extrae del
BaseControllerporque los middlewaresauthenticate/authorizetambién fallan antes de llegar a un controller y necesitan exactamente el mismo mapeo. Un solo punto = comportamiento consistente. - El stack nunca se envía en el cuerpo: eso sería una fuga de información.
Se conecta con
- Entrada:
BaseController.handleError(parcheado) y los middlewares de acceso (ISS-13). - Salida:
AppError(deshared/errors/app-error.ts).
Archivo: src/shared/http/base-controller.ts (parche)
Propósito
Mantener la regla transversal de Fase I («todo handler se envuelve en this.run(res, …)»), ahora delegando el mapeo de errores en sendError.
Explicación
- La única línea que cambia en
handleErrores la que ahora llama asendError(res, error). El resto del archivo (run,paramId) sigue igual. - El parche es quirúrgico: no se reescribe el controller base, se reutiliza. Justo lo que anunciaba la cabecera del ISS: los archivos de Fase I «se extienden, no se reescriben».
Se conecta con
- Entrada: los 7 métodos de cada controller de todo el proyecto.
- Salida:
error-response.ts.
Archivo: src/shared/http/swagger-security.ts
Propósito
Evitar que el esquema bearerAuth y las respuestas 401/403 se repitan en cada módulo de Swagger.
Explicación
bearerSecuritySchemedefine el esquemabearerAuth(Authorization: Bearer <token>), que Swagger UI usará para el botón Authorize.openSecurity,bearerSecurity,unauthorizedResponse,forbiddenResponse,invalidIdResponseynotFoundResponseson piezas reutilizables por$ref.- La correspondencia con las tres modalidades queda documentada en el propio archivo: OPEN →
openSecurity; JWT →bearerSecurity; RBAC →bearerSecurity+401y403.
Se conecta con
- Entrada: los módulos
*.swagger.tsde los 7 features de auth y los 5 de business (ISS-10 en adelante). - Salida: el ensamblaje de OpenAPI en
src/swagger/index.ts.
Archivo: src/features/auth/users/user.model.ts (y los otros cinco modelos)
Propósito
Definir la tabla users y, sobre todo, impedir por construcción que una contraseña se guarde en claro.
Explicación
- El hash se calcula en hooks (
beforeCreate,beforeUpdate,beforeBulkCreate). Cualquier ruta de escritura (create,update,bulkCreatedel seeder) pasa por ellos: no hay forma de olvidarse. El algoritmo y el coste viven enpassword.ts. beforeValidatenormalizausernameyemaila minúsculas y sin espacios antes de validar, para quelen/isEmailjuzguen el valor definitivo y el login (que compara por igualdad) sea predecible.passwordse declaraSTRING(255): el hash bcrypt ocupa 60, y 255 deja margen a algoritmos futuros.- La columna
statusesENUM('active','inactive')con defaultinactive(fail-safe), igual que en Fase I. - Los otros cinco modelos (
Role,Resource,RoleUser,ResourceRole,RefreshToken) siguen el mismo patrón. Dos detalles a retener: ResourcenormalizamethodypathenbeforeValidatey tiene UK compuesta(method, path).RefreshTokenes la única tabla cuyostatuspor defecto esactive(un token recién emitido nace vigente).
Se conecta con
- Entrada:
sequelize(dedatabase/db.ts) ypassword.ts(el modeloUser). - Salida: el repository de cada feature (ISS-10 en adelante) y
rbac.associations.ts.
Archivo: src/features/auth/rbac.associations.ts
Propósito
Declarar en un solo archivo el grafo completo de relaciones, para que la cadena de autorización se pueda leer entera.
Explicación
- Se declara después de los modelos porque los referencia (nunca al revés).
- El grafo:
User N:M RolemedianteRoleUser;Role N:M ResourcemedianteResourceRole;User 1:N RefreshToken. - Los alias (
as: "role",as: "resource",as: "resource_roles", …) son los que usarán losincludede los repositories RBAC (ISS-12/ISS-13). Cambiar un alias obliga a revisar esas consultas: por eso están todos juntos.
Se conecta con
- Entrada: los seis modelos.
- Salida:
config/index.tsyseeders/index.ts, que lo importan después de los modelos.
Archivo: src/config/index.ts y src/database/seeders/index.ts (parches)
Propósito
Hacer que Sequelize conozca los seis modelos y sus asociaciones al arrancar la app y al ejecutar los seeders.
Explicación
- Ambos archivos reciben el mismo bloque de
import: primero los seis*.model.ts, despuésrbac.associations.ts. El orden importa: Sequelize solo conoce las asociaciones que se han ejecutado. config/index.tstambién registra las rutas de Fase II (sessionRoutes,usersRoutes, …). En ISS-09 esas rutas aún no existen; el ISS muestra el bloque completo porque documenta el destino final y18-cierre-auth.mdlo detalla.seeders/index.tsrecibe el mismo bloque para quenpm run db:seedpueda hacersyncde las seis tablas.
Se conecta con
- Entrada:
sequelize.sync(...)endbConnection(config) y enrunAllSeeders(seeders). - Salida: la BD, con las seis tablas creadas.
Comandos explicados
Ningún comando va sin explicación. Usamos el esquema obligatorio del reglamento.
npm install jsonwebtoken@^9.0.3
COMANDO
↓
npm install jsonwebtoken@^9.0.3
↓
QUÉ HACE
Instala la librería de firma/verificación de JWT (versión 9.0.3 o superior
compatible dentro de la rama 9).
↓
POR QUÉ SE NECESITA
Es la dependencia que usan jwt.ts (firma/verificación HS256) y, más adelante,
el service de sesión. Sin ella el proyecto no compila.
↓
QUÉ CREA O MODIFICA
package.json (dependencies) y package-lock.json; node_modules/jsonwebtoken.
↓
RESULTADO ESPERADO
Árbol de dependencias resuelto sin errores de peer deps.
↓
CÓMO VERIFICARLO
Revisar package.json: debe aparecer jsonwebtoken. O `node -e
"require('jsonwebtoken')"` sin error.
npm install -D @types/jsonwebtoken@^9.0.10
COMANDO
↓
npm install -D @types/jsonwebtoken@^9.0.10
↓
QUÉ HACE
Instala los tipos de TypeScript de jsonwebtoken como dependencia de desarrollo.
↓
POR QUÉ SE NECESITA
El proyecto es TypeScript; sin los tipos, `import jwt from "jsonwebtoken"`
carece de firmas y `npx tsc --noEmit` fallaría.
↓
QUÉ CREA O MODIFICA
package.json (devDependencies) y package-lock.json.
↓
RESULTADO ESPERADO
Tipos disponibles; los imports de jwt.ts resuelven.
↓
CÓMO VERIFICARLO
`npx tsc --noEmit` no se queja de jsonwebtoken.
Ampliar .env con las tres variables nuevas
COMANDO
↓
cat >> .env << 'EOF' (bloque con JWT_SECRET, JWT_ACCESS_TTL, JWT_REFRESH_TTL_DAYS)
↓
QUÉ HACE
AÑADE al final del .env existente (no lo reemplaza) las tres variables de
seguridad de Fase II.
↓
POR QUÉ SE NECESITA
jwt.ts exige JWT_SECRET (mínimo 32 caracteres). JWT_ACCESS_TTL (segundos)
y JWT_REFRESH_TTL_DAYS (días) fijan las vidas útiles de los tokens.
↓
QUÉ CREA O MODIFICA
.env (parche). No toca las variables de Fase I.
↓
RESULTADO ESPERADO
Tres líneas nuevas al final del .env con sus comentarios.
↓
CÓMO VERIFICARLO
`grep JWT .env` muestra JWT_SECRET, JWT_ACCESS_TTL y JWT_REFRESH_TTL_DAYS.
Las dos unidades no son un descuido:
JWT_ACCESS_TTLva en segundos (900 = 15 min) yJWT_REFRESH_TTL_DAYSen días. El service las convierte a milisegundos al persistirexpires_at(ISS-14/ISS-15).
npx tsc --noEmit
COMANDO
↓
npx tsc --noEmit
↓
QUÉ HACE
Comprueba los tipos de todo el proyecto sin generar archivos de salida.
↓
POR QUÉ SE NECESITA
Es la verificación principal del ISS: detecta imports rotos, tipos mal
declarados y el uso incorrecto de Request.auth.
↓
QUÉ CREA O MODIFICA
Nada (no emite).
↓
RESULTADO ESPERADO
Salida vacía y código de salida 0.
↓
CÓMO VERIFICARLO
El propio comando: si imprime errores, el ISS no está cerrado.
npm run db:seed
COMANDO
↓
npm run db:seed
↓
QUÉ HACE
Ejecuta el SeedersRunner: conecta, hace `sync({ alter: true })` y siembra.
↓
POR QUÉ SE NECESITA
Es lo que crea físicamente las seis tablas (el `sync` las deriva de los
modelos). En ISS-09 no siembra datos de auth todavía; solo prepara el esquema.
↓
QUÉ CREA O MODIFICA
Las tablas users, roles, resources, role_users, resource_roles y
refresh_tokens (con sus UK e índices).
↓
RESULTADO ESPERADO
Mensaje de sincronización y ejecución de seeders sin error.
↓
CÓMO VERIFICARLO
`SHOW TABLES LIKE '%role%'` y `SHOW TABLES LIKE 'users';` en MySQL.
npm run dev
COMANDO
↓
npm run dev
↓
QUÉ HACE
Arranca el servidor Express en modo desarrollo (con recarga).
↓
POR QUÉ SE NECESITA
Comprueba que el cableado de modelos no rompe el arranque: primero conecta y
hace sync, después escucha en el puerto.
↓
QUÉ CREA O MODIFICA
Nada en disco; abre el puerto (por defecto 4000).
↓
RESULTADO ESPERADO
Mensajes de conexión y de base de datos sincronizada, y "Servidor ejecutándose".
↓
CÓMO VERIFICARLO
Que el log muestre el arranque sin `process.exit(1)` por fallo de BD.
Consulta opcional de tablas
COMANDO
↓
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"
↓
QUÉ HACE
Lista las tablas cuyo nombre contiene "role" y la tabla users.
↓
POR QUÉ SE NECESITA
Verificación visual de que el `sync` creó las seis tablas.
↓
QUÉ CREA O MODIFICA
Nada (consulta de solo lectura).
↓
RESULTADO ESPERADO
roles, role_users, resource_roles (y users).
↓
CÓMO VERIFICARLO
Las tres tablas con "role" y users aparecen en la salida.
Pregunta que responde: ¿qué hace cada comando del ISS y cómo sé que hizo lo que debía?
Flujos
Flujo 1 — Arranque de la app con los modelos cableados
sequenceDiagram
participant E as server.ts
participant A as App config
participant D as dbConnection
participant S as Sequelize sync
participant P as Puerto 4000
E->>A: new App().listen()
A->>D: conectar y sincronizar
D->>S: sync alter true
S-->>D: tablas listas
D-->>A: conexion ok
A->>P: listen
P-->>E: servidor ejecutandose
Pregunta que responde: ¿en qué orden arranca el servidor y por qué la BD va primero?
La BD va primero por una razón concreta: si el puerto se abre antes de terminar sync({ alter: true }), las sentencias DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya están entrando y provocan deadlocks y errores de FK intermitentes.
Flujo 2 — Qué hace el access token, de punta a punta
sequenceDiagram
participant C as Cliente
participant S as Service de sesion
participant J as jwt.ts
participant M as authenticate
C->>S: login credenciales
S->>J: signAccessToken user
J-->>S: token y expiresIn
S-->>C: access_token
C->>M: Authorization Bearer token
M->>J: verifyAccessToken token
J->>J: firma HS256 mas iss mas aud mas exp
J->>J: sub entero positivo y jti presente
J-->>M: payload
M-->>C: identidad en req.auth
Pregunta que responde: ¿cómo pasa un token de ser «emitido» a ser «aceptado» en una petición posterior?
Fíjate en que la verificación no toca la BD: eso es lo que hace el access token stateless. La BD solo interviene (en ISS-13) para revalidar que el usuario sigue activo.
Flujo 3 — Cómo decide el matcher RBAC
sequenceDiagram
participant R as Peticion real
participant N as normalizePath
participant P as pathMatches
participant G as isOperationGranted
R->>N: GET /api/productos/42
N-->>P: /api/productos/42 normalizado
P->>P: comparar contra el patron del recurso
P-->>G: mismo numero de segmentos
G-->>R: true si hay concesion, false si no
Pregunta que responde: ¿cómo se traduce una URL concreta a una concesión almacenada con parámetros?
Flujo 4 — El grafo RBAC (tablas y cardinalidades)
erDiagram
USERS ||--o{ ROLE_USERS : "tiene asignaciones"
ROLES ||--o{ ROLE_USERS : "agrupa"
ROLES ||--o{ RESOURCE_ROLES : "concede"
RESOURCES ||--o{ RESOURCE_ROLES : "es concedido"
USERS ||--o{ REFRESH_TOKENS : "abre sesiones"
Pregunta que responde: ¿cómo se relacionan las seis tablas entre sí?
Flujo 5 — Vida de un access token
stateDiagram-v2
[*] --> Emitido
Emitido --> Valido
Valido --> Expirado : pasa el exp
Valido --> Rechazado : firma o iss o aud mal
Emitido --> Rechazado : sub o jti ausentes
Expirado --> [*]
Rechazado --> [*]
Pregunta que responde: ¿qué estados atraviesa un token y en qué casos se rechaza?
Recorrido del ISS, paso a paso
A partir de aquí viene el contenido técnico completo del ISS, verbatim: sus 10 apartados (14.1 … 14.10),
sus bloques de código, sus criterios de aceptación y su DoD.
Se reproduce sin resumir y sin reformatear; solo se degradan los encabezados un nivel para que aniden bajo esta sección
y se reescriben los enlaces relativos a ../manual/ para que abran bien desde docs/aprendizaje/.
Si vas a copiar un bloque, cópialo de aquí: es la fuente autoritativa.
Fase II: Auth con RBAC — ISS-09 — Base de seguridad compartida y modelos Auth
Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto:
app-storelab-express-ii— Express 5 + TypeScript + Sequelize, arquitectura por features. - Fase I (Business) ya construida: 5 features, 5 tablas y Swagger, sin autenticación. Se cierra en10-cierre-business.md. - Esta Fase II añade Auth con RBAC y convive con lo anterior: las rutas de negocio pasan de SIN AUTH a JWT + RBAC. - Recorrido obligatorio de una petición:HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II):../bd-storelab.md§12–§22 — tablas, índices,CHECK, consulta de autorización efectiva, ciclo de vida del refresh token y las tres formas de acceder a la BD. - Capas, convenciones y reglas transversales:00-contexto.md.
Este ISS Título Base de seguridad compartida y modelos Auth Feature / tablas src/shared/auth/,src/shared/http/y los 6 modelos defeatures/auth/API — (infraestructura: aún no expone endpoints) Depende de Cierre de Fase I — Estructura final y DoD Habilita ISS-10 — Feature Users
Contenido de este ISS
- 14.1 Dependencias y variables de entorno
- 14.2
password.ts(bcrypt 12 rondas + SHA-256 + token opaco) - 14.3
jwt.ts(firma/verificación HS256 coniss/aud/exp/jti) - 14.4
resource-match.ts(normalizador y matcher de rutas parametrizadas) - 14.5
auth-user.ts(extensión deRequest+requireAuthUser) - 14.6
error-response.ts(mapper único error → HTTP) y PARCHE deBaseController - 14.7
swagger-security.ts(esquemabearerAuth+ respuestas 401/403) - 14.8 Los seis modelos Sequelize
- 14.9
rbac.associations.ts(grafo de relaciones) - 14.10 Cableado de modelos en
configyseeders
Objetivo: dejar listas las primitivas de seguridad (hash de contraseña, hashes de tokens opacos, firma y verificación de JWT, matcher de rutas) y las seis tablas del modelo RBAC con sus asociaciones, de modo que los ISS posteriores solo construyan capas sobre esta base.
Bloqueado por: Fase I cerrada (el App, Routes, SeedersRunner y Swagger ya existen y se extienden, no se reescriben).
Principios que fija este ISS
1. No existe la entidad Permission. La autorización se modela como un grafo:
Por tanto:
Ejemplo: el rol SELLER concede el recurso POST /api/ventas; eso es el permiso «SELLER puede crear ventas». No hay una fila «permiso» intermedia que haya que mantener sincronizada.
2. Las tres modalidades de acceso (se decidirán por ruta, no globalmente):
| Modalidad | Qué exige | Middleware | Ejemplos |
|---|---|---|---|
| OPEN | nada | — | POST /api/sesion/login, /refresh, /logout, /api/docs |
| JWT | access token válido | authenticate |
GET /api/sesion/perfil, /api/permisos, /api/sesiones/* |
| JWT + RBAC | token válido y concesión activa de (method, path) |
authenticate + authorize |
todo el CRUD de negocio y de administración |
3. status es 'active' | 'inactive' en las seis tablas, igual que en Fase I. Un registro inactive es invisible para la API y, en RBAC, no autoriza.
4. deny by default: sin concesión explícita, la respuesta es 403. No existe lista de excepciones ni «rol comodín».
Criterios de aceptación (ISS-09) — consolidados
- [ ] 14.1
.envdefineJWT_SECRET,JWT_ACCESS_TTLyJWT_REFRESH_TTL_DAYS;jsonwebtokeny@types/jsonwebtokeninstalados - [ ] 14.2
src/shared/auth/password.tsconhashPassword,verifyPassword,sha256Hex,generateOpaqueToken - [ ] 14.3
src/shared/auth/jwt.tsfirma y verifica con HS256,issuer,audience,expiresInyjti - [ ] 14.4
src/shared/auth/resource-match.tscasa/api/productos/42con el patrón/api/productos/:id - [ ] 14.5
src/shared/auth/auth-user.tsdeclaraRequest.authy exportarequireAuthUser - [ ] 14.6
sendErrorcentralizaerror → HTTP;BaseControllerlo reutiliza - [ ] 14.7
bearerSecurityScheme,unauthorizedResponseyforbiddenResponseexportados - [ ] 14.8 los 6 modelos (
User,Role,Resource,RoleUser,ResourceRole,RefreshToken) con sus UK e índices - [ ] 14.9
rbac.associations.tsdeclara el grafo completo (User↔Role, Role↔Resource, User→RefreshToken) - [ ] 14.10
config/index.tsyseeders/index.tsimportan los modelos y las asociaciones, en ese orden - [ ]
npx tsc --noEmitOK
14.1 Dependencias y variables de entorno
# Paquetes (una vez). bcryptjs ya venía de Fase I.
npm install jsonwebtoken@^9.0.3
npm install -D @types/jsonwebtoken@^9.0.10
Variables nuevas del .env (PARCHE: se añaden al final, no reemplazan las de Fase I):
cat >> .env << 'EOF'
# ─────────────────────────────────────────────────────────────
# Fase II — Seguridad (JWT + RBAC)
# ─────────────────────────────────────────────────────────────
# Secreto de firma del access token (HMAC SHA-256). Mínimo 32 caracteres.
# En producción: generar con `openssl rand -base64 48` y NO versionarlo.
JWT_SECRET=storelab-lab-secret-change-me-0123456789abcdef
# Vida útil del access token en segundos (900 = 15 min).
JWT_ACCESS_TTL=900
# Vida útil del refresh token en días.
JWT_REFRESH_TTL_DAYS=7
EOF
JWT_ACCESS_TTLse expresa en segundos yJWT_REFRESH_TTL_DAYSen días: son unidades distintas a propósito (el access token es de minutos; el refresh, de días). El service las convierte a milisegundos al persistirexpires_at.
14.2 password.ts — hash de contraseña y hashes de tokens
Tres responsabilidades, todas de la capa shared (no son propias de un feature):
hashPassword/verifyPassword: bcrypt con 12 rondas (coste alto, deliberado).sha256Hex: para los refresh tokens, que no se guardan en claro sino como hash.generateOpaqueToken:crypto.randomBytes(32).toString("base64url")→ token opaco (no JWT).
: > src/shared/auth/password.ts
cat >> src/shared/auth/password.ts << 'EOF'
import { hash, compare } from "bcryptjs";
/**
* Derivación y verificación de contraseñas (bcrypt).
*
* Se centraliza aquí porque lo usan tres sitios distintos y **debe** usar los
* mismos parámetros en los tres:
* - el hook `beforeCreate/beforeUpdate` del modelo `User` (hash al persistir);
* - el service de usuarios al cambiar la contraseña;
* - el login, que compara la credencial en memoria (nunca la devuelve).
*
* Coste 12 rondas: el valor de referencia del diseño de la base de datos
* (`docs/bd-storelab.md` §14.1). Es un compromiso entre coste de CPU del servidor
* y coste de fuerza bruta para un atacante que obtuviera el hash.
*/
const SALT_ROUNDS = 12;
/** Devuelve el hash bcrypt de una contraseña en claro. */
export async function hashPassword(plain: string): Promise<string> {
return hash(plain, SALT_ROUNDS);
}
/** `true` si la contraseña en claro corresponde al hash almacenado. */
export async function comparePassword(plain: string, passwordHash: string): Promise<boolean> {
return compare(plain, passwordHash);
}
/**
* Hash determinista (SHA-256, hex) para credenciales de **alta entropía**.
*
* Se usa con los refresh tokens, no con contraseñas: un token aleatorio de 64
* bytes no es adivinable, así que no necesita un algoritmo lento; basta con
* impedir que el valor en claro quede en la base de datos. Esto permite, además,
* buscar por índice único (`token_hash`) en O(1).
*/
import { createHash, randomBytes } from "node:crypto";
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/** Genera un token opaco no adivinable (URL-safe, 64 bytes ≈ 86 caracteres). */
export function generateOpaqueToken(): string {
return randomBytes(64).toString("base64url");
}
EOF
14.3 jwt.ts — firma y verificación del access token
¿Por qué JWT aquí y token opaco para el refresh? El access token viaja en cada petición y debe validarse sin tocar la BD (Stateless): JWT firmado. El refresh token, en cambio, debe poder revocarse; por eso es opaco y se persiste (hasheado) en refresh_tokens.
: > src/shared/auth/jwt.ts
cat >> src/shared/auth/jwt.ts << 'EOF'
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomUUID } from "node:crypto";
import { AppError } from "../errors/app-error";
/**
* Emisión y verificación del **access token** (JWT firmado, HS256).
*
* Referencias (fuentes oficiales):
* - RFC 7519 — JSON Web Token (`sub`, `iss`, `aud`, `exp`, `iat`, `jti`).
* - RFC 8725 §3.1 — *Perform Algorithm Verification*: el algoritmo se fija en el
* código (lista permitida), nunca se toma del encabezado `alg` del token.
* - RFC 8725 §3.8/§3.9 — validar `iss` (emisor) y `aud` (audiencia).
* - RFC 6750 — el token viaja en `Authorization: Bearer <token>`.
*
* El access token es **autocontenido y no se persiste**: se valida con la firma.
* La base de datos solo interviene para revalidar que el usuario sigue activo
* (ver `authenticate`), y para los refresh tokens.
*/
const ALGORITHM = "HS256";
/** Emisor/audiencia del sistema. Sirven para rechazar tokens de otro servicio. */
export const TOKEN_ISSUER = "app-storelab-express";
export const TOKEN_AUDIENCE = "app-storelab-api";
/** Vida útil del access token. Corta por diseño (Owasp/OAuth2: token de vida corta). */
export const ACCESS_TOKEN_TTL_SECONDS = Number(process.env.JWT_ACCESS_TTL ?? 900); // 15 min
export interface AccessTokenPayload extends JwtPayload {
sub: string;
username: string;
jti: string;
}
function getSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
throw new AppError(
500,
"JWT_SECRET no configurado (mínimo 32 caracteres). Ver .env"
);
}
return secret;
}
/** Firma un access token para un usuario. */
export function signAccessToken(user: { id: number; username: string }): {
token: string;
expiresIn: number;
} {
const token = jwt.sign(
{ username: user.username },
getSecret(),
{
algorithm: ALGORITHM,
subject: String(user.id),
issuer: TOKEN_ISSUER,
audience: TOKEN_AUDIENCE,
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
jwtid: randomUUID(),
}
);
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
/**
* Verifica firma y *claims* y devuelve el payload.
*
* Se pasan las opciones explícitas (no se confía en el token): `algorithms`,
* `issuer` y `audience`; y después se comprueban a mano `sub` y `jti`.
*
* Ojo: `jsonwebtoken` **no** tiene opción `require` (es de `jose`); pasarla no
* valida nada. Por eso los claims obligatorios se verifican explícitamente.
* Cualquier fallo se traduce a `AppError(401)` para que el middleware responda
* **no autenticado**.
*/
export function verifyAccessToken(token: string): AccessTokenPayload {
let payload: JwtPayload;
try {
payload = jwt.verify(token, getSecret(), {
algorithms: [ALGORITHM],
issuer: TOKEN_ISSUER,
audience: TOKEN_AUDIENCE,
// Tolerancia de reloj: evita 401 espurios entre máquinas desincronizadas.
clockTolerance: 5,
}) as JwtPayload;
} catch {
throw new AppError(401, "Invalid or expired access token");
}
// Los claims obligatorios se comprueban AQUÍ, no en `jwt.verify`.
//
// `jsonwebtoken` **no** admite la opción `require` (esa opción es de `jose`):
// pasarla no valida nada. `iss`, `aud` y `exp` sí los exige `jwt.verify` con
// las opciones de arriba; `sub` y `jti` hay que verificarle explícitamente.
//
// - sin `sub` no hay identidad -> no se puede autenticar;
// - `sub` debe ser un entero positivo: un valor no numérico llegaría al
// repositorio como `NaN` y provocaría un 500 en vez de un 401;
// - sin `jti` se pierde la trazabilidad del token (RFC 8725).
if (
typeof payload.sub !== "string" ||
!/^[1-9]\d*$/.test(payload.sub) ||
typeof payload.jti !== "string" ||
payload.jti.length === 0
) {
throw new AppError(401, "Invalid or expired access token");
}
return payload as AccessTokenPayload;
}
/** Extrae el token de `Authorization: Bearer <token>` (RFC 6750). */
export function extractBearerToken(header: string | undefined): string | null {
if (!header) return null;
const [scheme, value] = header.split(" ");
if (!scheme || !value || scheme.toLowerCase() !== "bearer") return null;
return value;
}
EOF
Puntos de seguridad (alineados con RFC 8725, JSON Web Token Best Current Practices):
| Práctica | Cómo se aplica |
|---|---|
| Fijar el algoritmo | algorithm: "HS256" explícito al firmar y al verificar (algorithms: ["HS256"]) → evita alg: none y confusión de algoritmos |
| Emisor y audiencia | issuer: "storelab" y audience: "storelab-api"; se exigen al verificar |
| Caducidad | expiresIn: JWT_ACCESS_TTL |
| Identificador único | claim jti (permite trazabilidad/correlación) |
| Carga útil mínima | solo sub (id), username y jti; nunca roles ni permisos |
Los roles NO viajan en el token. Si viajaran, revocar un permiso no tendría efecto hasta que caducara el token. La autorización se resuelve en cada petición contra la matriz (ISS-13), lo que hace la revocación inmediata (RFC 6749 / OWASP).
14.4 resource-match.ts — casar la petición con el recurso
Un recurso es un par (method, path) con el path en patrón: /api/productos/:id. El middleware authorize recibe la petición real (GET /api/productos/42) y debe encontrar el recurso. Este módulo hace esa traducción:
normalizePath: quita barra final, query string y colapsa barras repetidas.buildPathMatcher: convierte/api/productos/:iden una expresión regular anclada.pathsMatch: comprueba un patrón contra un path real.
: > src/shared/auth/resource-match.ts
cat >> src/shared/auth/resource-match.ts << 'EOF'
/**
* Coincidencia entre la ruta de una petición y un **recurso** almacenado.
*
* Un recurso se guarda como patrón (`method` + `path` con parámetros):
*
* ```text
* GET /api/productos/:id
* ```
*
* Y la petición llega con el valor concreto:
*
* ```text
* GET /api/productos/42
* ```
*
* Reglas de la comparación (deliberadamente estrictas):
* - El verbo HTTP debe coincidir exactamente.
* - Un segmento `:param` del patrón casa con **un** segmento cualquiera.
* - El resto de segmentos deben ser iguales carácter a carácter.
* - El número de segmentos debe coincidir (no hay comodines tipo `*`).
*
* Así, `/api/productos/42` **no** casa con `/api/productos` (evita que un permiso
* de listado autorice una lectura concreta por error) y `/api/productos/42/lotes`
* tampoco.
*/
/** Normaliza una ruta: sin cadena de consulta, sin barra final, sin duplicar `/`. */
export function normalizePath(path: string): string {
const withoutQuery = path.split("?")[0].split("#")[0];
const single = withoutQuery.replace(/\/{2,}/g, "/");
const trimmed = single.replace(/\/+$/, "");
return trimmed === "" ? "/" : trimmed;
}
/** `true` si `path` (concreto) casa con `pattern` (con `:param`). */
export function pathMatches(pattern: string, path: string): boolean {
const patternParts = normalizePath(pattern).split("/");
const pathParts = normalizePath(path).split("/");
if (patternParts.length !== pathParts.length) return false;
for (let i = 0; i < patternParts.length; i++) {
const p = patternParts[i];
if (p.startsWith(":")) continue; // parámetro: casa con cualquier segmento
if (p !== pathParts[i]) return false;
}
return true;
}
/**
* `true` si el conjunto de recursos concedidos cubre la operación solicitada.
*
* Es la decisión final del RBAC: se compara el par `(method, path)` de la
* petición contra las concesiones del usuario. **Deny by default**: si ninguna
* coincide, se devuelve `false`.
*
* (Referencia: `docs/bd-storelab.md` §16 — la base de datos es la única fuente
* de verdad de la matriz de permisos; la coincidencia por patrón se hace aquí.)
*/
export function isOperationGranted(
granted: ReadonlyArray<{ method: string; path: string }>,
method: string,
path: string
): boolean {
const upper = method.toUpperCase();
return granted.some(
(resource) => resource.method.toUpperCase() === upper && pathMatches(resource.path, path)
);
}
EOF
14.5 auth-user.ts — la identidad en Request
Extiende el tipo Request de Express con auth y expone requireAuthUser, que los controllers JWT usan para leer al usuario sin adivinar si el middleware corrió.
: > src/shared/auth/auth-user.ts
cat >> src/shared/auth/auth-user.ts << 'EOF'
import { Request } from "express";
import { AppError } from "../errors/app-error";
/**
* Identidad resuelta que los middlewares de acceso dejan en la petición.
*
* Se guarda en `req.auth` (ver la ampliación de tipos más abajo) y la consumen:
* - los controllers que necesitan saber quién llama (`GET /api/sesion/perfil`);
* - `authorize`, para consultar los permisos efectivos del usuario.
*/
export interface AuthUser {
id: number;
username: string;
email?: string;
/** Token con el que se autenticó (útil para cerrar la sesión actual). */
tokenId?: string;
}
/**
* Devuelve la identidad de la petición o falla con 401.
*
* Lo usan los controllers de rutas con modalidad JWT (sin `authorize`): allí el
* middleware ya garantizó que `req.auth` existe, pero el tipo es opcional, así
* que esta función cierra el caso sin recurrir a `!`.
*/
export function requireAuthUser(req: Request): AuthUser {
if (!req.auth) {
throw new AppError(401, "Authentication required");
}
return req.auth;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Identidad resuelta por el middleware `authenticate`. `undefined` = OPEN. */
auth?: AuthUser;
}
}
}
export {};
EOF
14.6 error-response.ts y PARCHE de BaseController
El mapeo error → HTTP deja de vivir solo en el controller: lo necesitan también los middlewares authenticate/authorize. Se extrae a un único punto (sendError) y BaseController lo reutiliza.
: > src/shared/http/error-response.ts
cat >> src/shared/http/error-response.ts << 'EOF'
import { Response } from "express";
import { AppError } from "../errors/app-error";
/**
* Traduce cualquier error a una respuesta HTTP. **Único punto** del proyecto
* donde se decide el mapeo error -> status.
*
* Lo usan los dos sitios que pueden fallar antes de llegar a un controller:
* - `BaseController.handleError` (handlers de los controllers);
* - los middlewares de acceso (`authenticate` / `authorize`), que responden
* 401/403 sin pasar por un controller.
*
* Regla: `AppError` -> su `statusCode`; cualquier otra cosa -> **500** (y el
* detalle solo en el cuerpo, nunca el stack).
*/
export function sendError(res: Response, error: unknown): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error", detail: String(error) });
}
EOF
PARCHE en src/shared/http/base-controller.ts: handleError delega en sendError.
: > src/shared/http/base-controller.ts
cat >> src/shared/http/base-controller.ts << 'EOF'
import { Request, Response } from "express";
import { AppError } from "../errors/app-error";
import { sendError } from "./error-response";
/**
* Base de los controllers HTTP.
*
* Aísla las tres responsabilidades puramente HTTP que, si no, se repetirían en
* los 7 métodos de cada controller:
*
* - `run`: ejecuta el cuerpo del handler y traduce el error a HTTP.
* - `paramId`: lee y valida el `:id` de la URL.
* - `handleError`: mapea `AppError` a su status y lo demás a 500.
*
* La capa de negocio (service) no conoce `req`/`res`.
*/
export abstract class BaseController {
/**
* Ejecuta el cuerpo de un handler y centraliza el manejo de errores.
*
* Sin este helper, cada uno de los 35 métodos de los controllers tendría su
* propio `try/catch`. Aquí el `catch` vive una sola vez.
*/
protected async run(res: Response, work: () => Promise<void>): Promise<void> {
try {
await work();
} catch (error) {
this.handleError(res, error);
}
}
/**
* Lee el `:id` de la URL y lo valida como entero positivo.
*
* Sin la validación, `GET /api/clientes/abc` llegaría al repository como
* `Number("abc") === NaN` y devolvería un 404 engañoso en vez de un 400.
*/
protected paramId(req: Request): number {
const raw = req.params.id;
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value || !/^\d+$/.test(value) || Number(value) < 1) {
throw new AppError(400, "Invalid id: must be a positive integer");
}
return Number(value);
}
/**
* Mapea errores: `AppError` -> su status; cualquier otro -> 500.
*
* La traducción vive en `sendError` porque los middlewares de acceso también
* la necesitan: un único punto decide el mapeo error -> HTTP.
*/
protected handleError(res: Response, error: unknown): void {
sendError(res, error);
}
}
EOF
14.7 swagger-security.ts — seguridad reutilizable para OpenAPI
| Export | Para qué |
|---|---|
bearerSecurityScheme |
Esquema bearerAuth (Authorization: Bearer <token>, RFC 6750) |
unauthorizedResponse |
Respuesta 401 reutilizable ($ref) |
forbiddenResponse |
Respuesta 403 reutilizable ($ref) |
: > src/shared/http/swagger-security.ts
cat >> src/shared/http/swagger-security.ts << 'EOF'
/**
* Piezas reutilizables de OpenAPI para las **tres modalidades de acceso**.
*
* Centralizar aquí el esquema `bearerAuth` y las respuestas 401/403 evita repetir
* la misma definición en los 7 módulos de Swagger (auth) y en los 5 de business.
* Al cambiar una descripción, cambia en toda la documentación.
*
* Convención de uso en cada operación:
*
* | Modalidad | `security` |
* |---|---|
* | OPEN | `openSecurity` (arreglo vacío: no exige credencial) |
* | JWT | `bearerSecurity` |
* | RBAC | `bearerSecurity` + respuestas 401 **y** 403 |
*/
/** Esquema de seguridad (RFC 6750: `Authorization: Bearer <token>`). */
export const bearerSecurityScheme = {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"Access token JWT obtenido en `POST /api/sesion/login`. Enviar como " +
"`Authorization: Bearer <access_token>`. Vida útil corta (por defecto 15 min); " +
"se renueva con `POST /api/sesion/refresh`.",
},
};
/** `security` de un endpoint OPEN (no exige credencial). */
export const openSecurity: unknown[] = [];
/** `security` de un endpoint JWT o RBAC (exige access token válido). */
export const bearerSecurity = [{ bearerAuth: [] }];
/** Respuesta 401: no hay identidad válida (token ausente, inválido o usuario inactivo). */
export const unauthorizedResponse = {
description:
"401 No autenticado — falta el Bearer token, el token es inválido/expiró o el usuario está inactivo",
};
/** Respuesta 403: hay identidad, pero la matriz RBAC no concede `(method, path)`. */
export const forbiddenResponse = {
description:
"403 Prohibido — autenticado, pero sin concesión activa para esta operación (deny by default)",
};
/** Respuesta 400 ante un `:id` que no es entero positivo. */
export const invalidIdResponse = {
description: "400 id inválido (debe ser un entero positivo)",
};
/** Respuesta 404 estándar. */
export const notFoundResponse = {
description: "404 No encontrado",
};
EOF
14.8 Los seis modelos Sequelize
| Entidad | Tabla | Responsabilidad |
|---|---|---|
User |
users |
identidad del usuario (contraseña hasheada) |
Role |
roles |
agrupación de responsabilidades |
RoleUser |
role_users |
asignación User ↔ Role (N:M) |
Resource |
resources |
endpoint/acción protegible, (method, path) |
ResourceRole |
resource_roles |
el permiso: concesión Role ↔ Resource (N:M) |
RefreshToken |
refresh_tokens |
sesión renovable y revocable |
User:
: > src/features/auth/users/user.model.ts
cat >> src/features/auth/users/user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { hashPassword } from "../../../shared/auth/password";
/**
* Modelo `User` (tabla `users`) — la identidad del sistema.
*
* Se diferencia de los modelos de business en un punto clave: **`password` nunca
* se guarda en claro**. El hash se calcula en los hooks, de modo que ningún
* service, repository o seeder puede olvidarse de hacerlo.
*
* El algoritmo y el coste viven en `shared/auth/password.ts` (única fuente), no
* aquí: si mañana se sube el coste, se cambia en un solo sitio.
*/
export interface UserI {
id?: number;
username: string;
email: string;
password: string;
avatar?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class User extends Model {
public id!: number;
public username!: string;
public email!: string;
public password!: string;
public avatar!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
User.init(
{
username: {
type: DataTypes.STRING(80),
allowNull: false,
// `unique` con nombre explícito -> la BD nombra la restricción `uq_users_username`
// (misma nomenclatura que el DDL de referencia en docs/bd-storelab.md §14).
unique: "uq_users_username",
validate: {
notEmpty: { msg: "Username cannot be empty" },
len: { args: [3, 80], msg: "Username must be between 3 and 80 characters" },
},
},
email: {
type: DataTypes.STRING(150),
allowNull: false,
unique: "uq_users_email",
validate: {
isEmail: { msg: "Email must be a valid email address" },
},
},
password: {
// 255: el hash bcrypt ocupa 60 y sobra margen para algoritmos futuros.
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: { msg: "Password cannot be empty" },
},
},
avatar: {
type: DataTypes.STRING(500),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "User",
tableName: "users",
timestamps: true,
hooks: {
// Los tres hooks que crean/actualizan el hash. Cualquier ruta de escritura
// (create, update, bulkCreate del seeder) pasa por aquí: no hay forma de
// persistir una contraseña en claro.
beforeCreate: async (user: User) => {
if (user.password) {
user.password = await hashPassword(user.password);
}
},
beforeUpdate: async (user: User) => {
if (user.changed("password") && user.password) {
user.password = await hashPassword(user.password);
}
},
beforeBulkCreate: async (users: User[]) => {
for (const user of users) {
if (user.password) {
user.password = await hashPassword(user.password);
}
}
},
// Normalización: `username` y `email` siempre en minúsculas y sin espacios.
// Se hace antes de validar para que el `isEmail`/`len` juzgue el valor final
// y para que el login (que compara por igualdad) sea predecible.
beforeValidate: (user: User) => {
if (user.username) user.username = user.username.trim().toLowerCase();
if (user.email) user.email = user.email.trim().toLowerCase();
},
},
}
);
EOF
Role:
: > src/features/auth/roles/role.model.ts
cat >> src/features/auth/roles/role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `Role` (tabla `roles`) — agrupador lógico de responsabilidades.
*
* Nota de diseño: **el nombre del rol no autoriza nada**. La autorización se
* decide por las concesiones (`resource_roles`) asociadas al rol. Un rol
* `ADMIN` sin concesiones activas no habilita ninguna operación.
*/
export interface RoleI {
id?: number;
name: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class Role extends Model {
public id!: number;
public name!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
Role.init(
{
name: {
type: DataTypes.STRING(80),
allowNull: false,
unique: "uq_roles_name",
validate: {
notEmpty: { msg: "Role name cannot be empty" },
},
},
description: {
type: DataTypes.STRING(255),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "Role",
tableName: "roles",
timestamps: true,
hooks: {
// El nombre del rol se normaliza a MAYÚSCULAS (ADMIN, SELLER, BUYER):
// es un identificador funcional, no una etiqueta libre.
beforeValidate: (role: Role) => {
if (role.name) role.name = role.name.trim().toUpperCase();
},
},
}
);
EOF
Resource:
: > src/features/auth/resources/resource.model.ts
cat >> src/features/auth/resources/resource.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
import { normalizePath } from "../../../shared/auth/resource-match";
/**
* Modelo `Resource` (tabla `resources`) — un punto de acceso protegible.
*
* Un recurso **no** es una entidad de negocio: es el par `(method, path)`.
* `GET /api/productos` y `POST /api/productos` son **dos recursos distintos**.
*
* Las rutas se guardan con el patrón, no con el valor concreto:
* `/api/productos/:id`. Así no se crea una fila por cada identificador y la
* coincidencia se resuelve por patrón (`shared/auth/resource-match.ts`).
*/
export interface ResourceI {
id?: number;
method: string;
path: string;
description?: string | null;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class Resource extends Model {
public id!: number;
public method!: string;
public path!: string;
public description!: string | null;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
Resource.init(
{
method: {
type: DataTypes.STRING(10),
allowNull: false,
validate: {
isIn: {
args: [["GET", "POST", "PUT", "PATCH", "DELETE"]],
msg: "Method must be one of GET, POST, PUT, PATCH, DELETE",
},
},
},
path: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
notEmpty: { msg: "Path cannot be empty" },
},
},
description: {
type: DataTypes.STRING(255),
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "Resource",
tableName: "resources",
timestamps: true,
// Clave única compuesta: el mismo verbo con distinta ruta (o al revés) son
// recursos distintos, pero la tupla exacta no se repite.
indexes: [
{
name: "uq_resources_method_path",
unique: true,
fields: ["method", "path"],
},
],
hooks: {
// Normalización: verbo en mayúsculas y ruta sin barra final ni duplicados,
// para que la comparación por patrón sea determinista.
beforeValidate: (resource: Resource) => {
if (resource.method) resource.method = resource.method.trim().toUpperCase();
if (resource.path) resource.path = normalizePath(resource.path.trim());
},
},
}
);
EOF
RoleUser:
: > src/features/auth/role-users/role-user.model.ts
cat >> src/features/auth/role-users/role-user.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `RoleUser` (tabla `role_users`) — asignación N:M `User` ↔ `Role`.
*
* Es el **primer eslabón** de la cadena de autorización. Un usuario sin filas
* activas aquí no tiene ningún permiso granular, aunque tenga roles asignados
* con estado `inactive`.
*
* La restricción única `(user_id, role_id)` impide duplicar la asignación:
* revocar y volver a conceder se hace cambiando `status`, no insertando filas.
*/
export interface RoleUserI {
id?: number;
user_id: number;
role_id: number;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class RoleUser extends Model {
public id!: number;
public user_id!: number;
public role_id!: number;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
RoleUser.init(
{
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
role_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "RoleUser",
tableName: "role_users",
timestamps: true,
indexes: [
{ name: "uq_role_users_user_role", unique: true, fields: ["user_id", "role_id"] },
{ name: "ix_role_users_user_id", fields: ["user_id"] },
{ name: "ix_role_users_role_id", fields: ["role_id"] },
],
}
);
EOF
ResourceRole:
: > src/features/auth/resource-roles/resource-role.model.ts
cat >> src/features/auth/resource-roles/resource-role.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `ResourceRole` (tabla `resource_roles`) — la **concesión** `Role` ↔ `Resource`.
*
* Esta tabla **es el permiso**. No existe una entidad `Permission`: el permiso
* es la tupla `(rol, recurso)` materializada aquí.
*
* - Conceder acceso -> insertar o reactivar una fila.
* - Retirar acceso -> `status = inactive`.
* - Cambiar la matriz -> no requiere código ni despliegue.
*/
export interface ResourceRoleI {
id?: number;
role_id: number;
resource_id: number;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class ResourceRole extends Model {
public id!: number;
public role_id!: number;
public resource_id!: number;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
ResourceRole.init(
{
role_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
resource_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
defaultValue: "inactive",
allowNull: false,
},
},
{
sequelize,
modelName: "ResourceRole",
tableName: "resource_roles",
timestamps: true,
indexes: [
{
name: "uq_resource_roles_role_resource",
unique: true,
fields: ["role_id", "resource_id"],
},
{ name: "ix_resource_roles_role_id", fields: ["role_id"] },
{ name: "ix_resource_roles_resource_id", fields: ["resource_id"] },
],
}
);
EOF
RefreshToken:
: > src/features/auth/refresh-tokens/refresh-token.model.ts
cat >> src/features/auth/refresh-tokens/refresh-token.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";
/**
* Modelo `RefreshToken` (tabla `refresh_tokens`) — sesión renovable y revocable.
*
* Es el **único** artefacto de sesión que se persiste. El access token (JWT) es
* autocontenido y no se guarda.
*
* Campos de seguridad:
* - `token_hash`: solo se almacena el SHA-256 del token opaco. Aunque se
* filtrara la tabla, no se puede reconstruir un token utilizable. Permite
* buscar por índice único en O(1).
* - `family_id`: agrupa todos los tokens derivados de un mismo login por
* rotación. Si un token ya rotado se reutiliza, se revoca **toda la familia**
* (detección de reutilización, Owasp/OAuth2).
* - `expires_at`: vigencia; un token vencido se trata como inválido.
* - `device_info`: soporte de auditoría y de listado de sesiones por dispositivo.
*
* Desviación deliberada: `status` predetermina **`active`**. Un token recién
* emitido nace vigente por definición, a diferencia del resto de tablas.
*/
export interface RefreshTokenI {
id?: number;
user_id: number;
token_hash: string;
family_id: string;
device_info?: string | null;
expires_at: Date;
status: "active" | "inactive";
createdAt?: Date;
updatedAt?: Date;
}
export class RefreshToken extends Model {
public id!: number;
public user_id!: number;
public token_hash!: string;
public family_id!: string;
public device_info!: string | null;
public expires_at!: Date;
public status!: "active" | "inactive";
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
RefreshToken.init(
{
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
},
token_hash: {
type: DataTypes.STRING(255),
allowNull: false,
unique: "uq_refresh_tokens_token_hash",
},
family_id: {
type: DataTypes.STRING(100),
allowNull: false,
},
device_info: {
type: DataTypes.STRING(500),
allowNull: true,
},
expires_at: {
type: DataTypes.DATE,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "inactive"),
// Única tabla cuyo estado por defecto es `active` (ver doc del modelo).
defaultValue: "active",
allowNull: false,
},
},
{
sequelize,
modelName: "RefreshToken",
tableName: "refresh_tokens",
timestamps: true,
indexes: [
{ name: "ix_refresh_tokens_family_id", fields: ["family_id"] },
{ name: "ix_refresh_tokens_user_id", fields: ["user_id"] },
],
}
);
EOF
14.9 rbac.associations.ts — el grafo en un solo lugar
Las asociaciones se declaran después de los modelos (referencian a los modelos, no al revés) y en un único archivo para que el grafo se lea entero:
: > src/features/auth/rbac.associations.ts
cat >> src/features/auth/rbac.associations.ts << 'EOF'
import { User } from "./users/user.model";
import { Role } from "./roles/role.model";
import { Resource } from "./resources/resource.model";
import { RoleUser } from "./role-users/role-user.model";
import { ResourceRole } from "./resource-roles/resource-role.model";
import { RefreshToken } from "./refresh-tokens/refresh-token.model";
/**
* Asociaciones de las seis entidades de seguridad.
*
* Se declaran en un solo archivo (y no dispersas por feature) porque la
* autorización es una **cadena** que atraviesa cinco tablas; verla junta hace
* evidente el camino que recorre la consulta de permisos:
*
* ```text
* ResourceRole ──► Role ──► RoleUser ──► (filtro por user_id)
* │
* └────────► Resource ──► (method, path)
* ```
*
* Los alias (`as`) son los que usan los `include` de los repositories, así que
* cambiar un alias aquí obliga a revisar las consultas RBAC.
*/
// --- La concesión conoce su rol y su recurso (los dos extremos del permiso) ---
ResourceRole.belongsTo(Role, { foreignKey: "role_id", as: "role" });
ResourceRole.belongsTo(Resource, { foreignKey: "resource_id", as: "resource" });
Role.hasMany(ResourceRole, { foreignKey: "role_id", as: "resource_roles" });
Resource.hasMany(ResourceRole, { foreignKey: "resource_id", as: "resource_roles" });
// --- La asignación conoce su usuario y su rol (primer eslabón de la cadena) ---
RoleUser.belongsTo(User, { foreignKey: "user_id", as: "user" });
RoleUser.belongsTo(Role, { foreignKey: "role_id", as: "role" });
User.hasMany(RoleUser, { foreignKey: "user_id", as: "role_users" });
Role.hasMany(RoleUser, { foreignKey: "role_id", as: "role_users" });
// --- Las sesiones pertenecen a un usuario ---
RefreshToken.belongsTo(User, { foreignKey: "user_id", as: "user" });
User.hasMany(RefreshToken, { foreignKey: "user_id", as: "refresh_tokens" });
EOF
14.10 Cableado de modelos en config y seeders
PARCHE en src/config/index.ts — los seis modelos, y después las asociaciones:
cat >> src/config/index.ts << 'EOF'
import dotenv from "dotenv";
import express, { Application, ErrorRequestHandler } from "express";
import morgan from "morgan";
var cors = require("cors");
import { sequelize, getDatabaseInfo, testConnection } from "../database/db";
import "../features/business/clients/client.model";
import "../features/business/product-types/product-type.model";
import "../features/business/products/product.model";
import "../features/business/sales/sale.model";
import "../features/business/product-sales/product-sale.model";
import "../features/business/products/products.associations";
import "../features/business/sales/sales.associations";
import "../features/business/product-sales/product-sales.associations";
// Fase II — Auth con RBAC: primero los seis modelos, después las asociaciones
// (las asociaciones referencian los modelos, no al revés).
import "../features/auth/users/user.model";
import "../features/auth/roles/role.model";
import "../features/auth/resources/resource.model";
import "../features/auth/role-users/role-user.model";
import "../features/auth/resource-roles/resource-role.model";
import "../features/auth/refresh-tokens/refresh-token.model";
import "../features/auth/rbac.associations";
import { Routes } from "../routes/index";
import { setupSwagger } from "../swagger/index";
dotenv.config();
export class App {
public app: Application;
public routePrv: Routes = new Routes();
constructor(private port?: number | string) {
this.app = express();
this.settings();
this.middlewares();
this.routes();
this.docs();
this.errorHandling();
}
private settings(): void {
this.app.set('port', this.port || process.env.PORT || 4000);
}
private middlewares(): void {
this.app.use(morgan('dev'));
this.app.use(cors());
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: false }));
}
private routes(): void {
// Fase I — Business (cada operación, modalidad JWT + RBAC)
this.routePrv.clientsRoutes.routes(this.app);
this.routePrv.productTypesRoutes.routes(this.app);
this.routePrv.productsRoutes.routes(this.app);
this.routePrv.salesRoutes.routes(this.app);
this.routePrv.productSalesRoutes.routes(this.app);
// Fase II — Auth con RBAC
// `sessionRoutes` registra los endpoints OPEN/JWT (login, refresh, logout,
// perfil, permisos); el resto son modalidad JWT + RBAC.
this.routePrv.sessionRoutes.routes(this.app);
this.routePrv.refreshTokensRoutes.routes(this.app);
this.routePrv.usersRoutes.routes(this.app);
this.routePrv.rolesRoutes.routes(this.app);
this.routePrv.resourcesRoutes.routes(this.app);
this.routePrv.roleUsersRoutes.routes(this.app);
this.routePrv.resourceRolesRoutes.routes(this.app);
}
private docs(): void {
setupSwagger(this.app);
}
/**
* Errores que ocurren **antes** de llegar a un controller o middleware.
*
* El caso típico es un cuerpo JSON malformado: `express.json()` lanza un
* `SyntaxError` que, sin manejador, cae en el de Express por defecto y responde
* 400 con un HTML que incluye el **stack trace y rutas absolutas del servidor**
* (fuga de información). Aquí se traduce a un 400 JSON limpio.
*
* Debe registrarse **después** de las rutas: Express reconoce un middleware de
* error por su aridad de 4 argumentos.
*/
private errorHandling(): void {
const bodyErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
if (err instanceof SyntaxError && "body" in err) {
res.status(400).json({ error: "Malformed JSON body" });
return;
}
next(err);
};
this.app.use(bodyErrorHandler);
}
private async dbConnection(): Promise<void> {
try {
const dbInfo = getDatabaseInfo();
console.log(`🔗 Intentando conectar a: ${dbInfo.engine.toUpperCase()}`);
const isConnected = await testConnection();
if (!isConnected) {
throw new Error(`No se pudo conectar a la base de datos ${dbInfo.engine.toUpperCase()}`);
}
// Lab: sync crea/altera tablas desde los modelos (BD limpia → snake_case desde cero).
const force = process.env.DB_SYNC_FORCE === "true";
const isMysql =
sequelize.getDialect() === "mysql" || sequelize.getDialect() === "mariadb";
if (isMysql) {
await sequelize.query("SET FOREIGN_KEY_CHECKS = 0");
}
try {
await sequelize.sync({ force, alter: !force });
} finally {
if (isMysql) {
await sequelize.query("SET FOREIGN_KEY_CHECKS = 1");
}
}
console.log(
force
? "📦 Base de datos recreada (DB_SYNC_FORCE=true)"
: "📦 Base de datos sincronizada exitosamente"
);
} catch (error) {
console.error("❌ Error al conectar con la base de datos:", error);
process.exit(1);
}
}
async listen() {
// Orden de arranque: primero la BD (conexión + `sync`), después abrir el puerto.
// Si se abre el puerto antes de terminar `sync({ alter: true })`, las sentencias
// DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya
// están entrando y provocan deadlocks y errores de FK intermitentes.
await this.dbConnection();
await this.app.listen(this.app.get('port'));
console.log(`🚀 Servidor ejecutándose en puerto ${this.app.get('port')}`);
}
}
EOF
PARCHE en src/database/seeders/index.ts — mismo bloque de imports (los seeders de auth se añaden en sus ISS). El orden importa: Sequelize solo conoce las asociaciones que se han declarado.
El detalle de estos dos archivos completos, ya con las rutas y los seeders de todos los ISS, está en
18-cierre-auth.md.
Verificación
npx tsc --noEmit
npm run db:seed # sync({ alter: true }) crea las 6 tablas vacías
npm run dev # el servidor debe arrancar
# Las 6 tablas existen
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"
DoD del ISS-09
- [ ] Todos los criterios de aceptación (14.1 … 14.10) cumplidos
- [ ]
npx tsc --noEmitsin errores - [ ]
npm run db:seedcreausers,roles,resources,role_users,resource_roles,refresh_tokenscon sus UK - [ ]
npm run devarranca el servidor - [ ] Sin endpoints nuevos todavía: la API de Fase I sigue funcionando SIN AUTH (la protección llega en ISS-13)
Diagnóstico
| Síntoma | Causa probable | Solución |
|---|---|---|
tsc se queja de jsonwebtoken sin tipos |
Faltó instalar @types/jsonwebtoken |
npm install -D @types/jsonwebtoken@^9.0.10 |
AppError: JWT_SECRET no configurado |
Falta la variable o tiene menos de 32 caracteres | Añadir JWT_SECRET al .env (mín. 32) y reiniciar |
db:seed no crea las tablas de auth |
Los modelos no se importaron en seeders/index.ts o se importaron mal de orden |
Importar primero los 6 modelos y después rbac.associations |
| Error de asociación «X is not associated to Y» | Se importó rbac.associations antes que algún modelo |
Reordenar los imports; las asociaciones referencian modelos |
/api/usuarios responde 404 |
Correcto en ISS-09: el feature aún no existe | Esperar a ISS-10; no es un fallo |
sync falla al crear FKs |
Orden de tablas / FOREIGN_KEY_CHECKS |
El runner ya las desactiva en MySQL; revisar que el dialecto se detecte |
Un GET de negocio pasa sin token |
Correcto: la protección llega en ISS-13 | No «arreglarlo» aquí; sería adelantar ISS-13 |
Token válido pero rechazado por iss/aud |
Se firmó con otro emisor/audiencia | Usar las constantes TOKEN_ISSUER/TOKEN_AUDIENCE |
Pregunta que responde: si algo falla aquí, ¿por dónde empiezo a mirar?
Conexión con el resto del curso
ISS-00 … ISS-08 (Fase I · Business)
│
│ entrega: App, Routes, SeedersRunner, Swagger,
│ shared/{errors,http,database}
▼
┌─────────────┐
│ ISS-09 │ primitivas + 6 modelos + asociaciones
└─────────────┘
│
┌─────────────────┼─────────────────────────────┐
▼ ▼ ▼
ISS-10 users ISS-11 roles/resources ISS-12 pivotes + permisos
│ │ │
└─────────────────┴──────────────┬──────────────┘
▼
ISS-13 access
(authenticate + authorize)
│
▼
ISS-14 refresh · ISS-15 sesión
- Piezas que este ISS reutiliza de Fase I: el patrón de features,
sequelize(database/db.ts),AppError,BaseControllery elSeedersRunner. - Piezas que este ISS entrega: todo lo que los ISS de auth posteriores dan por hecho: el hash, el JWT, el matcher,
sendError,req.authy las seis tablas. - Qué NO toca: ninguna ruta de negocio. Las cinco features de Fase I quedan exactamente como estaban.
Pregunta que responde: ¿de dónde vengo y hacia dónde me lleva este ISS dentro del curso?
Glosario
| Término | Significado |
|---|---|
| JWT | JSON Web Token: cadena header.payload.signature autocontenida y firmada. |
| HS256 | Algoritmo de firma HMAC con SHA-256; usa un secreto compartido. |
| Claim | Cada afirmación del payload de un JWT (sub, iss, aud, exp, iat, jti). |
sub |
subject: a quién representa el token (el id del usuario). |
jti |
JWT ID: identificador único del token. |
iss / aud |
Emisor / audiencia; rechazan tokens de otro servicio o para otra API. |
exp / iat |
Caducidad / emisión. |
| bcrypt | Algoritmo de hash lento y con salt incorporado, pensado para contraseñas. |
| Salt | Valor aleatorio por hash que hace que dos contraseñas iguales den hashes distintos; bcrypt lo genera y lo guarda dentro del propio hash. |
| Rondas (coste) | Cuántas veces se aplica el algoritmo; 12 es el valor de referencia del proyecto. |
| SHA-256 | Hash rápido y determinista, apto para valores de alta entropía (tokens), no para contraseñas. |
| Token opaco | Token sin estructura interna que hay que validar contra la BD (el refresh token). |
| RBAC | Role-Based Access Control: el acceso se decide por roles. |
| Recurso | Par (method, path) protegible; GET /api/productos y POST /api/productos son dos recursos. |
| Concesión | Fila de resource_roles: «este rol puede este recurso». Es el permiso. |
| Deny by default | Sin concesión explícita, la respuesta es 403. |
| N:M | Relación muchos-a-muchos, materializada por una tabla pivote (role_users, resource_roles). |
Criterios de aceptación
Los del ISS, textuales:
- [ ] 14.1
.envdefineJWT_SECRET,JWT_ACCESS_TTLyJWT_REFRESH_TTL_DAYS;jsonwebtokeny@types/jsonwebtokeninstalados - [ ] 14.2
src/shared/auth/password.tsconhashPassword,verifyPassword,sha256Hex,generateOpaqueToken - [ ] 14.3
src/shared/auth/jwt.tsfirma y verifica con HS256,issuer,audience,expiresInyjti - [ ] 14.4
src/shared/auth/resource-match.tscasa/api/productos/42con el patrón/api/productos/:id - [ ] 14.5
src/shared/auth/auth-user.tsdeclaraRequest.authy exportarequireAuthUser - [ ] 14.6
sendErrorcentralizaerror → HTTP;BaseControllerlo reutiliza - [ ] 14.7
bearerSecurityScheme,unauthorizedResponseyforbiddenResponseexportados - [ ] 14.8 los 6 modelos (
User,Role,Resource,RoleUser,ResourceRole,RefreshToken) con sus UK e índices - [ ] 14.9
rbac.associations.tsdeclara el grafo completo (User↔Role, Role↔Resource, User→RefreshToken) - [ ] 14.10
config/index.tsyseeders/index.tsimportan los modelos y las asociaciones, en ese orden - [ ]
npx tsc --noEmitOK
Evaluación
Preguntas de comprensión
-
¿Por qué este ISS no expone ningún endpoint? Porque su papel es construir la base sobre la que se apoyarán los demás ISS. Primero se definen las primitivas (hash, JWT, matcher) y las tablas; los endpoints llegan en ISS-10 (users) y siguientes. Construir de abajo arriba evita que un endpoint dependa de algo que aún no existe.
-
¿Por qué el access token es un JWT y el refresh token es opaco? Porque tienen requisitos opuestos. El access token viaja en cada petición y debe validarse sin tocar la BD → JWT firmado (stateless). El refresh token debe poder revocarse → es opaco y se persiste (hasheado) en
refresh_tokens, de modo que se puede invalidar una fila. Un JWT no se puede revocar antes de suexpsin una lista negra. -
¿Por qué el token no puede llevar los roles? Porque un token es inmutable durante su vida. Si los roles/permisos viajaran dentro, revocar un permiso no surtiría efecto hasta que el token caducara. La autorización se resuelve en cada petición contra la matriz (ISS-13), lo que hace la revocación inmediata.
-
¿Por qué
password.tsusa bcrypt para contraseñas y SHA-256 para tokens? bcrypt es lento y con salt: encarece la fuerza bruta sobre credenciales de baja entropía (las que elige una persona). Un refresh token es aleatorio de 64 bytes: no es adivinable, así que un hash rápido y determinista (SHA-256) basta y además permite buscar por índice único en O(1). -
¿Qué compra el proyecto al ser estricto el matcher de rutas? Que
/api/productos/42no case con/api/productos. Si un permiso de «listar» casara con «leer uno concreto», conceder la lista autorizaría de rebote la lectura individual. La estrictez convierte la matriz en mínima y predecible. -
¿Por qué se declaran las asociaciones en un solo archivo y después de los modelos? Porque la autorización es una cadena que atraviesa cinco tablas:
ResourceRole → Role → RoleUser → User. Verla junta hace evidente el camino de la consulta de permisos. Y se declaran después porque referencian a los modelos, no al revés. -
¿Por qué
jsonwebtokenno validasub/jtiaunque se pasen en las opciones? Porque la opciónrequireno existe enjsonwebtoken(es dejose): pasarla no valida nada. Por esoverifyAccessTokencompruebasub(entero positivo) yjtiexplícitamente, y traduce cualquier fallo aAppError(401).
Ejercicios
Ejercicio 1 — Traza el matcher.
Dada la concesión {"method": "GET", "path": "/api/productos/:id"} y estas tres peticiones, decide cuáles autoriza isOperationGranted y por qué:
(a) GET /api/productos/42 · (b) GET /api/productos · (c) GET /api/productos/42/lotes.
Respuesta razonada
- (a) **Sí**: tiene el mismo número de segmentos (`/api`, `/productos`, `/42`) y `:id` casa con `42`. - (b) **No**: la petición tiene un segmento menos; el patrón exige tres. Evita que un permiso de lectura individual autorice el listado. - (c) **No**: la petición tiene un segmento de más. No hay comodines `*`, así que no casa. La lección: la concesión es granular al nivel de `(method, path)` **exacto**; cada operación necesita su propia fila.Ejercicio 2 — Inspecciona un JWT.
Genera mentalmente (o con una herramienta local) un token con signAccessToken({ id: 7, username: "seller" }). Indica qué claims viajan y demuestra por qué modificar el payload a mano invalida el token.
Respuesta razonada
El payload incluye `sub: "7"`, `username: "seller"`, un `jti` aleatorio, `iss: "app-storelab-express"`, `aud: "app-storelab-api"`, `iat` y `exp` (ahora + 900 s). **No** incluye roles ni permisos. Si editas el payload (por ejemplo, cambias `sub` por `"1"`), la firma deja de corresponder con el contenido: `verifyAccessToken` recalcula el HMAC con `JWT_SECRET` y no coincide → `AppError(401)`. Por eso el token es «legible pero no modificable»: confidencialidad no garantizada, integridad sí.Ejercicio 3 — Justifica el orden de los imports.
Explica por qué config/index.ts y seeders/index.ts importan primero los seis *.model.ts y después rbac.associations.ts, y qué fallaría si se invirtiera.
Respuesta razonada
`rbac.associations.ts` ejecuta llamadas como `User.hasMany(RoleUser, …)`: necesita que las clases `User`, `Role`, etc. ya existan y hayan sido inicializadas con `Model.init`. Si se importara antes, esas clases estarían aún sin definir y las asociaciones no se registrarían (o darían error). Sequelize solo conoce las asociaciones que se han declarado; un `include` sobre una asociación no registrada falla en tiempo de ejecución.GATE
Para cerrar el ISS-09, ejecuta (del propio ISS):
npx tsc --noEmit
npm run db:seed # sync({ alter: true }) crea las 6 tablas vacías
npm run dev # el servidor debe arrancar
# Las 6 tablas existen
mysql -u admin -p tecnogua -e "SHOW TABLES LIKE '%role%'; SHOW TABLES LIKE 'users';"
Resultado esperado: tsc sin errores; el seeder crea users, roles, resources, role_users, resource_roles y refresh_tokens con sus UK; el servidor arranca. Sin endpoints nuevos todavía: la API de Fase I sigue funcionando SIN AUTH (la protección llega en ISS-13).
Checklist de cierre (DoD del ISS-09):
- [ ] Todos los criterios de aceptación (14.1 … 14.10) cumplidos
- [ ]
npx tsc --noEmitsin errores - [ ]
npm run db:seedcrea las 6 tablas con sus UK - [ ]
npm run devarranca el servidor - [ ] Sin endpoints nuevos: la API de Fase I sigue SIN AUTH
Con el GATE en verde puedes pasar al ISS-10 — Feature Users, donde sobre esta base nace el CRUD de identidades, el cambio de contraseña y la consulta de permisos efectivos.
Navegación de la ruta: ← CIERRE-BUSINESS · 🛠 Construir · ↑ Ruta Express · → ISS-09 · 🛠 Construir