Skip to main content

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.

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)​

  1. El client envia email i password.
  2. El backend:
    • valida format d'email i longitud de password,
    • genera passwordHash amb BCrypt,
    • crea usuari amb role = USER i tokenVersion = 0.
  3. Opcionalment:
    • pot retornar access token directament,
    • o bé forçar que es faci login en una petició separada.

4.2. Login (POST /auth/login)​

  1. El client envia email i password.
  2. 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).
  3. 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)​

  1. El client fa petició a /auth/refresh amb la cookie refreshToken.
  2. El backend:
    • llegeix i valida el refresh token,
    • comprova expiració,
    • comprova tokenVersion.
  3. Si tot és correcte:
    • genera un nou access token,
    • opcionalment renova també el refresh token.
  4. 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:

AspecteWebMòbil
On viu el refreshCookie HttpOnly (path=/auth)Body de la resposta (Keystore/Keychain al client)
TTL del refresh7 dies90 dies (jwt.refreshTtlMobileSec)
Identitat del dispositiu—device_id (UUID generat pel client) + taula user_devices
Rotació del refreshopcionala cada refresh (hash SHA-256 a user_devices)
Logout d'un sol dispositiunosí (/logout/mobile)

Endpoints (records JSON):

  • POST /api/v1/auth/login/mobile (públic) — credencials + bloc device. Valida les credencials reutilitzant la mateixa lògica que el web (lockout T-086, email verificat), fa UPSERT a user_devices i retorna accessToken + refreshToken (al body). Si el device_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é comprova tokenVersion (un logoutAll/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_REVOKE i PASSWORD_CHANGE → AUTH_REVOKED. Un testimoni que no és vàlid, o un dispositiu que no existeix, continuen sent AUTH_INVALID_REFRESH. L'entrada i la renovació posen al dia la versió de l'app del dispositiu amb les capçaleres X-App-Version-Code i X-App-Version (T-842; vegeu «Control de versions»).
  • POST /api/v1/auth/logout/mobile (autenticat) — {deviceId}. Revoca només aquest dispositiu (USER_LOGOUT); no bumpa tokenVersion (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).

PortaSessióGermanaLímit per IP (mateix cubell que la germana, D1)
POST /api/v1/auth/registerno/auth/register (cos tancat: els camps de la web)5/h
POST /api/v1/auth/check-inviteno/auth/check-invite20/h
POST /api/v1/auth/verify-emailno/auth/verify-email20/h
POST /api/v1/auth/resend-verificationno/auth/resend-verification (captcha)5/h
POST /api/v1/auth/forgot-passwordno/auth/forgot-password (captcha)5/h
POST /api/v1/auth/reset-passwordno/auth/reset-password (tanca totes les sessions)10/h
POST /api/v1/auth/change-password/mobileapp/auth/change-password10/h
POST /api/v1/auth/logoutAllsí/auth/logoutAll (LOGOUT_ALL, també aquest dispositiu)10/h
GET, PUT /api/v1/profile · PUT /api/v1/profile/public-namesí/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 el tokenVersion i dona una clau curta i una de renovació noves al mateix dispositiu (mateixa fila, mateix deviceCode). Torna un MobileTokenResponse amb user i deviceCode buits, 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 → 400 AUTH_NOT_APP_SESSION.
  • PUT /api/v1/profile substitueix el perfil sencer, com PUT /profile: un camp que no arriba s'esborra.
  • Si el registre és per invitació (C1): GET /api/v1/config/feature-flags (públic) porta registrationInviteRequired; 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í). MobileAccountIT comprova 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 xarxa AuthTwinRateLimitNetTest es posa vermella si una porta de /auth amb 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/mobile i refresh/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_EMAIL i PUBLIC_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 → 409 AUTH_DEVICE_LIMIT_EXCEEDED, i al registre de seguretat un LOGIN_MOBILE fallit amb motiu DEVICE_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/devices i POST /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; filtres platform, appVersion, active, sharedAccountsMin), GET /api/admin/app/devices/summary i POST /api/admin/app/devices/{deviceCode}/revoke (ADMIN_REVOKE; queda al registre de seguretat de la persona afectada amb revokedBy, sense la IP de l'administrador). Només ADMIN.
  • Revocar és un POST …/revoke, no un DELETE: 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):

codeHTTPQuan
AUTH_INVALID_DEVICE_ID400device_id no és un UUID
AUTH_INVALID_PLATFORM400platform no és IOS/ANDROID
AUTH_INVALID_REFRESH401refresh absent/invàlid/expirat, dispositiu inexistent, device_id no coincident, o sessió tancada per inactivitat (INACTIVE_180D)
AUTH_REFRESH_REUSE401reuse detection (refresh ja rotat) → device revocat; també cada intent posterior sobre un dispositiu tancat per aquest motiu
AUTH_REVOKED401tokenVersion bumped (logoutAll/canvi password) → device revocat; també qualsevol intent sobre un dispositiu tancat a propòsit (logout, «Els meus dispositius», administració)
AUTH_DEVICE_LIMIT_EXCEEDED409ja 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/locked del compte: l'estat actiu s'imposa al JwtAuthenticationFilter a 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-Code i X-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ó amb X-App-Platform i un X-App-Version-Code per sota de la mínima rep 426 a 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-check i /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. Taula app_version_policy (V183).
  • El codi de la versió, a cada dispositiu (T-842). user_devices.app_version_code (V187) desa el versionCode que l'app diu a X-App-Version-Code, i app_version el versionName de X-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/devices porta appVersionCode a cada fila, i GET /api/admin/app/devices/summary agrupa byAppVersion per plataforma, nom i codi, amb els dispositius active de cadascun: sumant els active de 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 amb appVersionCode null són els que encara no es poden comptar).
codeHTTPQuan
APP_UPDATE_REQUIRED426versió de l'app per sota de la mínima; porta minVersionCode, latestVersionCode, storeUrl
APP_VERSION_HEADER_INVALID400X-App-Version-Code present però no és un enter
APP_PLATFORM_INVALID400platform de version-check (o de l'administració) que el servidor no coneix
APP_VERSION_POLICY_INVALID400administració: falten els codis, o no es compleix 1 ≤ mínima ≤ última
APP_STORE_URL_INVALID400administració: 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 TEACHER que no existeix: l'enum Role només té USER i ADMIN. TEACHER és un rol de domini (PersonRoleType, a la Person), 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​

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.

9. Documents relacionats​