Skip to main content

OTP, SMS (Kannel) & vérification SBA (/api/otp)

backend/src/controllers/otp.controller.js + otp.routes.js + services/sms.service.js + services/sba.service.js — mécanisme générique utilisé par deux formulaires publics : Demande de prêt scolaire (avec vérification de compte) et Ouverture de compte (téléphone seul).

Dernière vérification contre le code

Vérifié le 2026-08-24 (deux passes le même jour, après un second git pull sur le backend) — code introduit sur main par les commits ab52c6c (OTP/SMS), 9a31b00 (première version du service SBA, 100% mock), puis 14c84d0 et eb315ff (SBA devient une vraie intégration UWM avec repli mock, section 4).

1. Ce qui a changé​

Avant ab52c6c, les deux formulaires publics simulaient entièrement l'OTP côté client (setTimeout, code accepté en dur). Ce n'est plus le cas depuis : il existe un vrai backend OTP, avec un vrai envoi de SMS (section 5). Plus récemment (14c84d0/eb315ff), le service SBA a lui aussi cessé d'être un mock pur : il appelle maintenant un vrai middleware bancaire (UWM), avec le mock conservé uniquement comme repli en cas d'échec de cet appel (section 4) — à ne pas prendre pour une vérification bancaire toujours fictive.

2. Schéma — table otp_codes​

model otp_codes {
id BigInt @id @default(autoincrement())
phone_number String @db.VarChar(50)
code String @db.VarChar(10)
expires_at DateTime
is_used Boolean @default(false)
created_at DateTime @default(now())

@@index([phone_number, is_used, expires_at])
}

Pas de relation vers users/account_requests/loan_requests — la table ne connaît qu'un numéro de téléphone, pas l'entité métier pour laquelle le code a été demandé.

3. Routes — publiques, sans limiteur dédié​

POST /api/otp/send (otp.controller.sendOtp)
POST /api/otp/verify (otp.controller.verifyOtp)

Aucun middleware verifyToken (normal — utilisé avant authentification/soumission). Aucun rate-limiter dédié (contrairement à /api/tracking, qui a son propre trackingLimiter à 15 req/15 min, voir API de suivi de dossier) : POST /otp/send/POST /otp/verify héritent uniquement du limiteur générique appliqué à toute méthode d'écriture de l'API (server.js, strictLimiter — 5 000 requêtes/15 min par IP, POST/PUT/DELETE confondus, pensé pour protéger l'API dans son ensemble contre l'abus, pas spécifiquement un code à 6 chiffres).

Piège pour un futur développeur : pas de protection anti brute-force dédiée

