PROYECTO_02
Biblioteca Maxipet
Plataforma corporativa de Maxipet cambiada de backend sin tocar la base de datos ni pedirle nada a quien la usa.
- TIPO
- Migración · Backend
- ESTADO
- EN PRODUCCIÓN
- DIAGRAMAS
- 4
- CAPTURAS
- 12
01 Qué es
API en Spring Boot 3 sobre Java 17 para la plataforma corporativa de Maxipet. Es la migración de la aplicación original en Flask: mismo PostgreSQL, mismos usuarios, backend nuevo. Sirve a un SPA en React + Vite hosteado en Vercel y cubre gestión documental, KPIs y objetivos, quejas, acciones correctivas, alertas de seguridad, cuestionarios, bitácora y directorio.
- Contenido por categoría (documentos, manuales, cursos, lecciones, boletín, almacenes): subida, listado, borrado y comentarios en boletín y lecciones.
- KPIs y objetivos con alta y baja; quejas con evidencia, edición, solución adjunta y estadísticas.
- Acciones correctivas con actividades por folio, pendientes globales y cambio de estatus.
- Seguridad: alertas, material general, podcast, video y cuestionarios con una sola respuesta por usuario.
- Bitácora de auditoría de toda mutación, dashboard con contador de días y directorio de usuarios.
- Cuenta: 2FA por TOTP o por correo, avisos de inicio de sesión, sesiones activas y actividad propia.
02 La migración
La plataforma ya estaba en producción y había que modernizarla sin detener la operación ni tocar los datos. Flyway toma el control del esquema con migraciones versionadas y Hibernate solo valida: nunca modifica.
- Flyway detecta que el esquema no está vacío y que no existe
flyway_schema_history(baseline-on-migrate=true,baseline-version=1). - Crea la tabla con un registro de baseline marcado como V1 y no re-ejecuta
V1: el esquema actual ya es V1. - Aplica V2 en adelante sobre el esquema existente; todas son idempotentes (
IF NOT EXISTS,WHERE NOT EXISTS). - Hibernate hace
validate. Si todo coincide, la app arranca.
| Migración | Qué hace |
|---|---|
V1 · V2 | 14 tablas iniciales con índices y las 30 categorías de contenido |
V3 · V4 | Tabla refresh_tokens; ON DELETE CASCADE en FKs y UNIQUE(questionnaire_id, user_name) |
V5 | users.totp_secret pasa a TEXT para alojar el secreto cifrado gcm:<iv>:<ct> |
V6 · V7 | Bloqueo por cuenta tras fallos de contraseña y de TOTP; el secreto pendiente de 2FA lo guarda el servidor |
V8 | Lista de revocación de access tokens por jti, para que logout cierre solo esa sesión |
V9 | Correo verificado, código de acceso por correo como segundo factor y avisos de inicio de sesión |
03 Arquitectura
Cadena: Cloudflare Tunnel → Nginx (127.0.0.1:80) → JVM (127.0.0.1:8080). La app no necesita puerto público; cloudflared apunta su ingress a Nginx local y el túnel pone el transporte cifrado. Spring procesa los X-Forwarded-* con forward-headers-strategy=framework, así la IP real llega al rate-limit y la cookie Secure sabe que la petición fue HTTPS.
| Pieza | Para qué |
|---|---|
| Java 17 + Spring Boot 3 | API REST con 9 controladores; Bean Validation en todos los DTOs de entrada |
| PostgreSQL + Flyway 10 | Esquema versionado; Hibernate en validate |
| jjwt 0.12 | Access token de 15 min con scope, refresh rotativo de 7 días en cookie httpOnly |
| Bucket4j | Rate-limit por IP y endpoint; en memoria, o Redis si hay varias instancias |
| AES-256-GCM · HMAC-SHA256 | Cifra los secretos TOTP en la BD; los códigos por correo se guardan como HMAC |
| ZXing · Resend | QR para el alta de 2FA; correo transaccional para códigos y avisos |
| Micrometer · Logback JSON | Métricas JVM, Hikari y HTTP; logs con requestId listos para Loki o ELK |
Nginx X-Accel-Redirect | Spring valida sesión y rol, Nginx sirve el archivo desde disco |
Dos perfiles: default para desarrollo (rate-limit apagado, secretos con fallback, errores detallados) y prod (rate-limit encendido, secretos obligatorios por entorno, errores mínimos, logs JSON). En prod, ProdSecretsValidator se niega a arrancar si JWT_SECRET o APP_ENCRYPTION_KEY siguen siendo los valores de desarrollo.
04 Puesta en marcha
Requisitos: Java 17, PostgreSQL 12+ (probado en 18) y, opcionalmente, Docker. Con Docker el contenedor corre como usuario no root y monta el volumen uploads.
> ./mvnw spring-boot:run # dev; lee DATABASE_URL, DB_USERNAME y DB_PASSWORD del .env
> ./mvnw -DskipTests package && java -jar target/biblioteca-api-1.0.0.jar --spring.profiles.active=prod
> docker compose up -d --build # api + db; usuario no root y volumen uploads
> docker compose logs -f api
> curl http://localhost:8080/actuator/health # {"status":"UP"}La aplicación no da de alta ningún usuario por su cuenta: contra una base vacía nadie puede entrar hasta insertar el primero a mano, una sola vez por instalación. A partir de ahí, el resto se crea desde Usuarios → Nuevo usuario.
> python3 -c "import bcrypt,getpass; print(bcrypt.hashpw(getpass.getpass().encode(), bcrypt.gensalt(12)).decode())"
> psql -U bibliotecario -d biblioteca_maxipet -c "INSERT INTO users (username, password_hash, role, full_name) VALUES (…, …, 'super_admin', …);"Variables de entorno
| Variable | Notas |
|---|---|
SPRING_PROFILES_ACTIVE | prod en producción |
DATABASE_URL · DB_USERNAME · DB_PASSWORD | JDBC: jdbc:postgresql://host:5432/biblioteca_maxipet |
JWT_SECRET | Obligatoria en prod. Base64, ≥ 64 caracteres: openssl rand -base64 64 |
APP_ENCRYPTION_KEY | Obligatoria en prod. Base64 de 32 bytes. No se cambia después del primer deploy: invalidaría todos los 2FA |
JWT_EXPIRATION_MS · JWT_REFRESH_EXPIRATION_MS | 15 min y 7 días por defecto |
CORS_ORIGINS | Lista exacta, sin barra final ni comodín; en prod el dominio del frontend |
UPLOAD_DIR | Carpeta de archivos subidos; en prod un bind-mount que Nginx también pueda leer |
RATE_LIMIT_ENABLED · RATE_LIMIT_BACKEND | true en prod; memory o redis (con SPRING_DATA_REDIS_URL) para 2+ instancias |
MAIL_ENABLED · RESEND_API_KEY · MAIL_FROM | Correo transaccional. Con MAIL_ENABLED=false la app levanta igual y los flujos de correo quedan apagados |
05 Referencia de la API
Todo bajo /api/* exige JWT (Authorization: Bearer …) salvo login, verify-2fa, refresh, logout y /actuator/health. Los errores llegan como JSON: 400 con fields en validación o JSON malformado, 401, 403 por rol, 404 en rutas inexistentes y 429 por rate-limit.
| Controlador | Prefijo | Qué expone |
|---|---|---|
Auth | /api/auth | Login en dos pasos, código por correo, refresh y logout, perfil, 2FA (alta, confirmación, baja, reset por admin), correo verificado y preferencias de avisos, sesiones, actividad, registro y usuarios |
Content | /api/content | Listado y subida por categoría, borrado, comentarios y categorías por tipo |
File | /api/files | Sirve un archivo por categoría y nombre; exige sesión y, en almacenes, rol |
KPI | /api/kpis | KPIs y objetivos: listado, alta y baja |
Queja | /api/quejas | Quejas con evidencia, edición, solución adjunta, borrado y estadísticas |
CorrectiveAction | /api/acciones | Acciones correctivas, actividades por folio, pendientes y cambio de estatus |
Seguridad | /api/seguridad | Alertas, material general, podcast, video y cuestionarios con respuestas |
Dashboard · AuditLog | /api/dashboard · /api/logs | Resumen con contador de días y bitácora de auditoría |
| Rol | Acceso |
|---|---|
super_admin | Todo: registra y elimina usuarios, cambia contraseñas ajenas y resetea 2FA |
admin | Gestiona el contenido, excepto almacenes |
almacen_admin · almacen_user | Gestión y lectura de la categoría almacenes |
user | Rol base: consulta y responde cuestionarios |
06 Seguridad
Cada control existe por un escenario concreto: un token robado, una contraseña filtrada, un archivo malicioso o una lectura de la base de datos. La idea es que ninguno de ellos, por sí solo, alcance.
Sesión y tokens
- JWT HS512 con
scope:accesspara uso normal y2fa-pending(5 min) que solo sirve en/verify-2fa; el filtro rechaza step tokens fuera de ese endpoint. - Access token de 15 min y refresh rotativo de 7 días en cookie httpOnly con
Path=/api/auth; en la BD solo vive su hash SHA-256. - Rotación atómica:
UPDATE … WHERE revoked_at IS NULL. Si dos peticiones llegan con el mismo refresh, una gana y la otra revoca toda la familia del usuario, como si fuera un robo. - Logout revoca el refresh y además el access por
jti(lista de revocación que se limpia sola); cierra solo esa sesión, no todos los dispositivos. - Cambiar la contraseña compara el claim
iatconpassword_changed_aty revoca todos los refresh del usuario. - Refresh cross-site: cookie
SameSite=None; Secureen prod porque el frontend vive en Vercel; la defensa es la allowlist de CORS, el path estrecho y la detección de reuso.
Segundo factor y bloqueos
- TOTP RFC 6238 en Base32 (Google Authenticator, Authy), ventana ±1. El secreto se cifra con AES-256-GCM y en la BD se ve como
gcm:<iv>:<ciphertext>. - El secreto pendiente lo emite y guarda el servidor en
/setup-2fa;/confirm-2fasolo confirma ese, nunca uno que mande el cliente. Alta y baja de 2FA exigencurrentPassword. - Alternativa por correo: la dirección se verifica con un código antes de servir para algo, y los códigos vivos se guardan como HMAC-SHA256 con clave de aplicación, no en claro.
- Avisos de inicio de sesión por dispositivo nuevo (
off·new_device·always); por dispositivo y no por IP, para no generar avisos falsos al cambiar de red.
| Techo | Dónde | Límite |
|---|---|---|
| Rate-limit por IP (Bucket4j) | /login · /verify-2fa | 8 por minuto |
| Rate-limit por IP (Bucket4j) | /register | 20 por hora |
| Bloqueo por cuenta | Contraseña | 10 fallos → 15 min |
| Bloqueo por cuenta | Código TOTP | 5 fallos → 15 min |
Archivos e inputs
- Path traversal cerrado en
FileStorageService: whitelist de categorías, nombre saneado ynormalize().startsWith(root). - Validación por extensión y por magic bytes (PDF, PNG, JPEG, OOXML/OLE, audio, video): un
.exerenombrado a.pdfse rechaza. MIME whitelist al servir yX-Content-Type-Options: nosniff. - Ningún archivo es público: todo
/api/files/**exige sesión yalmacenesademás exige rol. El frontend baja las imágenes como blob URL con el token. - Bean Validation en todos los DTOs con tamaños alineados al esquema; los cuestionarios acotan el mapa de respuestas (≤ 100 claves, 200 y 4000 caracteres) para cerrar el POST gigante.
- Mass assignment evitado con DTOs sin
id; URLs de video solohttps://; comentarios de máximo 1000 caracteres y solo en boletín y lecciones. - Todas las consultas son JPQL con parámetros nombrados; el rol de la app en Postgres puede separarse del dueño del esquema (
biblioteca_appcon solo DML, Flyway con el dueño).
07 Producción
Nginx es la pieza crítica: sin X-Forwarded-Proto https la cookie de refresh sale sin Secure, y sin real_ip_header CF-Connecting-IP (aceptado solo desde los rangos de Cloudflare) el rate-limit vería la IP del túnel.
> upstream biblioteca_api { server 127.0.0.1:8080; }
> real_ip_header CF-Connecting-IP; # con set_real_ip_from para cada rango de Cloudflare
> client_max_body_size 50M; # subidas grandes
> proxy_set_header X-Forwarded-Proto https; # la cookie Secure depende de esto
> location /_protected/ { internal; alias /srv/biblioteca-uploads/; } # X-Accel-Redirect- Archivos por
X-Accel-Redirect: Spring valida sesión y rol y responde con el header y cuerpo vacío; Nginx sirve desde disco. El volumenuploadses un bind-mount (/srv/biblioteca-uploads, UID 1000) legible por ambos. - Logs JSON en stdout con
requestId; se respeta elX-Request-Idque mande el balanceador o se genera un UUID, y se devuelve en la respuesta para correlacionar tickets. - En el borde de Cloudflare: rate-limit en
/api/auth/loginy/api/auth/refresh, WAF managed rules y Bot Fight Mode. - Para 2+ instancias:
RATE_LIMIT_BACKEND=redis; sin eso los buckets son por proceso.
> curl https://app.<dominio>/actuator/health # {"status":"UP"}
> curl -i -X POST https://app.<dominio>/api/auth/login -H "Content-Type: application/json" -d '{"username":"…","password":"…"}'
> # → Set-Cookie: refreshToken=…; HttpOnly; Secure; SameSite=None; Path=/api/auth
> journalctl -u biblioteca-api -f --output=cat | jq . # logs JSON con requestId
> ./mvnw test # 18 tests de cifrado y rotación, < 3 s08 Base de datos
| Dominio | Tablas |
|---|---|
| Cuentas | users (rol, hash BCrypt, TOTP cifrado, correo verificado, contadores de bloqueo), refresh_tokens, revoked_access_tokens |
| Contenido | categories (30 sembradas), content, comments |
| Calidad | quejas, corrective_action, correction_activity, kpis, objetivos |
| Seguridad industrial | seguridad, questionnaires, questionnaire_responses (única por usuario) |
| Sistema | audit_logs, system_config, flyway_schema_history |
La bitácora se escribe en su propia transacción (REQUIRES_NEW): si falla el insert no rompe la operación, pero se registra como ERROR para que un hueco en la auditoría sí dispare una alerta.
> SELECT version, description, installed_on, success FROM flyway_schema_history ORDER BY installed_rank;