Auth : rejeter les jetons dont le compte n'existe plus (validation de session)

Corrige un contournement : après réinitialisation de la base (même JWT_SECRET_KEY),
un jeton restait cryptographiquement valide et donnait accès alors que le compte
n'existait plus, permettant d'atteindre l'onboarding sans s'inscrire.

- Backend : JwtBearer.OnTokenValidated vérifie désormais l'existence du compte
  référencé par le claim sub (lu sous 'sub' ou NameIdentifier selon le remapping)
  et rejette le jeton (401) si le compte est introuvable.
- Backend : nouvel endpoint protégé GET /api/auth/moi (SessionController) renvoyant
  200 si la session est réellement valide, 401 sinon.
- Frontend : authGuard valide la session côté serveur (GET /auth/moi) au lieu de se
  fier au seul jeton local ; sur échec, redirection vers /login (jeton purgé par
  l'intercepteur sur 401). AuthService.validerSession() ajouté.

Tests backend : 54/54 au vert.
This commit is contained in:
2026-09-15 20:13:23 +02:00
parent f3465e04d2
commit 5c80102dca
4 changed files with 88 additions and 7 deletions
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
namespace SchotzIadKmTracker.Api.Controllers;
/// <summary>
/// Endpoint de validation de session. Ce controller est soumis à l'autorisation globale
/// (RequireAuthenticatedUser) : il n'est atteignable qu'avec un jeton valide ET un compte
/// existant (le middleware JWT rejette un jeton dont le compte est introuvable via
/// OnTokenValidated). Le frontend l'appelle pour confirmer qu'une session est réellement
/// valide au chargement des routes protégées.
/// </summary>
[ApiController]
[Route("api/auth")]
public class SessionController : ControllerBase
{
/// <summary>
/// Retourne 200 si la session est valide (jeton valide + compte existant), 401 sinon.
/// </summary>
[HttpGet("moi")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult Moi()
{
return Ok(new { authentifie = true });
}
}
+31
View File
@@ -137,6 +137,37 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)),
ClockSkew = TimeSpan.FromSeconds(30) // tolérance d'horloge réduite
};
// Un jeton peut être cryptographiquement valide (bonne signature, non expiré) alors
// que le compte n'existe plus (ex. base réinitialisée avec la même clé JWT). On vérifie
// donc l'EXISTENCE du compte référencé par le claim `sub` à chaque requête : sinon le
// jeton est rejeté (401), ce qui force la reconnexion / l'inscription côté frontend.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
// ASP.NET Core remappe par défaut le claim « sub » vers ClaimTypes.NameIdentifier.
// On cherche donc l'identifiant sous les deux noms possibles.
var principal = context.Principal;
var sub = principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value
?? principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(sub, out var compteId))
{
context.Fail("Jeton invalide : identifiant de compte manquant.");
return;
}
var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();
var compteExiste = await db.Comptes.AsNoTracking()
.AnyAsync(c => c.Id == compteId, context.HttpContext.RequestAborted);
if (!compteExiste)
{
context.Fail("Compte introuvable : jeton non valide.");
}
}
};
});
builder.Services.AddScoped<SchotzIadKmTracker.Api.Repositories.IVehiculeRepository,
+22 -7
View File
@@ -1,5 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
@@ -8,17 +10,30 @@ import { AuthService } from '../services/auth.service';
* authentification.
*
* Comportement (Exigences 4.6, 9.3) :
* - Si `AuthService.estAuthentifie()` est vrai → navigation autorisée (retourne `true`).
* - Sinon → redirection vers `/login` (retourne un `UrlTree`), ce qui bloque l'accès
* à la route protégée tant que l'utilisateur n'est pas connecté. (Exigences 4.6, 9.3)
* - Si aucun jeton n'est présent localement → redirection immédiate vers `/login`.
* - Sinon, la session est VALIDÉE côté serveur via `GET /api/auth/moi` (endpoint
* protégé) : cela garantit que le jeton est réellement valide ET que le compte
* existe toujours. Un jeton « fantôme » (cryptographiquement valide mais dont le
* compte a été supprimé, ex. base réinitialisée) est ainsi rejeté (401) au lieu de
* donner accès. En cas d'échec, on redirige vers `/login` (le jeton est purgé par
* l'intercepteur d'authentification sur le 401).
*
* Elle se compose avec la `profilConfigureGuard` existante : sur les routes de données,
* on applique `[authGuard, profilConfigureGuard]` (l'authentification est vérifiée d'abord,
* puis la configuration du profil).
* on applique `[authGuard, profilConfigureGuard]` (authentification vérifiée d'abord,
* puis configuration du profil).
*/
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
export const authGuard: CanActivateFn = (): Observable<boolean | UrlTree> => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.estAuthentifie() ? true : router.createUrlTree(['/login']);
// Pas de jeton local : inutile d'interroger le serveur, on redirige directement.
if (!authService.estAuthentifie()) {
return of(router.createUrlTree(['/login']));
}
// Jeton présent : on confirme sa validité réelle auprès du serveur.
return authService.validerSession().pipe(
map(() => true as boolean | UrlTree),
catchError(() => of(router.createUrlTree(['/login'])))
);
};
@@ -55,6 +55,15 @@ export class AuthService {
return this.http.get<{ existe: boolean }>(`${this.baseUrl}/compte-existe`);
}
/**
* Valide la session côté serveur (GET /auth/moi, endpoint protégé).
* 200 => le jeton est valide ET le compte existe ; toute erreur (401, etc.)
* signifie que la session n'est pas réellement valide.
*/
validerSession(): Observable<{ authentifie: boolean }> {
return this.http.get<{ authentifie: boolean }>(`${this.baseUrl}/moi`);
}
/** Retourne le token courant, ou null si non authentifié. */
getToken(): string | null {
return this._token();