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.phone renseigné et valide
  • Member.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 (purpose PASSWORD_RESET, scope member-portal dans metadata)
  • Envoie le code par SMS via NotificationsService.send (template otp.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 $transaction atomique
  • 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 OtpCode existante (créée pour les flux back-office TOTP / 2FA SMS / transaction)
  • Enum OtpPurpose.PASSWORD_RESET existant
  • Template SMS otp.reset.sms.fr.hbs existant :
    {{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 LoginScreen en mode Membre uniquement
  • Route /forgot-password exclue du redirect d'auth dans router.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/enable body 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)