ITSkillsCenter
Blog

Authentification JWT et autorisation RBAC avec Casbin sous NestJS 11

12 min de lecture

L’authentification et l’autorisation sont les deux barrières qui décident qui entre dans l’API et ce qu’il a le droit d’y faire. Confondre les deux est l’erreur la plus fréquente dans les projets en démarrage : on stocke un rôle dans le JWT, on vérifie ce rôle au niveau du controller avec un guard, et le jour où la règle devient seul l’auteur d’une commande peut l’annuler dans les six heures suivant la création, on se retrouve à coder de la logique d’autorisation dans dix services différents. Ce tutoriel pose une architecture séparée et évolutive : Passport pour l’authentification, JWT signés et rotatifs pour les sessions, et Casbin comme moteur de décisions externe pour l’autorisation fine.

📍 Article principal : NestJS 11 pour startup : architecture production 2026. Cette brique sécurité s’appuie sur la persistance Prisma et alimente les protections des autres modules.

Prérequis

Étape 1 — Installer Passport, JWT et Casbin

L’écosystème NestJS dispose de modules officiels pour Passport et JWT. Pour Casbin, l’intégration nest-authz maintenue par l’équipe node-casbin offre des décorateurs prêts à l’emploi mais on peut aussi consommer node-casbin directement, ce qui est l’approche choisie ici parce qu’elle expose mieux les concepts.

cd apps/api
pnpm add @nestjs/passport @nestjs/jwt passport passport-jwt argon2 casbin
pnpm add -D @types/passport-jwt

Les paquets @nestjs/passport et @nestjs/jwt sont alignés sur la version 11 du framework. passport-jwt est la stratégie qui décode et valide un JWT depuis un header Authorization: Bearer. argon2 reste l’algorithme de hashing recommandé par OWASP en 2026 — bcrypt est acceptable mais argon2id offre une meilleure résistance aux GPU. casbin est le paquet npm de node-casbin v5 qui charge les modèles et applique les politiques.

Étape 2 — Créer le module Auth et le service d’authentification

Le module Auth centralise tout ce qui concerne l’authentification : signup, login, refresh, logout. Il dépend de PrismaService pour vérifier les credentials et de JwtService pour signer les tokens. La séparation entre AuthService (logique) et AuthController (transport HTTP) reste fondamentale — on doit pouvoir tester le service sans monter d’application HTTP.

// auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService, private jwt: JwtService) {}

  async login(email: string, password: string) {
    const user = await this.prisma.user.findUnique({ where: { email } });
    if (!user) throw new UnauthorizedException('Invalid credentials');
    const ok = await argon2.verify(user.password, password);
    if (!ok) throw new UnauthorizedException('Invalid credentials');
    return this.issueTokens(user.id, user.role);
  }
}

Trois choix de sécurité importants. Le message d’erreur reste identique pour email inconnu et mot de passe incorrect — exposer la différence permettrait à un attaquant d’énumérer les comptes valides. Le hashing argon2 est vérifié avec la fonction verify qui inclut le sel dans le hash stocké. Aucune information sur l’utilisateur n’est ajoutée au JWT en dehors de son ID et son rôle : un token compromis ne révèle ni l’email ni les permissions exactes.

Étape 3 — Émettre des tokens d’accès courts et des refresh tokens rotatifs

La discipline de sécurité moderne sépare deux tokens. Le access token est un JWT signé valable 15 minutes maximum, transporté à chaque requête API. Le refresh token est un secret aléatoire stocké en base sous forme hashée, valable 7 à 30 jours, qui sert à demander un nouveau couple de tokens. Cette séparation limite l’impact d’une fuite : un access token volé n’est utilisable que 15 minutes ; un refresh token volé est révocable côté serveur.

private async issueTokens(userId: string, role: Role) {
  const accessToken = await this.jwt.signAsync(
    { sub: userId, role },
    { secret: process.env.JWT_SECRET, expiresIn: '15m' },
  );
  const refreshTokenRaw = randomBytes(48).toString('base64url');
  const refreshTokenHash = createHash('sha256').update(refreshTokenRaw).digest('hex');
  await this.prisma.refreshToken.create({
    data: { userId, tokenHash: refreshTokenHash, expiresAt: addDays(new Date(), 7) },
  });
  return { accessToken, refreshToken: refreshTokenRaw };
}

Le refresh token est généré via randomBytes(48) et seul son hash SHA-256 est stocké en base. Si la table RefreshToken fuit, les tokens en clair restent inutilisables sans la fonction de hash inverse. La rotation se fait à chaque appel à /auth/refresh : le ancien hash est marqué révoqué, un nouveau est inséré. Cette rotation détecte les vols : si un attaquant utilise un refresh token volé, le légitime aura son refresh révoqué silencieusement et sera déconnecté à la prochaine session, ce qui le force à se reconnecter — signal d’incident.

