Authentification & RBAC

Le module auth du Labo du Yeti combine trois couches de sécurité : un mot de passe haché bcrypt, un challenge 2FA email obligatoire en production, et un JWT HS256 signé côté backend. Au-dessus de cette pile, un système RBAC à 4 rôles canoniques se double d'une matrice Rôle × Feature appelée capabilities, administrable par l'owner pour activer/désactiver finement chaque module (Pages, Blog, Cocon, Réglages, etc.) par rôle.

Source de vérité. Toute la logique d'authentification réside dans backend/src/services/auth-service.js et backend/src/routes/auth.js. La matrice des droits vit dans backend/src/middleware/capabilities.js et est persistée dans la table SQLite stores sous la clé permissions.

Vue d'ensemble

L'authentification suit un schéma stateless classique : le serveur ne maintient aucune session. Toute requête authentifiée doit présenter soit un JWT court (utilisateur humain via cookie/bearer), soit un token API long (machine-to-machine, format cmstok_*). À chaque requête, un middleware requireAuth vérifie l'un OU l'autre, peuple req.user et passe la main aux gardes secondaires requireRole et requireCapability.

CoucheMécanismeRôle
Identitébcrypt (rounds=10)Hash du mot de passe en DB
ChallengeCode 6 chiffres par email2FA optionnelle ou imposée
SessionJWT HS256 (7j)Token signé porté par le frontend
Machine-to-machineToken API SHA-256Reporting, CI, MCP
Autorisation rôlerequireRole(...)Plancher minimum (owner/admin/...)
Autorisation featurerequireCapability(featureId)Matrice owner-administrée

Flow de connexion complet

Étape 1 — POST /api/auth/login

Le client envoie { login, password }login peut être un email ou un username (résolution case-insensitive via les indices uniques users_email_uk et users_username_uk). Le backend :

  1. Applique loginLimiter (rate limit 10 requêtes/minute par IP) pour bloquer les attaques par force brute.
  2. Charge l'utilisateur avec usersFindByLogin(login, { includeSecret: true }) pour récupérer password_hash.
  3. Compare via bcrypt.compare(password, password_hash). En cas d'échec, retourne 401 sans révéler si l'identifiant existait.
  4. Lit la config sécurité : auth.security = { mode, two_factor_enabled }.
  5. Décide :
    • Si mode === 'production' OU two_factor_enabled === true → déclenche un challenge 2FA (étape 2).
    • Sinon (mode développement, 2FA off) → émet directement le JWT (étape 3).
Production stricte. En mode production, la 2FA est imposée même si two_factor_enabled vaut false. C'est une garde-fou côté serveur : impossible de désactiver l'envoi de code en désactivant la case dans Réglages.

Étape 2 — Challenge 2FA email

Le backend génère un code à 6 chiffres aléatoire, le stocke en mémoire avec un identifiant de challenge (challenge_id), envoie le code par email via SMTP au compte de l'utilisateur et retourne au client :

{
  "challenge": {
    "id": "ch_8f2a...",
    "expires_in": 600,
    "max_attempts": 5
  }
}

Étape 3 — POST /api/auth/2fa/verify

Le client envoie { challenge_id, code }. Le backend :

  1. Vérifie l'expiration (TTL 10min) et le compteur de tentatives.
  2. Compare le code en constant-time pour éviter les attaques temporelles.
  3. En cas de succès, appelle usersTouchLastLogin(id) pour stamper last_login_at.
  4. Signe et retourne le JWT (étape 4).

Étape 4 — Émission du JWT

Le JWT est signé avec l'algorithme HS256 et la clé symétrique JWT_SECRET (variable d'environnement Plesk, jamais en .env en production). Sa payload contient :

{
  "sub": "<user.id>",
  "email": "user@example.com",
  "role": "owner",
  "iat": 1717200000,
  "exp": 1717804800
}
ParamètreValeur par défautVariable d'env
AlgorithmeHS256hardcodé
Durée de validité7 joursJWT_EXPIRES_IN
Secret≥ 24 caractères en prodJWT_SECRET
Issuer / audienceaucun
Boot guard. Au démarrage, si NODE_ENV=production et que JWT_SECRET est absent, vide ou inférieur à 24 caractères, le serveur refuse de démarrer. C'est une protection volontaire contre les déploiements bâclés sur Plesk. Chaque client SaaS doit avoir son propre JWT_SECRET — partager le secret entre clients romprait l'isolation des tenants.

Reset password (3 étapes)

La réinitialisation suit le même pattern que la 2FA : un challenge email à code court, puis un POST de confirmation avec le nouveau mot de passe.

POST /api/auth/password-reset/request

Body : { login } (email ou username). Le backend retourne toujours un succès (200 OK) que l'utilisateur existe ou non, pour éviter l'énumération de comptes. Si l'utilisateur existe :

