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

@@ -6,6 +6,7 @@ class CustomTextField extends StatelessWidget {
final String label;
final String? hintText;
final String? helperText;
final int? helperMaxLines;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool readOnly;
@@ -33,6 +34,7 @@ class CustomTextField extends StatelessWidget {
required this.label,
this.hintText,
this.helperText,
this.helperMaxLines,
this.prefixIcon,
this.suffixIcon,
this.readOnly = false,
@@ -79,6 +81,7 @@ class CustomTextField extends StatelessWidget {
labelText: isRequired ? "$label *" : label,
hintText: hintText,
helperText: helperText,
helperMaxLines: helperMaxLines,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
suffixIcon: suffixIcon,
border: const OutlineInputBorder(),
@@ -150,6 +153,7 @@ class CustomTextField extends StatelessWidget {
decoration: InputDecoration(
hintText: hintText,
helperText: helperText,
helperMaxLines: helperMaxLines,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
suffixIcon: suffixIcon,
border: OutlineInputBorder(

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
@@ -42,7 +44,10 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Vérifier si le logo de l'amicale est présent pour ajuster la largeur du leading
final amicale = CurrentAmicaleService.instance.currentAmicale;
final hasAmicaleLogo = amicale?.logoBase64 != null && amicale!.logoBase64!.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -52,6 +57,8 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
foregroundColor: theme.colorScheme.onPrimary,
elevation: 4,
leading: _buildLogo(),
// Ajuster la largeur du leading si le logo de l'amicale est présent
leadingWidth: hasAmicaleLogo ? 110 : 56, // 56 par défaut, 110 pour 2 logos + espacement
actions: _buildActions(context),
),
// Bordure colorée selon le rôle
@@ -65,6 +72,9 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Construction du logo dans l'AppBar
Widget _buildLogo() {
final amicale = CurrentAmicaleService.instance.currentAmicale;
final logoBase64 = amicale?.logoBase64;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
@@ -75,11 +85,56 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
width: 40,
height: 40,
),
// Afficher le logo de l'amicale s'il est disponible
if (logoBase64 != null && logoBase64.isNotEmpty) ...[
const SizedBox(width: 8),
_buildAmicaleLogo(logoBase64),
],
],
),
);
}
/// Construction du logo de l'amicale à partir du Base64
Widget _buildAmicaleLogo(String logoBase64) {
try {
// Le logoBase64 peut être au format "data:image/png;base64,..." ou juste la chaîne base64
String base64String = logoBase64;
if (logoBase64.contains('base64,')) {
base64String = logoBase64.split('base64,').last;
}
final decodedBytes = base64Decode(base64String);
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
decodedBytes,
width: 40,
height: 40,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// En cas d'erreur de décodage, ne rien afficher
debugPrint('Erreur lors du chargement du logo amicale: $error');
return const SizedBox.shrink();
},
),
),
);
} catch (e) {
// En cas d'erreur, ne rien afficher
debugPrint('Erreur lors du décodage du logo amicale: $e');
return const SizedBox.shrink();
}
}
/// Construction des actions de l'AppBar
List<Widget> _buildActions(BuildContext context) {
final theme = Theme.of(context);
@@ -263,9 +318,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
return Text(title);
}
// Construire un titre composé en fonction du rôle de l'utilisateur
final String prefix = isAdmin ? 'Administration' : title;
// Utiliser LayoutBuilder pour détecter la largeur disponible
return LayoutBuilder(
builder: (context, constraints) {
@@ -274,20 +326,14 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
final isMobilePlatform = Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS;
// Cacher le titre de page sur mobile ou écrans étroits
// Sur mobile ou écrans étroits, afficher seulement le titre principal
if (isNarrowScreen || isMobilePlatform) {
return Text(prefix);
return Text(title);
}
// Afficher le titre complet sur écrans larges (web desktop)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(prefix),
const Text(' - '),
Text(pageTitle!),
],
);
// Sur écrans larges (web desktop), afficher le titre de la page ou le titre principal
// Pour les admins, on affiche directement le titre de la page sans préfixe
return Text(pageTitle!);
},
);
}

View File

