================================================================================ PRD 4 — ACTIVE LIVENESS DETECTION — BLOKYC.ME Active Liveness + Anti-Spoofing + Lien Cross-Device ================================================================================ Projet : BloKYC — Active Liveness Module Complément : PRD 1 (KYC) + PRD 2 (NLP) + PRD 3 (LLMs) — tous opérationnels ✅ PRD Full Stack Laravel cPanel — opérationnel ✅ OS : AlmaLinux 9.7 (microservice) + cPanel Apache (Laravel) Port actif : 20900 (microservice FastAPI) URL prod : https://blokyc.me Rédigé pour : Cursor AI (exécution autonome étape par étape) Version : 4.0.0 ARCHITECTURE GLOBALE DE CE MODULE ──────────────────────────────────────────────────────────────────────────────── COUCHE A — Browser (JavaScript — AUCUN serveur impliqué) → MediaPipe FaceLandmarker (Apache 2.0) — CDN jsDelivr → Web Speech API (native navigateur) — lecture des chiffres → 5 défis actifs évalués 100% côté client → Snapshot capturé uniquement APRÈS validation des 5 défis COUCHE B — Microservice FastAPI Python (port 20900) → Silent-Face-Anti-Spoofing MiniFASNetV2 (MIT) — analyse du snapshot → AuraFace-v1 (déjà installé PRD 1) — embedding visage liveness → Nouveau endpoint : POST /liveness/verify COUCHE C — Laravel (cPanel) → Migration liveness_sessions → Livewire KycLivenessLink (QR code + polling) → Livewire KycLivenessPage (page mobile défis) → Controller LivenessController → Job ProcessLivenessResult NOUVEAUX MODÈLES À INSTALLER ──────────────────────────────────────────────────────────────────────────────── Browser MediaPipe FaceLandmarker CDN jsDelivr Apache 2.0 ~3 MB Browser face_landmarker.task Google Storage Apache 2.0 ~1.5 MB Python Silent-Face MiniFASNetV2 GitHub minivision MIT ~150 MB Python Silent-Face MiniFASNetV1SE GitHub minivision MIT ~50 MB RAM ajoutée côté serveur : ~200 MB (AuraFace déjà chargé = 0 ajout) RÈGLES IMPÉRATIVES POUR CURSOR ──────────────────────────────────────────────────────────────────────────────── 1. Exécuter les phases PYTHON d'abord (Phases 26-28), puis LARAVEL (Phases 29-32) 2. NE PAS modifier les modules existants (main.py, face_match.py, etc.) 3. Tout le JavaScript liveness est dans un seul fichier blade autonome 4. La page liveness est accessible SANS authentification (lien public signé) 5. Après chaque CHECKPOINT ✅, attendre confirmation avant de continuer 6. Les tokens de session sont signés HMAC-SHA256 — jamais stockés en clair 7. La caméra n'est activée que côté mobile — jamais côté PC (page QR code) ================================================================================ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 26 — INSTALLATION SILENT-FACE CÔTÉ MICROSERVICE PYTHON ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 26.1 — Cloner Silent-Face-Anti-Spoofing ─────────────────────────────────────────────── source /opt/kyc-service/venv/bin/activate cd /opt/kyc-service # Cloner le repo officiel minivision (MIT License) git clone https://github.com/minivision-ai/Silent-Face-Anti-Spoofing.git \ /opt/kyc-service/silent_face # Vérifier la structure ls /opt/kyc-service/silent_face/resources/anti_spoof_models/ CHECKPOINT ✅ : Le dossier anti_spoof_models/ doit contenir des fichiers .pth ÉTAPE 26.2 — Télécharger les poids MiniFASNet ─────────────────────────────────────────────── Créer /opt/kyc-service/scripts/download_silent_face.py : -------- DÉBUT DU SCRIPT download_silent_face.py -------- """ Télécharge les poids Silent-Face-Anti-Spoofing. Modèles : MiniFASNetV1SE + MiniFASNetV2 Licence : MIT — Usage commercial autorisé Source : minivision-ai/Silent-Face-Anti-Spoofing (GitHub) Taille : ~200 MB total Ces modèles détectent si le visage présenté à la caméra est : - Un vrai visage vivant (score proche de 1) - Une photo imprimée (score proche de 0) - Un écran affichant une vidéo (score proche de 0) - Un masque 3D (score proche de 0) """ import os import urllib.request from pathlib import Path MODELS_DIR = Path("/opt/kyc-service/models/silent_face") MODELS_DIR.mkdir(parents=True, exist_ok=True) # URLs des modèles pré-entraînés MODELS = [ { "filename": "2.7_80x80_MiniFASNetV2.pth", "url": "https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/raw/master/resources/anti_spoof_models/2.7_80x80_MiniFASNetV2.pth", "desc": "MiniFASNetV2 — modèle principal (~3.6 MB)", }, { "filename": "4_0_0_80x80_MiniFASNetV1SE.pth", "url": "https://github.com/minivision-ai/Silent-Face-Anti-Spoofing/raw/master/resources/anti_spoof_models/4_0_0_80x80_MiniFASNetV1SE.pth", "desc": "MiniFASNetV1SE — modèle complémentaire (~48 MB)", }, ] print("Téléchargement des modèles Silent-Face-Anti-Spoofing...\n") for model in MODELS: dest = MODELS_DIR / model["filename"] print(f"→ {model['desc']}") if dest.exists(): print(f" Déjà présent : {dest}") continue print(f" Téléchargement depuis GitHub...") urllib.request.urlretrieve(model["url"], dest) size_mb = dest.stat().st_size / 1e6 print(f" ✅ OK — {size_mb:.1f} MB\n") print("✅ Tous les modèles Silent-Face téléchargés !") print(f"Dossier : {MODELS_DIR}") for f in MODELS_DIR.iterdir(): print(f" {f.name} ({f.stat().st_size / 1e6:.1f} MB)") -------- FIN DU SCRIPT -------- python /opt/kyc-service/scripts/download_silent_face.py CHECKPOINT ✅ : ls /opt/kyc-service/models/silent_face/ doit lister 2 fichiers .pth CHECKPOINT ✅ : du -sh /opt/kyc-service/models/silent_face/ doit afficher > 50 MB ÉTAPE 26.3 — Installer les dépendances Python Silent-Face ─────────────────────────────────────────────────────────── source /opt/kyc-service/venv/bin/activate # Dépendances requises par Silent-Face pip install onnxruntime==1.18.1 # déjà installé — vérifier version pip install timm==1.0.11 # déjà installé — vérifier pip install scikit-learn==1.5.2 # pour les métriques # Vérifier python -c "import torch; import timm; print('Dépendances OK')" CHECKPOINT ✅ : "Dépendances OK" doit s'afficher ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 27 — MODULE PYTHON ANTI-SPOOFING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 27.1 — Créer app/liveness.py ───────────────────────────────────── Créer /opt/kyc-service/app/liveness.py : -------- DÉBUT DU FICHIER app/liveness.py -------- """ Module Active Liveness — Anti-Spoofing (Silent-Face MiniFASNetV2) Licence : MIT (minivision-ai/Silent-Face-Anti-Spoofing) Rôle : Vérifier que le snapshot selfie provient d'un vrai visage vivant et non d'une photo, vidéo ou masque préenregistré RAM : ~150 MB (modèles PyTorch légers) Pipeline de sécurité : 1. Browser → MediaPipe FaceLandmarker → 5 défis actifs validés 2. Browser → snapshot PNG envoyé au microservice 3. Microservice → Silent-Face analyse le snapshot 4. Microservice → AuraFace extrait l'embedding pour le face match 5. Décision finale : APPROVED / REVIEW / REJECTED """ import os import io import sys import logging import time import numpy as np from pathlib import Path from typing import Optional from PIL import Image logger = logging.getLogger("kyc.liveness") # ── Chemins des modèles ──────────────────────────────────────────────────────── MODELS_DIR = Path("/opt/kyc-service/models/silent_face") SILENT_FACE_DIR = Path("/opt/kyc-service/silent_face") MODEL_V2 = MODELS_DIR / "2.7_80x80_MiniFASNetV2.pth" MODEL_V1SE = MODELS_DIR / "4_0_0_80x80_MiniFASNetV1SE.pth" # ── Seuil de décision ───────────────────────────────────────────────────────── LIVENESS_THRESHOLD = float(os.getenv("LIVENESS_THRESHOLD", "0.75")) # ── Instances globales ──────────────────────────────────────────────────────── _model_v2 = None _model_v1se = None _face_detector = None # ── Ajouter le dossier silent_face au path Python ───────────────────────────── if str(SILENT_FACE_DIR) not in sys.path: sys.path.insert(0, str(SILENT_FACE_DIR)) def load_anti_spoof_models() -> None: """ Charge les modèles MiniFASNetV1SE et MiniFASNetV2 en mémoire. Appeler UNE SEULE FOIS au startup FastAPI. """ global _model_v2, _model_v1se, _face_detector logger.info("Chargement Silent-Face-Anti-Spoofing...") try: import torch from src.model_lib.MiniFASNet import MiniFASNetV2, MiniFASNetV1SE device = torch.device("cpu") # MiniFASNetV2 — modèle principal _model_v2 = MiniFASNetV2(conv6_kernel=(5, 5)).to(device) state_dict = torch.load(str(MODEL_V2), map_location=device) # Retirer le préfixe "module." si présent (DataParallel) state_dict = {k.replace("module.", ""): v for k, v in state_dict.items()} _model_v2.load_state_dict(state_dict) _model_v2.eval() # MiniFASNetV1SE — modèle complémentaire _model_v1se = MiniFASNetV1SE(conv6_kernel=(5, 5)).to(device) state_dict = torch.load(str(MODEL_V1SE), map_location=device) state_dict = {k.replace("module.", ""): v for k, v in state_dict.items()} _model_v1se.load_state_dict(state_dict) _model_v1se.eval() logger.info("✅ Silent-Face-Anti-Spoofing chargé (MiniFASNetV1SE + V2)") except Exception as e: logger.error(f"Erreur chargement Silent-Face : {e}") logger.warning("Anti-spoofing désactivé — continuer avec AuraFace uniquement") def is_anti_spoof_ready() -> bool: return _model_v2 is not None and _model_v1se is not None def _preprocess_image(pil_image: Image.Image, image_size: int = 80) -> "torch.Tensor": """Prétraite une image PIL pour l'inférence MiniFASNet.""" import torch import torchvision.transforms as transforms transform = transforms.Compose([ transforms.Resize((image_size, image_size)), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], ), ]) return transform(pil_image.convert("RGB")).unsqueeze(0) def _predict_single(model, tensor: "torch.Tensor") -> float: """Lance l'inférence et retourne le score de liveness (0-1).""" import torch with torch.no_grad(): output = model(tensor) probabilities = torch.softmax(output, dim=1) # Index 1 = visage réel, Index 0 = faux visage liveness_score = probabilities[0][1].item() return liveness_score def analyze_snapshot(pil_image: Image.Image) -> dict: """ Analyse un snapshot pour détecter un spoofing attempt. Pipeline : 1. MiniFASNetV2 → score liveness principal 2. MiniFASNetV1SE → score liveness secondaire 3. Score final = moyenne pondérée (V2 poids 0.6, V1SE poids 0.4) 4. Décision basée sur LIVENESS_THRESHOLD Args: pil_image : image PIL du visage capturé après les défis actifs Returns: dict avec : - success : bool - is_live : bool (True = visage réel) - liveness_score : float (0→1, >0.75 = réel) - score_v2 : float (score MiniFASNetV2) - score_v1se : float (score MiniFASNetV1SE) - decision : LIVE | SPOOF | UNCERTAIN - confidence : HIGH | MEDIUM | LOW - elapsed_sec : float - error : str | None """ start = time.time() if not is_anti_spoof_ready(): logger.warning("Anti-spoof non chargé — skip analyse (mode dégradé)") return { "success": True, "is_live": True, # fail open en mode dégradé "liveness_score": 1.0, "score_v2": 1.0, "score_v1se": 1.0, "decision": "LIVE", "confidence": "LOW", "elapsed_sec": 0.0, "error": "Anti-spoof non disponible (mode dégradé)", } try: tensor = _preprocess_image(pil_image) score_v2 = _predict_single(_model_v2, tensor) score_v1se = _predict_single(_model_v1se, tensor) # Score final : moyenne pondérée final_score = (score_v2 * 0.6) + (score_v1se * 0.4) # Décision if final_score >= LIVENESS_THRESHOLD: decision = "LIVE" is_live = True confidence = "HIGH" if final_score >= 0.90 else "MEDIUM" elif final_score >= 0.55: decision = "UNCERTAIN" is_live = False confidence = "LOW" else: decision = "SPOOF" is_live = False confidence = "HIGH" elapsed = round(time.time() - start, 3) logger.info( f"Anti-spoof: score={final_score:.3f} " f"(V2={score_v2:.3f}, V1SE={score_v1se:.3f}) " f"→ {decision}" ) return { "success": True, "is_live": is_live, "liveness_score": round(final_score, 4), "score_v2": round(score_v2, 4), "score_v1se": round(score_v1se, 4), "decision": decision, "confidence": confidence, "elapsed_sec": elapsed, "error": None, } except Exception as e: logger.error(f"Erreur anti-spoof : {e}") return { "success": False, "is_live": False, "liveness_score": 0.0, "score_v2": 0.0, "score_v1se": 0.0, "decision": "ERROR", "confidence": "LOW", "elapsed_sec": round(time.time() - start, 3), "error": str(e), } -------- FIN DU FICHIER app/liveness.py -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 28 — EXTENSION DU MICROSERVICE FASTAPI ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 28.1 — Mettre à jour app/main.py ──────────────────────────────────────── MODIFICATION A — Ajouter l'import en haut de main.py : Localiser la ligne des imports existants et ajouter : -------- IMPORT À AJOUTER -------- # PRD 4 — Active Liveness from app.liveness import ( load_anti_spoof_models, analyze_snapshot, is_anti_spoof_ready, ) -------- FIN IMPORT -------- MODIFICATION B — Ajouter dans le lifespan (bloc startup) : Après le dernier chargement de modèle existant, ajouter : -------- LIGNE À AJOUTER dans lifespan -------- logger.info("→ Chargement Silent-Face-Anti-Spoofing...") load_anti_spoof_models() -------- FIN LIGNE -------- MODIFICATION C — Mettre à jour /health : Dans le dict "models" de l'endpoint /health, ajouter : -------- LIGNE À AJOUTER dans /health -------- "silent_face_anti_spoof": is_anti_spoof_ready(), -------- FIN LIGNE -------- MODIFICATION D — Ajouter les 2 nouveaux endpoints à la FIN de main.py, avant if __name__ == "__main__" : -------- NOUVEAUX ENDPOINTS À AJOUTER -------- # ═══════════════════════════════════════════════════════════════════════════ # ACTIVE LIVENESS — Endpoint principal # ═══════════════════════════════════════════════════════════════════════════ @app.post("/liveness/verify") async def endpoint_liveness_verify( snapshot: UploadFile = File(..., description="Snapshot PNG/JPG capturé après validation des défis actifs"), session_token: str = Form(..., description="Token de session signé (HMAC) généré par Laravel"), challenges_passed: str = Form("[]", description="JSON array des défis validés côté browser"), ): """ Vérifie le snapshot de liveness après les défis actifs MediaPipe. Pipeline complet : 1. Anti-spoofing (Silent-Face) → le visage est-il réel ? 2. Face embedding (AuraFace) → extraire l'embedding pour le face match 3. Retourner le résultat complet à Laravel Appelé par la page JavaScript liveness APRÈS validation des 5 défis. Input : snapshot (image), session_token, challenges_passed (JSON) Output : résultat anti-spoof + embedding pour face match ultérieur """ import json start = time.time() contents = await snapshot.read() if not validate_image_size(contents, max_mb=5): raise HTTPException(413, "Snapshot trop volumineux (max 5 MB)") pil_img = bytes_to_pil(contents) # Parse les défis validés try: challenges = json.loads(challenges_passed) except Exception: challenges = [] # ── Étape 1 : Anti-spoofing ──────────────────────────────────────────── spoof_result = analyze_snapshot(pil_img) # ── Étape 2 : Embedding AuraFace (pour face match avec le document) ──── from app.face_match import get_face_embedding_aura from app.utils import pil_to_cv2 embedding_result = {"embedding": None, "face_found": False, "error": None} try: cv2_img = pil_to_cv2(pil_img) emb = get_face_embedding_aura(cv2_img) embedding_result["face_found"] = emb is not None if emb is not None: embedding_result["embedding"] = emb.tolist() except Exception as e: embedding_result["error"] = str(e) elapsed = round(time.time() - start, 2) return JSONResponse({ "success": True, "elapsed_sec": elapsed, "session_token": session_token, "challenges_passed": challenges, "anti_spoof": spoof_result, "face_detection": embedding_result, "liveness_passed": ( spoof_result["is_live"] and embedding_result["face_found"] and len(challenges) >= 3 # au moins 3 défis sur 5 réussis ), }) @app.post("/liveness/face-match-with-document") async def endpoint_liveness_face_match( document_snapshot: UploadFile = File(..., description="Photo du document d'identité (déjà uploadé)"), selfie_embedding: str = Form(..., description="Embedding JSON du selfie liveness (retourné par /liveness/verify)"), ): """ Compare l'embedding du selfie liveness avec le visage sur le document. Utilisé après /liveness/verify pour compléter le KYC. Input : document (image), embedding JSON (retourné par /liveness/verify) Output : score de similarité + décision finale KYC """ import json from app.face_match import get_face_embedding_aura, compare_faces from app.utils import pil_to_cv2, cosine_similarity, score_to_decision start = time.time() doc_bytes = await document_snapshot.read() if not validate_image_size(doc_bytes): raise HTTPException(413, "Document trop volumineux (max 5 MB)") doc_pil = bytes_to_pil(doc_bytes) # Extraire l'embedding du document doc_cv2 = pil_to_cv2(doc_pil) doc_emb = get_face_embedding_aura(doc_cv2) if doc_emb is None: raise HTTPException(422, "Aucun visage détecté sur le document") # Reconstruire l'embedding du selfie depuis le JSON try: import numpy as np selfie_emb = np.array(json.loads(selfie_embedding), dtype=np.float32) except Exception as e: raise HTTPException(422, f"Embedding selfie invalide : {e}") # Calculer la similarité cosinus score = cosine_similarity(doc_emb, selfie_emb) decision = score_to_decision(score) elapsed = round(time.time() - start, 2) return JSONResponse({ "success": True, "elapsed_sec": elapsed, "similarity_score": round(score, 4), "model_used": "AuraFace-v1 + Liveness Snapshot", **decision, }) -------- FIN DES NOUVEAUX ENDPOINTS -------- ÉTAPE 28.2 — Ajouter au .env du microservice ────────────────────────────────────────────── Ajouter à la FIN de /opt/kyc-service/.env : -------- LIGNES À AJOUTER -------- # ── Active Liveness ──────────────────────────────────────────────────────── LIVENESS_THRESHOLD=0.75 SILENT_FACE_MODELS_DIR=/opt/kyc-service/models/silent_face LIVENESS_MIN_CHALLENGES=3 LIVENESS_MAX_SESSION_MINUTES=30 -------- FIN LIGNES -------- ÉTAPE 28.3 — Redémarrer le microservice ──────────────────────────────────────── sudo systemctl restart kyc-service sleep 30 sudo systemctl status kyc-service # Vérifier le health check curl -s http://127.0.0.1:20900/health | python3 -m json.tool CHECKPOINT ✅ : "silent_face_anti_spoof": true dans la réponse /health CHECKPOINT ✅ : "active (running)" dans le statut systemd ÉTAPE 28.4 — Test du nouvel endpoint ────────────────────────────────────── # Créer une image de test python3 -c " from PIL import Image import numpy as np img = Image.fromarray(np.random.randint(0,255,(480,480,3), dtype=np.uint8)) img.save('/tmp/test_liveness.jpg') print('Image test créée') " # Tester l'endpoint curl -s -X POST http://127.0.0.1:20900/liveness/verify \ -F "snapshot=@/tmp/test_liveness.jpg" \ -F "session_token=test_token_123" \ -F 'challenges_passed=["blink","smile","head_left"]' \ | python3 -m json.tool CHECKPOINT ✅ : Réponse JSON avec "success": true et les champs anti_spoof et face_detection ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 29 — MIGRATION ET MODÈLE LARAVEL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 29.1 — Migration liveness_sessions ────────────────────────────────────────── cd /home/CPANEL_USERNAME/saas-platform php artisan make:migration create_liveness_sessions_table Contenu de la migration : -------- DÉBUT DE LA MIGRATION -------- 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'); // ["blink","smile","head_left","digits","follow"] // Chiffres aléatoires pour le défi vocal $table->string('digit_challenge', 10)->nullable(); // ex: "4 7 2" // Statut $table->enum('status', [ 'pending', // lien généré, en attente 'opened', // page ouverte sur mobile 'in_progress', // défis en cours 'completed', // tous les défis validés 'failed', // trop d'échecs ou timeout 'expired', // lien expiré ])->default('pending'); // Résultats des défis $table->json('challenges_passed')->nullable(); // ["blink","smile"] $table->json('challenges_failed')->nullable(); // ["head_right"] $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(); // LIVE | SPOOF | UNCERTAIN $table->json('anti_spoof_raw')->nullable(); // Embedding du selfie liveness (pour face match ultérieur) $table->text('selfie_embedding')->nullable(); // JSON array float32 // 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(); // mobile | tablet // Timestamps de cycle de vie $table->timestamp('opened_at')->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamp('expires_at'); // +30 minutes $table->timestamps(); $table->index(['public_token', 'status']); $table->index(['kyc_verification_id', 'status']); $table->index(['tenant_id', 'created_at']); }); -------- FIN DE LA MIGRATION -------- php artisan migrate CHECKPOINT ✅ : php artisan migrate:status → liveness_sessions en "Ran" ÉTAPE 29.2 — Modèle LivenessSession ────────────────────────────────────── Créer app/Models/LivenessSession.php : -------- DÉBUT DU FICHIER LivenessSession.php -------- 'array', 'challenges_passed' => 'array', 'challenges_failed' => 'array', 'anti_spoof_raw' => 'array', 'liveness_passed' => 'boolean', 'opened_at' => 'datetime', 'completed_at' => 'datetime', 'expires_at' => 'datetime', ]; // ── Relations ─────────────────────────────────────────────────────────── public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } public function user(): BelongsTo { return $this->belongsTo(User::class); } public function kycVerification(): BelongsTo { return $this->belongsTo(KycVerification::class); } // ── Boot ──────────────────────────────────────────────────────────────── protected static function booted(): void { static::creating(function (LivenessSession $session) { // Générer un token public aléatoire sécurisé (32 chars hex) $session->public_token = bin2hex(random_bytes(16)); // Expiration dans 30 minutes $session->expires_at = now()->addMinutes(30); // Ordre des défis shufflé aléatoirement côté serveur if (empty($session->challenges_order)) { $challenges = ['blink', 'smile', 'head_left', 'digits', 'follow_dot']; shuffle($challenges); $session->challenges_order = $challenges; } // Générer les chiffres aléatoires pour le défi vocal if (empty($session->digit_challenge)) { $digits = []; for ($i = 0; $i < 3; $i++) { $digits[] = random_int(1, 9); } $session->digit_challenge = implode(' ', $digits); } }); } // ── Helpers ───────────────────────────────────────────────────────────── public function isExpired(): bool { return $this->expires_at->isPast(); } public function isPending(): bool { return $this->status === 'pending'; } public function isCompleted(): bool { return $this->status === 'completed'; } public function isFailed(): bool { return in_array($this->status, ['failed', 'expired']); } public function livenessUrl(): string { return route('liveness.challenge', ['token' => $this->public_token]); } public function markAsExpired(): void { if (!$this->isCompleted()) { $this->update(['status' => 'expired']); } } } -------- FIN DU FICHIER LivenessSession.php -------- ÉTAPE 29.3 — Ajouter la relation dans KycVerification ─────────────────────────────────────────────────────── Dans app/Models/KycVerification.php, ajouter la relation : -------- LIGNE À AJOUTER dans KycVerification.php -------- use Illuminate\Database\Eloquent\Relations\HasOne; public function livenessSession(): HasOne { return $this->hasOne(LivenessSession::class); } -------- FIN LIGNE -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 30 — SERVICE LIVENESS LARAVEL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 30.1 — Créer app/Services/Kyc/LivenessService.php ───────────────────────────────────────────────────────── -------- DÉBUT DU FICHIER LivenessService.php -------- $tenantId, 'user_id' => $userId, 'kyc_verification_id' => $kycVerificationId, ]); } /** * Vérifier le snapshot liveness contre le microservice Python. * Appelé par le Controller quand la page mobile soumet le snapshot. */ public function verifySnapshot( LivenessSession $session, string $snapshotPath, array $challengesPassed, string $ipMobile, string $userAgent, ): array { $snapshotContent = Storage::get($snapshotPath); if (!$snapshotContent) { return ['success' => false, 'error' => 'Snapshot introuvable']; } try { $response = Http::timeout(30) ->attach('snapshot', $snapshotContent, 'snapshot.jpg') ->post(config('kyc.url') . '/liveness/verify', [ 'session_token' => $session->public_token, 'challenges_passed' => json_encode($challengesPassed), ]); if ($response->failed()) { Log::error('Liveness verify failed', [ 'status' => $response->status(), 'body' => $response->body(), ]); return ['success' => false, 'error' => 'Erreur microservice liveness']; } $result = $response->json(); // Mettre à jour la session avec les résultats $session->update([ 'status' => $result['liveness_passed'] ? 'completed' : 'failed', 'challenges_passed' => $challengesPassed, 'liveness_passed' => $result['liveness_passed'] ?? false, 'liveness_score' => $result['anti_spoof']['liveness_score'] ?? null, 'spoof_decision' => $result['anti_spoof']['decision'] ?? null, 'anti_spoof_raw' => $result['anti_spoof'] ?? null, 'selfie_embedding' => json_encode($result['face_detection']['embedding'] ?? []), 'snapshot_path' => $snapshotPath, 'ip_mobile' => $ipMobile, 'user_agent_mobile' => $userAgent, 'completed_at' => now(), ]); return $result; } catch (\Exception $e) { Log::error('Liveness service exception', ['error' => $e->getMessage()]); return ['success' => false, 'error' => $e->getMessage()]; } } /** * Compare l'embedding liveness avec le document KYC. */ public function matchWithDocument( LivenessSession $session, string $documentPath, ): array { if (empty($session->selfie_embedding)) { return ['success' => false, 'error' => 'Embedding selfie manquant']; } $documentContent = Storage::get($documentPath); $response = Http::timeout(30) ->attach('document_snapshot', $documentContent, 'document.jpg') ->post(config('kyc.url') . '/liveness/face-match-with-document', [ 'selfie_embedding' => $session->selfie_embedding, ]); return $response->json(); } } -------- FIN DU FICHIER LivenessService.php -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 31 — COMPOSANTS LIVEWIRE + JAVASCRIPT MEDIAPIPE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 31.1 — Composant Livewire KycLivenessLink (côté PC — génère le QR code) ──────────────────────────────────────────────────────────────────────────────── php artisan make:livewire Kyc/LivenessLink Contenu de app/Livewire/Kyc/LivenessLink.php : -------- DÉBUT DU FICHIER LivenessLink.php -------- verificationId); $session = $livenessService->createSession( tenantId: auth()->user()->tenant_id, userId: auth()->id(), kycVerificationId: $this->verificationId, ); $this->sessionId = $session->id; $this->status = 'link_generated'; } #[Computed] public function session(): ?LivenessSession { return $this->sessionId ? LivenessSession::find($this->sessionId) : null; } // Polling toutes les 3 secondes — surveille l'avancement sur mobile #[Poll(3000)] public function checkStatus(): void { $session = $this->session; if (!$session) return; if ($session->isExpired() && !$session->isCompleted()) { $session->markAsExpired(); $this->status = 'expired'; return; } $this->status = match($session->status) { 'pending' => 'link_generated', 'opened' => 'mobile_opened', 'in_progress' => 'mobile_opened', 'completed' => 'completed', 'failed' => 'failed', 'expired' => 'expired', default => $this->status, }; if ($session->isCompleted()) { $this->dispatch('liveness-completed', [ 'liveness_passed' => $session->liveness_passed, 'score' => $session->liveness_score, ]); } } public function render() { return view('livewire.kyc.liveness-link'); } } -------- FIN DU FICHIER LivenessLink.php -------- Créer resources/views/livewire/kyc/liveness-link.blade.php : -------- DÉBUT DU FICHIER liveness-link.blade.php --------
Pour des raisons de sécurité, la vérification du visage nécessite la caméra de votre smartphone.
Liveness vérifié avec succès ✅
Score : {{ number_format($session->liveness_score * 100, 1) }}%
@elseLiveness refusé ❌
Décision : {{ $session?->spoof_decision }}
Chargement...
Préparation de la caméra...
• Placez votre visage dans le cercle
• Bonne luminosité recommandée
• Ne portez pas de lunettes de soleil
Sécurisé par BloKYC · Vos données sont protégées
-------- FIN DU FICHIER liveness/challenge.blade.php -------- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 32 — ROUTES ET CONTROLLERS LARAVEL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 32.1 — Controller LivenessController ───────────────────────────────────────────── php artisan make:controller Kyc/LivenessController Contenu de app/Http/Controllers/Kyc/LivenessController.php : -------- DÉBUT DU FICHIER LivenessController.php -------- whereIn('status', ['pending', 'opened', 'in_progress']) ->first(); if (!$session) { abort(404, 'Lien invalide ou déjà utilisé.'); } // Marquer comme ouvert sur mobile if ($session->status === 'pending') { $session->update([ 'status' => 'opened', 'opened_at' => now(), ]); } return view('liveness.challenge', compact('session')); } /** * Recevoir le snapshot après validation des défis. * POST /liveness/{token}/submit */ public function submit(Request $request, string $token) { $session = LivenessSession::where('public_token', $token) ->whereIn('status', ['opened', 'in_progress']) ->first(); if (!$session || $session->isExpired()) { return response()->json([ 'success' => false, 'message' => 'Session expirée ou invalide.', ], 422); } $request->validate([ 'snapshot' => 'required|image|mimes:jpg,jpeg,png|max:5120', 'challenges_passed' => 'nullable|string', ]); // Stocker le snapshot de manière sécurisée $snapshotPath = $request->file('snapshot')->store( "liveness/{$session->tenant_id}/snapshots", 'private', ); $challengesPassed = json_decode($request->challenges_passed ?? '[]', true); // Vérification via le microservice Python $session->update(['status' => 'in_progress', 'attempts' => $session->attempts + 1]); $result = $this->livenessService->verifySnapshot( session: $session, snapshotPath: $snapshotPath, challengesPassed: $challengesPassed, ipMobile: $request->ip(), userAgent: $request->userAgent(), ); return response()->json([ 'success' => $result['success'] ?? false, 'liveness_passed' => $result['liveness_passed'] ?? false, 'message' => $result['liveness_passed'] ? 'Vérification réussie !' : 'Vérification échouée. Spoof ou erreur détectée.', ]); } } -------- FIN DU FICHIER LivenessController.php -------- ÉTAPE 32.2 — Ajouter les routes dans routes/web.php ────────────────────────────────────────────────────── Ajouter dans routes/web.php : -------- ROUTES À AJOUTER -------- use App\Http\Controllers\Kyc\LivenessController; // ── Liveness (routes PUBLIQUES — pas de middleware auth) ───────────────────── // Le token dans l'URL est le seul mécanisme d'auth de ces routes Route::prefix('liveness')->name('liveness.')->group(function () { Route::get('/{token}', [LivenessController::class, 'challenge']) ->name('challenge'); Route::post('/{token}/submit', [LivenessController::class, 'submit']) ->name('submit'); }); -------- FIN DES ROUTES -------- ÉTAPE 32.3 — Ajouter les packages NPM pour le QR code ─────────────────────────────────────────────────────── # Dans le Terminal cPanel ou en local avant upload npm install qrcode # Importer dans resources/js/app.js : # import QRCode from 'qrcode' # window.QRCode = QRCode npm run build # Alternative CDN (si npm non disponible sur cPanel) : # Ajouter dans le layout app.blade.php avant : # ÉTAPE 32.4 — Enregistrer LivenessService dans AppServiceProvider ───────────────────────────────────────────────────────────────── Dans app/Providers/AppServiceProvider.php, ajouter dans register() : use App\Services\Kyc\LivenessService; $this->app->singleton(LivenessService::class); ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PHASE 33 — INTÉGRATION DANS LE WORKFLOW KYC EXISTANT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ÉTAPE 33.1 — Modifier VerificationForm pour inclure le liveness ──────────────────────────────────────────────────────────────── Dans app/Livewire/Kyc/VerificationForm.php, modifier la méthode submit() : Après la création de $verification, ajouter : -------- AJOUT DANS submit() -------- // Créer la session liveness automatiquement $livenessService = app(\App\Services\Kyc\LivenessService::class); $livenessSession = $livenessService->createSession( tenantId: $tenant->id, userId: auth()->id(), kycVerificationId: $verification->id, ); $this->status = 'liveness_required'; // nouvel état -------- FIN AJOUT -------- Adapter la vue verification-form.blade.php pour afficher le composant LivenessLink quand $status === 'liveness_required' : -------- AJOUT DANS verification-form.blade.php -------- @elseif ($status === 'liveness_required')