PROJECT_02

Maxipet

Biblioteca Maxipet

Maxipet corporate platform moved to a different backend without touching the database or asking anything of its users.

TYPE
Migration · Backend
STATUS
IN PRODUCTION
DIAGRAMS
4
SCREENSHOTS
12
  • React 18
  • Vite
  • TailwindCSS
  • Java 17
  • Spring Boot 3
  • PostgreSQL
  • Flyway
  • Bucket4j
  • Docker
  • Nginx
  • Cloudflare Tunnel

01 What it is

A Spring Boot 3 API on Java 17 for the Maxipet corporate platform. It is the migration of the original Flask application: same PostgreSQL, same users, new backend. It serves a React + Vite SPA hosted on Vercel and covers document management, KPIs and objectives, complaints, corrective actions, safety alerts, questionnaires, audit log and staff directory.

  • Content by category (documents, manuals, courses, lessons, bulletin, warehouses): upload, list, delete and comments on bulletin and lessons.
  • KPIs and objectives with create and delete; complaints with evidence, editing, attached solution and stats.
  • Corrective actions with activities by reference number, global pending items and status changes.
  • Safety: alerts, general material, podcast, video and questionnaires with a single answer per user.
  • Audit log of every mutation, dashboard with a day counter and user directory.
  • Account: 2FA via TOTP or email, login alerts, active sessions and own activity.

02 The migration

The platform was already in production and had to be modernized without stopping operations or touching the data. Flyway takes over the schema with versioned migrations and Hibernate only validates: it never modifies.

  1. Flyway detects that the schema is not empty and that flyway_schema_history does not exist (baseline-on-migrate=true, baseline-version=1).
  2. It creates the table with a baseline record marked V1 and does not re-run V1: the current schema already is V1.
  3. It applies V2 onwards on the existing schema; all of them are idempotent (IF NOT EXISTS, WHERE NOT EXISTS).
  4. Hibernate runs validate. If everything matches, the app starts.
MigrationWhat it does
V1 · V214 initial tables with indexes and the 30 content categories
V3 · V4refresh_tokens table; ON DELETE CASCADE on FKs and UNIQUE(questionnaire_id, user_name)
V5users.totp_secret becomes TEXT to hold the encrypted gcm:<iv>:<ct> secret
V6 · V7Per-account lockout after password and TOTP failures; the pending 2FA secret is kept server-side
V8Access-token revocation list by jti, so logout closes only that session
V9Verified email, email access code as a second factor and login alerts

03 Architecture

Chain: Cloudflare Tunnel → Nginx (127.0.0.1:80) → JVM (127.0.0.1:8080). The app needs no public port; cloudflared points its ingress at local Nginx and the tunnel provides the encrypted transport. Spring processes X-Forwarded-* with forward-headers-strategy=framework, so the real IP reaches the rate limiter and the Secure cookie knows the request came over HTTPS.

PieceWhat for
Java 17 + Spring Boot 3REST API with 9 controllers; Bean Validation on every request DTO
PostgreSQL + Flyway 10Versioned schema; Hibernate in validate mode
jjwt 0.1215-minute access token with scope, 7-day rotating refresh in an httpOnly cookie
Bucket4jPer-IP, per-endpoint rate limiting; in memory, or Redis with several instances
AES-256-GCM · HMAC-SHA256Encrypts TOTP secrets in the DB; email codes are stored as HMAC
ZXing · ResendQR for 2FA setup; transactional email for codes and alerts
Micrometer · Logback JSONJVM, Hikari and HTTP metrics; logs with requestId ready for Loki or ELK
Nginx X-Accel-RedirectSpring validates session and role, Nginx serves the file from disk

Two profiles: default for development (rate limiting off, secrets with fallback, verbose errors) and prod (rate limiting on, secrets required from the environment, minimal errors, JSON logs). In prod, ProdSecretsValidator refuses to start if JWT_SECRET or APP_ENCRYPTION_KEY are still the development values.

04 Getting started

Requirements: Java 17, PostgreSQL 12+ (tested on 18) and, optionally, Docker. With Docker the container runs as a non-root user and mounts the uploads volume.

> ./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"}

The application never creates a user on its own: on an empty database nobody can log in until the first one is inserted by hand, once per installation. From then on, the rest are created from Users → New user.

> 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', …);"

Environment variables

