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).
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).
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? })
- Si
accountNumberest fourni, résout le téléphone cible viasbaService.getCustomerByAccount(accountNumber, accountCode, phoneNumber)(section 4) —404 Numéro de compte introuvablesi le compte n'existe nulle part (ni chez UWM, ni dans le mock de repli), ou400avec 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). - 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). - 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). - Envoie le SMS via
smsService.sendSMS(section 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).
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
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) :
POST {UWM_BASE_URL}/api/v1/sba/account-detailavec{ branch: branchCode, currency: "XOF", account: accountNumber, phoneNumber }(UWM_BASE_URL, défauthttp://localhost:4000— non documentée dans.env.example).branchCodevaut"01032"par défaut siaccountCoden'est pas transmis par le contrôleur OTP.- Si UWM répond
success: trueavec 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. - 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/verifyOtpla transforment en400côté client, avec un message utile. - Si UWM répond
404:throw new Error("Numéro de compte introuvable ou inactif."). - Pour toute autre erreur (UWM injoignable, timeout,
500...) : repli silencieux sur les deux comptes de test codés en dur (MOCK_CLIENTS,12345678901et00000000000), avec seulement unconsole.warncôté serveur ("Fallback sur le mock local"). Le code source annote lui-même ce repli// TEMPORAIRE.
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) :
| Variable | Repli codé en dur |
|---|---|
SMS_KANNEL_URL | http://192.168.0.5:13013/cgi-bin/sendsms |
SMS_USERNAME | tgtusr |
SMS_PASSWORD | tgtuseraccess |
SMS_SENDER_ID | UTB |
SMS_MOCK_ENABLED | false (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.
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 scolaire | Ouverture 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'OTP | Prouver 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
- 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 depuiseb315ff/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. - Pas de rate-limiter dédié sur
/api/otp/send//api/otp/verify(section 3) — hérite seulement dustrictLimitergénérique (5 000 req/15 min, toutes routes d'écriture) ; à corriger avant un vrai lancement. - 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.
sendOtpetverifyOtpinterrogent SBA indépendamment (section 3) — un UWM instable entre les deux appels peut faire échouer une vérification pourtant correcte.- Le code OTP est loggé en clair côté serveur (
console.log('[DEBUG OTP] ...')) — à retirer avant mise en production. .env.examplene documente ni les variablesSMS_*niUWM_BASE_URL— un déploiement réel doit les découvrir en lisantsms.service.js/sba.service.js.