Skip to content

J8 · Authentification et autorisation ​

Jour 8 · jeudi 19 novembre · Livrable : Users, login, routes protégées

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-jwt et @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 un RolesGuard qui lit les métadonnées via Reflector.
  • Livrer dans TaskFlow : module users, POST /v1/auth/register et POST /v1/auth/login, guard JWT global, visibilité des projets limitée aux membres.

Spring → NestJS ​

SpringNestJSÀ retenir
SecurityFilterChainGuards globaux (APP_GUARD) : ThrottlerGuard → JwtAuthGuard → RolesGuardL'ordre d'enregistrement est l'ordre d'exécution
.anyRequest().authenticated()JwtAuthGuard globalToute route exige un token par défaut
.requestMatchers("/auth/**").permitAll()@Public() sur la routeL'exception est déclarée à côté du handler, pas dans une liste centrale
oauth2ResourceServer().jwt(), JwtDecoderJwtStrategy (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) + RolesGuardListe de rôles statique, pas d'expression SpEL
SecurityContextHolder, @AuthenticationPrincipalrequest.user, @CurrentUser()Pas de ThreadLocal : l'utilisateur est passé en paramètre
UserDetailsServiceUsersService.findByEmail() appelé par AuthService.login()Code applicatif ordinaire

Comprendre ​

Passport, passport-jwt et @nestjs/jwt ​

Trois paquets, trois rôles :

  • @nestjs/jwt fournit JwtService pour signer (signAsync) et vérifier des tokens.
  • passport-jwt est une stratégie Passport : elle lit le token dans la requête, vérifie sa signature et son expiration.
  • @nestjs/passport transforme une stratégie en provider Nest (PassportStrategy) et fournit AuthGuard('jwt'), un guard qui l'exécute.
ts
// 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 :

  1. ExtractJwt.fromAuthHeaderAsBearerToken() lit Authorization: Bearer <token>.
  2. La signature est vérifiée avec JWT_SECRET (HMAC SHA-256, HS256), puis la date exp.
  3. Si tout est valide, validate(payload) est appelé ; sa valeur de retour devient request.user.
  4. Sinon, le guard lève UnauthorizedException et 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 :

ts
// 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.

ts
// 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() ​

ts
// 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 :

RouteOù est @Public()
POST /v1/auth/register, POST /v1/auth/loginSur chaque méthode de AuthController
/health, /health/live, /health/readySur 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() ​

ts
// 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 ​

ts
// @Roles(Role.Admin) plays the part of @PreAuthorize("hasRole('ADMIN')").
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
ts
// 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'enum Role (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 ​

ts
@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;
}
  • email est unique en base ; UsersService le met en minuscules à l'écriture et à la lecture.
  • passwordHash porte 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 de User (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.
  • role est un enum PostgreSQL (user_role), MEMBER par défaut (modèle de domaine).
ts
// 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 :

ts
@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 ​

ts
@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 {}
ts
@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;
  }
}
ts
@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é, sans passwordHash grâ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), email et role. expiresIn vient de la configuration typée (J7).
http
POST /v1/auth/register
Content-Type: application/json

{ "email": "alice@example.com", "password": "a long enough password" }
json
{
  "id": "3f2b…",
  "email": "alice@example.com",
  "role": "MEMBER",
  "createdAt": "2026-11-19T09:30:12.418Z"
}
http
POST /v1/auth/login
Content-Type: application/json

{ "email": "alice@example.com", "password": "a long enough password" }
json
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
  "tokenType": "Bearer",
  "expiresIn": "15m"
}

Le payload du token, une fois décodé (exp = iat + 900 secondes) :

json
{ "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 :

json
{
  "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 :

ts
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 :

ts
// 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 },
],
  1. ThrottlerGuard d'abord : une rafale de requêtes est coupée avant même de vérifier un token.
  2. JwtAuthGuard ensuite : il remplit request.user (ou laisse passer une route @Public()).
  3. RolesGuard en dernier : il a besoin de request.user. Placé avant JwtAuthGuard, il trouverait user indéfini et refuserait toute route @Roles() avec une 403.

Les deux refus, vérifiés par les tests e2e :

http
GET /v1/projects
x-correlation-id: test-123
json
{
  "status": 401,
  "code": "UNAUTHORIZED",
  "message": "Unauthorized",
  "path": "/v1/projects",
  "timestamp": "2026-11-19T09:35:02.611Z",
  "correlationId": "test-123"
}
http
GET /v1/users
Authorization: Bearer <token d'un MEMBER>
json
{
  "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 :

ts
// 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 un QueryBuilder, 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. TasksService passe par assertAccess(), 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 de project_members en 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).
  • passwordHash ne 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é sur register ou login : personne ne peut s'inscrire (401).
  • request.user est undefined sur une route publique : @CurrentUser() n'y renvoie rien.
  • Un guard qui renvoie false produit une 403. Pour une 401, levez UnauthorizedException.
  • Renvoyer { ...user } ou un résultat brut de requête : ce n'est plus une instance de User, @Exclude() ne s'applique pas et passwordHash part 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 register répond 409 sur un e-mail existant : TaskFlow accepte ce compromis.
  • Derrière un reverse proxy, req.ip est l'adresse du proxy : tous les clients partagent le même compteur de @Throttle(). Configurez trust proxy d'Express (app.set('trust proxy', 1) sur une NestExpressApplication). 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, passwordHash exclu, rôles ADMIN / MEMBER).
  • [ ] POST /v1/auth/register (hash argon2id) et POST /v1/auth/login (access token JWT).
  • [ ] Guard JWT global (APP_GUARD) et @Public() sur l'inscription, la connexion et /health.
  • [ ] Décorateur @CurrentUser() et route GET /v1/auth/me.
  • [ ] @Roles(Role.Admin) + RolesGuard sur GET /v1/users : un MEMBER reç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().

Pour aller plus loin ​