Un code à 6 chiffres offre 1 million de combinaisons et expire en 5 minutes. strictLimiter (5 000 req/15 min par IP, toutes routes d'écriture confondues) ne descend jamais au niveau "par numéro de téléphone" — rien n'empêche un client d'appeler /otp/verify en boucle avec le même phoneNumber dans cette enveloppe. Chaque appel à /otp/send déclenche en plus un vrai SMS (facturé via Kannel) et, si accountNumber est fourni, un vrai appel au middleware bancaire UWM (section 4). Avant d'exposer ce parcours à un trafic réel, ajouter un limiteur dédié (comme trackingLimiter) et/ou un compteur de tentatives par phone_number.

sendOtp({ phoneNumber, accountNumber?, accountCode? })​

  1. Si accountNumber est fourni, résout le téléphone cible via sbaService.getCustomerByAccount(accountNumber, accountCode, phoneNumber) (section 4) — 404 Numéro de compte introuvable si le compte n'existe nulle part (ni chez UWM, ni dans le mock de repli), ou 400 avec le message d'erreur renvoyé par UWM si le couple compte/téléphone est explicitement rejeté (ex : téléphone non rattaché à ce compte).
  2. Génère un code à 6 chiffres (Math.floor(100000 + Math.random() * 900000) — aléatoire, pas de générateur cryptographique, mais suffisant ici vu la fenêtre de validité courte et le canal SMS).
  3. Invalide (is_used: true) tous les anciens codes non utilisés pour ce numéro, puis crée le nouveau (expires_at = maintenant + 5 min).
  4. Envoie le SMS via smsService.sendSMS (section 5).
  5. Le code est aussi loggé en clair côté serveur (console.log('[DEBUG OTP] Code généré pour ...')) — commode en développement, à retirer avant une mise en production réelle (accès aux logs serveur = bypass total de l'OTP).

verifyOtp({ phoneNumber, code, accountNumber?, accountCode? })​

Si accountNumber est fourni, interroge SBA une seconde fois (indépendamment de l'appel déjà fait par sendOtp, aucune réutilisation du résultat précédent) pour retrouver clientData et le téléphone cible réel. Cherche ensuite un otp_codes non utilisé, non expiré, avec ce couple phone_number/code exact (findFirst, trié created_at desc — donc le code le plus récent gagne s'il y en avait plusieurs par accident). Marque is_used: true si trouvé, sinon 400 Code incorrect ou expiré (message générique, ne distingue pas "code faux" de "code expiré" — bonne pratique, cohérente avec le principe déjà appliqué sur /api/tracking).

Piège : deux appels SBA indépendants pour un seul parcours OTP

sendOtp et verifyOtp appellent chacun sbaService.getCustomerByAccount séparément. Si UWM devient indisponible entre les deux appels (ou inversement redevient disponible), le téléphone résolu par verifyOtp peut différer de celui utilisé par sendOtp pour enregistrer le code — dans ce cas, la recherche otp_codes ne trouvera rien pour le "nouveau" téléphone résolu, et l'utilisateur verra un 400 Code incorrect ou expiré alors que le code saisi est correct. Un scénario plausible si UWM a des pannes intermittentes en production.

4. sba.service.js — vraie intégration UWM depuis le 2026-08-24, mock conservé comme repli​

Changement important par rapport à une version antérieure de cette page

Une version précédente de cette page (2026-08-24, avant un second git pull sur le backend le même jour) décrivait ce service comme "un mock intégral, aucun appel réseau". Ce n'est plus le cas : sba.service.js fait désormais un vrai appel HTTP vers un middleware bancaire. Le mock existe toujours, mais uniquement comme filet de sécurité en cas d'échec de cet appel — voir l'avertissement plus bas sur ce que ça implique.

Commentaire en tête du fichier : "Service pour se connecter à UWM (UTB Web Middleware) — UWM s'occupe de faire le pont avec Sopra Banking Amplitude (SBA)". getCustomerByAccount(accountNumber, branchCode = "01032", phoneNumber = null) :

  1. POST {UWM_BASE_URL}/api/v1/sba/account-detail avec { branch: branchCode, currency: "XOF", account: accountNumber, phoneNumber } (UWM_BASE_URL, défaut http://localhost:4000 — non documentée dans .env.example). branchCode vaut "01032" par défaut si accountCode n'est pas transmis par le contrôleur OTP.
  2. Si UWM répond success: true avec des données, la réponse est reformatée (firstName, lastName, phone, email, city, status, accountNumber) et retournée — c'est une vraie donnée bancaire, plus une donnée factice.
  3. Si UWM répond 400 (rejet explicite, ex. "numéro de téléphone non rattaché à ce compte") : l'erreur est propagée (throw) avec le message renvoyé par UWM si disponible — sendOtp/verifyOtp la transforment en 400 côté client, avec un message utile.
  4. Si UWM répond 404 : throw new Error("Numéro de compte introuvable ou inactif.").
  5. Pour toute autre erreur (UWM injoignable, timeout, 500...) : repli silencieux sur les deux comptes de test codés en dur (MOCK_CLIENTS, 12345678901 et 00000000000), avec seulement un console.warn côté serveur ("Fallback sur le mock local"). Le code source annote lui-même ce repli // TEMPORAIRE.
Un UWM en panne peut passer inaperçu — le mock répond quand même pour deux comptes connus

Si le middleware UWM est indisponible en production (mauvaise config UWM_BASE_URL, panne réseau, service arrêté), le parcours OTP ne plante pas visiblement : il continue de répondre normalement... pour les deux numéros de compte du mock, avec de fausses données (dont un email personnel rwaan.yk@gmail.com codé en dur dans le dépôt). Pour tout autre numéro de compte réel, UWM injoignable produit le même throw générique que "compte introuvable", donc les vrais clients verront simplement une erreur — mais rien ne distingue ça, côté logs, d'un vrai compte inexistant sauf à lire le console.warn/console.error correspondant. Surveiller activement la disponibilité d'UWM plutôt que de se fier à l'absence d'erreurs visibles côté utilisateur.

Tant qu'UWM n'est pas fiabilisé/monitoré, le parcours reste donc partiellement vérifiable en conditions réelles : ça fonctionne avec un vrai compte si UWM répond, mais un développeur qui teste avec les deux comptes du mock peut avoir l'illusion que tout fonctionne alors qu'UWM n'a jamais réellement répondu. Voir aussi Dette technique connue.

5. sms.service.js — envoi réel via Kannel, avec un mode mock et un piège de fiabilité​

Configuration (variables d'env, aucune documentée dans .env.example à ce jour — valeurs de repli codées en dur dans le service) :

VariableRepli codé en dur
SMS_KANNEL_URLhttp://192.168.0.5:13013/cgi-bin/sendsms
SMS_USERNAMEtgtusr
SMS_PASSWORDtgtuseraccess
SMS_SENDER_IDUTB
SMS_MOCK_ENABLEDfalse (donc mode réel par défaut si la variable n'est pas positionnée)

Si SMS_MOCK_ENABLED=true : log [SMS MOCK] ... en console, aucun appel réseau. Sinon : GET réel vers la passerelle Kannel (axios, timeout 10s, smsc: 'TOGOTELECOM') — un véritable SMS part.

Échec réseau vers Kannel = succès silencieux côté appelant

Si l'appel Kannel échoue avec ECONNABORTED/ENOTFOUND/ETIMEDOUT (serveur injoignable), le catch retourne quand même { success: true } (commentaire dans le code : "Bypass d'erreur réseau (autorisation IP en attente)") — volontaire, pour ne pas bloquer un utilisateur si le serveur Kannel n'est pas joignable depuis l'environnement courant. Conséquence : sendOtp répond toujours 200 Code OTP envoyé avec succès même si aucun SMS n'a réellement été envoyé, et le seul indice d'un problème est un console.warn côté serveur. Même pattern de silence que l'échec d'archivage disque déjà documenté (Documents, tickets & archivage) — à surveiller activement dans les logs plutôt que de faire confiance à la réponse HTTP.

6. Utilisation par les deux formulaires publics​

Prêt scolaireOuverture de compte
Envoie accountNumber/accountCode✅❌ (téléphone seul)
Consulte le service SBA (UWM, repli mock)✅ (bloquant si compte introuvable)Jamais
Auto-remplissage d'identité (clientData)✅ (données réelles si UWM répond, sinon deux comptes de test)❌ (pas de notion d'identité à ce stade)
Rôle de l'OTPProuver la possession du compte et récupérer l'identitéProuver la possession du numéro de téléphone uniquement

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

  1. Ne pas confondre "OTP/SMS réel" et "vérification bancaire réelle" — les deux sont réels depuis le 2026-08-24 (SMS depuis ab52c6c, SBA depuis eb315ff/14c84d0), mais la vérification bancaire retombe silencieusement sur un mock à deux comptes si UWM est indisponible (section 4) — un test réussi n'est pas la preuve qu'UWM a réellement répondu.
  2. Pas de rate-limiter dédié sur /api/otp/send//api/otp/verify (section 3) — hérite seulement du strictLimiter générique (5 000 req/15 min, toutes routes d'écriture) ; à corriger avant un vrai lancement.
  3. Un échec réseau vers Kannel ne remonte jamais comme une erreur côté frontend (section 5) — un OTP peut sembler "envoyé" sans qu'aucun SMS ne parte.
  4. sendOtp et verifyOtp interrogent SBA indépendamment (section 3) — un UWM instable entre les deux appels peut faire échouer une vérification pourtant correcte.
  5. Le code OTP est loggé en clair côté serveur (console.log('[DEBUG OTP] ...')) — à retirer avant mise en production.
  6. .env.example ne documente ni les variables SMS_* ni UWM_BASE_URL — un déploiement réel doit les découvrir en lisant sms.service.js/sba.service.js.