PROYECTO_02

Maxipet

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
  • React 18
  • Vite
  • TailwindCSS
  • Java 17
  • Spring Boot 3
  • PostgreSQL
  • Flyway
  • Bucket4j
  • Docker
  • Nginx
  • Cloudflare Tunnel

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.

  1. Flyway detecta que el esquema no está vacío y que no existe flyway_schema_history (baseline-on-migrate=true, baseline-version=1).
  2. Crea la tabla con un registro de baseline marcado como V1 y no re-ejecuta V1: el esquema actual ya es V1.
  3. Aplica V2 en adelante sobre el esquema existente; todas son idempotentes (IF NOT EXISTS, WHERE NOT EXISTS).
  4. Hibernate hace validate. Si todo coincide, la app arranca.
MigraciónQué hace
V1 · V214 tablas iniciales con índices y las 30 categorías de contenido
V3 · V4Tabla refresh_tokens; ON DELETE CASCADE en FKs y UNIQUE(questionnaire_id, user_name)
V5users.totp_secret pasa a TEXT para alojar el secreto cifrado gcm:<iv>:<ct>
V6 · V7Bloqueo por cuenta tras fallos de contraseña y de TOTP; el secreto pendiente de 2FA lo guarda el servidor
V8Lista de revocación de access tokens por jti, para que logout cierre solo esa sesión
V9Correo 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.

PiezaPara qué
Java 17 + Spring Boot 3API REST con 9 controladores; Bean Validation en todos los DTOs de entrada
PostgreSQL + Flyway 10Esquema versionado; Hibernate en validate
jjwt 0.12Access token de 15 min con scope, refresh rotativo de 7 días en cookie httpOnly
Bucket4jRate-limit por IP y endpoint; en memoria, o Redis si hay varias instancias
AES-256-GCM · HMAC-SHA256Cifra los secretos TOTP en la BD; los códigos por correo se guardan como HMAC
ZXing · ResendQR para el alta de 2FA; correo transaccional para códigos y avisos
Micrometer · Logback JSONMétricas JVM, Hikari y HTTP; logs con requestId listos para Loki o ELK
Nginx X-Accel-RedirectSpring 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

VariableNotas
SPRING_PROFILES_ACTIVEprod en producción
DATABASE_URL · DB_USERNAME · DB_PASSWORDJDBC: jdbc:postgresql://host:5432/biblioteca_maxipet
JWT_SECRETObligatoria en prod. Base64, ≥ 64 caracteres: openssl rand -base64 64
APP_ENCRYPTION_KEYObligatoria 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_MS15 min y 7 días por defecto
CORS_ORIGINSLista exacta, sin barra final ni comodín; en prod el dominio del frontend
UPLOAD_DIRCarpeta de archivos subidos; en prod un bind-mount que Nginx también pueda leer
RATE_LIMIT_ENABLED · RATE_LIMIT_BACKENDtrue en prod; memory o redis (con SPRING_DATA_REDIS_URL) para 2+ instancias
MAIL_ENABLED · RESEND_API_KEY · MAIL_FROMCorreo 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.

ControladorPrefijoQué expone
Auth/api/authLogin 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/contentListado y subida por categoría, borrado, comentarios y categorías por tipo
File/api/filesSirve un archivo por categoría y nombre; exige sesión y, en almacenes, rol
KPI/api/kpisKPIs y objetivos: listado, alta y baja
Queja/api/quejasQuejas con evidencia, edición, solución adjunta, borrado y estadísticas
CorrectiveAction/api/accionesAcciones correctivas, actividades por folio, pendientes y cambio de estatus
Seguridad/api/seguridadAlertas, material general, podcast, video y cuestionarios con respuestas
Dashboard · AuditLog/api/dashboard · /api/logsResumen con contador de días y bitácora de auditoría
RolAcceso
super_adminTodo: registra y elimina usuarios, cambia contraseñas ajenas y resetea 2FA
adminGestiona el contenido, excepto almacenes
almacen_admin · almacen_userGestión y lectura de la categoría almacenes
userRol 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: access para uso normal y 2fa-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 iat con password_changed_at y revoca todos los refresh del usuario.
  • Refresh cross-site: cookie SameSite=None; Secure en 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-2fa solo confirma ese, nunca uno que mande el cliente. Alta y baja de 2FA exigen currentPassword.
  • 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.
TechoDóndeLímite
Rate-limit por IP (Bucket4j)/login · /verify-2fa8 por minuto
Rate-limit por IP (Bucket4j)/register20 por hora
Bloqueo por cuentaContraseña10 fallos → 15 min
Bloqueo por cuentaCódigo TOTP5 fallos → 15 min