POST /api/auth/password-reset/verify

Body : { challenge_id, code }. Si valide, le backend retourne un reset_token à usage unique (TTL 5 minutes) qui autorisera l'étape suivante. Ce token est lié au challenge et invalide après une seule utilisation.

POST /api/auth/password-reset/confirm

Body : { reset_token, new_password }. Le backend :

  1. Valide le reset_token (signature, TTL, usage unique).
  2. Vérifie que le nouveau mot de passe respecte la politique stricte.
  3. Hash via bcrypt.hash(password, 10).
  4. UPDATE users SET password_hash, updated_at.
  5. Insère un log d'audit password_reset dans la table logs.
  6. Invalide tous les JWT existants de l'utilisateur (en pratique, l'ancien hash ne sert plus à signer).

API tokens long-lived (machine-to-machine)

Les tokens API permettent l'accès non-interactif au CMS pour : scripts CI/CD, plateforme de reporting cross-sites, serveur MCP Claude Desktop, extensions tierces. Ils vivent dans la table api_tokens :

CREATE TABLE api_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  name TEXT NOT NULL,
  token_hash TEXT NOT NULL UNIQUE,
  scopes TEXT NOT NULL DEFAULT 'read',
  last_used_at TEXT,
  created_at TEXT NOT NULL,
  expires_at TEXT,
  revoked_at TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

Format & hachage

Cycle de vie

EndpointMéthodeGardeEffet
/api/auth/tokensGETrequireCapability('settings_tokens')Liste les tokens du user (sans plaintext)
/api/auth/tokensPOSTrequireCapability('settings_tokens')Crée un token, retourne plaintext (une fois)
/api/auth/tokens/:idDELETErequireCapability('settings_tokens')Révoque (soft delete, revoked_at = NOW())
Révocation soft. La colonne revoked_at agit comme tombstone : le row n'est jamais supprimé pour préserver l'historique d'audit. Un token révoqué est rejeté par requireAuth via la condition revoked_at IS NULL.

Politique mot de passe

Tous les mots de passe — création de compte, reset, changement par l'admin — sont validés par une politique unique, refusée côté serveur (le client peut afficher l'erreur en temps réel mais ne fait pas autorité).

RègleValeur
Longueur minimale12 caractères
Au moins une minuscule[a-z]
Au moins une majuscule[A-Z]
Au moins un chiffre[0-9]
Au moins un caractère spécial[^a-zA-Z0-9]
Algorithme de hashbcrypt, rounds = 10
Comparaisonbcrypt.compare() (constant-time)

Mode développement vs production

Le store auth.security contient :

{
  "mode": "development",
  "two_factor_enabled": false
}
Comportementdevelopmentproduction
2FA emailoptionnelle (selon flag)imposée (override flag)
JWT_SECRET ≥ 24cwarningboot refusé
Détails erreurs HTTPverbeuxmasqués
Endpoint /bootstrapactifactif tant que users est vide
Helmet CSPpermissifstrict
Modifier le mode. Seul l'owner peut modifier auth.security via PUT /api/auth/security. L'endpoint est verrouillé par requireRole('owner') sans capability associée — c'est une décision structurante du tenant, hors matrice.

RBAC — 4 rôles canoniques

Après la migration migrateLegacyRoles(), le CMS ne reconnaît plus que 4 rôles. Les anciens rôles (chef_projet, client_editor, viewer) ont été fusionnés dans les 4 canoniques pour réduire la complexité.

Rôle interneLabel UIDescription
owner Administrateur Accès total. Seul à pouvoir : changer les rôles utilisateur, supprimer un autre owner, modifier auth.security, modifier la matrice capabilities.
admin Chef projet Accès total au contenu et à la configuration. Peut créer/éditer/supprimer des users SAUF un owner. Ne peut PAS changer les rôles. Reçoit les rappels email cocon.
webmaster Webmaster Comme admin pour contenu et la plupart des Réglages, sauf : IA, API (images + recherche), Tokens API. Ne peut supprimer aucun utilisateur. Reçoit les rappels cocon.
editor Client Pages / Blog / Cocon / Médias (CRUD complet). Header/Footer/Theme. Création catégories/tags. Réglages Comptes : voit uniquement son compte. Réglages Général : tout sauf SMTP et Branding sidebar.

Rôles de notification cocon

Le système cocon (génération SEO programmée avec quota anti-spam) envoie des rappels email 5 minutes après chaque créneau libéré aux utilisateurs ayant un rôle dans la constante SLOT_NOTIFICATION_ROLES :

const SLOT_NOTIFICATION_ROLES = ['admin', 'webmaster'];

L'owner ne reçoit pas ces rappels par défaut (charge mentale ciblée chef projet/webmaster).

Capabilities — matrice owner-administrée

Au-dessus du plancher requireRole se superpose une couche optionnelle requireCapability(featureId). Elle vérifie qu'un rôle donné a accès à un module précis. C'est la matrice capabilities : 19 features core, plus les features ajoutées dynamiquement par les extensions.

Stockage & résolution

La matrice vit dans le KV permissions (table stores) :

{
  "version": 1,
  "overrides": {
    "admin":     { "settings_ai": false },
    "webmaster": { "blog": false },
    "editor":    { "header_footer": true }
  }
}

L'algorithme de résolution :

  1. Owner bypass : si req.user.role === 'owner', requireCapability appelle next() immédiatement. L'owner n'apparaît jamais dans la matrice UI.
  2. Override explicite : si overrides[role][featureId] est un booléen, il fait foi.
  3. Défaut feature : sinon, on lit feature.defaults[role] dans CORE_FEATURES.
  4. Fail-open legacy : feature inconnue → true (compatibilité avec extensions non encore enregistrées).
Double garde. Une route critique cumule presque toujours : requireRole('admin','webmaster','editor') comme plancher rôle + requireCapability('pages') comme switch fonctionnel. L'owner bypasse les deux. Cf. API REST pour la liste exhaustive des middlewares par route.

Matrice complète des 19 features core

Feature ID Label Groupe owner admin webmaster editor
pagesPagesContenu
mediaMédiasContenu
formsFormulairesContenu
blogBlogContenu
coconCocon SEOContenu
header_footerHeader / FooterConfiguration
layoutsLayout globalConfiguration
buildBuild du siteConfiguration
extensionsExtensionsConfiguration
settings_brandingRéglages → BrandingRéglages
settings_aiRéglages → IARéglages
settings_blogRéglages → BlogRéglages
settings_apisRéglages → API (images, recherche)Réglages
settings_buildRéglages → BuildRéglages
settings_notfoundRéglages → Page 404Réglages
settings_tokensRéglages → Tokens APIRéglages
settings_securityRéglages → SécuritéRéglages
settings_usersRéglages → ComptesRéglages
logsLogsOutils

Légende : ✓ = activé par défaut, ✗ = désactivé par défaut. Toutes ces valeurs sont remplaçables par un override owner via PUT /api/capabilities.

Features ajoutées par extensions

Les extensions peuvent enregistrer leurs propres capabilities via le manifest (manifest.permissions). Elles apparaissent alors dans la matrice UI sous le groupe Extensions, avec des défauts neutres : admin: true, webmaster: true, editor: false. Le hook extension.installed les injecte dans CORE_FEATURES à la volée. La désinstallation purge les overrides associés.

Endpoints d'administration

EndpointMéthodeGardeDescription
/api/capabilitiesGETrequireAuthLit la matrice effective (tous rôles authentifiés)
/api/capabilitiesPUTrequireRole('owner')Modifie les overrides
/api/capabilities/resetPOSTrequireRole('owner')Réinitialise tous les overrides au défaut

Middlewares — implémentation

requireAuth

Premier garde de toute route protégée. Cherche un bearer dans cet ordre : header Authorization: Bearer ..., puis cookie cms_jwt. Tente de le décoder comme JWT (signature HS256 + non-expiré), sinon le hache en SHA-256 et cherche un api_tokens.token_hash correspondant. Si l'une des deux pistes aboutit, charge le user lié dans req.user et appelle next(). Sinon, retourne 401 Authentification requise.

requireRole(...roles)

Vérifie que req.user.role figure dans la liste passée en arguments. L'owner est toujours implicitement inclus (bypass interne). Échec → 403 Rôle insuffisant.

requireCapability(featureId)

Résout la capability via hasCapability(role, featureId) (override puis défaut). L'owner bypasse. Échec → 403 Accès à la fonctionnalité « {featureId} » désactivé pour ton rôle. Demande à un Administrateur.

Rate limiting des endpoints d'auth

Tous les endpoints publics d'auth (login, 2fa, password-reset) sont protégés par loginLimiter : 10 requêtes/minute par IP. Au-delà, retour 429 Too Many Requests avec un header Retry-After.

EndpointLimitFenêtre
POST /api/auth/login101 min
POST /api/auth/2fa/verify101 min
POST /api/auth/password-reset/*101 min
POST /api/auth/bootstrap101 min

Bootstrap initial

Tant que la table users est vide (premier déploiement Plesk), l'endpoint POST /api/auth/bootstrap est ouvert. Il accepte { fullname, email, password }, valide la politique mot de passe, crée le tout premier user avec role = 'owner' et émet immédiatement un JWT. Dès qu'un user existe, l'endpoint retourne 403 pour toute requête ultérieure.

Audit & logs

Tous les événements sensibles sont insérés dans la table logs via logsInsert() :

L'IP, l'user_id (si connu) et le user-agent sont stockés dans la colonne meta (JSON). Voir Base de données → logs pour le schéma complet.

Références croisées