feat: Implémentation authentification NIST SP 800-63B v3.0.8

- Ajout du service PasswordSecurityService conforme NIST SP 800-63B
- Vérification des mots de passe contre la base Have I Been Pwned
- Validation : minimum 8 caractères, maximum 64 caractères
- Pas d'exigences de composition obligatoires (conforme NIST)
- Intégration dans LoginController et UserController
- Génération de mots de passe sécurisés non compromis

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-15 15:31:23 +02:00
parent 206c76c7db
commit 5e255ebf5e
49 changed files with 152716 additions and 149802 deletions

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use App\Services\PasswordSecurityService;
require_once __DIR__ . '/EmailTemplates.php';
require_once __DIR__ . '/PasswordSecurityService.php';
class ApiService {
@@ -277,34 +279,49 @@ class ApiService {
}
/**
* Génère un mot de passe sécurisé aléatoire
* Génère un mot de passe sécurisé aléatoire non compromis
* Utilise le service PasswordSecurityService pour vérifier contre HIBP
*
* @param int $minLength Longueur minimale du mot de passe (par défaut 12)
* @param int $maxLength Longueur maximale du mot de passe (par défaut 16)
* @return string Mot de passe généré
*/
public static function generateSecurePassword(int $minLength = 12, int $maxLength = 16): string {
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$length = random_int($minLength, $maxLength);
// Utiliser le nouveau service pour générer un mot de passe non compromis
$password = PasswordSecurityService::generateSecurePassword($length, 10);
// Si le service échoue (très rare), utiliser l'ancienne méthode
if ($password === null) {
LogService::log('Fallback vers génération de mot de passe classique', [
'level' => 'warning',
'reason' => 'PasswordSecurityService a échoué'
]);
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$length = rand($minLength, $maxLength);
$password = '';
$password = '';
// Au moins un de chaque type
$password .= $lowercase[rand(0, strlen($lowercase) - 1)];
$password .= $uppercase[rand(0, strlen($uppercase) - 1)];
$password .= $numbers[rand(0, strlen($numbers) - 1)];
$password .= $special[rand(0, strlen($special) - 1)];
// Au moins un de chaque type
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $special[random_int(0, strlen($special) - 1)];
// Compléter avec des caractères aléatoires
$all = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $all[rand(0, strlen($all) - 1)];
// Compléter avec des caractères aléatoires
$all = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $all[random_int(0, strlen($all) - 1)];
}
// Mélanger le mot de passe
return str_shuffle($password);
}
// Mélanger le mot de passe
return str_shuffle($password);
return $password;
}
}

View File