VariableNotes
SPRING_PROFILES_ACTIVEprod in production
DATABASE_URL · DB_USERNAME · DB_PASSWORDJDBC: jdbc:postgresql://host:5432/biblioteca_maxipet
JWT_SECRETRequired in prod. Base64, ≥ 64 characters: openssl rand -base64 64
APP_ENCRYPTION_KEYRequired in prod. 32-byte Base64. Never changed after the first deploy: it would invalidate every 2FA
JWT_EXPIRATION_MS · JWT_REFRESH_EXPIRATION_MS15 min and 7 days by default
CORS_ORIGINSExact list, no trailing slash or wildcard; in prod the frontend domain
UPLOAD_DIRUploads folder; in prod a bind mount that Nginx can read too
RATE_LIMIT_ENABLED · RATE_LIMIT_BACKENDtrue in prod; memory or redis (with SPRING_DATA_REDIS_URL) for 2+ instances
MAIL_ENABLED · RESEND_API_KEY · MAIL_FROMTransactional email. With MAIL_ENABLED=false the app still starts and email flows stay off

05 API reference

Everything under /api/* requires a JWT (Authorization: Bearer …) except login, verify-2fa, refresh, logout and /actuator/health. Errors come as JSON: 400 with fields on validation or malformed JSON, 401, 403 by role, 404 on unknown routes and 429 on rate limit.

ControllerPrefixWhat it exposes
Auth/api/authTwo-step login, email code, refresh and logout, profile, 2FA (setup, confirm, disable, admin reset), verified email and alert preferences, sessions, activity, registration and users
Content/api/contentList and upload by category, delete, comments and categories by type
File/api/filesServes a file by category and name; requires a session and, for almacenes, a role
KPI/api/kpisKPIs and objectives: list, create and delete
Queja/api/quejasComplaints with evidence, editing, attached solution, deletion and stats
CorrectiveAction/api/accionesCorrective actions, activities by reference number, pending items and status changes
Seguridad/api/seguridadAlerts, general material, podcast, video and questionnaires with answers
Dashboard · AuditLog/api/dashboard · /api/logsSummary with day counter and audit log
RoleAccess
super_adminEverything: registers and deletes users, changes other passwords and resets 2FA
adminManages content, except warehouses
almacen_admin · almacen_userManagement and read access to the warehouses category
userBase role: reads and answers questionnaires

06 Security

Every control exists for a concrete scenario: a stolen token, a leaked password, a malicious file or a read of the database. The idea is that none of them, on its own, is enough.

Session and tokens

  • HS512 JWT with scope: access for normal use and 2fa-pending (5 min) that only works on /verify-2fa; the filter rejects step tokens anywhere else.
  • 15-minute access token and 7-day rotating refresh in an httpOnly cookie with Path=/api/auth; only its SHA-256 hash lives in the DB.
  • Atomic rotation: UPDATE … WHERE revoked_at IS NULL. If two requests arrive with the same refresh, one wins and the other revokes the whole family for that user, as if it were theft.
  • Logout revokes the refresh and also the access token by jti (a self-cleaning revocation list); it closes only that session, not every device.
  • Changing the password compares the iat claim against password_changed_at and revokes every refresh token for the user.
  • Cross-site refresh: SameSite=None; Secure cookie in prod because the frontend lives on Vercel; the defense is the CORS allowlist, the narrow path and reuse detection.

Second factor and lockouts

  • RFC 6238 TOTP in Base32 (Google Authenticator, Authy), ±1 window. The secret is encrypted with AES-256-GCM and appears in the DB as gcm:<iv>:<ciphertext>.
  • The pending secret is issued and stored by the server in /setup-2fa; /confirm-2fa only confirms that one, never one sent by the client. Enabling and disabling 2FA require currentPassword.
  • Email alternative: the address is verified with a code before it is used for anything, and live codes are stored as HMAC-SHA256 with an application key, never in the clear.
  • Login alerts on new device (off · new_device · always); per device rather than per IP, to avoid false alerts when switching networks.
CeilingWhereLimit
Per-IP rate limit (Bucket4j)/login · /verify-2fa8 per minute
Per-IP rate limit (Bucket4j)/register20 per hour
Per-account lockoutPassword10 failures → 15 min
Per-account lockoutTOTP code5 failures → 15 min

Files and inputs

  • Path traversal closed in FileStorageService: category whitelist, sanitized name and normalize().startsWith(root).
  • Validation by extension and magic bytes (PDF, PNG, JPEG, OOXML/OLE, audio, video): an .exe renamed to .pdf is rejected. MIME whitelist when serving and X-Content-Type-Options: nosniff.
  • No file is public: all of /api/files/** requires a session and almacenes also requires a role. The frontend fetches images as blob URLs with the token.
  • Bean Validation on every DTO with sizes aligned to the schema; questionnaires cap the answer map (≤ 100 keys, 200 and 4000 characters) to close the giant POST.
  • Mass assignment avoided with id-less DTOs; video URLs https:// only; comments capped at 1000 characters and only on bulletin and lessons.
  • Every query is JPQL with named parameters; the app role in Postgres can be split from the schema owner (biblioteca_app with DML only, Flyway with the owner).

07 Production

Nginx is the critical piece: without X-Forwarded-Proto https the refresh cookie ships without Secure, and without real_ip_header CF-Connecting-IP (accepted only from Cloudflare ranges) the rate limiter would see the tunnel IP.

> 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
  • Files via X-Accel-Redirect: Spring validates session and role and replies with the header and an empty body; Nginx serves from disk. The uploads volume is a bind mount (/srv/biblioteca-uploads, UID 1000) readable by both.
  • JSON logs on stdout with requestId; an X-Request-Id sent by the load balancer is honored or a UUID is generated, and it is returned in the response to correlate tickets.
  • At the Cloudflare edge: rate limiting on /api/auth/login and /api/auth/refresh, WAF managed rules and Bot Fight Mode.
  • For 2+ instances: RATE_LIMIT_BACKEND=redis; otherwise buckets are per process.
> 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 Database

DomainTables
Accountsusers (role, BCrypt hash, encrypted TOTP, verified email, lockout counters), refresh_tokens, revoked_access_tokens
Contentcategories (30 seeded), content, comments
Qualityquejas, corrective_action, correction_activity, kpis, objetivos
Industrial safetyseguridad, questionnaires, questionnaire_responses (unique per user)
Systemaudit_logs, system_config, flyway_schema_history

The audit log is written in its own transaction (REQUIRES_NEW): if the insert fails it does not break the operation, but it is logged as ERROR so a gap in the audit trail does raise an alert.

> SELECT version, description, installed_on, success FROM flyway_schema_history ORDER BY installed_rank;

DIAGRAMS

RETIRED JPQL ON STARTUP MIGRATES THEN Flask before · Python PostgreSQL same schema, same users Spring Boot 3 now · Java 17 Flyway baseline V1 · applies V2…V9 Hibernate ddl-auto: validate
01 New backend, same database
POST JWT AUTHORIZATION SET-COOKIE EVERY 15 MIN NEW PAIR ALREADY ROTATED User browser /auth/login BCrypt · cost 12 Access token 15 min · scope access /api/* Bearer on every request Refresh in cookie 7 days · httpOnly · Path=/api/auth /auth/refresh rotates: revokes the previous one Reuse detected revokes the whole family
02 Session: short access, rotating refresh
BEARER PROXY SENDFILE Browser GET /api/files/{cat}/{f} Nginx proxy · internal location Spring JWT · role · category /srv/biblioteca-uploads bind mount · sendfile On upload magic bytes · no path traversal /_protected/… internal: 404 from outside ◀ file◀ X-Accel-Redirect · empty bodydirect access to the internal path: blocked
03 Files: Spring decides, Nginx serves
POST STEPTOKEN CODE OK User password /auth/login checks the hash 2fa-pending 5-min JWT · only valid here /verify-2fa TOTP or email code Session access + refresh 10 failures on the account 15-min lockout 8 per minute per IP Bucket4j 5 code failures 15-min lockout
04 Login: two ceilings against brute force

01 04

01 New backend, same database
RETIRED JPQL ON STARTUP MIGRATES THEN Flask before · Python PostgreSQL same schema, same users Spring Boot 3 now · Java 17 Flyway baseline V1 · applies V2…V9 Hibernate ddl-auto: validate

Flyway marks the existing schema as V1 and applies V2…V9 on top · Hibernate only validates, never modifies

SCREENSHOTS

Home: days without accidents, new and closed complaints, quick links, safety alerts and latest documents
Home
Documents: version-controlled repository, search and cards by category (production, sales, logistics, general)
Documents
Merits and results: gallery of staff acknowledgements, diplomas and certificates
Merits and results
Audit log: date, user, action and IP address of every login and change
Audit log
My profile: confirmed email, per-device login alerts, active sessions and recent activity
Alerts and sessions
Corrective actions: reference number, status, process, owner and activities with progress
Corrective actions
Complaints: new, closed and history, with reference number, customer, reason, solution and status
Complaints
My profile: password change, two-step verification activation and email for access codes
Password and 2FA
In-app PDF viewer showing a safety data sheet, with download
PDF viewer
Upload file: title, description, category and drag-and-drop area for the document
Upload file
Directory: staff cards with role, area, email and link to the profile
Directory
PDF viewer with the sales process flowchart, a controlled QMS document
Controlled document

01 01