Skip to main content

Authentification backoffice & 2FA

backend/src/controllers/auth.controller.js + frontend/src/views/backoffice/LoginView.vue + frontend/src/stores/auth.js.

Dernière vérification contre le code

Vérifié ligne à ligne le 2026-08-13, auth.controller.js lu intégralement.

1. Flux de connexion​

POST /api/auth/login (public) vérifie l'email et le mot de passe (bcrypt.compare). Deux issues possibles, sans jamais émettre de token à ce stade :

  • Aucun two_factor_secret en base (premier login) : un secret TOTP est généré (otplib) et sauvegardé immédiatement, un QR code est renvoyé (require2faSetup: true). Le compte a donc un secret 2FA en base dès la première tentative de connexion, avant même que l'utilisateur ait scanné quoi que ce soit.
  • Secret déjà présent : réponse require2fa: true, le code à 6 chiffres est attendu.

POST /api/auth/verify-2fa (public) vérifie authenticator.check(token, secret). C'est cet endpoint — pas un endpoint séparé de confirmation — qui bascule two_factor_enabled à true la toute première fois que le code est validé. C'est seulement après validation qu'un JWT est émis (expiresIn: '8h', contient id, role, email, id_agence, id_zone, permissions — id_zone ajouté le 2026-08-13, absent avant : sans lui, tout filtrage backend basé sur la zone d'un CHEF_ZONE était systématiquement inopérant, voir Agences, zones & GAB).

Il existe séparément POST /api/auth/setup-2fa et POST /api/auth/confirm-2fa (protégés, JWT requis) permettant de régénérer un secret 2FA une fois déjà connecté. Ce chemin sert à une reconfiguration ultérieure — LoginView.vue ne l'appelle jamais au premier login, qui passe uniquement par /login puis /verify-2fa.

Secret TOTP stocké en clair

users.two_factor_secret est une colonne String non chiffrée. Pas de champ de rotation ni de codes de secours (backup codes).

2. Le bypass 000000 n'existe pas​

Il n'existe aucun code de contournement développeur pour la 2FA — verify2fa, confirm2fa et setup2fa n'appellent que authenticator.check(token, secret), aucune branche ne compare token à '000000'. Le README backend et un commentaire dans SchoolLoanView.vue affirmaient à tort le contraire — corrigés le 2026-08-13 (le README ne mentionne plus ce bypass ; le commentaire de SchoolLoanView.vue a été reformulé pour ne plus citer ce faux précédent). Le placeholder OTP 000000 du formulaire de prêt scolaire — mécanisme distinct, sans lien avec la 2FA backoffice — n'existe d'ailleurs lui-même plus depuis le 2026-08-24 : l'OTP de ce formulaire envoie désormais un vrai SMS avec un code aléatoire (voir OTP, SMS & vérification SBA).

3. Deux chemins de création de compte​

  • POST /api/users (verifyToken + isSuperAdmin, voir Utilisateurs & rôles) : crée l'utilisateur avec password_hash: null (compte inutilisable tant que le mot de passe n'est pas défini), génère setup_token (32 octets aléatoires, crypto.randomBytes) valable 24h, envoie un email d'invitation. C'est le chemin utilisé par UsersView.vue.
  • POST /api/auth/register (réservé isSuperAdmin) : crée directement un compte avec mot de passe fourni en clair dans la requête, pas de token d'invitation, pas d'email envoyé. Chemin distinct, non utilisé par le frontend actuel.

Flux d'invitation (POST /api/users)​

  1. mailer.sendInvitationEmail (backend/src/utils/mailer.js) construit le lien à partir de process.env.FRONTEND_URL (repli sur http://localhost:5173 si absent — corrigé le 2026-08-13, l'URL était auparavant codée en dur).
  2. SetupPasswordView.vue lit token depuis l'URL, poste sur /auth/setup-password — aucune vérification d'expiration côté client, l'erreur "lien invalide ou expiré" vient uniquement de la réponse backend (setup_token_expires_at dépassé).
  3. À la validation, setup_token/setup_token_expires_at sont effacés — le lien ne peut servir qu'une fois.

4. "Mot de passe oublié" — ajouté le 2026-08-24 des deux côtés, mais toujours cassé (deux URL codées en dur)​

LoginView.vue a un lien "Oublié ?" vers ForgotPasswordView.vue (/admin/forgot-password), qui envoie vers ResetPasswordView.vue (/admin/reset-password) — un vrai flux UI (formulaire email → lien de réinitialisation → nouveau mot de passe) existe côté frontend.

Le backend a rattrapé son retard le même jour (cdde188, 2026-08-20, mais visible seulement après un second git pull du dépôt backend le 2026-08-24) : POST /api/auth/forgot-password/POST /api/auth/reset-password existent désormais (auth.controller.js), avec de bonnes pratiques — ne révèle jamais si l'email existe en base, token crypto.randomBytes(32) unique (users.reset_token, expire en 15 min), regex de mot de passe fort exigée à la réinitialisation.

Toujours cassé de bout en bout — pour deux raisons indépendantes, une de chaque côté
  1. Backend : services/mailer.js::sendResetPasswordEmail construit le lien de réinitialisation avec `http://localhost:5173/admin/reset-password?token=${token}` codé en dur, alors que process.env.FRONTEND_URL existe déjà dans .env.example et sert déjà exactement à ça pour l'email d'invitation (utils/mailer.js, corrigé le 2026-08-13 pour la même raison — voir Dette technique connue).
  2. Frontend : ForgotPasswordView.vue/ResetPasswordView.vue appellent une URL codée en dur (fetch('http://localhost:5000/api/auth/forgot-password', ...)) au lieu de passer par l'instance api (axios, src/api/axios.js) utilisée partout ailleurs dans l'app avec une baseURL configurable.

En pratique, un déploiement hors localhost casse silencieusement des deux côtés : l'email de réinitialisation pointe vers un lien injoignable, et même en le corrigeant à la main, le formulaire d'origine ne contacterait de toute façon pas le bon backend.

Voir aussi Bugs & failles actives connues.

5. Pièges connus pour un futur développeur​

  1. POST /api/users sans restriction de rôle — corrigé le 2026-08-13, voir Utilisateurs & rôles.
  2. La doc Swagger de users.routes.js est obsolète : elle indique password comme champ requis du body de création, alors que create() n'utilise jamais de mot de passe (le flux passe par le token d'invitation).
  3. LoginView.vue contient un tableau quickLogins avec des identifiants en clair par rôle et une fonction quickLogin() — code mort, jamais rendu dans le template. Ne pas supposer que ce raccourci est utilisable en l'état.
  4. Le mot de passe n'est jamais loggé dans l'historique (activity-log) même en cas de modification — seul un indicateur (mot de passe modifié) apparaît dans le diff, jamais la valeur.
  5. "Mot de passe oublié" est cassé de bout en bout (section 4) — ne pas supposer que ce lien fonctionne avant d'avoir vérifié l'état du backend et l'URL codée en dur.
  6. Voir aussi Utilisateurs & rôles pour la suite (gestion des rôles/permissions, journalisation).