@@ -0,0 +1,415 @@
<?php
declare(strict_types=1);
namespace App\Services;
use LogService;
require_once __DIR__ . '/LogService.php';
/**
* Service de sécurité des mots de passe conforme à NIST SP 800-63B
* Vérifie les mots de passe contre la base de données Have I Been Pwned
* Utilise l'API k-anonymity pour préserver la confidentialité
*/
class PasswordSecurityService {
private const HIBP_API_URL = 'https://api.pwnedpasswords.com/range/';
private const MIN_PASSWORD_LENGTH = 8;
private const MAX_PASSWORD_LENGTH = 64;
private const REQUEST_TIMEOUT = 5; // secondes
/**
* Vérifie si un mot de passe a été compromis
* Utilise l'API Have I Been Pwned avec k-anonymity
*
* @param string $password Le mot de passe à vérifier
* @return array ['compromised' => bool, 'occurrences' => int, 'error' => string|null]
*/
public static function checkPasswordCompromised(string $password): array {
try {
// Calculer le hash SHA-1 du mot de passe
$sha1 = strtoupper(sha1($password));
// Extraire les 5 premiers caractères pour k-anonymity
$prefix = substr($sha1, 0, 5);
$suffix = substr($sha1, 5);
// Appeler l'API HIBP
$response = self::callHibpApi($prefix);
if ($response === null) {
// En cas d'erreur API, on laisse passer le mot de passe
// pour ne pas bloquer l'utilisateur (fail open)
return [
'compromised' => false,
'occurrences' => 0,
'error' => 'Impossible de vérifier le mot de passe contre la base de données'
];
}
// Rechercher le suffixe dans la réponse
$lines = explode("\n", $response);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
[$hashSuffix, $count] = explode(':', $line);
if ($hashSuffix === $suffix) {
LogService::log('Mot de passe compromis détecté', [
'level' => 'warning',
'occurrences' => intval($count)
]);
return [
'compromised' => true,
'occurrences' => intval($count),
'error' => null
];
}
}
// Mot de passe non trouvé dans la base de données
return [
'compromised' => false,
'occurrences' => 0,
'error' => null
];
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification HIBP', [
'level' => 'error',
'error' => $e->getMessage()
]);
// En cas d'erreur, on laisse passer (fail open)
return [
'compromised' => false,
'occurrences' => 0,
'error' => $e->getMessage()
];
}
}
/**
* Appelle l'API Have I Been Pwned
*
* @param string $prefix Les 5 premiers caractères du hash SHA-1
* @return string|null La réponse de l'API ou null en cas d'erreur
*/
private static function callHibpApi(string $prefix): ?string {
$url = self::HIBP_API_URL . $prefix;
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
'User-Agent: GeoSector-API',
'Accept: text/plain'
],
'timeout' => self::REQUEST_TIMEOUT,
'ignore_errors' => false
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true
]
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
LogService::log('Échec de l\'appel à l\'API HIBP', [
'level' => 'error',
'url' => $url
]);
return null;
}
return $response;
}
/**
* Valide un mot de passe selon les critères NIST SP 800-63B
* NIST recommande d'être très permissif : pas d'obligation de composition
*
* @param string $password Le mot de passe à valider
* @param bool $checkCompromised Vérifier si le mot de passe est compromis
* @return array ['valid' => bool, 'errors' => array, 'warnings' => array]
*/
public static function validatePassword(string $password, bool $checkCompromised = true): array {
$errors = [];
$warnings = [];
// Calculer la longueur réelle en tenant compte de l'UTF-8
$length = mb_strlen($password, 'UTF-8');
// Vérification de la longueur minimale (NIST : minimum 8)
if ($length < self::MIN_PASSWORD_LENGTH) {
$errors[] = sprintf('Le mot de passe doit contenir au moins %d caractères', self::MIN_PASSWORD_LENGTH);
}
// Vérification de la longueur maximale (NIST : maximum 64 minimum)
if ($length > self::MAX_PASSWORD_LENGTH) {
$errors[] = sprintf('Le mot de passe ne doit pas dépasser %d caractères', self::MAX_PASSWORD_LENGTH);
}
// NIST : Les espaces sont acceptés (pas d'erreur, juste un avertissement informatif)
if ($password !== trim($password)) {
// C'est juste informatif, pas une erreur selon NIST
$warnings[] = 'Note : Le mot de passe contient des espaces en début ou fin (c\'est autorisé)';
}
// Vérification contre les mots de passe compromis (NIST : obligatoire)
if ($checkCompromised && empty($errors)) {
$compromisedCheck = self::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$errors[] = sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données. Veuillez en choisir un autre.',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
} elseif ($compromisedCheck['error']) {
$warnings[] = 'Impossible de vérifier si le mot de passe a été compromis';
}
}
// Avertissements optionnels (pas des erreurs selon NIST)
// Ces vérifications sont juste informatives
if (self::hasSimplePattern($password)) {
$warnings[] = 'Suggestion : Évitez les motifs répétitifs pour plus de sécurité';
}
if (self::hasCommonSequence($password)) {
$warnings[] = 'Suggestion : Évitez les séquences communes pour plus de sécurité';
}
// NIST : Pas d'obligation de majuscules, minuscules, chiffres ou caractères spéciaux
// On peut ajouter des suggestions mais PAS d'erreurs
$hasLower = preg_match('/[a-z]/u', $password);
$hasUpper = preg_match('/[A-Z]/u', $password);
$hasDigit = preg_match('/[0-9]/u', $password);
$hasSpecial = preg_match('/[^a-zA-Z0-9]/u', $password);
$complexity = ($hasLower ? 1 : 0) + ($hasUpper ? 1 : 0) + ($hasDigit ? 1 : 0) + ($hasSpecial ? 1 : 0);
if ($complexity < 2 && $length < 12) {
$warnings[] = 'Suggestion : Un mot de passe plus long ou plus varié serait plus sécurisé';
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings
];
}
/**
* Génère un mot de passe sécurisé non compromis
*
* @param int $length Longueur du mot de passe (12-20 caractères)
* @param int $maxAttempts Nombre maximum de tentatives
* @return string|null Le mot de passe généré ou null si échec
*/
public static function generateSecurePassword(int $length = 14, int $maxAttempts = 10): ?string {
$length = max(12, min(20, $length));
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// Générer un mot de passe aléatoire
$password = self::generateRandomPassword($length);
// Vérifier s'il est compromis
$check = self::checkPasswordCompromised($password);
if (!$check['compromised']) {
return $password;
}
LogService::log('Mot de passe généré était compromis, nouvelle tentative', [
'level' => 'info',
'attempt' => $attempt + 1,
'occurrences' => $check['occurrences']
]);
}
LogService::log('Impossible de générer un mot de passe non compromis', [
'level' => 'error',
'attempts' => $maxAttempts
]);
return null;
}
/**
* Génère un mot de passe aléatoire
*
* @param int $length Longueur du mot de passe
* @return string Le mot de passe généré
*/
private static function generateRandomPassword(int $length): string {
// Caractères autorisés (sans ambiguïté visuelle)
$lowercase = 'abcdefghijkmnopqrstuvwxyz'; // sans l
$uppercase = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // sans I, O
$numbers = '23456789'; // sans 0, 1
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
// Garantir au moins un caractère de chaque type
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $special[random_int(0, strlen($special) - 1)];
// Compléter avec des caractères aléatoires
$allChars = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
// Mélanger les caractères
$passwordArray = str_split($password);
shuffle($passwordArray);
return implode('', $passwordArray);
}
/**
* Vérifie si le mot de passe contient des motifs répétitifs simples
*
* @param string $password Le mot de passe à vérifier
* @return bool True si des motifs répétitifs sont détectés
*/
private static function hasSimplePattern(string $password): bool {
$lowPassword = strtolower($password);
// Vérifier les caractères répétés (aaa, 111, etc.)
if (preg_match('/(.)\1{2,}/', $lowPassword)) {
return true;
}
// Vérifier les motifs répétés (ababab, 121212, etc.)
if (preg_match('/(.{2,})\1{2,}/', $lowPassword)) {
return true;
}
return false;
}
/**
* Vérifie si le mot de passe contient des séquences communes
*
* @param string $password Le mot de passe à vérifier
* @return bool True si des séquences communes sont détectées
*/
private static function hasCommonSequence(string $password): bool {
$lowPassword = strtolower($password);
$commonSequences = [
'123', '234', '345', '456', '567', '678', '789',
'abc', 'bcd', 'cde', 'def', 'efg', 'fgh',
'qwerty', 'azerty', 'qwertz',
'password', 'motdepasse', 'admin', 'user'
];
foreach ($commonSequences as $sequence) {
if (stripos($lowPassword, $sequence) !== false) {
return true;
}
}
return false;
}
/**
* Estime la force d'un mot de passe selon l'approche NIST
* NIST privilégie la longueur sur la complexité
*
* @param string $password Le mot de passe à évaluer
* @return array ['score' => int (0-100), 'strength' => string, 'feedback' => array]
*/
public static function estimatePasswordStrength(string $password): array {
$score = 0;
$feedback = [];
// Longueur (NIST : facteur le plus important)
$length = mb_strlen($password, 'UTF-8');
if ($length >= 8) $score += 20; // Minimum requis
if ($length >= 12) $score += 20; // Bon
if ($length >= 16) $score += 20; // Très bon
if ($length >= 20) $score += 15; // Excellent
if ($length >= 30) $score += 10; // Exceptionnel
// Diversité des caractères (bonus, pas obligatoire selon NIST)
$hasLower = preg_match('/[a-z]/u', $password);
$hasUpper = preg_match('/[A-Z]/u', $password);
$hasDigit = preg_match('/[0-9]/u', $password);
$hasSpecial = preg_match('/[^a-zA-Z0-9]/u', $password);
$diversity = ($hasLower ? 1 : 0) + ($hasUpper ? 1 : 0) + ($hasDigit ? 1 : 0) + ($hasSpecial ? 1 : 0);
// Bonus pour la diversité (mais pas de pénalité si absent)
if ($diversity >= 4) {
$score += 15;
} elseif ($diversity >= 3) {
$score += 10;
} elseif ($diversity >= 2) {
$score += 5;
}
// Suggestions constructives (pas de pénalités selon NIST)
if ($length < 12) {
$feedback[] = 'Suggestion : Un mot de passe plus long est plus sécurisé';
}
if ($diversity < 2 && $length < 16) {
$feedback[] = 'Suggestion : Variez les types de caractères ou augmentez la longueur';
}
// Pénalités légères pour les mauvaises pratiques évidentes
if (self::hasSimplePattern($password)) {
$score = max(0, $score - 10);
$feedback[] = 'Attention : Motifs répétitifs détectés';
}
if (self::hasCommonSequence($password)) {
$score = max(0, $score - 10);
$feedback[] = 'Attention : Séquences communes détectées';
}
// Vérification compromission (critique selon NIST)
$compromisedCheck = self::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$score = min($score, 10); // Score très bas si compromis
$feedback[] = sprintf(
'CRITIQUE : Mot de passe trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
}
// Déterminer la force basée principalement sur la longueur (approche NIST)
$strength = 'Très faible';
if ($compromisedCheck['compromised']) {
$strength = 'Compromis';
} elseif ($length >= 20) {
$strength = 'Très fort';
} elseif ($length >= 16) {
$strength = 'Fort';
} elseif ($length >= 12) {
$strength = 'Bon';
} elseif ($length >= 8) {
$strength = 'Acceptable';
} else {
$strength = 'Trop court';
}
return [
'score' => max(0, min(100, $score)),
'strength' => $strength,
'feedback' => $feedback,
'length' => $length,
'diversity' => $diversity
];
}
}