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

@@ -216,10 +216,12 @@ class _UserFormState extends State<UserForm> {
}
}
// Nettoyer une chaîne pour l'username
// Nettoyer une chaîne pour l'username (NIST: garder plus de caractères)
String _cleanString(String input) {
// Avec NIST, on peut garder plus de caractères, mais pour la génération automatique
// on reste sur des caractères simples pour éviter les problèmes
return input.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]'), ''); // Garder seulement lettres et chiffres
.replaceAll(RegExp(r'[^a-z0-9\s]'), ''); // Garder lettres, chiffres et espaces
}
// Extraire une partie aléatoire d'une chaîne
@@ -235,7 +237,7 @@ class _UserFormState extends State<UserForm> {
return cleaned.substring(0, length);
}
// Générer un username selon l'algorithme spécifié
// Générer un username selon les nouvelles normes NIST
String _generateUsername() {
// Récupérer les données nécessaires
final nom = _nameController.text.isNotEmpty ? _nameController.text : _sectNameController.text;
@@ -248,22 +250,21 @@ class _UserFormState extends State<UserForm> {
final villePart = _extractRandomPart(ville, 2, 4);
final nombreAleatoire = 10 + _random.nextInt(990); // 10 à 999
// Choisir des séparateurs aléatoires (uniquement ceux autorisés: ., _, -)
final separateurs = ['', '.', '_', '-'];
// Choisir des séparateurs aléatoires (on peut maintenant utiliser des espaces aussi)
final separateurs = ['', '.', '_', '-', ' '];
final sep1 = separateurs[_random.nextInt(separateurs.length)];
final sep2 = separateurs[_random.nextInt(separateurs.length)];
// Assembler l'username
String username = '$nomPart$sep1$cpPart$sep2$villePart$nombreAleatoire';
// Si trop court, ajouter des chiffres pour atteindre minimum 10 caractères
while (username.length < 10) {
// Si trop court, ajouter des chiffres pour atteindre minimum 8 caractères (NIST)
while (username.length < 8) {
username += _random.nextInt(10).toString();
}
// S'assurer que l'username ne contient que des caractères autorisés (a-z, 0-9, ., -, _)
// Normalement déjà le cas avec notre algorithme, mais au cas où
username = username.toLowerCase().replaceAll(RegExp(r'[^a-z0-9._-]'), '');
// Avec NIST, on n'a plus besoin de nettoyer les caractères spéciaux
// Tous les caractères sont acceptés
return username;
}
@@ -341,7 +342,7 @@ class _UserFormState extends State<UserForm> {
}
}
// Valider le mot de passe selon les règles
// Valider le mot de passe selon les normes NIST SP 800-63B
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
// Pour un nouveau membre, le mot de passe est obligatoire si le champ est affiché
@@ -351,85 +352,101 @@ class _UserFormState extends State<UserForm> {
return null; // En modification, vide = garder l'ancien
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le mot de passe ne peut pas être vide";
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le mot de passe doit contenir au moins 8 caractères";
}
// Vérifier qu'il n'y a pas d'espaces dans le mot de passe
if (trimmedValue.contains(' ')) {
return "Le mot de passe ne doit pas contenir d'espaces";
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le mot de passe ne doit pas dépasser 64 caractères";
}
// Vérifier la longueur
if (trimmedValue.length < 12) {
return "Le mot de passe doit contenir au moins 12 caractères";
}
if (trimmedValue.length > 16) {
return "Le mot de passe ne doit pas dépasser 16 caractères";
}
// Vérifier qu'il n'est pas égal au username (après trim des deux)
if (trimmedValue == _usernameController.text.trim()) {
return "Le mot de passe ne doit pas être identique au nom d'utilisateur";
}
// Vérifier la présence d'au moins une minuscule
if (!trimmedValue.contains(RegExp(r'[a-z]'))) {
return "Le mot de passe doit contenir au moins une lettre minuscule";
}
// Vérifier la présence d'au moins une majuscule
if (!trimmedValue.contains(RegExp(r'[A-Z]'))) {
return "Le mot de passe doit contenir au moins une lettre majuscule";
}
// Vérifier la présence d'au moins un chiffre
if (!trimmedValue.contains(RegExp(r'[0-9]'))) {
return "Le mot de passe doit contenir au moins un chiffre";
}
// Vérifier la présence d'au moins un caractère spécial
if (!trimmedValue.contains(RegExp(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]'))) {
return "Le mot de passe doit contenir au moins un caractère spécial (!@#\$%^&*()_+-=[]{}|;:,.<>?)";
}
// NIST: Pas de vérification de composition obligatoire
// Les espaces sont autorisés, tous les caractères sont acceptés
// L'API vérifiera contre Have I Been Pwned
// Pas de vérification password == username (demande client)
return null;
}
// Générer un mot de passe aléatoire respectant les règles
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
String _generatePassword() {
const String lowercase = 'abcdefghijklmnopqrstuvwxyz';
const String uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const String digits = '0123456789';
const String special = '!@#\$%^&*()_+-=[]{}|;:,.<>?';
// Listes de mots pour créer des phrases de passe mémorables
final sujets = [
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
];
// Longueur aléatoire entre 12 et 16
final length = 12 + _random.nextInt(5);
final noms = [
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
];
// S'assurer d'avoir au moins un caractère de chaque type
List<String> password = [];
password.add(lowercase[_random.nextInt(lowercase.length)]);
password.add(uppercase[_random.nextInt(uppercase.length)]);
password.add(digits[_random.nextInt(digits.length)]);
password.add(special[_random.nextInt(special.length)]);
final verbes = [
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
];
// Compléter avec des caractères aléatoires
const String allChars = lowercase + uppercase + digits + special;
for (int i = password.length; i < length; i++) {
password.add(allChars[_random.nextInt(allChars.length)]);
final complements = [
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
];
// Choisir un type de phrase aléatoirement
final typePhrase = _random.nextInt(3);
String phrase;
switch (typePhrase) {
case 0:
// Type: Sujet + nom propre + verbe + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final nom = noms[_random.nextInt(noms.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $nom $verbe $complement';
break;
case 1:
// Type: Nom propre + a + nombre + ans + point d'exclamation
final nom = noms[_random.nextInt(noms.length)];
final age = 1 + _random.nextInt(20);
phrase = '$nom a $age ans!';
break;
default:
// Type: Sujet + verbe + nombre + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final nombre = 1 + _random.nextInt(100);
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $verbe $nombre fois $complement';
}
// Mélanger les caractères
password.shuffle(_random);
// Ajouter éventuellement un caractère spécial à la fin
if (_random.nextBool()) {
final speciaux = ['!', '?', '.', '...', '', '', '', ''];
phrase += speciaux[_random.nextInt(speciaux.length)];
}
return password.join('');
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
if (phrase.length < 8) {
phrase += ' ${1000 + _random.nextInt(9000)}';
}
// Tronquer si trop long (max 64 caractères selon NIST)
if (phrase.length > 64) {
phrase = phrase.substring(0, 64);
}
return phrase;
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
final password = _passwordController.text.trim();
final password = _passwordController.text; // NIST: ne pas faire de trim, les espaces sont autorisés
return password.isNotEmpty ? password : null;
}
@@ -437,7 +454,7 @@ class _UserFormState extends State<UserForm> {
UserModel? validateAndGetUser() {
if (_formKey.currentState!.validate()) {
return widget.user?.copyWith(
username: _usernameController.text.trim(), // Appliquer trim
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
@@ -450,7 +467,7 @@ class _UserFormState extends State<UserForm> {
) ??
UserModel(
id: 0,
username: _usernameController.text.trim(), // Appliquer trim
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
@@ -729,30 +746,23 @@ class _UserFormState extends State<UserForm> {
)
: null,
helperText: canEditUsername
? "Min. 10 caractères (a-z, 0-9, . - _)"
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperMaxLines: 2,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le nom d'utilisateur doit contenir au moins 8 caractères";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le nom d'utilisateur ne doit pas dépasser 64 caractères";
}
// Pas de vérification sur le type de caractères (NIST: tous acceptés)
return null;
}
: null,
@@ -800,7 +810,8 @@ class _UserFormState extends State<UserForm> {
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperMaxLines: 3,
validator: _validatePassword,
),
),
@@ -837,30 +848,23 @@ class _UserFormState extends State<UserForm> {
)
: null,
helperText: canEditUsername
? "Min. 10 caractères (a-z, 0-9, . - _)"
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperMaxLines: 2,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
// Vérifier la longueur minimale (NIST: 8 caractères)
if (value.length < 8) {
return "Le nom d'utilisateur doit contenir au moins 8 caractères";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
// Vérifier la longueur maximale (NIST: 64 caractères)
if (value.length > 64) {
return "Le nom d'utilisateur ne doit pas dépasser 64 caractères";
}
// Pas de vérification sur le type de caractères (NIST: tous acceptés)
return null;
}
: null,
@@ -906,7 +910,8 @@ class _UserFormState extends State<UserForm> {
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperMaxLines: 3,
validator: _validatePassword,
),
const SizedBox(height: 16),