Étape 4 — Configurer la stratégie JwtStrategy

Passport applique le pattern strategy où chaque méthode d’authentification est une classe qui implémente validate. La stratégie JWT extrait le token du header, vérifie sa signature, et passe le payload à validate qui retourne l’utilisateur attaché à la requête. Le résultat est accessible via @Req() req ou via le décorateur custom @CurrentUser().

// auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET!,
    });
  }
  async validate(payload: { sub: string; role: Role }) {
    return { id: payload.sub, role: payload.role };
  }
}

La méthode validate peut interroger la base si on veut récupérer l’utilisateur complet, mais à chaque requête API. Pour des performances stables, le pattern recommandé reste token contient le rôle, ce qui évite un round-trip Postgres par requête. Un guard JwtAuthGuard appliqué globalement via APP_GUARD protège tous les endpoints sauf ceux marqués @Public() avec un décorateur custom.

Étape 5 — Définir le modèle Casbin pour RBAC

Casbin sépare le modèle (la grammaire de la politique) et la policy (les règles concrètes). Le modèle s’écrit dans un fichier .conf, la policy dans une table SQL via l’adapter Prisma. Cette séparation autorise le changement de modèle (RBAC vers ABAC) sans modifier la policy, et l’ajout dynamique de règles sans déploiement.

# auth/casbin.model.conf
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act

Le modèle déclare une requête à trois éléments : sujet (l’utilisateur ou son rôle), objet (la ressource visée), action (read, write, delete). La fonction keyMatch2 permet d’écrire des objets paramétrés comme /orders/:id, qui matchent /orders/123. La règle g définit la hiérarchie de rôles : un OWNER hérite des permissions ADMIN, qui hérite de MEMBER. Ce modèle reste lisible et évolutif vers ABAC quand des contraintes contextuelles apparaissent.

Étape 6 — Persister les policies dans PostgreSQL

Plutôt que d’éditer un policy.csv, on stocke les règles en base via une table casbin_rule. node-casbin propose plusieurs adapters ; pour Prisma, l’adapter casbin-prisma-adapter maintient la table synchronisée. Les règles deviennent administrables via une interface ou des migrations versionnées.

// auth/casbin.service.ts
@Injectable()
export class CasbinService implements OnModuleInit {
  enforcer!: Enforcer;
  async onModuleInit() {
    const adapter = await PrismaAdapter.newAdapter(prisma);
    this.enforcer = await newEnforcer('auth/casbin.model.conf', adapter);
  }
  async can(role: string, obj: string, act: string) {
    return this.enforcer.enforce(role, obj, act);
  }
}

Le service charge le modèle au démarrage et expose une méthode can qui retourne un booléen. Toute mise à jour des policies via enforcer.addPolicy() est immédiatement persistée. Un seed initial peut amorcer les règles classiques : OWNER peut tout, ADMIN peut lire et écrire les ressources métier, MEMBER peut seulement lire ses propres ressources. Cette politique vit ensuite dans la base, ce qui simplifie les changements opérationnels.

Étape 7 — Créer un PoliciesGuard NestJS

Le guard est l’endroit où l’autorisation s’applique. Il extrait le rôle depuis l’utilisateur authentifié, l’objet depuis la route, et l’action depuis la méthode HTTP, puis interroge Casbin. Un décorateur @CheckPolicies(...) permet de spécifier des règles plus fines au niveau d’un endpoint particulier.

// auth/policies.guard.ts
@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(private casbin: CasbinService) {}
  async canActivate(ctx: ExecutionContext) {
    const req = ctx.switchToHttp().getRequest();
    const role = req.user.role;
    const obj = req.route.path;
    const act = req.method.toLowerCase();
    return this.casbin.can(role, obj, act);
  }
}

Le guard s’enregistre globalement après JwtAuthGuard. L’ordre est critique : on doit d’abord savoir qui avant de décider quoi. Pour les endpoints publics (signup, login, healthcheck), le décorateur @Public() court-circuite les deux guards. Le tutoriel rate-limiting Redis ajoute un troisième guard pour limiter les tentatives de login brute-force.

Étape 8 — Audit log et révocation

Toute action sensible (login, modification de rôle, accès admin) doit laisser une trace dans une table AuditLog indexée par utilisateur et par date. Cette trace ne sert pas qu’à la conformité — c’est l’outil principal de réponse à incident quand on découvre une compromission. La révocation d’un compte se fait en supprimant tous ses refresh tokens et en marquant l’utilisateur locked, ce qui invalide les sessions actives à la prochaine vérification.

