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.
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.
| Couche | Mécanisme | Rôle |
|---|---|---|
| Identité | bcrypt (rounds=10) | Hash du mot de passe en DB |
| Challenge | Code 6 chiffres par email | 2FA optionnelle ou imposée |
| Session | JWT HS256 (7j) | Token signé porté par le frontend |
| Machine-to-machine | Token API SHA-256 | Reporting, CI, MCP |
| Autorisation rôle | requireRole(...) | Plancher minimum (owner/admin/...) |
| Autorisation feature | requireCapability(featureId) | Matrice owner-administrée |
Flow de connexion complet
Étape 1 — POST /api/auth/login
Le client envoie { login, password } où 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 :
- Applique
loginLimiter(rate limit 10 requêtes/minute par IP) pour bloquer les attaques par force brute. - Charge l'utilisateur avec
usersFindByLogin(login, { includeSecret: true })pour récupérerpassword_hash. - Compare via
bcrypt.compare(password, password_hash). En cas d'échec, retourne401sans révéler si l'identifiant existait. - Lit la config sécurité :
auth.security = { mode, two_factor_enabled }. - Décide :
- Si
mode === 'production'OUtwo_factor_enabled === true→ déclenche un challenge 2FA (étape 2). - Sinon (mode développement, 2FA off) → émet directement le JWT (étape 3).
- Si
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
}
}
- TTL : 10 minutes (600 secondes). Passé ce délai, le code est invalidé.
- Tentatives : 5 maximum. Au 6e essai erroné, le challenge est invalidé et l'utilisateur doit relancer un
/login. - Unicité : chaque nouvelle tentative de login pour un même user invalide les challenges précédents.
- Transport : l'email utilise la config SMTP globale (
global.smtp). Si SMTP n'est pas configuré, l'API échoue avec un message explicite.
Étape 3 — POST /api/auth/2fa/verify
Le client envoie { challenge_id, code }. Le backend :
- Vérifie l'expiration (TTL 10min) et le compteur de tentatives.
- Compare le code en constant-time pour éviter les attaques temporelles.
- En cas de succès, appelle
usersTouchLastLogin(id)pour stamperlast_login_at. - 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ètre | Valeur par défaut | Variable d'env |
|---|---|---|
| Algorithme | HS256 | hardcodé |
| Durée de validité | 7 jours | JWT_EXPIRES_IN |
| Secret | ≥ 24 caractères en prod | JWT_SECRET |
| Issuer / audience | aucun | — |
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 :
- Génère un
challenge_id+ code 6 chiffres (TTL 10min, 5 tentatives). - Envoie le code par email à l'adresse du compte.
- Stocke le challenge en mémoire, lié au
user_id.
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 :
- Valide le
reset_token(signature, TTL, usage unique). - Vérifie que le nouveau mot de passe respecte la politique stricte.
- Hash via
bcrypt.hash(password, 10). - UPDATE
usersSETpassword_hash,updated_at. - Insère un log d'audit
password_resetdans la tablelogs. - 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
- Préfixe :
cmstok_+ 48 caractères aléatoires base64url → exemplecmstok_a7Kd92.... - Stockage : seul le SHA-256 du token est persisté (colonne
token_hash). Le plaintext n'existe en RAM que le temps de la réponse HTTP. - Affichage : le plaintext est retourné une seule fois à la création via
POST /api/auth/tokens. Aucune route ne le re-révèle ensuite. - Vérification : à chaque requête,
requireAuthhache le bearer reçu en SHA-256 et requêteapiTokensFindByHash(). Si trouvé non-révoqué et non-expiré, charge le user lié. - Traçabilité :
apiTokensTouchLastUsed(id)est appelé à chaque usage validé pour alimenter la colonnelast_used_at.
Cycle de vie
| Endpoint | Méthode | Garde | Effet |
|---|---|---|---|
/api/auth/tokens | GET | requireCapability('settings_tokens') | Liste les tokens du user (sans plaintext) |
/api/auth/tokens | POST | requireCapability('settings_tokens') | Crée un token, retourne plaintext (une fois) |
/api/auth/tokens/:id | DELETE | requireCapability('settings_tokens') | Révoque (soft delete, revoked_at = NOW()) |
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ègle | Valeur |
|---|---|
| Longueur minimale | 12 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 hash | bcrypt, rounds = 10 |
| Comparaison | bcrypt.compare() (constant-time) |
Mode développement vs production
Le store auth.security contient :
{
"mode": "development",
"two_factor_enabled": false
}
| Comportement | development | production |
|---|---|---|
| 2FA email | optionnelle (selon flag) | imposée (override flag) |
| JWT_SECRET ≥ 24c | warning | boot refusé |
| Détails erreurs HTTP | verbeux | masqués |
Endpoint /bootstrap | actif | actif tant que users est vide |
| Helmet CSP | permissif | strict |
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 interne | Label UI | Description |
|---|---|---|
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 :
- Owner bypass : si
req.user.role === 'owner',requireCapabilityappellenext()immédiatement. L'owner n'apparaît jamais dans la matrice UI. - Override explicite : si
overrides[role][featureId]est un booléen, il fait foi. - Défaut feature : sinon, on lit
feature.defaults[role]dansCORE_FEATURES. - Fail-open legacy : feature inconnue →
true(compatibilité avec extensions non encore enregistrées).
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 |
|---|---|---|---|---|---|---|
pages | Pages | Contenu | ✓ | ✓ | ✓ | ✓ |
media | Médias | Contenu | ✓ | ✓ | ✓ | ✓ |
forms | Formulaires | Contenu | ✓ | ✓ | ✓ | ✓ |
blog | Blog | Contenu | ✓ | ✓ | ✓ | ✓ |
cocon | Cocon SEO | Contenu | ✓ | ✓ | ✓ | ✓ |
header_footer | Header / Footer | Configuration | ✓ | ✓ | ✓ | ✗ |
layouts | Layout global | Configuration | ✓ | ✓ | ✓ | ✗ |
build | Build du site | Configuration | ✓ | ✓ | ✓ | ✗ |
extensions | Extensions | Configuration | ✓ | ✓ | ✓ | ✗ |
settings_branding | Réglages → Branding | Réglages | ✓ | ✓ | ✓ | ✗ |
settings_ai | Réglages → IA | Réglages | ✓ | ✓ | ✗ | ✗ |
settings_blog | Réglages → Blog | Réglages | ✓ | ✓ | ✓ | ✗ |
settings_apis | Réglages → API (images, recherche) | Réglages | ✓ | ✓ | ✗ | ✗ |
settings_build | Réglages → Build | Réglages | ✓ | ✓ | ✓ | ✗ |
settings_notfound | Réglages → Page 404 | Réglages | ✓ | ✓ | ✓ | ✗ |
settings_tokens | Réglages → Tokens API | Réglages | ✓ | ✓ | ✗ | ✗ |
settings_security | Réglages → Sécurité | Réglages | ✓ | ✓ | ✓ | ✗ |
settings_users | Réglages → Comptes | Réglages | ✓ | ✓ | ✓ | ✓ |
logs | Logs | Outils | ✓ | ✓ | ✓ | ✗ |
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
| Endpoint | Méthode | Garde | Description |
|---|---|---|---|
/api/capabilities | GET | requireAuth | Lit la matrice effective (tous rôles authentifiés) |
/api/capabilities | PUT | requireRole('owner') | Modifie les overrides |
/api/capabilities/reset | POST | requireRole('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.
| Endpoint | Limit | Fenêtre |
|---|---|---|
POST /api/auth/login | 10 | 1 min |
POST /api/auth/2fa/verify | 10 | 1 min |
POST /api/auth/password-reset/* | 10 | 1 min |
POST /api/auth/bootstrap | 10 | 1 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() :
user_login_success/user_login_faileduser_2fa_challenged/user_2fa_verified/user_2fa_failedpassword_reset_requested/password_reset_confirmeduser_created/user_updated/user_deletedapi_token_created/api_token_revokedcapability_matrix_updated/capability_matrix_resetsecurity_settings_updated
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
- Base de données — schéma des tables
users,api_tokens,logs - API REST — liste exhaustive des routes avec leurs middlewares
- Sécurité — Helmet, CORS, durcissement global
- Système d'extensions — comment une extension déclare ses capabilities
- Guide utilisateur → Comptes — vue côté administrateur final