Archivos e inputs

  • Path traversal cerrado en FileStorageService: whitelist de categorías, nombre saneado y normalize().startsWith(root).
  • Validación por extensión y por magic bytes (PDF, PNG, JPEG, OOXML/OLE, audio, video): un .exe renombrado a .pdf se rechaza. MIME whitelist al servir y X-Content-Type-Options: nosniff.
  • Ningún archivo es público: todo /api/files/** exige sesión y almacenes ademá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 solo https://; 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_app con 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 volumen uploads es un bind-mount (/srv/biblioteca-uploads, UID 1000) legible por ambos.
  • Logs JSON en stdout con requestId; se respeta el X-Request-Id que 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/login y /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 s

08 Base de datos

DominioTablas
Cuentasusers (rol, hash BCrypt, TOTP cifrado, correo verificado, contadores de bloqueo), refresh_tokens, revoked_access_tokens
Contenidocategories (30 sembradas), content, comments
Calidadquejas, corrective_action, correction_activity, kpis, objetivos
Seguridad industrialseguridad, questionnaires, questionnaire_responses (única por usuario)
Sistemaaudit_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;

DIAGRAMAS

RETIRADO JPQL AL ARRANCAR MIGRA LUEGO Flask antes · Python PostgreSQL mismo esquema, mismos usuarios Spring Boot 3 ahora · Java 17 Flyway baseline V1 · aplica V2…V9 Hibernate ddl-auto: validate
01 Backend nuevo, misma base de datos
POST JWT AUTHORIZATION SET-COOKIE CADA 15 MIN NUEVO PAR TOKEN YA ROTADO Usuario navegador /auth/login BCrypt · coste 12 Access token 15 min · scope access /api/* Bearer en cada petición Refresh en cookie 7 días · httpOnly · Path=/api/auth /auth/refresh rota: revoca el anterior Reuso detectado revoca toda la familia
02 Sesión: access corto, refresh que rota
BEARER PROXY SENDFILE Navegador GET /api/files/{cat}/{f} Nginx proxy · location interna Spring JWT · rol · categoría /srv/biblioteca-uploads bind-mount · sendfile Al subir magic bytes · sin path traversal /_protected/… internal: 404 desde fuera ◀ archivo◀ X-Accel-Redirect · cuerpo vacíoacceso directo a la ruta interna: bloqueado
03 Archivos: Spring decide, Nginx sirve
POST STEPTOKEN CÓDIGO OK Usuario contraseña /auth/login verifica el hash 2fa-pending JWT de 5 min · solo sirve aquí /verify-2fa TOTP o código por correo Sesión access + refresh 10 fallos en la cuenta bloqueo 15 min 8 por minuto por IP Bucket4j 5 fallos de código bloqueo 15 min
04 Entrar: dos techos contra la fuerza bruta

01 04

01 Backend nuevo, misma base de datos
RETIRADO JPQL AL ARRANCAR MIGRA LUEGO Flask antes · Python PostgreSQL mismo esquema, mismos usuarios Spring Boot 3 ahora · Java 17 Flyway baseline V1 · aplica V2…V9 Hibernate ddl-auto: validate

Flyway marca el esquema existente como V1 y aplica V2…V9 encima · Hibernate solo valida, nunca modifica

CAPTURAS

Inicio: días sin accidentes, quejas nuevas y cerradas, accesos rápidos, alertas de seguridad y últimos documentos
Inicio
Documentos: repositorio con control de versiones, buscador y tarjetas por categoría (producción, ventas, logística, general)
Documentos
Méritos y resultados: galería de reconocimientos, diplomas y certificados del personal
Méritos y resultados
Bitácora: fecha, usuario, acción y dirección IP de cada inicio de sesión y cambio
Bitácora
Mi perfil: correo confirmado, avisos de inicio de sesión por dispositivo, sesiones activas y actividad reciente
Avisos y sesiones
Acciones correctivas: folio, estado, proceso, responsable y actividades con su avance
Acciones correctivas
Quejas: nuevas, cerradas e histórico, con folio, cliente, motivo, solución y estado
Quejas
Mi perfil: cambio de contraseña, activación de la verificación en dos pasos y correo para códigos de acceso
Contraseña y 2FA
Visor de PDF dentro de la aplicación mostrando una hoja de seguridad, con descarga
Visor de PDF
Subir archivo: título, descripción, categoría y zona para arrastrar el documento
Subir archivo
Directorio: tarjetas de personal con rol, área, correo y acceso al perfil
Directorio
Visor de PDF con el diagrama de flujo del proceso de ventas, documento controlado del SGC
Documento controlado

01 01