#13 — Multi-appareils
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/device_manager.py
Dépendances: #6 rgz-radius, #4 rgz-db
Description
Gestion des appareils (MAC) par abonné avec tracking historique et détection MAC aléatoire. Limite de 2 connexions simultanées gérée par FreeRADIUS (Simultaneous-Use attribute), PAS par l'application.
La table subscriber_devices enregistre l'historique complet des MACs vues (aucune limite de slots) pour analytics, détection fraude et conformité ARCEP.
Identité: MSISDN (abonné), PAS MAC. La MAC est un attribut réseau technique uniquement.
Architecture Interne
Principes DG
- Identité ≠ MAC: Un abonné = son MSISDN. La MAC peut changer, aléatoire, multidevice.
- Simultaneous-Use = RADIUS: FreeRADIUS gère le limit de 2 sessions. Pas d'app logic.
- MAC randomisée = pas de blocage: iOS 14+, Android 10+ peuvent randomiser → jamais bloquer l'abonné.
- Tracking complet:
subscriber_devicesenregistre TOUTES les MACs vues (historique, fraude, ARCEP).
Workflow
Auth MSISDN → OTP verified → lookup/create subscriber
↓
Device connect (MAC découverte en RADIUS Acct-Request)
↓
Upsert subscriber_devices (mac_address, mac_type, oui_vendor)
↓
RADIUS retourne Simultaneous-Use attribute (2 par forfait)
↓
Si 3e connexion tentée → RADIUS Access-Reject
↓
Portail affiche: "2 appareils connectés. Déconnecter un ou acheter un 2e pass."Service Device Manager (app/services/device_manager.py)
class DeviceManager:
async def register_device(
self,
subscriber_id: UUID,
mac_address: str,
nas_id: str
) -> SubscriberDevice:
"""
Enregistre/met à jour MAC pour un abonné.
- Validation format MAC
- Détection MAC randomisée
- Lookup OUI IEEE
- Upsert DB
Returns: SubscriberDevice model
"""
async def list_devices(
self,
subscriber_id: UUID
) -> List[SubscriberDevice]:
"""Tous les devices de l'abonné (historique)"""
async def get_active_sessions(
self,
subscriber_id: UUID
) -> List[RadiusSession]:
"""Sessions RADIUS actuelles (count ≤ 2)"""
async def detect_mac_type(self, mac_address: str) -> str:
"""
Détecte si MAC est randomisée.
Règle: (int(mac[:2], 16) & 0x02) != 0 → randomized
"""
async def validate_mac_format(self, mac_address: str) -> bool:
"""Regex + rejection rules"""
async def lookup_oui_vendor(self, mac_address: str) -> str:
"""IEEE OUI lookup (offline cache)"""Configuration
Variables d'env (.env)
# Device Management
MAC_RANDOMIZED_DETECTION=true
SIMULTANEOUS_USE_DEFAULT=2 # Par forfait
OUI_VENDOR_DB_PATH=/app/data/oui.txt # IEEE OUI listeMAC Validation Rules
Format acceptable:
^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$Normalisation: Uppercase + séparateur :
Rejetées (jamais stocker):
00:00:00:00:00:00(null MAC)FF:FF:FF:FF:FF:FF(broadcast)- Multicast (1er octet impair: bit 0 = 1)
Exemple rejet multicast:
first_byte = int(mac[:2], 16)
if first_byte & 0x01: # LSB = 1 → multicast
raise ValueError("Multicast MAC not allowed")MAC Randomization Detection
def is_mac_randomized(mac: str) -> bool:
"""
iOS 14+, Android 10+ → randomly assigned MACs
Rule: locally administered bit (bit 1 of 1st octet) = 1
"""
first_byte = int(mac[:2], 16)
return (first_byte & 0x02) != 0 # Bit 1 setEndpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/subscribers/{id}/devices | - | 200 {items:[devices], total} | JWT | Liste MACs |
| POST | /api/v1/subscribers/{id}/devices | {mac_address} | 201 {device} | JWT | Register device |
| DELETE | /api/v1/subscribers/{id}/devices/{mac} | - | 204 | JWT | Forget device |
| GET | /api/v1/subscribers/{id}/sessions | - | 200 {active_sessions, limit} | JWT | Sessions actuelles |
GET /api/v1/subscribers/{id}/devices
Response 200:
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"mac_address": "2E:1F:C4:A2:B1:9E",
"mac_type": "randomized",
"oui_vendor": "Apple Inc.",
"first_seen_at": "2026-02-20T14:30:00Z",
"last_seen_at": "2026-02-21T10:15:00Z",
"last_nas_id": "access_kossou",
"session_count": 47
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"mac_address": "00:1A:2B:3C:4D:5E",
"mac_type": "permanent",
"oui_vendor": "Samsung Electronics",
"first_seen_at": "2026-02-15T09:00:00Z",
"last_seen_at": "2026-02-21T08:45:00Z",
"last_nas_id": "access_kossou",
"session_count": 23
}
],
"total": 2
}POST /api/v1/subscribers/{id}/devices
Request:
{
"mac_address": "2E:1F:C4:A2:B1:9E"
}Response 201:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"mac_address": "2E:1F:C4:A2:B1:9E",
"mac_type": "randomized",
"oui_vendor": "Apple Inc.",
"first_seen_at": "2026-02-21T10:20:00Z"
}Response 400 (invalid MAC):
{
"error": {
"code": "ERR_MAC_INVALID",
"message": "MAC address format invalid. Expected: XX:XX:XX:XX:XX:XX",
"details": {"provided": "00-00-00-00-00-00"}
}
}GET /api/v1/subscribers/{id}/sessions
Response 200:
{
"active_sessions": [
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"mac_address": "2E:1F:C4:A2:B1:9E",
"nas_id": "access_kossou",
"forfait_name": "24h 500MB",
"session_start": "2026-02-21T08:30:00Z",
"bytes_in": 123456789,
"bytes_out": 987654321
}
],
"active_count": 1,
"limit": 2,
"can_connect_more": true
}DELETE /api/v1/subscribers/{id}/devices/{mac}
Response 204: (no content)
Supprime device de la liste active mais garde l'historique en subscriber_devices.
Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:devices:{subscriber_id} | Set | ∞ | MACs enregistrées (lookup rapide) |
rgz:session:{subscriber_id} | Hash | session_duration | Métadonnées session active |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-01 | IDOR check: if current_user.subscriber_id != requested_id: raise 403 |
| MAC format | Regex validation AVANT stockage |
| MAC multicast | Rejeter (LSB first octet = 1) |
| MAC random | Détecter mais NE PAS bloquer (mac_type=randomized) |
| Dédup | Lookup par mac_address unique dans DB (pas duplquer historique) |
Implémentation TODO
- [ ] Service
app/services/device_manager.py - [ ] POST
/api/v1/subscribers/{id}/devices(register) - [ ] GET
/api/v1/subscribers/{id}/devices(list) - [ ] DELETE
/api/v1/subscribers/{id}/devices/{mac}(forget) - [ ] GET
/api/v1/subscribers/{id}/sessions(active) - [ ] Table subscriber_devices migration
- [ ] MAC format validation (regex + multicast rejection)
- [ ] MAC randomization detection
- [ ] OUI vendor lookup (offline cache)
- [ ] SEC-01 IDOR checks
- [ ] Unit tests MAC validation
- [ ] E2E tests device management flow
Lessons Learned
- LL#8: subscriber_id UUID (jamais int)
- LL#16 SEC-01:
if current_user.subscriber_id != resource.subscriber_id: raise HTTPException(403) - LL#5: colonnes DB exactes (mac_address TEXT UNIQUE, mac_type CHECK(...))
- Simultaneous-Use: Configuré en RADIUS (outil #6), pas ici
- MAC randomized: Détecter mais jamais utiliser comme raison de blocage
Dernière mise à jour: 2026-02-21