Reset mot de passe portail membre par SMS OTP
Permet à un membre d'auto-réinitialiser son mot de passe du portail sans intervention d'un opérateur, via un code à 6 chiffres envoyé sur son téléphone enregistré.
Pré-requis
Pour pouvoir utiliser le reset SMS, le membre doit avoir :
Member.portalEnabled = true(activé par un DIRECTOR / CREDIT_MANAGER / FIELD_AGENT au moins une fois)Member.phonerenseigné et valideMember.status = 'ACTIVE'
Si l'une de ces conditions n'est pas remplie, l'endpoint renvoie
{ ok: true } quand même (réponse uniforme anti-énumération) mais
ne fait rien.
Endpoints
POST /v1/portal/auth/password-reset/request
Public. Throttle : 3 / minute.
// Body
{ "tenantSlug": "demo", "memberNumber": "M-000123" }
// Réponse (toujours 200, identique que ça marche ou pas)
{ "ok": true }
Effets si toutes les conditions sont OK :
- Invalide les OTP non-consommés précédents pour ce numéro (un seul code actif à la fois)
- Génère un code à 6 chiffres aléatoire
- Stocke un hash bcrypt du code dans
OtpCode(purposePASSWORD_RESET, scopemember-portaldansmetadata) - Envoie le code par SMS via
NotificationsService.send(templateotp.reset, immediate=true) - TTL : 10 minutes
- Audit log
member.portal.password.reset.requested
POST /v1/portal/auth/password-reset/confirm
Public. Throttle : 5 / minute.
// Body
{
"tenantSlug": "demo",
"memberNumber": "M-000123",
"code": "123456",
"newPassword": "monNouveauMdp"
}
// Réponse succès
{ "ok": true }
// Réponses erreur (toujours UnauthorizedException pour ne pas leaker)
{ "error": { "message": "Code invalide ou expiré" } }
Effets si OK :
- Vérifie l'OTP le plus récent non-consommé non-expiré
- Limite à 5 tentatives erronées (puis l'OTP est consommé d'office)
- bcrypt-compare le code
- Met à jour
Member.portalPasswordHash(bcrypt rounds=12) - Marque l'OTP
consumed = true - Le tout en une
$transactionatomique - Audit log
member.portal.password.reset.confirmed
Sécurité
Anti-énumération
La requête request répond uniformément { ok: true } quel que
soit le résultat :
- Tenant inexistant → réponse OK
- Numéro membre inexistant → réponse OK
portalEnabled = false→ réponse OK- Téléphone manquant → réponse OK
- Membre inactif → réponse OK
Cela empêche un attaquant d'énumérer les numéros membres existants.
Anti-brute force OTP
- 6 chiffres → 1/1 000 000 chances par tentative
- Max 5 tentatives avant invalidation forcée de l'OTP
- TTL 10 minutes
- Throttling Nest : 5 confirm/minute par IP
Anti-spam
- 3 request/minute par IP
- Invalide les OTP actifs précédents à chaque nouvelle demande (évite le spam si l'attaquant re-déclenche en boucle)
Pas de leak sur le succès
La 1re réponse 200 OK ne dévoile rien. Seule la vraie acceptation
du code lors du confirm confirme la possession du numéro.
Infrastructure réutilisée
Aucune migration DB nécessaire. Réutilise :
- Table
OtpCodeexistante (créée pour les flux back-office TOTP / 2FA SMS / transaction) - Enum
OtpPurpose.PASSWORD_RESETexistant - Template SMS
otp.reset.sms.fr.hbsexistant :{{tenantName}}: votre code de reinitialisation est {{code}}. Valide {{ttlMinutes}}min. Ne le partagez pas. - Provider KingSMS déjà configuré
UI mobile
Côté Flutter :
lib/screens/forgot_password_screen.dart— flow 2 étapes (request → confirm)- Bouton « Mot de passe oublié ? » sur
LoginScreenen mode Membre uniquement - Route
/forgot-passwordexclue du redirect d'auth dansrouter.dart
L'écran pré-remplit le tenantSlug et le memberNumber depuis la
saisie courante de l'écran de login (passés via state.extra).
Alternative : reset par opérateur
Si le membre n'a pas de téléphone enregistré ou si le numéro a changé, l'opérateur reste la voie de recours :
POST /v1/members/:id/portal/enablebody vide (rôles DIRECTOR, CREDIT_MANAGER, FIELD_AGENT)- Génère un mot de passe temporaire 10 caractères lisibles
- L'opérateur le transmet OOB au membre (papier, oral)