Skip to content

#12 — OTP SMS

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/api/v1/endpoints/otp.py

Dépendances: #1 rgz-api


Description

Envoi et vérification de code OTP 6 chiffres par SMS pour authentification d'abonné. Utilise Letexto API. Taux limite strict (3 tentatives/min), validation avec hmac.compare_digest(), invalidation après utilisation.

Stack: Letexto API (pip: requests) | LETEXTO_API_KEY env var


Architecture Interne

Workflow

Abonné → Saisit MSISDN (+229XXXXXXXX)

POST /api/v1/auth/otp/send

Rate limit check: rgz:rate:{phone} (3/min)

Générer OTP 6 chiffres aléatoire

Stockage Redis: rgz:otp:{phone} TTL=300s

Envoi Letexto SMS: "Votre code OTP: 123456"

Response 200 {message: "OTP envoyé"}

---

POST /api/v1/auth/otp/verify {msisdn, code}

Rate limit check: rgz:rate:{phone}

Lookup Redis: rgz:otp:{phone}

hmac.compare_digest(code, stored_code) → constant-time comparison

Si valide: lookup/create subscriber → générer JWT → supprimer OTP Redis

Response 200 {access_token, subscriber_ref}

Service Letexto (app/services/letexto.py)

python
class LetextoService:
    async def send_otp(self, phone: str, code: str) -> dict:
        """
        Envoie OTP via Letexto.
        Returns: {message_id, status, delivery_status}
        Raises: LetextoError si API fails
        """

    async def send_template(
        self,
        phone: str,
        template_name: str,
        variables: dict
    ) -> dict:
        """Generic template SMS (réutilisé par #61)"""

Dependencies (app/deps.py)

python
async def get_redis() -> AsyncRedis:
    """Redis connection pour OTP storage"""

async def get_otp_service() -> OtpService:
    """OTP business logic"""

Configuration

Variables d'env (.env)

bash
# Letexto
LETEXTO_API_KEY=your_key_here
LETEXTO_SENDER_ID=RGZ_BENIN    # Afficheur SMS

# OTP
OTP_LENGTH=6
OTP_VALIDITY_SECONDS=300       # 5 minutes
OTP_MAX_ATTEMPTS_PER_MINUTE=3
OTP_RESEND_DELAY_SECONDS=30

# SMS Templates
SMS_OTP_TEMPLATE="Votre code OTP RGZ: {code}. Valide 5 min. Ne partagez pas."

Redis Keys

CléTypeTTLUsage
rgz:otp:{phone}String300sCode OTP (6 chiffres)
rgz:rate:{phone}String60sCompteur tentatives (incr)

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
POST/api/v1/auth/otp/send{msisdn}200 {message:"OTP sent"}NonLetexto send
POST/api/v1/auth/otp/verify{msisdn, code}200 {access_token, subscriber_ref}NonJWT issuance
POST/api/v1/auth/otp/resend{msisdn}200NonRate limit check

POST /api/v1/auth/otp/send

Request:

json
{
  "msisdn": "+22901979799"
}

Response 200:

json
{
  "message": "OTP sent successfully",
  "resend_available_in_seconds": 30,
  "validity_seconds": 300
}

Response 429 (rate limit):

json
{
  "error": {
    "code": "ERR_OTP_RATE_LIMIT",
    "message": "Too many OTP requests. Try again in 60 seconds.",
    "details": {"retry_after_seconds": 45}
  }
}

Response 400 (invalid MSISDN):

json
{
  "error": {
    "code": "ERR_OTP_INVALID_MSISDN",
    "message": "MSISDN must be in E.164 format (+229XXXXXXXX)",
    "details": {"format": "+229XXXXXXXX"}
  }
}

POST /api/v1/auth/otp/verify

Request:

json
{
  "msisdn": "+22901979799",
  "code": "123456"
}

Response 200 (valid OTP, subscriber exists):

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "subscriber_ref": "RGZ-0197979799",
  "subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "active",
  "token_type": "Bearer",
  "expires_in": 900
}

Response 200 (valid OTP, new subscriber):

json
{
  "access_token": "...",
  "subscriber_ref": "RGZ-0197979799",
  "subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "next_step": "identity_verification",
  "token_type": "Bearer",
  "expires_in": 900
}

Response 401 (invalid code):

json
{
  "error": {
    "code": "ERR_OTP_INVALID",
    "message": "Invalid or expired OTP code.",
    "details": {}
  }
}

Response 429 (too many verification attempts):

json
{
  "error": {
    "code": "ERR_OTP_MAX_ATTEMPTS",
    "message": "Too many failed attempts. Request new OTP.",
    "details": {}
  }
}

POST /api/v1/auth/otp/resend

Request:

json
{
  "msisdn": "+22901979799"
}

Response 200:

json
{
  "message": "New OTP sent",
  "resend_available_in_seconds": 30
}

Sécurité

RègleImplémentation
SEC-06OTP généré aléatoire secrets.randbelow(1000000)
SEC-06Comparaison constante hmac.compare_digest(input_code, stored_code)
SEC-06OTP lié à subscriber_id (uuid), pas au MSISDN
SEC-14Rate limit 3 tentatives/min (rgz:rate:{phone} counter)
MSISDNFormat E.164 validation: ^\+229[0-9]{8}$
TimeoutOTP invalide après 300s (5 minutes)
InvalidationOTP supprimé de Redis après 1 utilisation réussie
Resend delay30s minimum entre envois (éviter spam)

JWT Token

Header: RS256 (clé privée serveur, clé publique clients)

Payload:

json
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "subscriber_ref": "RGZ-0197979799",
  "role": "subscriber",
  "iat": 1708524000,
  "exp": 1708524900,
  "jti": "unique_jwt_id"
}

TTL: 15 minutes (expires_in: 900)

Refresh: Token refresh endpoint (optional, V2+)


Implémentation TODO

  • [ ] Service app/services/letexto.py — Letexto API client
  • [ ] Service app/services/otp.py — OTP generation + validation
  • [ ] POST /api/v1/auth/otp/send avec rate limiting
  • [ ] POST /api/v1/auth/otp/verify avec hmac.compare_digest
  • [ ] MSISDN format validation (E.164)
  • [ ] JWT generation avec RS256
  • [ ] Subscriber lookup/create atomique
  • [ ] Redis rate limit counter (rgz:rate:{phone})
  • [ ] Unit tests OTP generation
  • [ ] E2E tests send+verify flow
  • [ ] Letexto mock tests (no real SMS)

Lessons Learned

  • LL#6 (SEC-06): hmac.compare_digest(user_input, stored_value) — constant-time
  • LL#43: Letexto API endpoint — utiliser IP ou FQDN, pas localhost (conteneur)
  • LL#14 (SEC-14): limit_req_zone nginx: limit_req_zone $binary_remote_addr zone=otp:10m rate=3r/m;
  • LL#8: subscriber_id UUID (jamais int)
  • LL#26: DB first (create subscriber) → Redis cache → JWT return

Dernière mise à jour: 2026-02-21

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone