#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)
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)
async def get_redis() -> AsyncRedis:
"""Redis connection pour OTP storage"""
async def get_otp_service() -> OtpService:
"""OTP business logic"""Configuration
Variables d'env (.env)
# 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é | Type | TTL | Usage |
|---|---|---|---|
rgz:otp:{phone} | String | 300s | Code OTP (6 chiffres) |
rgz:rate:{phone} | String | 60s | Compteur tentatives (incr) |
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| POST | /api/v1/auth/otp/send | {msisdn} | 200 {message:"OTP sent"} | Non | Letexto send |
| POST | /api/v1/auth/otp/verify | {msisdn, code} | 200 {access_token, subscriber_ref} | Non | JWT issuance |
| POST | /api/v1/auth/otp/resend | {msisdn} | 200 | Non | Rate limit check |
POST /api/v1/auth/otp/send
Request:
{
"msisdn": "+22901979799"
}Response 200:
{
"message": "OTP sent successfully",
"resend_available_in_seconds": 30,
"validity_seconds": 300
}Response 429 (rate limit):
{
"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):
{
"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:
{
"msisdn": "+22901979799",
"code": "123456"
}Response 200 (valid OTP, subscriber exists):
{
"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):
{
"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):
{
"error": {
"code": "ERR_OTP_INVALID",
"message": "Invalid or expired OTP code.",
"details": {}
}
}Response 429 (too many verification attempts):
{
"error": {
"code": "ERR_OTP_MAX_ATTEMPTS",
"message": "Too many failed attempts. Request new OTP.",
"details": {}
}
}POST /api/v1/auth/otp/resend
Request:
{
"msisdn": "+22901979799"
}Response 200:
{
"message": "New OTP sent",
"resend_available_in_seconds": 30
}Sécurité
| Règle | Implémentation |
|---|---|
| SEC-06 | OTP généré aléatoire secrets.randbelow(1000000) |
| SEC-06 | Comparaison constante hmac.compare_digest(input_code, stored_code) |
| SEC-06 | OTP lié à subscriber_id (uuid), pas au MSISDN |
| SEC-14 | Rate limit 3 tentatives/min (rgz:rate:{phone} counter) |
| MSISDN | Format E.164 validation: ^\+229[0-9]{8}$ |
| Timeout | OTP invalide après 300s (5 minutes) |
| Invalidation | OTP supprimé de Redis après 1 utilisation réussie |
| Resend delay | 30s minimum entre envois (éviter spam) |
JWT Token
Header: RS256 (clé privée serveur, clé publique clients)
Payload:
{
"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/sendavec rate limiting - [ ] POST
/api/v1/auth/otp/verifyavec 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_zonenginx: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