PROJECT_02
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
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.
- Flyway detects that the schema is not empty and that
flyway_schema_historydoes not exist (baseline-on-migrate=true,baseline-version=1). - It creates the table with a baseline record marked V1 and does not re-run
V1: the current schema already is V1. - It applies V2 onwards on the existing schema; all of them are idempotent (
IF NOT EXISTS,WHERE NOT EXISTS). - Hibernate runs
validate. If everything matches, the app starts.
| Migration | What it does |
|---|---|
V1 · V2 | 14 initial tables with indexes and the 30 content categories |
V3 · V4 | refresh_tokens table; ON DELETE CASCADE on FKs and UNIQUE(questionnaire_id, user_name) |
V5 | users.totp_secret becomes TEXT to hold the encrypted gcm:<iv>:<ct> secret |
V6 · V7 | Per-account lockout after password and TOTP failures; the pending 2FA secret is kept server-side |
V8 | Access-token revocation list by jti, so logout closes only that session |
V9 | Verified 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.
| Piece | What for |
|---|---|
| Java 17 + Spring Boot 3 | REST API with 9 controllers; Bean Validation on every request DTO |
| PostgreSQL + Flyway 10 | Versioned schema; Hibernate in validate mode |
| jjwt 0.12 | 15-minute access token with scope, 7-day rotating refresh in an httpOnly cookie |
| Bucket4j | Per-IP, per-endpoint rate limiting; in memory, or Redis with several instances |
| AES-256-GCM · HMAC-SHA256 | Encrypts TOTP secrets in the DB; email codes are stored as HMAC |
| ZXing · Resend | QR for 2FA setup; transactional email for codes and alerts |
| Micrometer · Logback JSON | JVM, Hikari and HTTP metrics; logs with requestId ready for Loki or ELK |
Nginx X-Accel-Redirect | Spring 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
| Variable | Notes |
|---|---|
SPRING_PROFILES_ACTIVE | prod in production |
DATABASE_URL · DB_USERNAME · DB_PASSWORD | JDBC: jdbc:postgresql://host:5432/biblioteca_maxipet |
JWT_SECRET | Required in prod. Base64, ≥ 64 characters: openssl rand -base64 64 |
APP_ENCRYPTION_KEY | Required in prod. 32-byte Base64. Never changed after the first deploy: it would invalidate every 2FA |
JWT_EXPIRATION_MS · JWT_REFRESH_EXPIRATION_MS | 15 min and 7 days by default |
CORS_ORIGINS | Exact list, no trailing slash or wildcard; in prod the frontend domain |
UPLOAD_DIR | Uploads folder; in prod a bind mount that Nginx can read too |
RATE_LIMIT_ENABLED · RATE_LIMIT_BACKEND | true in prod; memory or redis (with SPRING_DATA_REDIS_URL) for 2+ instances |
MAIL_ENABLED · RESEND_API_KEY · MAIL_FROM | Transactional 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.
| Controller | Prefix | What it exposes |
|---|---|---|
Auth | /api/auth | Two-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/content | List and upload by category, delete, comments and categories by type |
File | /api/files | Serves a file by category and name; requires a session and, for almacenes, a role |
KPI | /api/kpis | KPIs and objectives: list, create and delete |
Queja | /api/quejas | Complaints with evidence, editing, attached solution, deletion and stats |
CorrectiveAction | /api/acciones | Corrective actions, activities by reference number, pending items and status changes |
Seguridad | /api/seguridad | Alerts, general material, podcast, video and questionnaires with answers |
Dashboard · AuditLog | /api/dashboard · /api/logs | Summary with day counter and audit log |
| Role | Access |
|---|---|
super_admin | Everything: registers and deletes users, changes other passwords and resets 2FA |
admin | Manages content, except warehouses |
almacen_admin · almacen_user | Management and read access to the warehouses category |
user | Base 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:accessfor normal use and2fa-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
iatclaim againstpassword_changed_atand revokes every refresh token for the user. - Cross-site refresh:
SameSite=None; Securecookie 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-2faonly confirms that one, never one sent by the client. Enabling and disabling 2FA requirecurrentPassword. - 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.
| Ceiling | Where | Limit |
|---|---|---|
| Per-IP rate limit (Bucket4j) | /login · /verify-2fa | 8 per minute |
| Per-IP rate limit (Bucket4j) | /register | 20 per hour |
| Per-account lockout | Password | 10 failures → 15 min |
| Per-account lockout | TOTP code | 5 failures → 15 min |
Files and inputs
- Path traversal closed in
FileStorageService: category whitelist, sanitized name andnormalize().startsWith(root). - Validation by extension and magic bytes (PDF, PNG, JPEG, OOXML/OLE, audio, video): an
.exerenamed to.pdfis rejected. MIME whitelist when serving andX-Content-Type-Options: nosniff. - No file is public: all of
/api/files/**requires a session andalmacenesalso 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 URLshttps://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_appwith 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. Theuploadsvolume is a bind mount (/srv/biblioteca-uploads, UID 1000) readable by both. - JSON logs on stdout with
requestId; anX-Request-Idsent 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/loginand/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 s08 Database
| Domain | Tables |
|---|---|
| Accounts | users (role, BCrypt hash, encrypted TOTP, verified email, lockout counters), refresh_tokens, revoked_access_tokens |
| Content | categories (30 seeded), content, comments |
| Quality | quejas, corrective_action, correction_activity, kpis, objetivos |
| Industrial safety | seguridad, questionnaires, questionnaire_responses (unique per user) |
| System | audit_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;