J8 · Authentification et autorisation
Spring Security arrive avec une chaîne de filtres préconfigurée. Nest ne fournit que des briques : Passport et @nestjs/jwt pour vérifier un token, des guards pour décider si une requête passe, des métadonnées pour marquer les routes. TaskFlow les assemble pour être sécurisé par défaut : toute route exige un JWT valide sauf celles marquées @Public(), certaines routes sont réservées aux administrateurs, et un membre ne voit que les projets dont il fait partie.
Objectifs
- Vérifier un JWT avec
@nestjs/passport,passport-jwtet@nestjs/jwt. - Stocker les mots de passe avec argon2id (ou bcrypt) et vérifier une connexion sans fuite d'information.
- Protéger toutes les routes avec un guard global et ouvrir les exceptions avec
@Public(). - Lire l'utilisateur courant avec un décorateur de paramètre
@CurrentUser(). - Mettre en place un RBAC avec
@Roles()et unRolesGuardqui lit les métadonnées viaReflector. - Livrer dans TaskFlow : module
users,POST /v1/auth/registeretPOST /v1/auth/login, guard JWT global, visibilité des projets limitée aux membres.
Spring → NestJS
| Spring | NestJS | À retenir |
|---|---|---|
SecurityFilterChain | Guards globaux (APP_GUARD) : ThrottlerGuard → JwtAuthGuard → RolesGuard | L'ordre d'enregistrement est l'ordre d'exécution |
.anyRequest().authenticated() | JwtAuthGuard global | Toute route exige un token par défaut |
.requestMatchers("/auth/**").permitAll() | @Public() sur la route | L'exception est déclarée à côté du handler, pas dans une liste centrale |
oauth2ResourceServer().jwt(), JwtDecoder | JwtStrategy (passport-jwt) | Vérifie signature et expiration, remplit request.user |
PasswordEncoder (Argon2PasswordEncoder, BCryptPasswordEncoder) | argon2.hash() / argon2.verify() | Appels directs, pas de bean dédié |
@PreAuthorize("hasRole('ADMIN')") | @Roles(Role.Admin) + RolesGuard | Liste de rôles statique, pas d'expression SpEL |
SecurityContextHolder, @AuthenticationPrincipal | request.user, @CurrentUser() | Pas de ThreadLocal : l'utilisateur est passé en paramètre |
UserDetailsService | UsersService.findByEmail() appelé par AuthService.login() | Code applicatif ordinaire |
Comprendre
Passport, passport-jwt et @nestjs/jwt
Trois paquets, trois rôles :
@nestjs/jwtfournitJwtServicepour signer (signAsync) et vérifier des tokens.passport-jwtest une stratégie Passport : elle lit le token dans la requête, vérifie sa signature et son expiration.@nestjs/passporttransforme une stratégie en provider Nest (PassportStrategy) et fournitAuthGuard('jwt'), un guard qui l'exécute.
// Checks the signature and expiry of "Authorization: Bearer <token>". What
// validate() returns becomes request.user (see @CurrentUser()).
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(@Inject(jwtConfig.KEY) config: JwtConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.secret,
});
}
validate(payload: JwtPayload): AuthUser {
// The role is read from the token: a role change applies at the next login.
return { id: payload.sub, email: payload.email, role: payload.role };
}
}À chaque requête protégée :
ExtractJwt.fromAuthHeaderAsBearerToken()litAuthorization: Bearer <token>.- La signature est vérifiée avec
JWT_SECRET(HMAC SHA-256,HS256), puis la dateexp. - Si tout est valide,
validate(payload)est appelé ; sa valeur de retour devientrequest.user. - Sinon, le guard lève
UnauthorizedExceptionet le filtre global répond 401.
Un JWT est signé, pas chiffré : n'importe qui peut décoder son payload (sub, email, role, iat, exp), simple JSON encodé en base64url. N'y mettez rien de confidentiel. Il est aussi sans état : le serveur ne garde aucune session, donc un token reste valide jusqu'à son expiration. D'où une durée courte (15 minutes dans TaskFlow).
Passport n'est pas obligatoire : la doc officielle montre aussi un guard qui appelle directement JwtService.verifyAsync(). Passport devient utile quand plusieurs stratégies coexistent (JWT, OAuth2, clé d'API).
Hash de mot de passe : argon2 ou bcrypt
TaskFlow utilise le paquet argon2. argon2.hash(password) produit un hash argon2id avec un sel aléatoire, encodé dans une seule chaîne qui commence par $argon2id$ et contient l'algorithme, les paramètres et le sel. argon2.verify(hash, password) relit tout cela. Le test unitaire vérifie le préfixe et que le mot de passe n'apparaît pas dans le hash.
Argon2id est volontairement lent et gourmand en mémoire, ce qui rend coûteuse une attaque par force brute sur une base volée. bcrypt reste un choix acceptable :
// Exemple hors TaskFlow : la même chose avec bcrypt (npm i bcrypt)
import * as bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12); // 12 = facteur de coût (2^12 itérations)
const ok = await bcrypt.compare(password, hash);bcrypt ignore tout ce qui dépasse 72 octets : deux mots de passe longs qui partagent leurs 72 premiers octets ont le même hash. argon2 n'a pas cette limite. Dans les deux cas, bornez la longueur côté DTO (@MaxLength(128) dans RegisterDto et LoginDto) pour qu'une entrée géante ne coûte pas un hash démesuré.
Côté Spring, c'est PasswordEncoder.encode() / matches(). Spring préfixe le hash par l'algorithme ({bcrypt}...) pour pouvoir en changer ; le format argon2 porte déjà l'algorithme et ses paramètres.
Guard global et AuthGuard
Un guard implémente CanActivate.canActivate(context) et renvoie true (la requête continue), false (Nest répond 403 avec le message Forbidden resource) ou lève une exception (401, 429…). Il s'exécute après les middleware et avant les interceptors et les pipes (ordre d'exécution). Contrairement à un middleware, il reçoit un ExecutionContext : context.getHandler() et context.getClass() désignent la méthode et le contrôleur ciblés, et donnent accès à leurs métadonnées.
// Registered globally (APP_GUARD): every route needs a valid token unless it
// is marked @Public(). Secure by default, like an anyRequest().authenticated().
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return isPublic ? true : super.canActivate(context);
}
}JwtAuthGuard étend AuthGuard('jwt') ; 'jwt' est le nom sous lequel passport-jwt enregistre sa stratégie. Il ajoute une seule règle : si la route porte @Public(), il laisse passer sans vérifier de token.
Déclaré via APP_GUARD, il s'applique à toutes les routes, y compris celles ajoutées plus tard. C'est l'équivalent de anyRequest().authenticated(). Avec l'approche inverse (@UseGuards() contrôleur par contrôleur), un oubli laisse une route ouverte ; ici, un @Public() oublié ne produit qu'une 401.
Où l'analogie avec SecurityFilterChain s'arrête : les guards ne protègent que les handlers Nest. Ce qui est servi en dehors des contrôleurs, comme l'interface Swagger sur /docs montée par SwaggerModule, n'y passe pas (Swagger).
Routes publiques : @Public()
// Metadata read by the global JwtAuthGuard: this route needs no token.
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);SetMetadata(clé, valeur) attache une métadonnée à la méthode ou à la classe décorée. JwtAuthGuard la lit avec reflector.getAllAndOverride(IS_PUBLIC_KEY, [handler, class]) : la valeur de la méthode prime, sinon celle de la classe. Les routes publiques de TaskFlow :
| Route | Où est @Public() |
|---|---|
POST /v1/auth/register, POST /v1/auth/login | Sur chaque méthode de AuthController |
/health, /health/live, /health/ready | Sur la classe HealthController |
/demo/* (hors production) | Sur la classe EventLoopDemoController |
GET /v1/auth/me, dans le même contrôleur que login, exige un token : la métadonnée est posée par méthode.
Décorateur de paramètre : @CurrentUser()
// A custom parameter decorator: @CurrentUser() user: AuthUser
// instead of SecurityContextHolder.getContext().getAuthentication().
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser =>
ctx.switchToHttp().getRequest<Request & { user: AuthUser }>().user,
);createParamDecorator() crée un décorateur comme @Body() ou @Param(). Nest l'évalue au moment de construire les arguments du handler et lui passe l'ExecutionContext. Ici, il renvoie request.user, c'est-à-dire l'objet AuthUser produit par JwtStrategy.validate().
C'est le rôle de SecurityContextHolder ou de @AuthenticationPrincipal. L'analogie s'arrête au mécanisme : Spring range l'authentification dans un ThreadLocal lisible depuis n'importe quel service. Node sert toutes les requêtes sur un seul thread, il n'y a donc pas de contexte implicite : le contrôleur lit l'utilisateur et le passe en paramètre au service (projects.findOne(id, user)). Le service est plus simple à tester, et la dépendance à l'utilisateur est visible dans sa signature. Injecter REQUEST dans un service est possible, mais le rend request-scoped, avec le coût décrit en J3.
RBAC : @Roles() et RolesGuard
// @Roles(Role.Admin) plays the part of @PreAuthorize("hasRole('ADMIN')").
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);// Runs after JwtAuthGuard, so request.user is already set. Returning false
// makes Nest answer 403 Forbidden.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!roles?.length) return true;
const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user;
return user !== undefined && roles.includes(user.role);
}
}@Roles(Role.Admin) ne fait rien seul : il pose une métadonnée. RolesGuard, enregistré globalement après JwtAuthGuard, la lit via Reflector. Sans @Roles(), il laisse passer ; sinon il compare le rôle de request.user. false se traduit par une 403.
Différences avec @PreAuthorize("hasRole('ADMIN')") :
@PreAuthorizeévalue une expression SpEL, qui peut lire les arguments (#projectId).@Roles()est une liste fixe ; les règles qui dépendent de la ressource vont dans le service (membres).- La sécurité de méthode Spring s'applique à n'importe quel bean. Un guard ne s'applique qu'aux handlers de routes.
- Pas de préfixe
ROLE_: les valeurs sont celles de l'enumRole(ADMIN,MEMBER). - Le rôle vient du token, pas de la base : un changement de rôle s'applique à la prochaine connexion. L'ancien rôle reste accepté tant que le token n'a pas expiré, soit au plus 15 minutes.
Cas TaskFlow
Module users
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 254, unique: true })
email: string;
// Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
// @ApiHideProperty keeps it out of the OpenAPI schema as well.
@Exclude()
@ApiHideProperty()
@Column({ name: 'password_hash', type: 'varchar' })
passwordHash: string;
@Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
role: Role;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}emailest unique en base ;UsersServicele met en minuscules à l'écriture et à la lecture.passwordHashporte deux décorateurs, un par usage.@Exclude()agit à l'exécution :ClassSerializerInterceptor(global) retire le champ de toute réponse JSON, mais seulement sur des instances deUser(entités brutes).@ApiHideProperty()agit sur la documentation : le champ n'apparaît pas dans le schéma OpenAPI de/docs(J11). L'un ne remplace pas l'autre.roleest un enum PostgreSQL (user_role),MEMBERpar défaut (modèle de domaine).
// No component scan: a provider is only visible to modules that import this
// one, and only because it is listed in `exports`.
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}UsersService (création, findByEmail, findById, findAll) est exporté pour AuthModule. Le contrôleur n'a qu'une route, réservée aux administrateurs :
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.Admin) // every route of this controller: ADMIN only, otherwise 403
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
findAll() {
return this.users.findAll();
}
}L'inscription crée toujours un MEMBER : RegisterDto n'a pas de champ role, et le ValidationPipe global rejette un corps qui en contient un (400, property role should not exist). Le premier administrateur vient du seed (npm run seed crée admin@taskflow.local).
Inscription et connexion
@Module({
imports: [
UsersModule,
PassportModule,
// forRootAsync / registerAsync: the module is configured from other
// providers (here the typed JWT config) once they exist.
JwtModule.registerAsync({
inject: [jwtConfig.KEY],
useFactory: (config: JwtConfig) => ({
secret: config.secret,
// '15m', '1h'... checked at startup by validateEnv.
signOptions: { expiresIn: config.expiresIn as JwtSignOptions['expiresIn'] },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('register')
register(@Body() dto: RegisterDto) {
// Returns the User entity: @Exclude() keeps passwordHash out of the JSON.
return this.auth.register(dto);
}
@Public()
@Throttle({ default: { limit: 5, ttl: 60_000 } }) // 5 attempts per minute per IP
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@ApiBearerAuth()
@Get('me')
me(@CurrentUser() user: AuthUser) {
return user;
}
}@Injectable()
export class AuthService {
// Verified against when the email is unknown, so both cases take as long.
private readonly dummyHash = argon2.hash('timing-equalizer');
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
@Inject(jwtConfig.KEY) private readonly config: JwtConfig,
) {}
async register(dto: RegisterDto): Promise<User> {
if (await this.users.findByEmail(dto.email)) {
throw new ConflictException('Email already registered');
}
// argon2id with a random salt; never store or log the password itself.
return this.users.create(dto.email, await argon2.hash(dto.password));
}
async login(dto: LoginDto): Promise<AccessToken> {
const user = await this.users.findByEmail(dto.email);
const valid = await argon2.verify(user?.passwordHash ?? (await this.dummyHash), dto.password);
if (!user || !valid) throw new UnauthorizedException('Invalid credentials');
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
return { accessToken: await this.jwt.signAsync(payload), tokenType: 'Bearer', expiresIn: this.config.expiresIn };
}
}register()refuse un e-mail existant (409), puis stocke le hash argon2id. Il renvoie l'entité, sanspasswordHashgrâce à@Exclude().login()donne la même réponse (401,Invalid credentials) pour un e-mail inconnu et un mauvais mot de passe. Pour que le temps de réponse ne trahisse pas non plus l'existence du compte, il vérifie un hash factice (dummyHash) quand l'utilisateur n'existe pas.- Le payload signé contient
sub(l'id),emailetrole.expiresInvient de la configuration typée (J7).
POST /v1/auth/register
Content-Type: application/json
{ "email": "alice@example.com", "password": "a long enough password" }{
"id": "3f2b…",
"email": "alice@example.com",
"role": "MEMBER",
"createdAt": "2026-11-19T09:30:12.418Z"
}POST /v1/auth/login
Content-Type: application/json
{ "email": "alice@example.com", "password": "a long enough password" }{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"tokenType": "Bearer",
"expiresIn": "15m"
}Le payload du token, une fois décodé (exp = iat + 900 secondes) :
{ "sub": "3f2b…", "email": "alice@example.com", "role": "MEMBER", "iat": 1795080612, "exp": 1795081512 }GET /v1/auth/me avec Authorization: Bearer <token> renvoie { id, email, role }.
Limitation des tentatives. @Throttle({ default: { limit: 5, ttl: 60_000 } }) remplace, pour login, la limite globale de ThrottlerModule (100 requêtes par minute ; /health et /demo/* en sont exemptés par @SkipThrottle()). Le compteur est tenu par route et par adresse IP. La 6e tentative dans la minute reçoit une 429, avec un en-tête Retry-After :
{
"status": 429,
"code": "TOO_MANY_REQUESTS",
"message": "ThrottlerException: Too Many Requests",
"path": "/v1/auth/login",
"timestamp": "2026-11-19T09:31:40.207Z",
"correlationId": "c1d7e0b2-5a8f-4f3e-9b61-0e2f4d7a9c35"
}La suite e2e y est soumise : ses tests précédents font déjà cinq connexions depuis la même adresse, le test de la 429 doit donc rester le dernier. Le test e2e principal enchaîne inscription, connexion et appels authentifiés :
it('register -> login -> project -> task -> status change', async () => {
const register = await http.post('/v1/auth/register').send({ email: 'alice@example.com', password }).expect(201);
expect(register.body.passwordHash).toBeUndefined(); // @Exclude + ClassSerializerInterceptor
expect(register.body.role).toBe(Role.Member);
const login = await http.post('/v1/auth/login').send({ email: 'alice@example.com', password }).expect(200);
const auth = { Authorization: `Bearer ${login.body.accessToken}` };
const project = await http.post('/v1/projects').set(auth).send({ name: 'Website' }).expect(201);
const task = await http
.post(`/v1/projects/${project.body.id}/tasks`)
.set(auth)
.send({ title: 'Write the README', dueDate: '2026-11-24' })
.expect(201);
expect(task.body.status).toBe('TODO');
await http.patch(`/v1/tasks/${task.body.id}/status`).set(auth).send({ status: 'DONE' }).expect(200);
const done = await http.get(`/v1/projects/${project.body.id}/tasks?status=DONE`).set(auth).expect(200);
expect(done.body).toHaveLength(1);
});Option : refresh token (hors TaskFlow)
Le plan cite le refresh token comme option ; TaskFlow ne l'implémente pas. Le principe, si vous en avez besoin :
- À la connexion, le serveur renvoie l'access token (15 minutes) et un refresh token : une valeur aléatoire opaque (par exemple 32 octets de
crypto.randomBytes), valable plusieurs jours. - Le serveur stocke uniquement un hash du refresh token (table
refresh_tokens: utilisateur, hash, expiration, date de révocation), comme un mot de passe. POST /v1/auth/refresh(route@Public()) vérifie le refresh token, le révoque et en émet un nouveau avec un nouvel access token (rotation). Si un token déjà révoqué est présenté, c'est probablement un vol : révoquez toutes les sessions de l'utilisateur.- La déconnexion révoque le refresh token. Pour un navigateur, transportez-le dans un cookie
HttpOnly,Secure,SameSite. - Au rafraîchissement, relisez l'utilisateur en base : un changement de rôle s'applique alors dans les 15 minutes, sans attendre une nouvelle connexion.
Guard JWT global et routes publiques
Les trois guards sont déclarés dans AppModule, et s'exécutent dans cet ordre :
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true, // strip properties without a decorator
forbidNonWhitelisted: true, // ...or reject the request: 400
transform: true, // plain JSON -> DTO instances, "2" -> 2
}),
},
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],ThrottlerGuardd'abord : une rafale de requêtes est coupée avant même de vérifier un token.JwtAuthGuardensuite : il remplitrequest.user(ou laisse passer une route@Public()).RolesGuarden dernier : il a besoin derequest.user. Placé avantJwtAuthGuard, il trouveraituserindéfini et refuserait toute route@Roles()avec une 403.
Les deux refus, vérifiés par les tests e2e :
GET /v1/projects
x-correlation-id: test-123{
"status": 401,
"code": "UNAUTHORIZED",
"message": "Unauthorized",
"path": "/v1/projects",
"timestamp": "2026-11-19T09:35:02.611Z",
"correlationId": "test-123"
}GET /v1/users
Authorization: Bearer <token d'un MEMBER>{
"status": 403,
"code": "FORBIDDEN",
"message": "Forbidden resource",
"path": "/v1/users",
"timestamp": "2026-11-19T09:35:20.940Z",
"correlationId": "7a0e4c1f-2b9d-4e58-8c36-d51f0a6b2e97"
}401 signifie « je ne sais pas qui vous êtes » (token absent, invalide ou expiré) ; 403, « je sais qui vous êtes, et ce n'est pas permis ».
Un utilisateur ne voit que ses projets
Les rôles ne suffisent pas : deux membres ont le même rôle mais pas les mêmes projets. Cette règle porte sur les données, elle vit donc dans le service :
// Business rules live here; the controller only maps HTTP to these calls.
@Injectable()
export class ProjectsService {
constructor(@Inject(PROJECTS_STORE) private readonly store: ProjectsStore) {}
create(dto: CreateProjectDto, user: AuthUser): Promise<Project> {
return this.store.create(dto, user.id);
}
findAll(query: ProjectQueryDto, user: AuthUser): Promise<Page<Project>> {
// Admins see everything, members only their own projects.
return this.store.findPage(query, user.role === Role.Admin ? undefined : user.id);
}
// A non-member gets 404, not 403: the answer must not reveal that the
// project exists. A soft-deleted project is not found either, even though
// its membership rows are kept.
async findOne(id: string, user: AuthUser): Promise<Project> {
const project = await this.store.findById(id);
const allowed = user.role === Role.Admin || (await this.store.isMember(id, user.id));
if (!project || !allowed) throw new NotFoundException(`Project ${id} not found`);
return project;
}
async update(id: string, dto: UpdateProjectDto, user: AuthUser): Promise<Project> {
await this.findOne(id, user);
return (await this.store.update(id, dto))!;
}
async remove(id: string, user: AuthUser): Promise<void> {
await this.findOne(id, user);
await this.store.softDelete(id);
}
async archive(id: string, user: AuthUser): Promise<Project> {
await this.findOne(id, user);
return (await this.store.archive(id))!;
}
async addMember(id: string, userId: string, user: AuthUser): Promise<void> {
await this.findOne(id, user);
await this.store.addMember(id, userId);
}
/** Used by TasksService: same rule as findOne(). */
async assertAccess(projectId: string, user: AuthUser): Promise<void> {
await this.findOne(projectId, user);
}
}- Liste : un administrateur voit tout ; un membre reçoit seulement les projets où il figure dans
project_members(jointure avec unQueryBuilder, voir J4). - Accès unitaire :
findOne()exige que le projet existe (non supprimé logiquement) et que l'appelant en soit membre ou administrateur. Modification, suppression, archivage et ajout de membre l'appellent d'abord.TasksServicepasse parassertAccess(), qui délègue àfindOne(): les tâches d'un projet suivent la même règle, y compris après sa suppression. Le test d'existence compte, car une suppression logique laisse les lignes deproject_membersen place (J4) ; le test unitaire « closes a soft-deleted project to its former members (tasks included) » le vérifie. - Création : le créateur devient membre dans la même transaction que le projet (J5).
Un non-membre reçoit 404, pas 403. Une 403 confirmerait que le projet existe, ce qui laisse énumérer les identifiants. Avec 404, un projet inaccessible et un projet inexistant sont indiscernables. Le test e2e le vérifie : un second utilisateur reçoit 404 sur le projet du premier, et sa liste est vide ({ "items": [], "total": 0, ... }).
Situations
- Toutes les routes doivent exiger un token sauf quelques-unes → guard JWT global +
@Public()(guard global). - Une route est réservée aux administrateurs →
@Roles(Role.Admin)sur la méthode ou la classe (RBAC). - Le service a besoin de l'utilisateur courant →
@CurrentUser()dans le contrôleur, puis paramètre du service (current user). - Un utilisateur ne doit pas savoir qu'une ressource existe → vérification dans le service et 404 (membres).
- Le login doit résister à la force brute →
@Throttle()sur la route, hash lent, message d'erreur unique (inscription et connexion). passwordHashne doit jamais sortir →@Exclude()+ClassSerializerInterceptor, en renvoyant des instances, et@ApiHideProperty()pour la documentation (users).- Un projet supprimé doit être fermé à ses anciens membres → vérifier l'existence avant l'appartenance, à un seul endroit (
findOne()) (membres). - Un changement de rôle doit s'appliquer vite → access token court, relecture en base au rafraîchissement (refresh token).
Pièges
Secret JWT faible ou partagé
Le schéma impose 8 caractères minimum à JWT_SECRET : c'est un plancher, pas une recommandation. Qui connaît le secret peut signer un token ADMIN. En production, utilisez une longue valeur aléatoire, propre à l'environnement, fournie par l'orchestrateur et jamais versionnée.
Ordre des guards globaux
Les APP_GUARD s'exécutent dans l'ordre de déclaration. RolesGuard avant JwtAuthGuard refuse toutes les routes @Roles() (pas encore de request.user).
@Public()oublié surregisteroulogin: personne ne peut s'inscrire (401).request.userestundefinedsur une route publique :@CurrentUser()n'y renvoie rien.- Un guard qui renvoie
falseproduit une 403. Pour une 401, levezUnauthorizedException. - Renvoyer
{ ...user }ou un résultat brut de requête : ce n'est plus une instance deUser,@Exclude()ne s'applique pas etpasswordHashpart dans la réponse. @Exclude()sans@ApiHideProperty(): le champ ne sort jamais dans le JSON, mais reste décrit dans/docs(J11).- Des messages différents pour « e-mail inconnu » et « mauvais mot de passe » permettent d'énumérer les comptes. Notez que
registerrépond 409 sur un e-mail existant : TaskFlow accepte ce compromis. - Derrière un reverse proxy,
req.ipest l'adresse du proxy : tous les clients partagent le même compteur de@Throttle(). Configureztrust proxyd'Express (app.set('trust proxy', 1)sur uneNestExpressApplication). Avec plusieurs instances, le stockage en mémoire du throttler compte par instance. - bcrypt tronque à 72 octets (hash).
Exercices
- [ ] Créer le module
users: entitéUser(e-mail unique,passwordHashexclu, rôlesADMIN/MEMBER). - [ ]
POST /v1/auth/register(hash argon2id) etPOST /v1/auth/login(access token JWT). - [ ] Guard JWT global (
APP_GUARD) et@Public()sur l'inscription, la connexion et/health. - [ ] Décorateur
@CurrentUser()et routeGET /v1/auth/me. - [ ]
@Roles(Role.Admin)+RolesGuardsurGET /v1/users: unMEMBERreçoit 403. - [ ] Limiter les projets visibles aux membres ; un non-membre reçoit 404 sur un projet existant.
- [ ]
@Throttle()à 5 tentatives par minute sur le login ; vérifier la 429 à la 6e. - [ ] Vérifier avec
curl: sans token → 401, token expiré ou modifié → 401,"role": "ADMIN"dans l'inscription → 400. - [ ] (Option) Concevoir le refresh token avec rotation et révocation.
- [ ] Noter dans
LEARNINGS.md:SecurityFilterChain↔ guards globaux,@PreAuthorize↔@Roles()+RolesGuard,SecurityContextHolder↔request.user/@CurrentUser().