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

@@ -819,16 +819,16 @@ class LoginController {
// Chiffrement de l'email pour la recherche
$encryptedEmail = ApiService::encryptSearchableData($email);
// Recherche de l'utilisateur
// Recherche de TOUS les utilisateurs avec cet email (actifs ou non)
$stmt = $this->db->prepare('
SELECT id, encrypted_name, encrypted_user_name, chk_active
FROM users
WHERE encrypted_email = ?
');
$stmt->execute([$encryptedEmail]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$user) {
if (empty($users)) {
Response::json([
'status' => 'error',
'message' => 'Aucun compte trouvé avec cet email'
@@ -836,54 +836,74 @@ class LoginController {
return;
}
if ($user['chk_active'] == 0) {
Response::json([
'status' => 'error',
'message' => 'Ce compte est désactivé. Contactez l\'administrateur.'
], 403);
return;
}
// Déchiffrement du nom et du username
$name = ApiService::decryptData($user['encrypted_name']);
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
// Génération d'un nouveau mot de passe
// Génération d'un nouveau mot de passe unique pour tous les comptes
$newPassword = ApiService::generateSecurePassword();
$passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
// Mise à jour du mot de passe
// Mise à jour du mot de passe pour TOUS les comptes avec cet email
$updateStmt = $this->db->prepare('
UPDATE users
SET user_pass_hash = ?, updated_at = NOW()
WHERE id = ?
WHERE encrypted_email = ?
');
$updateStmt->execute([$passwordHash, $user['id']]);
$updateStmt->execute([$passwordHash, $encryptedEmail]);
// Récupération du nombre de comptes mis à jour
$updatedCount = $updateStmt->rowCount();
// Envoi de l'email avec le nouveau mot de passe
// Collecte des usernames et du premier nom pour l'email
$usernames = [];
$firstName = '';
foreach ($users as $user) {
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
if ($username) {
$usernames[] = $username;
}
// Utiliser le premier nom trouvé pour personnaliser l'email
if (empty($firstName) && !empty($user['encrypted_name'])) {
$firstName = ApiService::decryptData($user['encrypted_name']);
}
}
// Si aucun nom n'a été trouvé, utiliser "Utilisateur"
if (empty($firstName)) {
$firstName = 'Utilisateur';
}
// Envoi d'un seul email avec le nouveau mot de passe et la liste des comptes affectés
$emailData = [
'username' => implode(', ', $usernames), // Liste tous les usernames concernés
'password' => $newPassword
];
$emailSent = ApiService::sendEmail(
$email,
$name,
$firstName,
'lostpwd',
['username' => $username, 'password' => $newPassword]
$emailData
);
if ($emailSent) {
LogService::log('Réinitialisation mot de passe GeoSector réussie', [
'level' => 'info',
'userId' => $user['id'],
'email' => $email
'email' => $email,
'comptes_modifies' => $updatedCount,
'usernames' => $usernames
]);
$message = $updatedCount > 1
? sprintf('Un nouveau mot de passe a été envoyé pour les %d comptes associés à votre adresse email', $updatedCount)
: 'Un nouveau mot de passe a été envoyé à votre adresse email';
Response::json([
'status' => 'success',
'message' => 'Un nouveau mot de passe a été envoyé à votre adresse email'
'message' => $message
]);
} else {
LogService::log('Échec envoi email réinitialisation mot de passe GeoSector', [
'level' => 'error',
'userId' => $user['id'],
'email' => $email
'email' => $email,
'comptes_modifies' => $updatedCount
]);
Response::json([
@@ -999,7 +1019,9 @@ class LoginController {
}
// 4. Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
$encryptedEmail = ApiService::encryptSearchableData($email);
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ?');
$checkStmt->execute([$encryptedEmail]);
if ($checkStmt->fetch()) {
@@ -1009,6 +1031,7 @@ class LoginController {
], 409);
return;
}
*/
// 5. Vérification de l'existence du code postal dans la table entites
$checkPostalStmt = $this->db->prepare('SELECT id FROM entites WHERE code_postal = ?');

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/PasswordSecurityService.php';
require_once __DIR__ . '/../Services/LogService.php';
use Request;
use Response;
use LogService;
use App\Services\PasswordSecurityService;
/**
* Contrôleur pour la gestion de la sécurité des mots de passe
* Fournit des endpoints pour vérifier la force et la compromission des mots de passe
*/
class PasswordController {
/**
* Vérifie la force d'un mot de passe et s'il a été compromis
* Endpoint utilisable sans authentification pour le formulaire d'inscription
*
* POST /api/password/check
*/
public function checkStrength(): void {
try {
$data = Request::getJson();
if (!isset($data['password']) || empty($data['password'])) {
Response::json([
'status' => 'error',
'message' => 'Mot de passe requis'
], 400);
return;
}
$password = $data['password'];
$checkCompromised = $data['check_compromised'] ?? true;
// Validation du mot de passe
$validation = PasswordSecurityService::validatePassword($password, $checkCompromised);
// Estimation de la force
$strength = PasswordSecurityService::estimatePasswordStrength($password);
// Vérification spécifique de compromission si demandée
$compromisedInfo = null;
if ($checkCompromised) {
$compromisedCheck = PasswordSecurityService::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$compromisedInfo = [
'compromised' => true,
'occurrences' => $compromisedCheck['occurrences'],
'message' => sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
)
];
}
}
Response::json([
'status' => 'success',
'valid' => $validation['valid'],
'errors' => $validation['errors'],
'warnings' => $validation['warnings'],
'strength' => $strength,
'compromised' => $compromisedInfo
]);
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification du mot de passe', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la vérification du mot de passe'
], 500);
}
}
/**
* Génère un mot de passe sécurisé aléatoire
* Endpoint nécessitant une authentification
*
* GET /api/password/generate
*/
public function generate(): void {
try {
// Vérifier l'authentification
if (!isset($_SESSION['user_id'])) {
Response::json([
'status' => 'error',
'message' => 'Authentification requise'
], 401);
return;
}
// Récupérer les paramètres optionnels
$length = isset($_GET['length']) ? intval($_GET['length']) : 14;
$length = max(12, min(20, $length)); // Limiter entre 12 et 20
// Générer un mot de passe non compromis
$password = PasswordSecurityService::generateSecurePassword($length);
if ($password === null) {
// En cas d'échec, utiliser la méthode classique
$password = $this->generateFallbackPassword($length);
}
// Calculer la force du mot de passe généré
$strength = PasswordSecurityService::estimatePasswordStrength($password);
Response::json([
'status' => 'success',
'password' => $password,
'length' => strlen($password),
'strength' => $strength
]);
} catch (\Exception $e) {
LogService::log('Erreur lors de la génération du mot de passe', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la génération du mot de passe'
], 500);
}
}
/**
* Vérifie uniquement si un mot de passe est compromis
* Endpoint rapide pour vérification en temps réel
*
* POST /api/password/compromised
*/
public function checkCompromised(): void {
try {
$data = Request::getJson();
if (!isset($data['password']) || empty($data['password'])) {
Response::json([
'status' => 'error',
'message' => 'Mot de passe requis'
], 400);
return;
}
$password = $data['password'];
// Vérification de compromission
$compromisedCheck = PasswordSecurityService::checkPasswordCompromised($password);
$response = [
'status' => 'success',
'compromised' => $compromisedCheck['compromised'],
'occurrences' => $compromisedCheck['occurrences']
];
if ($compromisedCheck['compromised']) {
$response['message'] = sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
$response['recommendation'] = 'Il est fortement recommandé de choisir un autre mot de passe';
}
if ($compromisedCheck['error']) {
$response['warning'] = 'Impossible de vérifier complètement le mot de passe';
}
Response::json($response);
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification de compromission', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la vérification'
], 500);
}
}
/**
* Génère un mot de passe de secours si le service principal échoue
*
* @param int $length Longueur du mot de passe
* @return string Le mot de passe généré
*/
private function generateFallbackPassword(int $length): string {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
for ($i = 0; $i < $length; $i++) {
$password .= $chars[random_int(0, strlen($chars) - 1)];
}
return $password;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/PasswordSecurityService.php';
use PDO;
use PDOException;
@@ -16,6 +17,7 @@ use Response;
use Session;
use LogService;
use ApiService;
use App\Services\PasswordSecurityService;
class UserController {
private PDO $db;
@@ -270,6 +272,8 @@ class UserController {
$encryptedName = ApiService::encryptData($name);
// Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ?');
$checkStmt->execute([$encryptedEmail]);
if ($checkStmt->fetch()) {
@@ -279,6 +283,7 @@ class UserController {
], 409);
return;
}
*/
// Gestion du USERNAME selon chk_username_manuel
$encryptedUsername = '';
@@ -342,18 +347,31 @@ class UserController {
$password = $data['password'];
// Validation du mot de passe (minimum 8 caractères)
if (strlen($password) < 8) {
// Validation du mot de passe selon NIST SP 800-63B
$passwordValidation = PasswordSecurityService::validatePassword($password);
if (!$passwordValidation['valid']) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe doit contenir au moins 8 caractères'
'message' => 'Mot de passe invalide',
'errors' => $passwordValidation['errors'],
'warnings' => $passwordValidation['warnings']
], 400);
return;
}
// Si le mot de passe a des avertissements mais est valide, les logger
if (!empty($passwordValidation['warnings'])) {
LogService::log('Mot de passe manuel avec avertissements accepté lors de la création', [
'level' => 'warning',
'email' => $email,
'warnings' => $passwordValidation['warnings']
]);
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
} else {
// Génération automatique du mot de passe
// Génération automatique du mot de passe (déjà vérifié contre HIBP)
$password = ApiService::generateSecurePassword();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
@@ -505,6 +523,9 @@ class UserController {
$email = trim(strtolower($data['email']));
$encryptedEmail = ApiService::encryptSearchableData($email);
// Vérification de l'unicité de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ? AND id != ?');
$checkStmt->execute([$encryptedEmail, $id]);
if ($checkStmt->fetch()) {
@@ -514,6 +535,7 @@ class UserController {
], 409);
return;
}
*/
$updateFields[] = "encrypted_email = :encrypted_email";
$params['encrypted_email'] = $encryptedEmail;
@@ -556,13 +578,28 @@ class UserController {
// Mise à jour du mot de passe si fourni
if (isset($data['password']) && !empty($data['password'])) {
if (strlen($data['password']) < 8) {
// Validation du mot de passe selon NIST SP 800-63B
$passwordValidation = PasswordSecurityService::validatePassword($data['password']);
if (!$passwordValidation['valid']) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe doit contenir au moins 8 caractères'
'message' => 'Mot de passe invalide',
'errors' => $passwordValidation['errors'],
'warnings' => $passwordValidation['warnings']
], 400);
return;
}
// Si le mot de passe a des avertissements mais est valide, les logger
if (!empty($passwordValidation['warnings'])) {
LogService::log('Mot de passe avec avertissements accepté', [
'level' => 'warning',
'user_id' => $id,
'warnings' => $passwordValidation['warnings']
]);
}
$updateFields[] = "user_pass_hash = :password";
$params['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}