// admin/users.service.ts
async revoke(userId: string) {
  await this.prisma.$transaction([
    this.prisma.refreshToken.deleteMany({ where: { userId } }),
    this.prisma.user.update({ where: { id: userId }, data: { locked: true } }),
    this.prisma.auditLog.create({ data: { userId, action: 'REVOKE', byAdmin: true } }),
  ]);
}

L’opération est transactionnelle : si l’écriture de l’audit log échoue, les deux autres opérations sont annulées et l’admin obtient une erreur explicite. Côté JwtStrategy, ajouter un appel findUnique({ where: { id, locked: false }}) dans validate bloque les tokens d’accès toujours valides mais émis avant la révocation. C’est un compromis : un round-trip Postgres par requête contre une révocation immédiate. Pour les charges très fortes, mettre en cache le statut locked dans Redis avec un TTL court reste une optimisation rentable.

Étape 9 — Tester les politiques avec un harnais dédié

Une politique d’autorisation sans test est une bombe à retardement. La pratique éprouvée consiste à écrire un fichier de tests qui charge le modèle Casbin avec un fixtures de policies, puis vérifie un par un les triplets rôle/objet/action attendus comme autorisés et ceux attendus comme refusés. Toute modification de la policy doit faire passer ces tests avant le merge.

// auth/casbin.spec.ts
describe('CasbinService', () => {
  it.each([
    ['ADMIN', '/orders/123', 'get', true],
    ['MEMBER', '/orders/123', 'delete', false],
    ['MEMBER', '/orders/me', 'get', true],
  ])('%s %s %s => %s', async (role, obj, act, expected) => {
    expect(await casbin.can(role, obj, act)).toBe(expected);
  });
});

Le tableau it.each de Jest lit comme une matrice de droits, ce qui rend le diff git d’une évolution lisible en une seconde. Quand on ajoute un rôle ou une ressource, on étend simplement le tableau. Cette discipline transforme l’autorisation d’un fil noir entremêlé en une matrice testable.

Erreurs fréquentes

Erreur Cause Solution
Token JWT accepté après logout Stateless JWT non révocable Utiliser refresh token rotatif + table en base
Brute force sur /auth/login Rate limit absent ou par instance @nestjs/throttler avec storage Redis
Casbin renvoie toujours false Modèle .conf non chargé au boot Vérifier onModuleInit et logs
Mot de passe vérifié en O(n) argon2.verify non utilisé Toujours argon2.verify(hash, plain)
Fuites de tokens en logs Logger qui sérialise headers Interceptor de masquage des secrets

L’audit régulier des logs pour repérer les fuites est une discipline qui paie. Un projet en croissance accumule des intercepteurs et des middlewares écrits par différentes mains : sans contrôle, un seul console.log(req) oublié exporte vos tokens dans Datadog. Une simple recherche de regex Authorization|Bearer|access_?token dans les fichiers de log devrait remonter zéro résultat sur les sept derniers jours.

FAQ

Pourquoi pas un cookie HttpOnly plutôt qu’un header Authorization ?
Les deux sont valables. Le cookie HttpOnly protège du XSS mais expose au CSRF — il faut alors un token CSRF en plus. Le header Authorization est plus simple côté client mais nécessite de bien gérer le stockage du token (jamais en localStorage si XSS possible). Pour une SPA Next.js consommant la même origine, le cookie HttpOnly avec SameSite=Strict reste le compromis le plus sûr.

Peut-on signer les JWT avec une paire RSA plutôt qu’un secret HS256 ?
Oui et c’est même recommandé dès que plusieurs services valident les tokens. La clé privée signe côté Auth, la clé publique vérifie côté API ; aucun service consommateur ne peut forger un token. NestJS supporte ce mode via privateKey et publicKey dans la config JWT.

Comment gérer la 2FA TOTP ?
Ajouter un champ totpSecret chiffré sur User, exposer un endpoint /auth/2fa/setup qui retourne un QR code, et un endpoint /auth/2fa/verify qui valide le code à 6 chiffres avant d’émettre les tokens. La bibliothèque otplib couvre toute l’implémentation TOTP RFC 6238.

RBAC ou ABAC ?
RBAC suffit tant que les décisions dépendent uniquement du rôle. Dès que des règles contextuelles apparaissent (l’auteur d’une commande, l’horaire, la géolocalisation), passer à ABAC en enrichissant la requête Casbin avec des attributs supplémentaires. Casbin supporte les deux dans le même modèle.

Tutoriels associés

Références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité