Seguretat i Autenticació JWT
Mòdul: Autenticació
Versió: 1.0
Actualitzat: 2024-12
Referència: Veure també Security Playbook
Aquest document descriu com funciona el sistema de seguretat i autenticació amb JWT a la Line Dance Platform (LDP), pensat perquè:
- sigui stateless (sense sessió de servidor),
- admeti diversos clients (web, mòbil, futurs),
- permeti revocar tokens de forma controlada.
1. Visió general
1.1. Per què JWT?
- Tenim backend REST i clients diversos (web, Android, futurs).
- No volem gestionar sessió de servidor (HttpSession).
- JWT permet:
- enviar identitat i rol a cada petició,
- escalar fàcilment el backend,
- evitar dependència d'un "state" centralitzat al servidor.
1.2. Conceptes clau
-
Access Token:
- s'envia a cada petició protegida (header Authorization),
- durada curta (per exemple uns 15 minuts).
-
Refresh Token:
- s'envia només a /auth/refresh,
- es guarda com a cookie HttpOnly (no accessible per JavaScript),
- durada més llarga (per exemple 7 dies).
Els dos tokens contenen un identificador de versió de token (tokenVersion) per poder invalidar tots els tokens d'un usuari.
2. Arquitectura de seguretat
2.1. Components principals
-
Entitat User (BD):
- id
- email (únic, en minúscules)
- passwordHash (BCrypt)
- role (USER, ADMIN) — vegeu
security/Role.java - tokenVersion (enter)
- created_at, updated_at
-
JWT Utils:
- genera access i refresh tokens,
- valida signatura i expiració,
- llegeix claims (sub, uid, role, tv, exp…).
-
Spring Security:
- SecurityFilterChain:
- defineix rutes públiques i protegides,
- marca la sessió com STATELESS.
- Filtre JwtAuthFilter:
- llegeix el header Authorization,
- valida el token,
- carrega Authentication al SecurityContext.
- SecurityFilterChain:
3. Access Token vs Refresh Token
3.1. Access Token
-
Va al header Authorization, per exemple:
Authorization: Bearer ACCESS_TOKEN
-
Durada curta (minuts).
-
Inclou, com a mínim:
- sub (email)
- uid (id d'usuari)
- role
- tv (tokenVersion)
- exp (caducitat)
Si l'access token caduca, el client ha de demanar un token nou via /auth/refresh.
3.2. Refresh Token
- Es retorna en una cookie HttpOnly, per exemple:
- nom: refreshToken
- path: /auth
- HttpOnly: true
- Secure: true (en producció)
- Durada més llarga (dies).
- Només s'utilitza a:
- POST /auth/refresh
4. Fluxos principals
4.1. Registre d'usuari (POST /auth/register)
- El client envia email i password.
- El backend:
- valida format d'email i longitud de password,
- genera passwordHash amb BCrypt,
- crea usuari amb role = USER i tokenVersion = 0.
- Opcionalment:
- pot retornar access token directament,
- o bé forçar que es faci login en una petició separada.
4.2. Login (POST /auth/login)
- El client envia email i password.
- El backend:
- busca usuari per email,
- compara la password amb passwordHash,
- si és correcte, crea:
- un access token (TTL curt),
- un refresh token (TTL més llarg).
- Resposta:
- JSON amb dades d'usuari i accessToken,
- cookie HttpOnly amb refreshToken.
4.3. Peticions autenticades (/api/**)
El client inclou el header:
Authorization: Bearer ACCESS_TOKEN
El filtre JWT:
- valida el token,
- comprova expiració (exp),
- comprova que tokenVersion del token coincideix amb user.tokenVersion a BD.
Si tot és correcte:
- es carrega Authentication amb el rol corresponent,
- el controlador pot accedir a l'usuari autenticat.
4.4. Refresh (POST /auth/refresh)
- El client fa petició a /auth/refresh amb la cookie refreshToken.
- El backend:
- llegeix i valida el refresh token,
- comprova expiració,
- comprova tokenVersion.
- Si tot és correcte:
- genera un nou access token,
- opcionalment renova també el refresh token.
- Resposta:
- nou accessToken en JSON,
- cookie actualitzada si cal.
4.5. Logout (POST /auth/logout i POST /auth/logoutAll)
-
/auth/logout:
- expira o esborra la cookie de refresh,
- l'access token actual caducarà de forma natural.
-
/auth/logoutAll:
- incrementa user.tokenVersion a BD,
- qualsevol access o refresh token existent amb tv antic queda invalidat.
4.6. Autenticació nativa mòbil (T-187 / T-105)
L'auth web desa el refresh token en una cookie HttpOnly, que un client natiu
(Android/iOS) no pot gestionar. Per això hi ha un canal paral·lel d'auth per a
apps, que no toca el web. Neix directament versionat a /api/v1/auth/...mobile
(sense variant /api legacy) i retorna el refresh token al body.
Diferències clau respecte al web:
| Aspecte | Web | Mòbil |
|---|---|---|
| On viu el refresh | Cookie HttpOnly (path=/auth) | Body de la resposta (Keystore/Keychain al client) |
| TTL del refresh | 7 dies | 90 dies (jwt.refreshTtlMobileSec) |
| Identitat del dispositiu | — | device_id (UUID generat pel client) + taula user_devices |
| Rotació del refresh | opcional | a cada refresh (hash SHA-256 a user_devices) |
| Logout d'un sol dispositiu | no | sí (/logout/mobile) |
Endpoints (records JSON):
POST /api/v1/auth/login/mobile(públic) — credencials + blocdevice. Valida les credencials reutilitzant la mateixa lògica que el web (lockout T-086, email verificat), fa UPSERT auser_devicesi retornaaccessToken+refreshToken(al body). Si eldevice_idés nou, envia l'email "Nou dispositiu connectat".POST /api/v1/auth/refresh/mobile(públic) —{refreshToken, deviceId}. Rota el refresh i aplica reuse detection (RFC 8252 §8.2): si arriba un refresh ja rotat, es revoca tot el dispositiu (COMPROMISED). També comprovatokenVersion(unlogoutAll/canvi de contrasenya revoca el dispositiu amb raóPASSWORD_CHANGE). Un dispositiu que ja és tancat (T-841) no passa per cap d'aquestes comprovacions: es respon segons el motiu amb què es va tancar i no s'hi escriu res (ni data, ni motiu, ni cap apunt nou al registre de seguretat).COMPROMISED→AUTH_REFRESH_REUSE;INACTIVE_180D→AUTH_INVALID_REFRESH;USER_LOGOUT,ADMIN_REVOKEiPASSWORD_CHANGE→AUTH_REVOKED. Un testimoni que no és vàlid, o un dispositiu que no existeix, continuen sentAUTH_INVALID_REFRESH. L'entrada i la renovació posen al dia la versió de l'app del dispositiu amb les capçaleresX-App-Version-CodeiX-App-Version(T-842; vegeu «Control de versions»).POST /api/v1/auth/logout/mobile(autenticat) —{deviceId}. Revoca només aquest dispositiu (USER_LOGOUT); no bumpatokenVersion(no afecta web ni altres dispositius). Des de T-811 S1 (D2) té el límit de/auth/logout(30/h per IP, al mateix cubell).
Les portes del compte de l'app (T-811 S1, 23/10/2026). Les portes del compte de la web viuen a
/auth/** i /profile/**, que el versionat de T-188 no dobla (només dobla el que comença per /api/).
L'app només parla amb /api/v1, i per això té bessons explícits, en una llista tancada: criden el
mateix servei que la germana i fan i desen exactament el mateix, però cap resposta porta galetes.
No s'ha estès el versionat a /auth/**: hauria tret a /api/v1 les portes de la web que van amb galeta
(/auth/login, /auth/refresh, /auth/logout).
| Porta | Sessió | Germana | Límit per IP (mateix cubell que la germana, D1) |
|---|---|---|---|
POST /api/v1/auth/register | no | /auth/register (cos tancat: els camps de la web) | 5/h |
POST /api/v1/auth/check-invite | no | /auth/check-invite | 20/h |
POST /api/v1/auth/verify-email | no | /auth/verify-email | 20/h |
POST /api/v1/auth/resend-verification | no | /auth/resend-verification (captcha) | 5/h |
POST /api/v1/auth/forgot-password | no | /auth/forgot-password (captcha) | 5/h |
POST /api/v1/auth/reset-password | no | /auth/reset-password (tanca totes les sessions) | 10/h |
POST /api/v1/auth/change-password/mobile | app | /auth/change-password | 10/h |
POST /api/v1/auth/logoutAll | sí | /auth/logoutAll (LOGOUT_ALL, també aquest dispositiu) | 10/h |
GET, PUT /api/v1/profile · PUT /api/v1/profile/public-name | sí | /profile, /profile/public-name | — (com les germanes) |
- Canviar la contrasenya des de l'app (
/change-password/mobile): en una sola transacció comprova la contrasenya actual, tanca les ALTRES sessions (web i app,PASSWORD_CHANGE), puja eltokenVersioni dona una clau curta i una de renovació noves al mateix dispositiu (mateixa fila, mateixdeviceCode). Torna unMobileTokenResponseambuserideviceCodebuits, com la renovació. La clau de renovació d'abans ja no s'ha de fer servir: seria una reutilització (AUTH_REFRESH_REUSE, dispositiu tancat). Amb una clau de la web → 400AUTH_NOT_APP_SESSION. PUT /api/v1/profilesubstitueix el perfil sencer, comPUT /profile: un camp que no arriba s'esborra.- Si el registre és per invitació (C1):
GET /api/v1/config/feature-flags(públic) portaregistrationInviteRequired; l'app hi decideix si fa servir la seva pantalla de crear el compte o la pàgina de la web. - Seguretat: una regla explícita per porta a
SecurityConfig(cap comodí).MobileAccountITcomprova que cap porta respon 403 a qui hi ha d'arribar i que cap resposta porta galetes. - Límits:
RateLimitFilter.TWIN_BUCKETS. Un bessó compta al cubell de la germana: alternar les dues portes no dona el doble d'intents. La xarxaAuthTwinRateLimitNetTestes posa vermella si una porta de/authamb límit no té bessó, si el bessó no té el mateix límit o el mateix cubell, o si una porta de/api/v1/auth/**no té límit (forats declarats:login/mobileirefresh/mobile, de T-187). - Codis d'error propis (també a les germanes de la web, amb el mateix text):
CAPTCHA_FAILED,AUTH_EMAIL_REQUIRED,VERIFICATION_CODE_REQUIRED,VERIFICATION_RESEND_COOLDOWN(429),INVITE_INVALID,DATE_OF_BIRTH_REQUIRED,AGE_BELOW_MINIMUM,TOS_NOT_ACCEPTED,PRIVACY_NOT_ACCEPTED,CONSENT_VERSION_INVALID,CONSENT_OUTDATED,DUPLICATE_LOGIN_EMAIL(409),AUTH_CURRENT_PASSWORD_REQUIRED,AUTH_CURRENT_PASSWORD_INCORRECT,AUTH_NOT_APP_SESSION,PROFILE_DATE_OF_BIRTH_UNDERAGE,PERSON_NAME_CONTAINS_EMAILiPUBLIC_NAME_REQUIRED. La llista sencera per porta és al contracte del mapa del mòbil (26-mapa-mobil.md§ 3).
Els dispositius des de fora de l'app (T-806). Cada fila de user_devices té un codi del
dispositiu (device_code, V184): 6 caràcters base32 de Crockford, únic, generat a l'atzar pel
servidor (mai derivat del maquinari). L'entrada mòbil el torna com a deviceCode.
- Sostre: com a màxim
app.devices.max-active-per-user(5) dispositius actius per persona. Entrar des d'un dispositiu que no té la sessió oberta (nou, o un de tancat que es reactivaria) quan ja n'hi ha 5 → 409AUTH_DEVICE_LIMIT_EXCEEDED, i al registre de seguretat unLOGIN_MOBILEfallit amb motiuDEVICE_LIMIT_EXCEEDED. El recompte es fa amb la fila de l'usuari bloquejada (SELECT … FOR UPDATE): dues entrades simultànies no el poden saltar. GET /api/users/me/devicesiPOST /api/users/me/devices/{deviceCode}/revoke(autenticat, també a/api/v1): «Els meus dispositius» i «Tancar la sessió en aquest dispositiu» (USER_LOGOUT). BOLA al servei: un codi inexistent o d'una altra persona → 404, mai 403.GET /api/admin/app/devices(paginat a la BD; filtresplatform,appVersion,active,sharedAccountsMin),GET /api/admin/app/devices/summaryiPOST /api/admin/app/devices/{deviceCode}/revoke(ADMIN_REVOKE; queda al registre de seguretat de la persona afectada ambrevokedBy, sense la IP de l'administrador). Només ADMIN.- Revocar és un
POST …/revoke, no unDELETE: tancar una sessió no és esborrar dades (ADR-004). El testimoni d'accés que el dispositiu ja tenia caduca sol (15 minuts).
Codis d'error (ProblemDetail code):
code | HTTP | Quan |
|---|---|---|
AUTH_INVALID_DEVICE_ID | 400 | device_id no és un UUID |
AUTH_INVALID_PLATFORM | 400 | platform no és IOS/ANDROID |
AUTH_INVALID_REFRESH | 401 | refresh absent/invàlid/expirat, dispositiu inexistent, device_id no coincident, o sessió tancada per inactivitat (INACTIVE_180D) |
AUTH_REFRESH_REUSE | 401 | reuse detection (refresh ja rotat) → device revocat; també cada intent posterior sobre un dispositiu tancat per aquest motiu |
AUTH_REVOKED | 401 | tokenVersion bumped (logoutAll/canvi password) → device revocat; també qualsevol intent sobre un dispositiu tancat a propòsit (logout, «Els meus dispositius», administració) |
AUTH_DEVICE_LIMIT_EXCEEDED | 409 | ja té el màxim de dispositius actius (T-806) |
Credencials incorrectes, compte bloquejat i email no verificat reutilitzen els codis del
web (BAD_CREDENTIALS, USER_LOCKED, EMAIL_NOT_VERIFIED).
user_devices (migració V109): una fila per parella (user_id, device_id). Guarda
el hash del refresh (mai el token en clar), platform/os_version/app_version/
app_version_code (V187, T-842: el codi intern de la versió)/device_model (per a panell admin i
force-update futurs), i l'estat de revocació
(revoked_at/revoked_reason). És la pedra angular del cicle de vida mòbil (push,
device limits per tier, panell admin). Des de T-806 hi ha el codi curt (V184), el sostre i les
portes de la persona i de l'administració (vegeu més amunt); el límit per nivell de subscripció
(T-108) i el push segueixen sent futurs.
El refresh mòbil no comprova
enabled/lockeddel compte: l'estat actiu s'imposa alJwtAuthenticationFiltera cada petició autenticada, així que un access token refrescat per a un compte inactiu no autoritza res.
Control de versions de l'app (T-106). El servidor sap quina versió de l'app li parla
i pot obligar a actualitzar-la sense publicar res de nou. Contracte amb l'app:
.claude/knowledge/mobil/26-mapa-mobil.md § 3; funcionament i política de suport:
.claude/knowledge/mobil/t106-versions.md.
- L'app envia a totes les crides
X-App-Platform,X-App-Version-CodeiX-App-Version. La web i l'ETL no les envien i el servidor no els aplica res. GET /api/v1/app/version-check?platform=ANDROID&versionCode=12(públic) →{status: OK|SOFT_UPDATE|FORCE_UPDATE, minVersionCode, latestVersionCode, storeUrl}.- Filtre del 426 (
AppVersionFilter): una petició ambX-App-Platformi unX-App-Version-Codeper sota de la mínima rep426a qualsevol ruta, abans del límit de peticions, de la comprovació d'origen i de l'autenticació (una app vella ha de rebre «actualitza», no un 401 confús). Exempts:version-checki/actuator/health. No és una barrera de seguretat: les capçaleres les posa el client. GET/PUT /api/admin/app/version-policy[/{platform}](només ADMIN): la política (mínima, última, adreça de la botiga) i qui l'ha canviat. Taulaapp_version_policy(V183).- El codi de la versió, a cada dispositiu (T-842).
user_devices.app_version_code(V187) desa elversionCodeque l'app diu aX-App-Version-Code, iapp_versionelversionNamedeX-App-Version. Es posen al dia a l'entrada i a cada renovació de sessió; si una capçalera falta, o no serveix (un codi que no és un enter positiu, un nom de més de 32 caràcters), es conserva el valor anterior: mai s'esborra. Les files d'abans de V187 tenen el codi buit (null) fins que el dispositiu entra o renova. Són capçaleres del client: informatives, no una frontera de seguretat.GET /api/admin/app/devicesportaappVersionCodea cada fila, iGET /api/admin/app/devices/summaryagrupabyAppVersionper plataforma, nom i codi, amb els dispositiusactivede cadascun: sumant elsactivede les files amb un codi inferior a una mínima, l'administració sap quants dispositius amb la sessió oberta quedarien bloquejats abans de pujar-la (les files ambappVersionCodenullsón els que encara no es poden comptar).
code | HTTP | Quan |
|---|---|---|
APP_UPDATE_REQUIRED | 426 | versió de l'app per sota de la mínima; porta minVersionCode, latestVersionCode, storeUrl |
APP_VERSION_HEADER_INVALID | 400 | X-App-Version-Code present però no és un enter |
APP_PLATFORM_INVALID | 400 | platform de version-check (o de l'administració) que el servidor no coneix |
APP_VERSION_POLICY_INVALID | 400 | administració: falten els codis, o no es compleix 1 ≤ mínima ≤ última |
APP_STORE_URL_INVALID | 400 | administració: l'adreça de la botiga no és https vàlida |
5. Control d'accés per rols (RBAC)
5.1. Rols definits
⚠️ Correcció 2026-07-12 (T-329). Aquest document descrivia un rol
TEACHERque no existeix: l'enumRolenomés téUSERiADMIN.TEACHERés un rol de domini (PersonRoleType, a laPerson), no un rol de seguretat. No dona cap permís de plataforma. L'accés als recursos es decideix per propietat (BOLA al service layer), no per rol.
- USER
- pot llegir dades públiques (i el que es defineixi per defecte).
- pot gestionar els seus propis recursos (ownership validat al service layer).
- ADMIN
- pot gestionar usuaris,
- pot aprovar ownership requests,
- pot modificar configuracions sensibles.
5.2. A nivell de codi
Es pot utilitzar, per exemple:
@PreAuthorize("hasRole('ADMIN')")
o bé configurar-ho a la SecurityFilterChain per ruta (/admin/**, etc.).
6. Configuració CORS (resum)
Per permetre que el frontend (dev o producció) cridi l'API:
6.1. Orígens permesos
- http://localhost:5173 (dev)
- domini de producció (quan existeixi)
6.2. Mètodes permesos
- GET, POST, PUT, DELETE, PATCH, OPTIONS
6.3. Headers
- Content-Type
- Authorization
6.4. Cookies / credencials
- allowCredentials = true si s'utilitzen cookies de refresh.
- Al client cal fer servir withCredentials = true (Axios/fetch).
6.5. Errors típics CORS
- Origen del frontend no inclòs a allowedOrigins.
- Falta allowCredentials o no s'està fent servir withCredentials al client.
- Preflight OPTIONS no configurat correctament.
7. Bones pràctiques
7.1. Passwords
- sempre amb BCrypt (o equivalent segur),
- no loguejar mai contrasenyes ni hash complet.
7.2. JWT secret
- guardar-lo com a variable d'entorn (JWT_SECRET),
- no posar-lo mai al repositori,
- utilitzar un valor llarg i difícil d'endevinar.
7.3. HTTPS
- obligatori en entorns públics (producció),
- protegeix tokens, cookies i la resta de trànsit.
7.4. Expiració
- tokens sense expiració són una mala idea,
- accessToken de durada curta,
- refreshToken amb durada moderada.
7.5. Logging
- loguejar errors d'autenticació (per exemple TOKEN_EXPIRED, BAD_CREDENTIALS),
- no loguejar tokens sencers ni dades sensibles.
8. Troubleshooting habitual
8.1. 401 Unauthorized amb token
Possibles causes:
- token caducat,
- header Authorization no s'envia correctament,
- secret JWT incorrecte entre entorns.
Solucions:
- regenerar el token,
- revisar el client (header),
- revisar configuració de secrets a l'entorn.
8.2. 403 Forbidden amb token vàlid
Possibles causes:
- usuari autenticat però sense rol suficient,
- @PreAuthorize massa restrictiu,
- configuració de rutes a SecurityFilterChain massa tancada.
Solucions:
- comprovar rol a BD,
- revisar condicions de seguretat als controllers,
- revisar mapping de rutes i configuració de seguretat.
8.3. Problemes amb refresh
Possibles causes:
- cookie de refreshToken no arriba (path, domini, CORS),
- cookie expirada,
- tokenVersion incrementat (logoutAll, reset…).
Solucions:
- revisar configuració de cookies,
- revisar CORS i withCredentials,
- comprovar si s'ha fet logoutAll o s'ha canviat tokenVersion.