Skip to content

#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

  1. Identité ≠ MAC: Un abonné = son MSISDN. La MAC peut changer, aléatoire, multidevice.
  2. Simultaneous-Use = RADIUS: FreeRADIUS gère le limit de 2 sessions. Pas d'app logic.
  3. MAC randomisée = pas de blocage: iOS 14+, Android 10+ peuvent randomiser → jamais bloquer l'abonné.
  4. Tracking complet: subscriber_devices enregistre 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)

python
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)

bash
# Device Management
MAC_RANDOMIZED_DETECTION=true
SIMULTANEOUS_USE_DEFAULT=2      # Par forfait
OUI_VENDOR_DB_PATH=/app/data/oui.txt  # IEEE OUI liste

MAC Validation Rules

Format acceptable:

regex
^([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:

python
first_byte = int(mac[:2], 16)
if first_byte & 0x01:  # LSB = 1 → multicast
    raise ValueError("Multicast MAC not allowed")

MAC Randomization Detection

python
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 set

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/subscribers/{id}/devices-200 {items:[devices], total}JWTListe MACs
POST/api/v1/subscribers/{id}/devices{mac_address}201 {device}JWTRegister device
DELETE/api/v1/subscribers/{id}/devices/{mac}-204JWTForget device
GET/api/v1/subscribers/{id}/sessions-200 {active_sessions, limit}JWTSessions actuelles

GET /api/v1/subscribers/{id}/devices

Response 200:

json
{
  "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:

json
{
  "mac_address": "2E:1F:C4:A2:B1:9E"
}

Response 201:

json
{
  "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):

json
{
  "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:

json
{
  "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éTypeTTLUsage
rgz:devices:{subscriber_id}SetMACs enregistrées (lookup rapide)
rgz:session:{subscriber_id}Hashsession_durationMétadonnées session active

Sécurité

RègleImplémentation
SEC-01IDOR check: if current_user.subscriber_id != requested_id: raise 403
MAC formatRegex validation AVANT stockage
MAC multicastRejeter (LSB first octet = 1)
MAC randomDétecter mais NE PAS bloquer (mac_type=randomized)
DédupLookup 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

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