@@ -155,14 +155,25 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
/// Construction de la barre de navigation inférieure pour mobile
Widget _buildBottomNavigationBar() {
final theme = Theme.of(context);
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
return NavigationBar(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
backgroundColor: theme.colorScheme.surface,
elevation: 8,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
destinations: widget.destinations,
return Theme(
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
onSecondaryContainer: selectedColor, // Couleur de l'icône sélectionnée
secondaryContainer: selectedColor.withOpacity(0.15), // Couleur de fond de l'indicateur
),
),
child: NavigationBar(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
backgroundColor: theme.colorScheme.surface,
elevation: 8,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
destinations: widget.destinations,
),
);
}
@@ -345,6 +356,10 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
final theme = Theme.of(context);
final isSelected = widget.selectedIndex == index;
final IconData? iconData = (icon is Icon) ? (icon).icon : null;
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
final Color unselectedColor = theme.colorScheme.onSurface.withOpacity(0.6);
// Remplacer certains titres si l'interface est de type "user"
String displayTitle = title;
@@ -371,16 +386,14 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
height: 50,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary.withOpacity(0.1)
? selectedColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
color: isSelected ? selectedColor : unselectedColor,
size: 24,
)
: icon,
@@ -394,22 +407,18 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
leading: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
color: isSelected ? selectedColor : unselectedColor,
)
: icon,
title: Text(
displayTitle,
style: TextStyle(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
color: isSelected ? selectedColor : theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
tileColor:
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
isSelected ? selectedColor.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
class SectorDistributionCard extends StatelessWidget {
// Enum pour les types de tri
enum SortType { name, count, progress }
enum SortOrder { none, asc, desc }
class SectorDistributionCard extends StatefulWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
@@ -17,11 +23,81 @@ class SectorDistributionCard extends StatelessWidget {
this.padding,
});
@override
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
}
class _SectorDistributionCardState extends State<SectorDistributionCard> {
SortType? _currentSortType;
SortOrder _currentSortOrder = SortOrder.none;
void _onSortPressed(SortType sortType) {
setState(() {
if (_currentSortType == sortType) {
// Cycle through: none -> asc -> desc -> none
if (_currentSortOrder == SortOrder.none) {
_currentSortOrder = SortOrder.asc;
} else if (_currentSortOrder == SortOrder.asc) {
_currentSortOrder = SortOrder.desc;
} else {
_currentSortOrder = SortOrder.none;
_currentSortType = null;
}
} else {
_currentSortType = sortType;
_currentSortOrder = SortOrder.asc;
}
});
}
Widget _buildSortButton(String label, SortType sortType) {
final isActive = _currentSortType == sortType && _currentSortOrder != SortOrder.none;
final isAsc = _currentSortType == sortType && _currentSortOrder == SortOrder.asc;
final isDesc = _currentSortType == sortType && _currentSortOrder == SortOrder.desc;
return InkWell(
onTap: () => _onSortPressed(sortType),
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isActive ? Colors.blue.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isActive ? Colors.blue : Colors.grey[400]!,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? Colors.blue : Colors.grey[700],
),
),
if (isActive) ...[
const SizedBox(width: 2),
Icon(
isAsc ? Icons.arrow_upward : Icons.arrow_downward,
size: 12,
color: Colors.blue,
),
],
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Container(
height: height,
padding: padding ?? const EdgeInsets.all(AppTheme.spacingM),
height: widget.height,
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
@@ -30,12 +106,29 @@ class SectorDistributionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
// Ligne du titre avec boutons de tri
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
// Boutons de tri groupés
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSortButton('Nom', SortType.name),
const SizedBox(width: 4),
_buildSortButton('Nb', SortType.count),
const SizedBox(width: 4),
_buildSortButton('%', SortType.progress),
],
),
],
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
@@ -75,6 +168,16 @@ class SectorDistributionCard extends StatelessWidget {
);
}
// Appliquer le tri
_applySorting(sectorStats);
// Trouver le maximum de passages pour un secteur
final maxCount = sectorStats.fold<int>(
0,
(max, sector) => sector['count'] > max ? sector['count'] : max,
);
// Liste des secteurs directement sans sous-titre
return ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
@@ -84,6 +187,7 @@ class SectorDistributionCard extends StatelessWidget {
sector['count'],
Color(sector['color']),
sectorStats,
maxCount, // Passer le max pour calculer les proportions
);
},
);
@@ -103,83 +207,281 @@ class SectorDistributionCard extends StatelessWidget {
final List<SectorModel> sectors = sectorsBox.values.toList();
final List<PassageModel> passages = passagesBox.values.toList();
// Compter les passages par secteur (en excluant ceux où fkType==2)
final Map<int, int> sectorCounts = {};
for (final passage in passages) {
// Exclure les passages où fkType==2 et ceux sans secteur
if (passage.fkType != 2 && passage.fkSector != null) {
sectorCounts[passage.fkSector!] =
(sectorCounts[passage.fkSector!] ?? 0) + 1;
}
}
// Préparer les données pour l'affichage
// Préparer les données pour l'affichage - AFFICHER TOUS LES SECTEURS
List<Map<String, dynamic>> stats = [];
for (final sector in sectors) {
final count = sectorCounts[sector.id] ?? 0;
if (count > 0) {
stats.add({
'name': sector.libelle,
'count': count,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
// Compter les passages par type pour ce secteur
Map<int, int> passagesByType = {};
int totalCount = 0;
int passagesNotType2 = 0;
// Compter tous les passages pour ce secteur
for (final passage in passages) {
if (passage.fkSector == sector.id) {
final type = passage.fkType;
passagesByType[type] = (passagesByType[type] ?? 0) + 1;
totalCount++;
if (type != 2) {
passagesNotType2++;
}
}
}
// Calculer le pourcentage d'avancement
final int progressPercentage = totalCount > 0
? ((passagesNotType2 / totalCount) * 100).round()
: 0;
stats.add({
'id': sector.id,
'name': sector.libelle,
'count': totalCount,
'passagesByType': passagesByType,
'progressPercentage': progressPercentage,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
}
// Trier par nombre de passages (décroissant)
stats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
return stats;
}
void _applySorting(List<Map<String, dynamic>> stats) {
if (_currentSortType == null || _currentSortOrder == SortOrder.none) {
// Tri par défaut : par nombre de passages décroissant, puis par nom
stats.sort((a, b) {
int countCompare = (b['count'] as int).compareTo(a['count'] as int);
if (countCompare != 0) return countCompare;
return (a['name'] as String).compareTo(b['name'] as String);
});
return;
}
switch (_currentSortType!) {
case SortType.name:
stats.sort((a, b) {
final result = (a['name'] as String).compareTo(b['name'] as String);
return _currentSortOrder == SortOrder.asc ? result : -result;
});
break;
case SortType.count:
stats.sort((a, b) {
final result = (a['count'] as int).compareTo(b['count'] as int);
return _currentSortOrder == SortOrder.asc ? result : -result;
});
break;
case SortType.progress:
stats.sort((a, b) {
final result = (a['progressPercentage'] as int).compareTo(b['progressPercentage'] as int);
return _currentSortOrder == SortOrder.asc ? result : -result;
});
break;
}
}
Widget _buildSectorItem(
String name,
int count,
Color color,
List<Map<String, dynamic>> allStats,
int maxCount,
) {
final totalCount =
allStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
// Récupérer les données du secteur actuel
final sectorData = allStats.firstWhere((s) => s['name'] == name);
final Map<int, int> passagesByType = sectorData['passagesByType'] ?? {};
final int progressPercentage = sectorData['progressPercentage'] ?? 0;
final int sectorId = sectorData['id'] ?? 0;
// Calculer le ratio par rapport au maximum (éviter division par zéro)
final double widthRatio = maxCount > 0 ? count / maxCount : 0;
// Style différent pour les secteurs sans passages
final bool hasPassages = count > 0;
final textColor = hasPassages ? Colors.black87 : Colors.grey;
// Vérifier si l'utilisateur est admin
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom du secteur et total
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
child: isAdmin
? InkWell(
onTap: () {
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('admin_selectedSectorId', sectorId);
settingsBox.put('adminSelectedPageIndex', 4); // Index de la page carte
// Naviguer vers le dashboard admin qui chargera la page carte
context.go('/admin');
},
child: Text(
name,
style: TextStyle(
fontSize: 14,
color: textColor,
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
decoration: TextDecoration.underline,
decorationColor: textColor.withOpacity(0.5),
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
name,
style: TextStyle(
fontSize: 14,
color: textColor,
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
hasPassages
? '$count passages ($progressPercentage% d\'avancement)'
: '0 passage',
style: TextStyle(
fontWeight: hasPassages ? FontWeight.bold : FontWeight.normal,
fontSize: 13,
color: textColor,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
const SizedBox(height: 6),
// Barre horizontale cumulée avec largeur proportionnelle
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: widthRatio,
child: _buildStackedBar(passagesByType, count, sectorId, name),
),
),
],
),
);
}
}
Widget _buildStackedBar(Map<int, int> passagesByType, int totalCount, int sectorId, String sectorName) {
if (totalCount == 0) {
// Barre vide pour les secteurs sans passages
return Container(
height: 24,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
);
}
// Ordre des types : 1, 3, 4, 5, 6, 7, 8, 9, puis 2 en dernier
final typeOrder = [1, 3, 4, 5, 6, 7, 8, 9, 2];
return Container(
height: 24,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey[300]!, width: 0.5),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
// Barre de fond
Container(color: Colors.grey[100]),
// Barres empilées
Row(
children: typeOrder.map((typeId) {
final count = passagesByType[typeId] ?? 0;
if (count == 0) return const SizedBox.shrink();
final percentage = (count / totalCount) * 100;
final typeInfo = AppKeys.typesPassages[typeId];
final color = typeInfo != null
? Color(typeInfo['couleur2'] as int)
: Colors.grey;
// Vérifier si l'utilisateur est admin pour les clics
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
return Expanded(
flex: count,
child: isAdmin
? InkWell(
onTap: () {
// Sauvegarder les filtres dans Hive pour la page historique
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', sectorName);
settingsBox.put('history_selectedTypeId', typeId);
settingsBox.put('adminSelectedPageIndex', 2); // Index de la page historique
// Naviguer vers le dashboard admin qui chargera la page historique
context.go('/admin');
},
child: Container(
color: color,
child: Center(
child: percentage >= 5 // N'afficher le texte que si >= 5%
? Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0.5, 0.5),
blurRadius: 1.0,
color: Colors.black45,
),
],
),
)
: null,
),
),
)
: Container(
color: color,
child: Center(
child: percentage >= 5 // N'afficher le texte que si >= 5%
? Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0.5, 0.5),
blurRadius: 1.0,
color: Colors.black45,
),
],
),
)
: null,
),
),
);
}).toList(),
),
],
),
),
);
}
}

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),