CineBase utilizza un sistema di autenticazione basato su JWT (JSON Web Token) con refresh token. Il sistema ha subito un'evoluzione significativa dalla versione iniziale (token in localStorage) alla versione attuale (cookie HTTP-only per il refresh token).
Nella prima implementazione, sia l'access token che il refresh token venivano memorizzati in localStorage:
localStorage.setItem('cb_access_token', data.accessToken)localStorage.setItem('cb_refresh_token', data.refreshToken)
Problemi: vulnerabilità XSS (qualsiasi script nella pagina poteva leggere i token), nessuna protezione contro il furto di token.
L'access token è in memoria JavaScript (Auth._accessToken), il refresh token è in un cookie HTTP-only (cinebase_refresh). I riferimenti a localStorage per i token vengono puliti durante login/refresh.
sequenceDiagram
participant Browser
participant API as FilmAPI
participant DB as MariaDB
Browser->>API: POST /api/auth/login
Note right of Browser: body: email, password, deviceId
API->>DB: Cerca utente per email normalizzata
API->>API: BCrypt.Verify della password
API->>API: Genera JWT access token
API->>API: Genera refresh token (64 byte random)
API->>DB: Salva hash SHA256 del refresh token
API-->>Browser: 200 OK: accessToken + refreshToken + user
Note right of API: Set-Cookie: cinebase_refresh (HttpOnly, Secure, SameSite=Strict)
Browser->>Browser: Salva access token in memoria
Browser->>Browser: Salva user info in localStorage
Browser->>Browser: Rimuove eventuali token legacy da localStorage
Browser->>Browser: dispatchEvent auth:changed
sequenceDiagram
participant Browser
participant API as FilmAPI
participant DB as MariaDB
Browser->>API: GET /api/checkout/orders (Authorization: Bearer token)
API->>API: Valida firma JWT, expiry, issuer, audience
API->>API: Estrae claims: sub, email, role
API-->>Browser: 200 OK
Note over Browser: Se il token è scaduto...
Browser->>API: GET /api/checkout/orders (token scaduto)
API-->>Browser: 401 Unauthorized
Note over Browser: api.js intercetta il 401
Browser->>API: POST /api/auth/refresh (cookie + deviceId)
API->>DB: Cerca refresh token per hash + deviceId
API->>DB: Revoca il vecchio refresh token
API->>API: Genera nuovo JWT + nuovo refresh token
API->>DB: Salva nuovo refresh token hash
API-->>Browser: 200 OK: nuovi token
Note right of API: Set-Cookie: nuovo cinebase_refresh
Browser->>Browser: Aggiorna access token in memoria
Browser->>API: Ritenta GET /api/checkout/orders
API-->>Browser: 200 OK
sequenceDiagram
participant Browser
participant API as FilmAPI
Note over Browser: Pagina caricata, Auth.getUser() non è null
Browser->>Browser: Verifica token in memoria (assente o scaduto)
Browser->>API: POST /api/auth/refresh (cookie auto-incluso + deviceId)
API-->>Browser: 200 OK: nuovo access token
Browser->>Browser: Aggiorna access token in memoria
Browser->>Browser: dispatchEvent auth:ready
Note over Browser: La pagina può caricare dati autenticati
sequenceDiagram
participant Browser
participant API as FilmAPI
participant DB as MariaDB
Browser->>API: POST /api/auth/logout (cookie + deviceId)
API->>API: Legge refresh token dal cookie
API->>DB: Revoca refresh token (RevokedAt = now)
API-->>Browser: 200 OK: revoked true
Note right of API: Set-Cookie: cinebase_refresh scaduto
Browser->>Browser: Auth.clearAuth()
Browser->>Browser: Rimuove token da memoria
Browser->>Browser: Rimuove user da localStorage
Generato con HMAC-SHA256, payload:
{
"sub": "42",
"email": "utente@email.com",
"role": "User",
"nome": "Mario",
"auth_version": "1",
"iss": "CineBaseAPI",
"aud": "CineBaseWeb",
"exp": 1717000000
}- Scadenza: 15 minuti (configurabile via
JWT_ACCESS_TOKEN_EXPIRY_MINUTES) - Firma:
JWT_SECRET(minimo 32 byte) - Role mapping: il claim
roleviene mappato aClaimsIdentity.Rolenell'eventoOnTokenValidated
- Generazione: 64 byte random (
RandomNumberGenerator.GetBytes(64)) codificati Base64 - Storage nel DB: SHA256 del valore raw (
RefreshTokenProtector.HashToken()) - Rotazione: ad ogni refresh, il vecchio token viene revocato e ne viene creato uno nuovo
- Unicità: un solo refresh token attivo per coppia (userId, deviceId)
- Scadenza: 7 giorni (configurabile via
JWT_REFRESH_TOKEN_EXPIRY_DAYS) - Cleanup:
RefreshTokenCleanupServiceelimina periodicamente i token scaduti/revocati
// AuthCookieService.cs
new CookieOptions
{
HttpOnly = true, // Inaccessibile a JavaScript
Secure = !isDevelopment, // HTTPS only in produzione
SameSite = SameSiteMode.Strict, // Solo richieste same-site
Path = "/api/auth", // Limitato agli endpoint auth
Expires = expiresAtUtc
};Il cookie si chiama cinebase_refresh e viene automaticamente incluso dal browser in tutte le richieste a /api/auth/*.
Ogni browser ha un device ID univoco generato con crypto.randomUUID() (o fallback deterministico), persistito in localStorage come cb_device_id. Permette di associare i refresh token a un dispositivo specifico e di avere sessioni multiple su dispositivi diversi.
Ogni utente ha un campo AuthVersion incrementato ad ogni cambio password. Il valore è incluso nel JWT. Se un token ha una versione inferiore, viene rigettato — questo invalida tutti i token esistenti dopo un cambio password.
Gli endpoint sensibili (/auth/refresh, /auth/logout) verificano l'header Origin o Referer tramite AuthRequestGuard.HasAllowedOriginOrReferer(). Questo, combinato con SameSite=Strict, protegge contro CSRF.
Il rate limiter per gli endpoint di autenticazione usa una partition composita {IP}|{SHA256(email)} per distinguere utenti diversi dietro lo stesso IP (proxy aziendale, NAT). L'email viene estratta dal body JSON da un middleware asincrono prima di UseRateLimiter() e salvata in HttpContext.Items["RateLimitEmail"].
| Policy | Algoritmo | Limite | Finestra | Segmenti |
|---|---|---|---|---|
LoginRateLimit |
SlidingWindow | 10 tentativi | 60 secondi | 4 (15s ciascuno) |
ForgotPasswordRateLimit |
FixedWindow | 3 tentativi | 300 secondi | — |
- Login:
SlidingWindowLimitercon 4 segmenti — i permessi si rigenerano gradualmente ogni 15s, evitando l'effetto "burst al confine" del FixedWindow. - Forgot-password:
FixedWindowLimiter— il contatore si azzera completamente allo scadere della finestra.
Se il body non contiene un campo email valido, la partition diventa {IP}|__anon__ con limiti ridotti:
| Policy | Limite anonimo |
|---|---|
LoginRateLimit |
5 tentativi |
ForgotPasswordRateLimit |
1 tentativo |
L'IP del client viene letto dall'header X-Forwarded-For (primo IP a sinistra, compatibile con reverse proxy). In assenza dell'header, fallback a connection.RemoteIpAddress.
In caso di superamento del limite, l'API restituisce:
{"message":"Troppi tentativi. Riprova tra {N} secondi."}con header Retry-After: {N}. Il valore di N è letto dinamicamente dal metadata del lease (MetadataName.RetryAfter).
Le soglie sono configurabili tramite builder.Configuration (che legge da variabili d'ambiente, .env, e override in-memory per i test):
| Chiave | Default |
|---|---|
LOGIN_RATE_LIMIT_PERMITS |
10 |
LOGIN_RATE_LIMIT_WINDOW_SECONDS |
60 |
FORGOT_PASSWORD_RATE_LIMIT_PERMITS |
3 |
FORGOT_PASSWORD_RATE_LIMIT_WINDOW_SECONDS |
300 |
Il file tests/backend/Integration/RateLimiterIntegrationTests.cs contiene 7 test case (RL1-RL7) che verificano tutti gli aspetti del rate limiter: blocco dopo N tentativi, isolamento per email diversa, risposta 429 JSON+Retry-After, forgot-password, header X-Forwarded-For, fallback anonimo. I test usano TightRateLimitWebApplicationFactory, una factory derivata che sovrascrive la configurazione in-memory con limiti bassi (6/60s e 3/60s) senza alterare lo stato globale del processo.
sequenceDiagram
participant Client
participant API as FilmAPI
participant RL as RateLimiter
Client->>API: POST /api/auth/login
Note right of Client: body: email, password, deviceId
API->>API: Middleware: ReadToEndAsync body JSON
API->>API: Estrae email, salva in HttpContext.Items
API->>API: Reset stream position a 0
API->>RL: PartitionKey = {IP}|{SHA256(email)}
RL->>RL: SlidingWindow: controlla contatore
alt Contatore < 10 (email valida)
RL-->>API: Consenti
API-->>Client: 401/200
else Contatore >= 10
RL-->>API: Bloccato
API-->>Client: 429 + Retry-After + JSON
end
Note over Client,RL: Se email assente nel body
RL->>RL: PartitionKey = {IP}|__anon__
RL->>RL: Limite ridotto: 5 tentativi
### Sicurezza
- **Nessun token in localStorage** nella versione attuale (pulizia forzata durante login/refresh di eventuali residui legacy)
- **Refresh token hashati** nel database (SHA256) — anche con accesso al DB, i valori raw non sono recuperabili
- **Rotazione automatica** ad ogni refresh (revoca il vecchio, emette il nuovo)
- **Auth version** per invalidazione massiva dopo cambio password
- **Audit log** (`UserSecurityAuditLog`) per tutte le operazioni sensibili