================================================================================ PRD 4.1 — GUIDE D'INTÉGRATION ACTIVE LIVENESS DANS LARAVEL Endpoints Microservice Python → Projet Laravel Existant ================================================================================ Projet : BloKYC — Module Active Liveness Complément : Documentation technique d'intégration OS : AlmaLinux 9.7 (microservice) + cPanel Apache (Laravel) Port actif : 20900 (microservice FastAPI) URL prod : https://blokyc.me Version : 4.1.0 Date : 19 mai 2026 Ce document explique le fonctionnement des 2 nouveaux endpoints FastAPI et comment les intégrer dans un projet Laravel déjà en cours de développement. ================================================================================ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1. ARCHITECTURE DU MODULE LIVENESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ FLUX COMPLET (3 couches) : ┌──────────────────────────────────────────────────────────────────────┐ │ COUCHE A — BROWSER MOBILE (JavaScript) │ │ │ │ 1. L'utilisateur ouvre le lien liveness sur son smartphone │ │ 2. MediaPipe FaceLandmarker se charge via CDN (~4.5 MB) │ │ 3. 5 défis actifs sont évalués 100% côté client : │ │ - Cligner les yeux (Eye Aspect Ratio via landmarks) │ │ - Sourire (blendshapes mouthSmileLeft+Right) │ │ - Tourner la tête (headYaw via landmarks 33 vs 263) │ │ - Lire des chiffres (Web Speech API reconnaissance vocale) │ │ - Suivre un point (Iris landmarks 468-477 vs point) │ │ 4. Un snapshot PNG est capturé APRÈS validation des 5 défis │ │ 5. Le snapshot est envoyé au microservice via POST /liveness/verify │ └──────────────────────────┬───────────────────────────────────────────┘ │ HTTP multipart ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ COUCHE B — MICROSERVICE PYTHON (port 20900) │ │ │ │ POST /liveness/verify │ │ → Silent-Face MiniFASNetV2 + V1SE analysent le snapshot │ │ → Score liveness 0→1 (>0.75 = visage réel) │ │ → AuraFace extrait l'embedding 512-D du visage │ │ → Retourne { anti_spoof, face_detection, liveness_passed } │ │ │ │ POST /liveness/face-match-with-document │ │ → AuraFace extrait l'embedding du document d'identité │ │ → Comparaison cosinus avec l'embedding du selfie liveness │ │ → Retourne { similarity_score, decision } │ └──────────────────────────┬───────────────────────────────────────────┘ │ HTTP multipart ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ COUCHE C — LARAVEL (Backend + Livewire) │ │ │ │ LivenessController │ │ → GET /liveness/{token} → affiche la page mobile │ │ → POST /liveness/{token}/submit → reçoit le snapshot + appelle B │ │ │ │ LivenessService │ │ → verifySnapshot() → appelle POST /liveness/verify │ │ → matchWithDocument() → appelle POST /liveness/face-match │ │ │ │ LivenessSession (Modèle Eloquent) │ │ → public_token, challenges_order, status, liveness_score, etc. │ │ │ │ Livewire LivenessLink (côté PC) │ │ → Génère le QR code + polling 3s pour surveiller le statut │ └──────────────────────────────────────────────────────────────────────┘ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2. ENDPOINT #1 : POST /liveness/verify ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ URL : http://135.181.211.44:20900/liveness/verify Méthode : POST (multipart/form-data) Content-Type : multipart/form-data Timeout : 30 secondes (recommandé) ─── Paramètres d'entrée ──────────────────────────────────────────────────────── | Champ | Type | Requis | Description | |--------------------|--------|--------|--------------------------------------| | snapshot | File | OUI | Image JPG/PNG capturée après les | | | | | défis (max 5 MB) | | session_token | String | OUI | Token de session public UUID v4 | | | | | généré par Laravel | | challenges_passed | String | NON | JSON array des défis validés | | | | | Ex: ["blink","smile","head_left"] | ─── Réponse JSON (200 OK) ───────────────────────────────────────────────────── { "success": true, "elapsed_sec": 0.28, "session_token": "a1b2c3d4e5f6...", "challenges_passed": ["blink", "smile", "head_left", "digits", "follow_dot"], "anti_spoof": { "success": true, "is_live": true, "liveness_score": 0.9234, "score_v2": 0.9512, "score_v1se": 0.8821, "decision": "LIVE", "confidence": "HIGH", "elapsed_sec": 0.015, "error": null }, "face_detection": { "embedding": [0.0123, -0.0456, 0.0789, ...], // 512 floats "face_found": true, "error": null }, "liveness_passed": true } ─── Détail des champs de réponse ─────────────────────────────────────────────── anti_spoof.is_live : true = visage réel, false = photo/vidéo/masque anti_spoof.liveness_score : 0.0 → 1.0 (seuil par défaut : 0.75) anti_spoof.score_v2 : Score du modèle principal MiniFASNetV2 anti_spoof.score_v1se : Score du modèle secondaire MiniFASNetV1SE anti_spoof.decision : "LIVE" | "SPOOF" | "UNCERTAIN" | "ERROR" anti_spoof.confidence : "HIGH" | "MEDIUM" | "LOW" face_detection.embedding : Vecteur 512-D (float) du visage détecté → À stocker en base pour le face match ultérieur → null si aucun visage détecté face_detection.face_found : true si un visage a été détecté dans le snapshot liveness_passed : true SI les 3 conditions sont remplies : 1. anti_spoof.is_live == true 2. face_detection.face_found == true 3. len(challenges_passed) >= 3 ─── Seuils de décision anti-spoofing ─────────────────────────────────────────── | Score final | Décision | is_live | Signification | |---------------|------------|---------|---------------------------------| | ≥ 0.90 | LIVE | true | Visage réel, confiance haute | | 0.75 – 0.89 | LIVE | true | Visage réel, confiance moyenne | | 0.55 – 0.74 | UNCERTAIN | false | Incertain → refus | | < 0.55 | SPOOF | false | Spoofing détecté, confiance | | | | | haute → refus | Score final = (score_v2 × 0.6) + (score_v1se × 0.4) ─── Codes d'erreur HTTP ──────────────────────────────────────────────────────── | Code | Cause | |------|--------------------------------------| | 413 | Snapshot > 5 MB | | 500 | Erreur interne (modèle non chargé) | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3. ENDPOINT #2 : POST /liveness/face-match-with-document ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ URL : http://135.181.211.44:20900/liveness/face-match-with-document Méthode : POST (multipart/form-data) Content-Type : multipart/form-data Timeout : 30 secondes (recommandé) ─── Paramètres d'entrée ──────────────────────────────────────────────────────── | Champ | Type | Requis | Description | |--------------------|--------|--------|-----------------------------------| | document_snapshot | File | OUI | Photo du document d'identité | | | | | (JPG/PNG, max 5 MB) | | selfie_embedding | String | OUI | Embedding JSON 512-D retourné | | | | | par /liveness/verify | | | | | (le champ face_detection.embedding| ─── Réponse JSON (200 OK) ───────────────────────────────────────────────────── { "success": true, "elapsed_sec": 0.35, "similarity_score": 0.7823, "model_used": "AuraFace-v1 + Liveness Snapshot", "decision": "APPROVED", "confidence": "HIGH" } ─── Détail des champs de réponse ─────────────────────────────────────────────── similarity_score : Score de similarité cosinus entre le visage du document et le visage du selfie liveness (0.0 → 1.0) decision : Décision basée sur les seuils : | Score | Décision | Action | |---------------|-----------|----------------| | ≥ 0.55 | APPROVED | KYC validé | | 0.40 – 0.54 | REVIEW | Révision man. | | < 0.40 | REJECTED | KYC refusé | ─── Codes d'erreur HTTP ──────────────────────────────────────────────────────── | Code | Cause | |------|------------------------------------------------| | 413 | Document > 5 MB | | 422 | Aucun visage détecté sur le document | | 422 | Embedding selfie invalide (JSON mal formaté) | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4. INTÉGRATION LARAVEL — FICHIERS À CRÉER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Cette section liste TOUS les fichiers nécessaires pour intégrer le module liveness dans un projet Laravel existant. Adaptez les namespaces et chemins selon la structure de votre projet. ─── 4.1 Migration ─────────────────────────────────────────────────────────── php artisan make:migration create_liveness_sessions_table database/migrations/YYYY_MM_DD_HHMMSS_create_liveness_sessions_table.php Schema::create('liveness_sessions', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('kyc_verification_id') ->nullable()->constrained()->nullOnDelete(); // Token public (dans l'URL) — UUID v4 $table->string('public_token', 64)->unique(); // Ordre des défis (shuffled côté serveur) $table->json('challenges_order'); // Chiffres aléatoires pour le défi vocal $table->string('digit_challenge', 10)->nullable(); // Statut : pending|opened|in_progress|completed|failed|expired $table->enum('status', [ 'pending', 'opened', 'in_progress', 'completed', 'failed', 'expired' ])->default('pending'); // Résultats des défis $table->json('challenges_passed')->nullable(); $table->json('challenges_failed')->nullable(); $table->integer('attempts')->default(0); // Résultat anti-spoofing $table->boolean('liveness_passed')->nullable(); $table->decimal('liveness_score', 5, 4)->nullable(); $table->string('spoof_decision')->nullable(); $table->json('anti_spoof_raw')->nullable(); // Embedding du selfie liveness (pour face match ultérieur) $table->text('selfie_embedding')->nullable(); // Chemin du snapshot $table->string('snapshot_path')->nullable(); // Métadonnées sécurité $table->ipAddress('ip_mobile')->nullable(); $table->string('user_agent_mobile')->nullable(); $table->string('device_type')->nullable(); // Timestamps de cycle de vie $table->timestamp('opened_at')->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamp('expires_at'); $table->timestamps(); $table->index(['public_token', 'status']); $table->index(['kyc_verification_id', 'status']); $table->index(['tenant_id', 'created_at']); }); ─── 4.2 Modèle Eloquent ─────────────────────────────────────────────────── app/Models/LivenessSession.php Ce modèle gère le cycle de vie complet d'une session liveness : - Génération automatique du public_token (32 chars hex) - Expiration automatique (+30 minutes) - Ordre des défis shuffled aléatoirement - Chiffres aléatoires pour le défi vocal Propriétés clés : $fillable → tous les champs de la migration $casts → challenges_order, challenges_passed, challenges_failed, anti_spoof_raw en 'array', dates en 'datetime' Méthodes : isExpired() → bool isPending() → bool isCompleted() → bool livenessUrl() → string (URL complète vers la page mobile) markAsExpired() → void Relations : tenant() → BelongsTo Tenant::class user() → BelongsTo User::class kycVerification() → BelongsTo KycVerification::class ─── 4.3 Service ──────────────────────────────────────────────────────────── app/Services/Kyc/LivenessService.php Ce service communique avec le microservice Python : createSession(int $tenantId, int $userId, ?int $kycVerificationId) → Crée une LivenessSession (token + challenges + expiry auto) → Retourne LivenessSession verifySnapshot(LivenessSession $session, string $snapshotPath, array $challengesPassed, string $ipMobile, string $userAgent) → Envoie le snapshot au microservice POST /liveness/verify → Met à jour la session avec les résultats → Retourne array (résultat complet du microservice) matchWithDocument(LivenessSession $session, string $documentPath) → Compare l'embedding selfie avec le document → Appelle POST /liveness/face-match-with-document → Retourne array { similarity_score, decision, confidence } ─── 4.4 Controller ───────────────────────────────────────────────────────── app/Http/Controllers/Kyc/LivenessController.php challenge(string $token) → GET /liveness/{token} → Route PUBLIQUE (pas de middleware auth) → Cherche la session par public_token → Marque status = 'opened' + opened_at → Retourne la vue liveness.challenge submit(Request $request, string $token) → POST /liveness/{token}/submit → Route PUBLIQUE (la sécurité est le token) → Valide : snapshot (image, max 5 MB), challenges_passed → Stocke le snapshot dans le filesystem privé → Appelle LivenessService::verifySnapshot() → Retourne JSON { success, liveness_passed, message } ─── 4.5 Composant Livewire (côté PC) ────────────────────────────────────── app/Livewire/Kyc/LivenessLink.php resources/views/livewire/kyc/liveness-link.blade.php Ce composant s'affiche SUR LE PC de l'utilisateur après l'upload du document KYC. Il affiche : État "idle" : → Bouton "Générer le lien sécurisé" → Appelle LivenessService::createSession() État "link_generated" : → QR code contenant l'URL liveness → Lien copiable en texte → Timer d'expiration État "mobile_opened" : → "Téléphone connecté — Défis en cours..." → Polling toutes les 3 secondes via #[Poll(3000)] État "completed" : → Résultat final : ✅ Liveness vérifié / ❌ Liveness refusé → Score affiché → Dispatch l'événement 'liveness-completed' au parent ─── 4.6 Page Mobile (Blade + JavaScript MediaPipe) ──────────────────────── resources/views/liveness/challenge.blade.php Page AUTONOME — un seul fichier blade contient TOUT le JavaScript. Aucune dépendance serveur. Charge les éléments suivants depuis le CDN : Dépendances CDN : - Tailwind CSS → cdn.tailwindcss.com - MediaPipe tasks-vision → cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.15 Cette page : 1. Charge MediaPipe FaceLandmarker (modèle ~1.5 MB) 2. Demande l'accès à la caméra frontale 3. Affiche les 5 défis dans l'ordre défini par le serveur 4. Évalue chaque défi en temps réel (landmarks + blendshapes) 5. Capture un snapshot après validation de tous les défis 6. Envoie le snapshot via fetch() au Controller Laravel 7. Affiche le résultat final Variables Blade injectées : $session->public_token → Token de session $session->challenges_order → Ordre des défis (JSON) $session->digit_challenge → Chiffres à lire (ex: "4 7 2") $session->isExpired() → Vérifie expiration ─── 4.7 Routes ───────────────────────────────────────────────────────────── routes/web.php — Ajouter : use App\Http\Controllers\Kyc\LivenessController; // Routes PUBLIQUES (pas de middleware auth) Route::prefix('liveness')->name('liveness.')->group(function () { Route::get('/{token}', [LivenessController::class, 'challenge']) ->name('challenge'); Route::post('/{token}/submit', [LivenessController::class, 'submit']) ->name('submit'); }); ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5. SÉQUENCE COMPLÈTE D'UN KYC AVEC LIVENESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Utilisateur (PC) Serveur Laravel Microservice Python ─────────────── ────────────── ─────────────────── 1. Upload document + selfie ──────────────────────────► 2. POST /kyc/verify 3. Appelle POST /kyc/verify ──────────────────────► 4. OCR + Face Match ◄────────────────────── 5. Retourne résultat 6. Clic "Générer lien liveness" ──────────────────────────► 7. Crée LivenessSession (token, challenges shuffled, digit_challenge random) 8. Affiche QR code 9. Scanne QR code avec mobile ──── (sur smartphone) ────► 10. GET /liveness/{token} 11. Affiche page challenge.blade.php 12. Réalise les 5 défis (cligner, sourire, tête, lire chiffres, suivre point) MediaPipe évalue en local dans le navigateur mobile 13. Snapshot capturé + envoyé ──── (depuis mobile) ────► 14. POST /liveness/{token}/submit 15. Stocke snapshot 16. Appelle POST /liveness/verify ──────────────────────► 17. Silent-Face analyse snapshot 18. AuraFace extrait embedding 512-D ◄────────────────────── 19. Retourne résultat 20. Met à jour LivenessSession (score, decision, embedding) 21. [Polling Livewire 3s] ◄──────────────────────── 22. Status = "completed" 23. Affiche ✅ Liveness OK 24. [Optionnel] Face match complet ──────────────────────────► 25. Appelle POST /liveness/face-match-with-document ──────────────────────► 26. Compare embedding selfie vs document ◄────────────────────── 27. Score similarité 28. Décision finale KYC ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6. TEST DES ENDPOINTS DEPUIS LARAVEL (Http Facade) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ─── 6.1 Test /liveness/verify ──────────────────────────────────────────────── use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; $snapshotContent = Storage::get('liveness/1/snapshots/test.jpg'); $response = Http::timeout(30) ->attach('snapshot', $snapshotContent, 'snapshot.jpg') ->post('http://135.181.211.44:20900/liveness/verify', [ 'session_token' => 'test_token_123', 'challenges_passed' => json_encode(["blink","smile","head_left"]), ]); $result = $response->json(); // $result['liveness_passed'] → bool // $result['anti_spoof']['liveness_score'] → float 0→1 // $result['anti_spoof']['decision'] → "LIVE"|"SPOOF"|"UNCERTAIN" // $result['face_detection']['embedding'] → array[512] de floats // $result['face_detection']['face_found'] → bool ─── 6.2 Test /liveness/face-match-with-document ──────────────────────────── $documentContent = Storage::get('kyc/1/documents/id_card.jpg'); $selfieEmbedding = '[0.0123, -0.0456, 0.0789, ...]'; // Depuis liveness_verify $response = Http::timeout(30) ->attach('document_snapshot', $documentContent, 'document.jpg') ->post('http://135.181.211.44:20900/liveness/face-match-with-document', [ 'selfie_embedding' => $selfieEmbedding, ]); $result = $response->json(); // $result['similarity_score'] → float 0→1 // $result['decision'] → "APPROVED"|"REVIEW"|"REJECTED" // $result['confidence'] → "HIGH"|"MEDIUM"|"LOW" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 7. TEST DES ENDPOINTS DEPUIS LE TERMINAL (curl) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Vérifier le statut du service curl -s http://135.181.211.44:20900/health | python3 -m json.tool # Tester liveness/verify curl -s -X POST http://135.181.211.44:20900/liveness/verify \ -F "snapshot=@/chemin/vers/snapshot.jpg" \ -F "session_token=test_token_123" \ -F 'challenges_passed=["blink","smile","head_left"]' \ | python3 -m json.tool # Tester liveness/face-match-with-document curl -s -X POST http://135.181.211.44:20900/liveness/face-match-with-document \ -F "document_snapshot=@/chemin/vers/document.jpg" \ -F 'selfie_embedding=[0.012,-0.045,0.078,...]' \ | python3 -m json.tool ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 8. SÉCURITÉ — POINTS IMPORTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────── 1. Les routes /liveness/* sont PUBLIQUES (pas de middleware auth) → La sécurité repose sur le token UUID v4 (128 bits aléatoires) → Le token expire après 30 minutes → Le token ne peut être utilisé qu'UNE SEULE FOIS 2. Le snapshot est stocké dans un filesystem PRIVÉ (pas accessible via URL) → Utiliser Storage::put() sur un disque 'private' 3. L'embedding selfie contient des données biométriques → Chiffrer en base si nécessaire (RGPD) → Ne jamais exposer dans une API response publique 4. L'anti-spoofing côté serveur est un filet de sécurité → La détection principale se fait côté client (MediaPipe) → Silent-Face détecte les photos/imprimés/vidéos présentés à la caméra → Les deux couches doivent passer pour que liveness_passed = true 5. Limiter le taux de requêtes → Ajouter un throttle sur les routes liveness → RateLimiter::for('liveness', fn() => Limit::perMinute(5)); ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9. DÉPENDANCES ADDITIONNELLES LARAVEL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Package PHP : AUCUN — les endpoints utilisent uniquement la Http Facade native de Laravel Package NPM (optionnel, pour le QR code côté PC) : npm install qrcode # OU utiliser le CDN :