#03 — rgz-portal
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE A · Conteneur: rgz-portal · Code: portal/
Dépendances: #01 rgz-api, #06 rgz-radius
Interface Portail Captif
Voir la documentation interface pour captures d'écran et flux utilisateur.
Description
rgz-portal est le portail captif WiFi que voit chaque abonné lorsqu'il tente de se connecter au réseau ACCESS pour la première fois ou après expiration de sa session. C'est le point d'entrée pour l'inscription (avec OCR de pièce d'identité via l'outil #11), la demande d'OTP (#12), le choix de forfait (#14), le paiement mobile (#15) et l'accès Internet final via FreeRADIUS (#6).
La contrainte principale du portail captif est sa légèreté : le fichier portal/index.html et ses assets JS/CSS ne doivent pas dépasser 50 KB au total. Cette limite est imposée par les conditions réseau dégradées (3G/4G faible, congestion VLAN) au Bénin. Le portail utilise du HTML/JS/CSS vanilla — aucun framework, aucune dépendance NPM. Le code est écrit directement, minifié lors du build Docker.
Le flux d'authentification suit le modèle subscriber_ref de la plateforme : la MAC de l'appareil est détectée automatiquement (l'abonné ne la voit jamais), l'abonné saisit son numéro de téléphone (MSISDN), reçoit un OTP par SMS (Letexto), valide l'OTP, et la session RADIUS est créée. Si l'abonné n'existe pas, un formulaire d'inscription complet est proposé (avec upload photo CNI/CIP/PASSEPORT pour l'OCR).
Le branding est dynamique : le portail interroge /api/v1/branding/{nas_id} au chargement pour récupérer le logo et les couleurs du revendeur (outil #18). Cette personnalisation est mise en cache Redis côté API (rgz:portal:config:{nas_id}, TTL 3600s). Le domaine public est access-rgz.duckdns.org, protégé par Traefik et Let's Encrypt.
Architecture Interne
Abonné connecte à WiFi ACCESS
│
▼
[rgz-gateway — nftables] — redirige HTTP/HTTPS non-authentifié
│
▼
[rgz-dns — Unbound sinkhole] — résout *.* → 302 vers access-rgz.duckdns.org
│
▼
rgz-portal (nginx:alpine)
│
├── GET / → index.html (SPA portail <50KB)
├── GET /css/main.css → styles portail
├── GET /css/brand.css → branding dynamique (#18)
├── GET /js/portal.js → flux auth MSISDN → OTP → session
├── GET /js/banners.js → rotation bannières 8s (#16)
├── /api/ → proxy http://rgz-api:8000/api/ (LL#28)
└── /pwa/ → Service Worker + manifest (#69)
Flux JS portal.js:
1. Détecter MAC (User-Agent + NAS-ID depuis query string)
2. GET /api/v1/branding/{nas_id} → appliquer thème
3. Afficher page: MSISDN input
4. POST /api/v1/auth/otp/request → {"msisdn": "...", "nas_id": "..."}
5. Afficher page: OTP input (6 chiffres)
6. POST /api/v1/auth/otp/verify → {"otp": "...", "otp_token": "..."}
7. Si nouveau → formulaire inscription
8. Sinon → choisir forfait actif ou acheter (#14, #15)
9. Session RADIUS créée → redirection vers InternetConfiguration
Variables d'environnement
| Variable | Exemple | Description |
|---|---|---|
PORTAL_API_URL | http://rgz-api:8000 | URL interne API (dans nginx) |
PORTAL_DOMAIN | access-rgz.duckdns.org | Domaine public (meta og, PWA) |
Fichiers importants
portal/
├── index.html # SPA portail, <50KB total (HTML + CSS inline critique)
├── css/
│ ├── main.css # Styles ACCESS brand + responsive mobile
│ └── brand.css # Variables CSS branding (#18), chargé dynamiquement
├── js/
│ ├── portal.js # Flux auth complet (vanilla JS, ES6+)
│ ├── banners.js # Rotation bannières (#16) + tracking impressions
│ └── offline.js # Service Worker PWA (#69), cache offline
└── pwa/
├── manifest.json # PWA manifest (theme-color: #3f68ae)
└── icons/ # Icônes PWA (192x192, 512x512)
docker/portal/
├── Dockerfile # nginx:alpine + COPY portal/ /usr/share/nginx/html/
└── nginx.conf # Config nginx: proxy /api/, headers sécuritéCSS Variables ACCESS (dans main.css)
:root {
--access-yellow: #f5c445;
--access-blue: #3f68ae;
--access-red: #da3747;
--access-dark: #34383c;
--access-white: #ffffff;
}
/* Override par branding revendeur (brand.css chargé dynamiquement) */
:root.reseller-theme {
--brand-primary: var(--reseller-color-primary, var(--access-blue));
--brand-secondary: var(--reseller-color-secondary, var(--access-yellow));
}Flux Auth Détaillé
Étape 1 — Détection contexte
// portal.js — lire les paramètres injectés par le gateway
const params = new URLSearchParams(window.location.search);
const nasId = params.get('nas_id') || 'access_default';
const clientMac = params.get('mac') || ''; // injecté par nftables redirect
const redirectUrl = params.get('redirect') || 'https://www.google.com';Étape 2 — Branding
// Charger le thème du revendeur (cache Redis TTL=3600s côté API)
const brand = await fetch(`/api/v1/branding/${nasId}`).then(r => r.json());
document.documentElement.style.setProperty('--brand-primary', brand.color_primary);
// SEC-13 : jamais innerHTML pour brand.name → textContent uniquement
document.getElementById('reseller-name').textContent = brand.name;Étape 3 — OTP Flow
// Demande OTP
const res = await fetch('/api/v1/auth/otp/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msisdn: phone, nas_id: nasId }),
});Endpoints API Consommés
| Endpoint | Usage | Auth requise |
|---|---|---|
GET /api/v1/branding/{nas_id} | Thème revendeur | Non |
POST /api/v1/auth/otp/request | Demander OTP par MSISDN | Non |
POST /api/v1/auth/otp/verify | Vérifier OTP | Non (token temporaire) |
POST /api/v1/portal/register | Inscrire nouvel abonné | Non |
GET /api/v1/portal/forfaits?nas_id=X | Lister forfaits disponibles | Session OTP |
POST /api/v1/payments/initiate | Paiement KKiaPay | Session OTP |
GET /api/v1/portal/session | État de la session courante | Session JWT |
POST /api/v1/banners/impression | Tracker impression bannière | Non |
Healthcheck
# Healthcheck Docker (LL#43 : 127.0.0.1 JAMAIS localhost)
wget -qO- http://127.0.0.1:80/ | grep -q "ACCESS" && echo "OK"
# Depuis l'hôte
curl -s -o /dev/null -w "%{http_code}" https://access-rgz.duckdns.org/
# Attendu: 200
# Tester le proxy /api/
curl -s https://access-rgz.duckdns.org/api/v1/health | jq .
# Vérifier la taille totale des assets (<50KB)
docker exec rgz-portal find /usr/share/nginx/html -type f | \
xargs du -sb | awk '{sum+=$1} END {print sum/1024 " KB"}'Sécurité
| Règle | Implémentation |
|---|---|
| SEC-13 XSS | Toutes les données utilisateur affichées via element.textContent, jamais innerHTML |
| SEC-14 Rate limit | nginx limit_req_zone sur /api/v1/auth/otp/request (3 req/min par IP) |
| SEC-11 CORS | Le portail proxy via nginx, pas d'appels cross-origin directs |
| SEC-12 Upload | Magic bytes check + Content-Type + max 5MB pour les photos CNI (#11) |
| LL#28 nginx | resolver 127.0.0.11; + set $backend pour proxy dynamique |
# docker/portal/nginx.conf — rate limiting + proxy correct
limit_req_zone $binary_remote_addr zone=otp_zone:10m rate=3r/m;
location /api/v1/auth/otp/ {
limit_req zone=otp_zone burst=2 nodelay;
resolver 127.0.0.11 valid=30s;
set $api_backend "http://rgz-api:8000";
proxy_pass $api_backend;
}
location /api/ {
resolver 127.0.0.11 valid=30s;
set $api_backend "http://rgz-api:8000";
proxy_pass $api_backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-NAS-ID $arg_nas_id;
}Commandes Utiles
# Démarrage avec rebuild
docker compose -f /home/claude-dev/RGZ/docker-compose.core.yml up -d --build rgz-portal
# Logs nginx (inclut accès abonnés)
docker logs rgz-portal -f --tail=200
# Status conteneur
docker inspect rgz-portal --format='{}'
# Taille des assets (objectif <50KB)
docker exec rgz-portal du -sh /usr/share/nginx/html/
docker exec rgz-portal find /usr/share/nginx/html -name "*.js" -o -name "*.css" \
| xargs wc -c
# Simuler une redirection captive (depuis hôte)
curl -v "https://access-rgz.duckdns.org/?nas_id=access_kossou&mac=AA:BB:CC:DD:EE:FF"
# Vérifier les headers sécurité nginx
curl -I https://access-rgz.duckdns.org/ | grep -E "X-Frame|X-Content|Content-Security"Implémentation TODO
- [x] Dockerfile créé (
docker/portal/Dockerfile) - [x] nginx.conf créé (
docker/portal/nginx.conf) - [x] Service défini dans
docker-compose.core.yml - [ ]
portal/index.html— Structure HTML5, meta viewport, Poppins Google Fonts - [ ]
portal/css/main.css— Variables ACCESS + styles responsive mobile-first - [ ]
portal/css/brand.css— Variables override branding revendeur - [ ]
portal/js/portal.js— Flux auth complet (MSISDN → OTP → session) - [ ]
portal/js/banners.js— Rotation bannières 8s + impressions (#16) - [ ]
portal/js/offline.js— Service Worker PWA (#69) - [ ]
portal/pwa/manifest.json— PWA manifest - [ ] Minification des assets dans le Dockerfile (< 50KB total)
- [ ] Tests E2E flux captif (Playwright)
Dernière mise à jour: 2026-02-21