feat: Release version 3.1.4 - Mode terrain et génération PDF

 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-19 19:38:03 +02:00
parent 4f7247eb2d
commit 3443277d4a
185 changed files with 109354 additions and 102937 deletions

334
api/docs/API-SECURITY.md Normal file
View File

@@ -0,0 +1,334 @@
# API Security & Performance Monitoring
## 📋 Vue d'ensemble
Système complet de sécurité et monitoring pour l'API GeoSector implémenté et opérationnel.
### ✅ Fonctionnalités implémentées
- **Détection d'intrusions** : Brute force, SQL injection, patterns de scan
- **Monitoring des performances** : Temps de réponse, utilisation mémoire, requêtes DB
- **Alertes email intelligentes** : Throttling, niveaux de priorité
- **Blocage d'IP automatique** : Temporaire ou permanent
- **Traçabilité complète** : Historique pour audit et analyse
## 🏗️ Architecture
### Tables de base de données (préfixe `sec_`)
```sql
-- 4 tables créées dans scripts/sql/create_security_tables.sql
sec_alerts -- Alertes de sécurité
sec_performance_metrics -- Métriques de performance
sec_failed_login_attempts -- Tentatives de connexion échouées
sec_blocked_ips -- IPs bloquées
```
### Services PHP implémentés
```
src/Services/Security/
├── AlertService.php # Gestion centralisée des alertes
├── EmailThrottler.php # Anti-spam pour emails
├── SecurityMonitor.php # Détection des menaces
├── PerformanceMonitor.php # Monitoring des temps
└── IPBlocker.php # Gestion des blocages IP
```
### Contrôleur d'administration
```
src/Controllers/SecurityController.php # Interface d'administration
```
## 🚀 Installation
### 1. Créer les tables
```bash
# Exécuter le script SQL sur chaque environnement
mysql -u root -p geo_app < scripts/sql/create_security_tables.sql
```
### 2. Configurer le cron de purge
```bash
# Ajouter dans crontab (crontab -e)
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/log/security_cleanup.log 2>&1
```
### 3. Tester l'installation
```bash
php test_security.php
```
## 🔒 Fonctionnement
### Détection automatique
Le système détecte et bloque automatiquement :
- **Brute force** : 5 tentatives échouées en 5 minutes → IP bloquée 1h
- **SQL injection** : Patterns suspects → IP bloquée définitivement
- **Scan de vulnérabilités** : Accès aux fichiers sensibles → IP bloquée 1h
- **Rate limiting** : Plus de 60 requêtes/minute → Rejet temporaire
### Monitoring de performance
Chaque requête est automatiquement monitorée :
```php
// Dans index.php
PerformanceMonitor::startRequest();
// ... traitement ...
PerformanceMonitor::endRequest($endpoint, $method, $statusCode);
```
### Alertes email
Configuration des niveaux :
- **INFO** : Log uniquement
- **WARNING** : Email avec throttling 1h
- **ERROR** : Email avec throttling 15min
- **CRITICAL** : Email avec throttling 5min
- **SECURITY** : Email immédiat, priorité haute
## 📊 Endpoints d'administration
Tous les endpoints nécessitent une authentification admin (role >= 2) :
```
GET /api/admin/metrics # Métriques de performance
GET /api/admin/alerts # Alertes actives
POST /api/admin/alerts/:id/resolve # Résoudre une alerte
GET /api/admin/blocked-ips # IPs bloquées
POST /api/admin/unblock-ip # Débloquer une IP
POST /api/admin/block-ip # Bloquer une IP manuellement
GET /api/admin/security-report # Rapport complet
POST /api/admin/cleanup # Nettoyer les anciennes données
POST /api/admin/test-alert # Tester les alertes
```
## 🔧 Configuration
### Seuils par défaut (modifiables dans les services)
```php
// PerformanceMonitor.php
const DEFAULT_THRESHOLDS = [
'response_time_warning' => 1000, // 1 seconde
'response_time_critical' => 3000, // 3 secondes
'db_time_warning' => 500, // 500ms
'db_time_critical' => 1000, // 1 seconde
'memory_warning' => 64, // 64 MB
'memory_critical' => 128 // 128 MB
];
// SecurityMonitor.php
- Brute force : 5 tentatives en 5 minutes
- Rate limit : 60 requêtes par minute
- 404 pattern : 10 erreurs 404 en 10 minutes
// EmailThrottler.php
const DEFAULT_CONFIG = [
'max_per_hour' => 10,
'max_per_day' => 50,
'digest_after' => 5,
'cooldown_minutes' => 60
];
```
### Rétention des données
Configurée dans `scripts/cron/cleanup_security_data.php` :
```php
$RETENTION_DAYS = [
'performance_metrics' => 30, // 30 jours
'failed_login_attempts' => 7, // 7 jours
'resolved_alerts' => 90, // 90 jours
'expired_blocks' => 0 // Déblocage immédiat
];
```
## 📈 Métriques surveillées
### Performance
- Temps de réponse total
- Temps cumulé des requêtes DB
- Nombre de requêtes DB
- Utilisation mémoire (pic et moyenne)
- Codes HTTP de réponse
### Sécurité
- Tentatives de connexion échouées
- IPs bloquées (temporaires/permanentes)
- Patterns d'attaque détectés
- Alertes par type et niveau
## 🛡️ Patterns de détection
### SQL Injection
```php
// Patterns détectés dans SecurityMonitor.php
- UNION SELECT
- DROP TABLE
- INSERT INTO
- UPDATE SET
- DELETE FROM
- Script tags
- OR 1=1
- Commentaires SQL (--)
```
### Fichiers sensibles
```php
// Patterns de scan détectés
- admin, administrator
- wp-admin, phpmyadmin
- .git, .env
- config.php
- backup, .sql, .zip
- shell.php, eval.php
```
## 📝 Exemples d'utilisation
### Déclencher une alerte manuelle
```php
use App\Services\Security\AlertService;
AlertService::trigger('CUSTOM_ALERT', [
'message' => 'Événement important détecté',
'details' => ['user' => $userId, 'action' => $action]
], 'WARNING');
```
### Bloquer une IP manuellement
```php
use App\Services\Security\IPBlocker;
// Blocage temporaire (1 heure)
IPBlocker::block('192.168.1.100', 3600, 'Comportement suspect');
// Blocage permanent
IPBlocker::blockPermanent('192.168.1.100', 'Attaque confirmée');
```
### Obtenir les statistiques
```php
use App\Services\Security\SecurityMonitor;
use App\Services\Security\PerformanceMonitor;
$securityStats = SecurityMonitor::getSecurityStats();
$perfStats = PerformanceMonitor::getStats(null, 24); // 24h
```
## ⚠️ Points d'attention
### RGPD
- Les IPs sont des données personnelles
- Durée de conservation limitée (voir rétention)
- Anonymisation après traitement
### Performance
- Overhead < 5ms par requête
- Optimisation des tables avec index
- Purge automatique des anciennes données
### Sécurité
- Pas d'exposition de données sensibles dans les alertes
- Chiffrement des données utilisateur
- Whitelist pour IPs de confiance (localhost)
## 🔄 Maintenance
### Quotidienne (cron)
```bash
# Purge automatique à 2h du matin
0 2 * * * php /var/www/geosector/api/scripts/cron/cleanup_security_data.php
```
### Hebdomadaire
- Vérifier les alertes actives
- Analyser les tendances de performance
- Ajuster les seuils si nécessaire
### Mensuelle
- Analyser le rapport de sécurité
- Mettre à jour les IPs whitelist/blacklist
- Optimiser les tables si nécessaire
## 🐛 Dépannage
### Les tables n'existent pas
```bash
# Créer les tables
mysql -u root -p geo_app < scripts/sql/create_security_tables.sql
```
### Pas d'alertes email
- Vérifier la configuration email dans `AppConfig`
- Vérifier les logs : `tail -f logs/geosector-*.log`
- Tester avec : `POST /api/admin/test-alert`
### IP bloquée par erreur
```bash
# Via API
curl -X POST https://dapp.geosector.fr/api/admin/unblock-ip \
-H "Authorization: Bearer TOKEN" \
-d '{"ip": "192.168.1.100"}'
# Via MySQL
UPDATE sec_blocked_ips SET unblocked_at = NOW() WHERE ip_address = '192.168.1.100';
```
## 📚 Ressources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [PHP Security Best Practices](https://www.php.net/manual/en/security.php)
- Code source : `/src/Services/Security/`
- Tests : `test_security.php`
- Logs : `/logs/geosector-*.log`
## 🎯 Statut d'implémentation
**Phase 1** : Infrastructure de base - COMPLÉTÉ
- Tables créées avec préfixe `sec_`
- Services PHP implémentés
- Intégration dans index.php et Database.php
**Phase 2** : Monitoring de Performance - COMPLÉTÉ
- Chronométrage automatique des requêtes
- Monitoring des requêtes DB
- Alertes sur dégradation
**Phase 3** : Détection d'intrusions - COMPLÉTÉ
- Détection brute force
- Détection SQL injection
- Blocage IP automatique
**Phase 4** : Alertes Email - COMPLÉTÉ
- Service d'alertes avec throttling
- Templates d'emails
- Niveaux de priorité
**Phase 5** : Administration - COMPLÉTÉ
- Endpoints d'administration
- Interface de gestion
- Rapports de sécurité
**Phase 6** : Maintenance - COMPLÉTÉ
- Script de purge automatique
- Optimisation des tables
- Documentation complète
---
*Dernière mise à jour : 2025-01-17*
*Version : 1.0.0*

419
api/docs/CHAT_MODULE.md Normal file
View File

@@ -0,0 +1,419 @@
# Module Chat - Documentation API
## Vue d'ensemble
Le module Chat permet aux utilisateurs de l'application GeoSector de communiquer entre eux via une messagerie intégrée. Il supporte les conversations privées, de groupe et les diffusions (broadcast).
## Architecture
### Tables de base de données
- `chat_rooms` : Salles de conversation
- `chat_messages` : Messages échangés
- `chat_participants` : Participants aux conversations
- `chat_read_receipts` : Accusés de lecture
### Permissions par rôle
| Rôle | Permissions |
|------|------------|
| **1 - Utilisateur** | Conversations privées et groupes avec membres de son entité |
| **2 - Admin entité** | Toutes conversations de son entité + création de diffusions |
| **> 2 - Super admin** | Accès total à toutes les conversations |
## Flux d'utilisation du module Chat
### 📱 Vue d'ensemble du flux
Le module Chat fonctionne en mode **chargement dynamique** : les données sont récupérées à la demande, pas toutes en une fois au login.
### 1. Au login (`/api/login`)
La réponse du login contient un objet `chat` avec les informations de base :
```json
{
"status": "success",
"user": {...},
"amicale": {...},
"chat": {
"total_rooms": 5, // Nombre total de conversations
"unread_messages": 12, // Total messages non lus
"chat_enabled": true, // Module activé pour cet utilisateur
"last_active_room": { // Dernière conversation active
"id": "uuid-room-123",
"title": "Discussion équipe",
"type": "group",
"last_message": "À demain !",
"last_message_at": "2025-01-17 18:30:00"
}
}
}
```
→ Permet d'afficher un **badge de notification** et de savoir si le chat est disponible
### 2. Ouverture de la page Chat
#### Étape 1 : Chargement initial
```
GET /api/chat/rooms
```
→ Récupère la liste des conversations avec aperçu du dernier message
#### Étape 2 : Sélection d'une conversation
```
GET /api/chat/rooms/{room_id}/messages?limit=50
```
→ Charge les 50 derniers messages (pagination disponible)
#### Étape 3 : Marquage comme lu
```
POST /api/chat/rooms/{room_id}/read
```
→ Met à jour les compteurs de messages non lus
### 3. Actions utilisateur
| Action | Endpoint | Description |
|--------|----------|-------------|
| **Envoyer un message** | `POST /api/chat/rooms/{id}/messages` | Envoie et retourne le message créé |
| **Créer une conversation** | `POST /api/chat/rooms` | Crée une nouvelle room |
| **Obtenir les destinataires** | `GET /api/chat/recipients` | Liste des contacts disponibles |
| **Charger plus de messages** | `GET /api/chat/rooms/{id}/messages?before={msg_id}` | Pagination |
### 4. Stratégies de rafraîchissement
#### Polling (recommandé pour débuter)
- Rafraîchir `/api/chat/rooms` toutes les 30 secondes
- Rafraîchir les messages de la conversation active toutes les 10 secondes
#### Pull to refresh
- Permettre à l'utilisateur de rafraîchir manuellement
#### Lifecycle events
- Recharger quand l'app revient au premier plan
- Rafraîchir après envoi d'un message
### 5. Exemple d'implémentation Flutter
```dart
class ChatService {
Timer? _roomsTimer;
Timer? _messagesTimer;
// 1. Au login, stocker les infos de base
void initFromLogin(Map<String, dynamic> chatData) {
_unreadCount = chatData['unread_messages'];
_chatEnabled = chatData['chat_enabled'];
notifyListeners();
}
// 2. À l'ouverture du chat
Future<void> openChatPage() async {
// Charger les conversations
final rooms = await api.get('/api/chat/rooms');
_rooms = rooms['rooms'];
// Démarrer le polling
_startPolling();
}
// 3. Sélection d'une conversation
Future<void> selectRoom(String roomId) async {
// Charger les messages
final response = await api.get('/api/chat/rooms/$roomId/messages');
_currentMessages = response['messages'];
// Marquer comme lu
await api.post('/api/chat/rooms/$roomId/read');
// Rafraîchir plus fréquemment cette conversation
_startMessagePolling(roomId);
}
// 4. Polling automatique
void _startPolling() {
_roomsTimer = Timer.periodic(Duration(seconds: 30), (_) {
_refreshRooms();
});
}
// 5. Nettoyage
void dispose() {
_roomsTimer?.cancel();
_messagesTimer?.cancel();
}
}
```
## Endpoints API
### 1. GET /api/chat/rooms
**Description** : Récupère la liste des conversations de l'utilisateur
**Réponse** :
```json
{
"status": "success",
"rooms": [
{
"id": "uuid-room-1",
"title": "Discussion équipe",
"type": "group",
"created_at": "2025-01-17 10:00:00",
"created_by": 123,
"updated_at": "2025-01-17 14:30:00",
"last_message": "Bonjour tout le monde",
"last_message_at": "2025-01-17 14:30:00",
"unread_count": 3,
"participant_count": 5,
"participants": [
{
"user_id": 123,
"name": "Jean Dupont",
"first_name": "Jean",
"is_admin": true
}
]
}
]
}
```
### 2. POST /api/chat/rooms
**Description** : Crée une nouvelle conversation
**Body** :
```json
{
"type": "private|group|broadcast",
"title": "Titre optionnel (requis pour group/broadcast)",
"participants": [456, 789], // IDs des participants
"initial_message": "Message initial optionnel"
}
```
**Règles** :
- `private` : Maximum 2 participants (incluant le créateur)
- `group` : Plusieurs participants possibles
- `broadcast` : Réservé aux admins (rôle >= 2)
**Réponse** :
```json
{
"status": "success",
"room": {
"id": "uuid-new-room",
"title": "Nouvelle conversation",
"type": "group",
"created_at": "2025-01-17 15:00:00",
"participants": [...]
},
"existing": false // true si conversation privée existante trouvée
}
```
### 3. GET /api/chat/rooms/{id}/messages
**Description** : Récupère les messages d'une conversation
**Paramètres** :
- `limit` : Nombre de messages (défaut: 50, max: 100)
- `before` : ID du message pour pagination
**Réponse** :
```json
{
"status": "success",
"messages": [
{
"id": "uuid-message-1",
"content": "Bonjour !",
"sender_id": 123,
"sender_name": "Jean Dupont",
"sender_first_name": "Jean",
"sent_at": "2025-01-17 14:00:00",
"edited_at": null,
"is_deleted": false,
"is_read": true,
"is_mine": false,
"read_count": 3
}
],
"has_more": true
}
```
### 4. POST /api/chat/rooms/{id}/messages
**Description** : Envoie un message dans une conversation
**Body** :
```json
{
"content": "Contenu du message (max 5000 caractères)"
}
```
**Réponse** :
```json
{
"status": "success",
"message": {
"id": "uuid-new-message",
"content": "Message envoyé",
"sender_id": 123,
"sender_name": "Jean Dupont",
"sent_at": "2025-01-17 15:30:00",
"is_mine": true,
"is_read": false,
"read_count": 0
}
}
```
### 5. POST /api/chat/rooms/{id}/read
**Description** : Marque les messages comme lus
**Body (optionnel)** :
```json
{
"message_ids": ["uuid-1", "uuid-2"] // Si omis, marque tous les messages
}
```
**Réponse** :
```json
{
"status": "success",
"unread_count": 0 // Nombre de messages non lus restants
}
```
### 6. GET /api/chat/recipients
**Description** : Liste des destinataires possibles pour créer une conversation
**Réponse** :
```json
{
"status": "success",
"recipients": [
{
"id": 456,
"name": "Marie Martin",
"first_name": "Marie",
"role": 1,
"entite_id": 5
}
],
"recipients_by_entity": {
"Amicale de Grenoble": [
{...}
],
"Amicale de Lyon": [
{...}
]
}
}
```
## Fonctionnalités clés
### 1. Types de conversations
#### Private (Conversation privée)
- Entre 2 utilisateurs uniquement
- Détection automatique de conversation existante
- Pas de titre requis
#### Group (Groupe)
- Plusieurs participants
- Titre optionnel mais recommandé
- Admin de groupe (créateur)
#### Broadcast (Diffusion)
- Réservé aux admins (rôle >= 2)
- Communication unidirectionnelle possible
- Pour annonces importantes
### 2. Gestion des permissions
Le système vérifie automatiquement :
- L'appartenance à une conversation avant lecture/écriture
- Les droits de création selon le type de conversation
- La visibilité des destinataires selon le rôle
### 3. Statuts de lecture
- **Accusés de lecture individuels** : Chaque message peut être marqué comme lu
- **Compteur de non-lus** : Par conversation et global
- **Last read** : Timestamp de dernière lecture par participant
### 4. Optimisations
- **Pagination** : Chargement progressif des messages
- **Index optimisés** : Pour les requêtes fréquentes
- **Vue SQL** : Pour récupération rapide du dernier message
## Sécurité
### Chiffrement
- Les noms d'utilisateurs sont stockés chiffrés (AES-256)
- Déchiffrement à la volée lors de la lecture
### Validation
- Longueur maximale des messages : 5000 caractères
- Trim automatique du contenu
- Vérification des permissions à chaque action
### Isolation
- Les utilisateurs ne voient que leurs conversations autorisées
- Filtrage par entité selon le rôle
- Soft delete pour conservation de l'historique
## Migration
Exécuter le script SQL :
```bash
mysql -u root -p geo_app < scripts/sql/create_chat_tables.sql
```
## Évolutions futures possibles
1. **Notifications push** : Intégration avec Firebase/WebSocket
2. **Fichiers joints** : Support d'images et documents
3. **Réactions** : Emojis sur les messages
4. **Mentions** : @username pour notifier
5. **Recherche** : Dans l'historique des messages
6. **Chiffrement E2E** : Pour conversations sensibles
7. **Statuts de présence** : En ligne/Hors ligne
8. **Indicateur de frappe** : "X est en train d'écrire..."
## Tests
### Cas de test recommandés
1. **Création de conversation privée**
- Vérifier la détection de conversation existante
- Tester avec utilisateurs de différentes entités
2. **Envoi de messages**
- Messages avec caractères UTF-8 (émojis, accents)
- Messages très longs (limite 5000)
- Messages vides (doivent être rejetés)
3. **Marquage comme lu**
- Marquer messages spécifiques
- Marquer tous les messages d'une room
- Vérifier les compteurs
4. **Permissions**
- Utilisateur simple ne peut pas créer de broadcast
- Accès refusé aux conversations non autorisées
- Filtrage correct des destinataires
## Support
Pour toute question ou problème :
- Vérifier les logs dans `/logs/`
- Consulter les tables `chat_*` en base de données
- Tester avec les scripts de test fournis

View File

@@ -0,0 +1,176 @@
# Correction des erreurs 400 lors de la création d'utilisateurs
## Problème identifié
Un administrateur (fk_role=2) rencontrait des erreurs 400 répétées lors de tentatives de création de membre, menant à un bannissement par fail2ban :
- 17:09:39 - POST /api/users HTTP/1.1 400 (Bad Request)
- 17:10:44 - POST /api/users/check-username HTTP/1.1 400 (Bad Request)
- 17:11:21 - POST /api/users HTTP/1.1 400 (Bad Request)
## Causes identifiées
### 1. Conflit de routage (CRITIQUE)
**Problème:** La route `/api/users/check-username` était déclarée APRÈS la route générique `/api/users` dans Router.php, causant une mauvaise interprétation où "check-username" était traité comme un ID utilisateur.
**Solution:** Déplacer la déclaration de la route spécifique AVANT les routes avec paramètres.
### 2. Messages d'erreur non informatifs
**Problème:** Les erreurs 400 retournaient des messages génériques sans détails sur le champ problématique.
**Solution:** Ajout de messages d'erreur détaillés incluant :
- Le champ en erreur (`field`)
- La valeur problématique (`value`)
- Le format attendu (`format`)
- La raison de l'erreur (`reason`)
### 3. Manque de logs de débogage
**Problème:** Aucun log n'était généré pour tracer les erreurs de validation.
**Solution:** Ajout de logs détaillés à chaque point de validation.
## Modifications apportées
### 1. Router.php (ligne 36-44)
```php
// AVANT (incorrect)
$this->post('users', ['UserController', 'createUser']);
$this->post('users/check-username', ['UserController', 'checkUsername']);
// APRÈS (correct)
$this->post('users/check-username', ['UserController', 'checkUsername']); // Route spécifique en premier
$this->post('users', ['UserController', 'createUser']);
```
### 2. UserController.php - Amélioration des validations
#### Validation de l'email
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Email requis',
'field' => 'email' // Indique clairement le champ problématique
], 400);
```
#### Validation du username manuel
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Le nom d\'utilisateur est requis pour cette entité',
'field' => 'username',
'reason' => 'L\'entité requiert la saisie manuelle des identifiants'
], 400);
```
#### Format du username
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Format du nom d\'utilisateur invalide',
'field' => 'username',
'format' => '10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
'value' => $username // Montre la valeur soumise
], 400);
```
### 3. Ajout de logs détaillés
Chaque point de validation génère maintenant un log avec :
- Le type d'erreur
- L'utilisateur qui fait la requête
- Les données reçues (sans données sensibles)
- Le contexte de l'erreur
Exemple :
```php
LogService::log('Erreur création utilisateur : Format username invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username' => $username,
'username_length' => strlen($username)
]);
```
## Cas d'erreur 400 possibles
### Pour /api/users (création)
1. **Email manquant ou vide**
- Message: "Email requis"
- Field: "email"
2. **Nom manquant ou vide**
- Message: "Nom requis"
- Field: "name"
3. **Format email invalide**
- Message: "Format d'email invalide"
- Field: "email"
- Value: [email soumis]
4. **Username manuel requis mais manquant** (si chk_username_manuel=1)
- Message: "Le nom d'utilisateur est requis pour cette entité"
- Field: "username"
- Reason: "L'entité requiert la saisie manuelle des identifiants"
5. **Format username invalide**
- Message: "Format du nom d'utilisateur invalide"
- Field: "username"
- Format: "10-30 caractères, commence par une lettre..."
- Value: [username soumis]
6. **Mot de passe manuel requis mais manquant** (si chk_mdp_manuel=1)
- Message: "Le mot de passe est requis pour cette entité"
- Field: "password"
- Reason: "L'entité requiert la saisie manuelle des mots de passe"
### Pour /api/users/check-username
1. **Username manquant**
- Message: "Username requis pour la vérification"
- Field: "username"
2. **Format username invalide**
- Message: "Format invalide"
- Field: "username"
- Format: "10-30 caractères, commence par une lettre..."
- Value: [username soumis]
## Test de la solution
Un script de test a été créé : `/tests/test_user_creation.php`
Il teste tous les cas d'erreur possibles et vérifie que :
1. Les codes HTTP sont corrects
2. Les messages d'erreur sont informatifs
3. Les champs en erreur sont identifiés
## Recommandations pour éviter le bannissement fail2ban
1. **Côté client (application Flutter)** :
- Valider les données AVANT l'envoi
- Afficher clairement les erreurs à l'utilisateur
- Implémenter un délai entre les tentatives (rate limiting côté client)
2. **Côté API** :
- Les messages d'erreur détaillés permettent maintenant de corriger rapidement les problèmes
- Les logs permettent de diagnostiquer les problèmes récurrents
3. **Configuration fail2ban** :
- Considérer d'augmenter le seuil pour les erreurs 400 (ex: 5 tentatives au lieu de 3)
- Exclure certaines IP de confiance si nécessaire
## Suivi des logs
Les logs sont maintenant générés dans :
- `/logs/geosector-[environment]-[date].log` : Logs généraux avec détails des erreurs
Format des logs :
```
timestamp;browser;os;client_type;level;metadata;message
```
Les erreurs de validation sont loggées avec le niveau "warning" pour permettre un suivi sans être critiques.

View File

@@ -779,7 +779,7 @@ fetch('/api/endpoint', {
## Changements récents
### Version 3.0.7 (Janvier 2025)
### Version 3.0.7 (Août 2025)
#### 1. Implémentation complète de la norme NIST SP 800-63B pour les mots de passe
- **Nouveau service :** `PasswordSecurityService` pour la gestion sécurisée des mots de passe
@@ -797,7 +797,7 @@ fetch('/api/endpoint', {
- **Choix client :** Permet d'avoir un mot de passe identique au nom d'utilisateur
- **Pas de vérification contextuelle :** Aucune vérification nom/email dans le mot de passe
### Version 3.0.6 (Janvier 2025)
### Version 3.0.6 (Août 2025)
#### 1. Correction des rôles administrateurs
- **Avant :** Les administrateurs d'amicale devaient avoir `fk_role > 2`
@@ -836,3 +836,28 @@ fetch('/api/endpoint', {
- **Format d'envoi des images :** Base64 data URL pour compatibilité multiplateforme
- **Structure de réponse enrichie :** Le logo est inclus dans l'objet `amicale` lors du login
- **Optimisation :** Pas de requête HTTP supplémentaire nécessaire pour afficher le logo
### Version 3.0.8 (Janvier 2025)
#### 1. Système de génération automatique de reçus fiscaux pour les dons
- **Nouveau service :** `ReceiptService` pour la génération automatique de reçus PDF
- **Déclencheurs automatiques :**
- Création d'un passage avec `fk_type=1` (don) et email valide
- Mise à jour d'un passage en don si `nom_recu` est vide/null
- **Caractéristiques techniques :**
- PDF ultra-légers (< 5KB) générés en format natif sans librairie externe
- Support des caractères accentués avec conversion automatique
- Stockage structuré : `/uploads/entites/{entite_id}/recus/{operation_id}/`
- Enregistrement dans la table `medias` avec catégorie `recu`
- **Queue d'envoi email :**
- Envoi automatique par email avec pièce jointe PDF
- Format MIME multipart pour compatibilité maximale
- Gestion dans la table `email_queue` avec statut de suivi
- **Nouvelle route API :**
- `GET /api/passages/{id}/receipt` : Récupération du PDF d'un reçu
- Retourne le PDF en base64 ou téléchargement direct selon Accept header
- **Champs base de données utilisés :**
- `nom_recu` : Nom du fichier PDF généré
- `date_creat_recu` : Date de génération du reçu
- `date_sent_recu` : Date d'envoi par email
- `chk_email_sent` : Indicateur d'envoi réussi

View File

@@ -0,0 +1,135 @@
# Changements de validation des usernames - Version ultra-souple
## Date : 17 janvier 2025
## Contexte
Suite aux problèmes d'erreurs 400 et au besoin d'avoir une approche plus moderne et inclusive, les règles de validation des usernames ont été assouplies pour accepter tous les caractères UTF-8, similaire à l'approche NIST pour les mots de passe.
## Anciennes règles (trop restrictives)
- ❌ 10-30 caractères
- ❌ Doit commencer par une lettre minuscule
- ❌ Seulement : a-z, 0-9, ., -, _
- ❌ Pas d'espaces
- ❌ Pas de majuscules
- ❌ Pas d'accents ou caractères spéciaux
## Nouvelles règles (ultra-souples)
-**8-30 caractères UTF-8**
-**Tous caractères acceptés** :
- Lettres (majuscules/minuscules)
- Chiffres
- Espaces
- Caractères spéciaux (!@#$%^&*()_+-=[]{}|;:'"<>,.?/)
- Accents (é, è, à, ñ, ü, etc.)
- Émojis (😀, 🎉, ❤️, etc.)
- Caractères non-latins (中文, العربية, Русский, etc.)
-**Sensible à la casse** (Jean ≠ jean)
-**Trim automatique** des espaces début/fin
-**Unicité vérifiée** dans toute la base
## Exemples de usernames valides
### Noms classiques
- `Jean-Pierre`
- `Marie Claire` (avec espace)
- `O'Connor`
- `José García`
### Avec chiffres et caractères spéciaux
- `admin2024`
- `user@company`
- `test_user#1`
- `Marie*123!`
### International
- `李明` (chinois)
- `محمد` (arabe)
- `Владимир` (russe)
- `さくら` (japonais)
- `Παύλος` (grec)
### Modernes/Fun
- `🦄Unicorn`
- `Player_1 🎮`
- `☕Coffee.Lover`
- `2024_User`
## Exemples de usernames invalides
- `short` ❌ (moins de 8 caractères)
- ` ` ❌ (espaces seulement)
- `very_long_username_that_exceeds_thirty_chars` ❌ (plus de 30 caractères)
## Modifications techniques
### 1. Code PHP (UserController.php)
```php
// Avant (restrictif)
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username))
// Après (ultra-souple)
$username = trim($data['username']);
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
// Erreur : trop court
}
if ($usernameLength > 30) {
// Erreur : trop long
}
// C'est tout ! Pas d'autre validation
```
### 2. Base de données
```sql
-- Script à exécuter : scripts/sql/migration_username_utf8_support.sql
ALTER TABLE `users`
MODIFY COLUMN `encrypted_user_name` varchar(255) DEFAULT '';
```
### 3. Messages d'erreur simplifiés
- Avant : "Format du nom d'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)"
- Après :
- "Identifiant trop court" + "Minimum 8 caractères"
- "Identifiant trop long" + "Maximum 30 caractères"
- "Identifiant déjà utilisé"
## Impact sur l'expérience utilisateur
### Avantages
1. **Inclusivité** : Support de toutes les langues et cultures
2. **Modernité** : Permet les émojis et caractères spéciaux
3. **Simplicité** : Règles faciles à comprendre (juste la longueur)
4. **Flexibilité** : Les utilisateurs peuvent choisir l'identifiant qu'ils veulent
5. **Moins d'erreurs** : Moins de rejets pour format invalide
### Points d'attention
1. **Support client** : Former le support aux nouveaux formats possibles
2. **Affichage** : S'assurer que l'UI supporte bien l'UTF-8
3. **Recherche** : La recherche d'utilisateurs doit gérer la casse et l'UTF-8
4. **Export** : Vérifier que les exports CSV/Excel gèrent bien l'UTF-8
## Sécurité
### Pas d'impact sur la sécurité
- ✅ Les usernames sont toujours chiffrés en base (AES-256-CBC)
- ✅ L'unicité est toujours vérifiée
- ✅ Les injections SQL sont impossibles (prepared statements)
- ✅ Le trim empêche les espaces invisibles
### Recommandations
- Continuer à générer automatiquement des usernames simples (ASCII) pour éviter les problèmes
- Mais permettre la saisie manuelle de tout format
- Logger les usernames "exotiques" pour détecter d'éventuels abus
## Tests
- Script de test disponible : `/tests/test_username_validation.php`
- Teste tous les cas limites et formats internationaux
## Rollback si nécessaire
Si besoin de revenir en arrière :
1. Restaurer l'ancienne validation dans UserController
2. Les usernames UTF-8 existants continueront de fonctionner
3. Seuls les nouveaux seront restreints
## Conclusion
Cette approche ultra-souple aligne les usernames sur les standards modernes d'inclusivité et d'accessibilité, tout en maintenant la sécurité grâce au chiffrement et à la validation de l'unicité.

BIN
api/docs/_logo_recu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
api/docs/_recu_template.pdf Normal file

Binary file not shown.

View File

@@ -18,168 +18,39 @@
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE TABLE `chat_anonymous_users` (
`id` varchar(50) NOT NULL,
`device_id` varchar(100) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`converted_to_user_id` int(10) unsigned DEFAULT NULL,
`metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),
PRIMARY KEY (`id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_converted_user` (`converted_to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
-- Tables préfixées "chat_"
CREATE TABLE chat_rooms (
id VARCHAR(36) PRIMARY KEY,
title VARCHAR(255),
type ENUM('private', 'group', 'broadcast'),
created_at TIMESTAMP,
created_by INT
);
CREATE TABLE `chat_attachments` (
`id` varchar(50) NOT NULL,
`fk_message` varchar(50) NOT NULL,
`file_name` varchar(255) NOT NULL,
`file_path` varchar(500) NOT NULL,
`file_type` varchar(100) NOT NULL,
`file_size` int(10) unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_message` (`fk_message`),
CONSTRAINT `fk_chat_attachments_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_messages (
id VARCHAR(36) PRIMARY KEY,
room_id VARCHAR(36),
content TEXT,
sender_id INT,
sent_at TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES chat_rooms(id)
);
CREATE TABLE `chat_audience_targets` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`target_type` enum('role','entity','all','combined') NOT NULL DEFAULT 'all',
`target_id` varchar(50) DEFAULT NULL,
`role_filter` varchar(20) DEFAULT NULL,
`entity_filter` varchar(50) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_type` (`target_type`),
CONSTRAINT `fk_chat_audience_targets_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_participants (
room_id VARCHAR(36),
user_id INT,
role INT,
entite_id INT,
joined_at TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE `chat_broadcast_lists` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`name` varchar(100) NOT NULL,
`description` text DEFAULT NULL,
`fk_user_creator` int(10) unsigned NOT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user_creator` (`fk_user_creator`),
CONSTRAINT `fk_chat_broadcast_lists_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_messages` (
`id` varchar(50) NOT NULL,
`fk_room` varchar(50) NOT NULL,
`fk_user` int(10) unsigned DEFAULT NULL,
`sender_type` enum('user','anonymous','system') NOT NULL DEFAULT 'user',
`content` text DEFAULT NULL,
`content_type` enum('text','image','file') NOT NULL DEFAULT 'text',
`date_sent` timestamp NOT NULL DEFAULT current_timestamp(),
`date_delivered` timestamp NULL DEFAULT NULL,
`date_read` timestamp NULL DEFAULT NULL,
`statut` enum('envoye','livre','lu','error') NOT NULL DEFAULT 'envoye',
`is_announcement` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user` (`fk_user`),
KEY `idx_date` (`date_sent`),
KEY `idx_status` (`statut`),
KEY `idx_messages_unread` (`fk_room`,`statut`),
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_notifications` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`fk_user` int(10) unsigned NOT NULL,
`fk_message` varchar(50) DEFAULT NULL,
`fk_room` varchar(50) DEFAULT NULL,
`type` varchar(50) NOT NULL,
`contenu` text DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
`date_lecture` timestamp NULL DEFAULT NULL,
`statut` enum('non_lue','lue') NOT NULL DEFAULT 'non_lue',
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_message` (`fk_message`),
KEY `idx_room` (`fk_room`),
KEY `idx_statut` (`statut`),
KEY `idx_notifications_unread` (`fk_user`,`statut`),
CONSTRAINT `fk_chat_notifications_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_chat_notifications_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_offline_queue` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`operation_type` varchar(50) NOT NULL,
`operation_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`operation_data`)),
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`processed_at` timestamp NULL DEFAULT NULL,
`status` enum('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
`error_message` text DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_participants` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`id_room` varchar(50) NOT NULL,
`id_user` int(10) unsigned DEFAULT NULL,
`anonymous_id` varchar(50) DEFAULT NULL,
`role` enum('administrateur','participant','en_lecture_seule') NOT NULL DEFAULT 'participant',
`date_ajout` timestamp NOT NULL DEFAULT current_timestamp(),
`notification_activee` tinyint(1) unsigned NOT NULL DEFAULT 1,
`last_read_message_id` varchar(50) DEFAULT NULL,
`via_target` tinyint(1) unsigned NOT NULL DEFAULT 0,
`can_reply` tinyint(1) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uc_room_user` (`id_room`,`id_user`),
KEY `idx_room` (`id_room`),
KEY `idx_user` (`id_user`),
KEY `idx_anonymous_id` (`anonymous_id`),
KEY `idx_participants_active` (`id_room`,`id_user`,`notification_activee`),
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`id_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_read_messages` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`fk_message` varchar(50) NOT NULL,
`fk_user` int(10) unsigned NOT NULL,
`date_read` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uc_message_user` (`fk_message`,`fk_user`),
KEY `idx_message` (`fk_message`),
KEY `idx_user` (`fk_user`),
CONSTRAINT `fk_chat_read_messages_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_rooms` (
`id` varchar(50) NOT NULL,
`type` enum('privee','groupe','liste_diffusion','broadcast','announcement') NOT NULL,
`title` varchar(100) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
`fk_user` int(10) unsigned NOT NULL,
`fk_entite` int(10) unsigned DEFAULT NULL,
`statut` enum('active','archive') NOT NULL DEFAULT 'active',
`description` text DEFAULT NULL,
`reply_permission` enum('all','admins_only','sender_only','none') NOT NULL DEFAULT 'all',
`is_pinned` tinyint(1) unsigned NOT NULL DEFAULT 0,
`expiry_date` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_entite` (`fk_entite`),
KEY `idx_type` (`type`),
KEY `idx_statut` (`statut`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_read_receipts (
message_id VARCHAR(36),
user_id INT,
read_at TIMESTAMP,
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE `email_counter` (
`id` int(10) unsigned NOT NULL DEFAULT 1,

BIN
api/docs/recu_537254062.pdf Normal file

Binary file not shown.

BIN
api/docs/recu_972506460.pdf Normal file

Binary file not shown.

View File

@@ -15,6 +15,12 @@ require_once __DIR__ . '/src/Core/Response.php';
require_once __DIR__ . '/src/Utils/ClientDetector.php';
require_once __DIR__ . '/src/Services/LogService.php';
// Chargement des services de sécurité
require_once __DIR__ . '/src/Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/src/Services/Security/IPBlocker.php';
require_once __DIR__ . '/src/Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/src/Services/Security/AlertService.php';
// Chargement des contrôleurs
require_once __DIR__ . '/src/Controllers/LogController.php';
require_once __DIR__ . '/src/Controllers/LoginController.php';
@@ -26,6 +32,8 @@ require_once __DIR__ . '/src/Controllers/VilleController.php';
require_once __DIR__ . '/src/Controllers/FileController.php';
require_once __DIR__ . '/src/Controllers/SectorController.php';
require_once __DIR__ . '/src/Controllers/PasswordController.php';
require_once __DIR__ . '/src/Controllers/ChatController.php';
require_once __DIR__ . '/src/Controllers/SecurityController.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
@@ -57,8 +65,132 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
// Initialiser la session
Session::start();
// ===== DÉBUT DU MONITORING DE SÉCURITÉ =====
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\IPBlocker;
use App\Services\Security\SecurityMonitor;
use App\Services\Security\AlertService;
// Obtenir l'IP du client
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Vérifier si l'IP est bloquée
if (IPBlocker::isBlocked($clientIp)) {
http_response_code(403);
Response::json([
'success' => false,
'message' => 'Access denied. Your IP has been blocked.',
'error_code' => 'IP_BLOCKED'
], 403);
exit;
}
// Vérifier le rate limiting
if (!SecurityMonitor::checkRateLimit($clientIp)) {
http_response_code(429);
Response::json([
'success' => false,
'message' => 'Too many requests. Please try again later.',
'error_code' => 'RATE_LIMIT_EXCEEDED'
], 429);
exit;
}
// Démarrer le monitoring de performance
PerformanceMonitor::startRequest();
// Capturer le endpoint pour le monitoring
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
// Vérifier les patterns de scan
if (!SecurityMonitor::checkScanPattern($requestUri)) {
// Pattern suspect détecté, bloquer l'IP temporairement
IPBlocker::block($clientIp, 3600, 'Suspicious scan pattern detected');
http_response_code(404);
Response::json([
'success' => false,
'message' => 'Not found'
], 404);
exit;
}
// Vérifier les paramètres pour injection SQL
$allParams = array_merge($_GET, $_POST, json_decode(file_get_contents('php://input'), true) ?? []);
if (!empty($allParams) && !SecurityMonitor::checkRequestParameters($allParams)) {
// Injection SQL détectée, bloquer l'IP définitivement
IPBlocker::blockPermanent($clientIp, 'SQL injection attempt');
http_response_code(400);
Response::json([
'success' => false,
'message' => 'Bad request'
], 400);
exit;
}
// Créer l'instance de routeur
$router = new Router();
// Enregistrer une fonction de shutdown pour capturer les métriques
register_shutdown_function(function() use ($requestUri, $requestMethod) {
$statusCode = http_response_code();
// Terminer le monitoring de performance
PerformanceMonitor::endRequest($requestUri, $requestMethod, $statusCode);
// Vérifier les patterns 404
if ($statusCode === 404) {
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
SecurityMonitor::check404Pattern($clientIp);
}
// Alerter sur les erreurs 500
if ($statusCode >= 500) {
$error = error_get_last();
AlertService::trigger('HTTP_500', [
'endpoint' => $requestUri,
'method' => $requestMethod,
'error_message' => $error['message'] ?? 'Unknown error',
'error_file' => $error['file'] ?? 'Unknown',
'error_line' => $error['line'] ?? 0,
'message' => "Erreur serveur 500 sur $requestUri"
], 'ERROR');
}
// Nettoyer périodiquement les IPs expirées (1% de chance)
if (rand(1, 100) === 1) {
IPBlocker::cleanupExpired();
}
});
// Gérer les erreurs non capturées
set_exception_handler(function($exception) use ($requestUri, $requestMethod) {
// Logger l'erreur
error_log("Uncaught exception: " . $exception->getMessage());
// Créer une alerte
AlertService::trigger('UNCAUGHT_EXCEPTION', [
'endpoint' => $requestUri,
'method' => $requestMethod,
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => substr($exception->getTraceAsString(), 0, 1000)
], 'ERROR');
// Retourner une erreur 500
http_response_code(500);
Response::json([
'success' => false,
'message' => 'Internal server error'
], 500);
});
// Gérer la requête
$router->handle();
try {
$router->handle();
} catch (Exception $e) {
// Les exceptions sont gérées par le handler ci-dessus
throw $e;
}

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env php
<?php
/**
* Script de nettoyage des données de sécurité
* À exécuter via cron quotidiennement
* Exemple crontab: 0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php
*/
declare(strict_types=1);
// Configuration
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
$db = Database::getInstance();
// Configuration de rétention (en jours)
$RETENTION_DAYS = [
'performance_metrics' => 30, // Garder 30 jours de métriques
'failed_login_attempts' => 7, // Garder 7 jours de tentatives
'resolved_alerts' => 90, // Garder 90 jours d'alertes résolues
'expired_blocks' => 0 // Débloquer immédiatement les IPs expirées
];
echo "[" . date('Y-m-d H:i:s') . "] Début du nettoyage des données de sécurité\n";
try {
$totalDeleted = 0;
// 1. Nettoyer les métriques de performance
echo "- Nettoyage des métriques de performance (>" . $RETENTION_DAYS['performance_metrics'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_performance_metrics
WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['performance_metrics']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 2. Nettoyer les tentatives de login échouées
echo "- Nettoyage des tentatives de login (>" . $RETENTION_DAYS['failed_login_attempts'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_failed_login_attempts
WHERE attempt_time < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['failed_login_attempts']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 3. Nettoyer les alertes résolues
echo "- Nettoyage des alertes résolues (>" . $RETENTION_DAYS['resolved_alerts'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_alerts
WHERE resolved = 1
AND resolved_at < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['resolved_alerts']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 4. Débloquer les IPs expirées
echo "- Déblocage des IPs expirées...\n";
$stmt = $db->prepare('
UPDATE sec_blocked_ips
SET unblocked_at = NOW()
WHERE blocked_until <= NOW()
AND unblocked_at IS NULL
AND permanent = 0
');
$stmt->execute();
$unblocked = $stmt->rowCount();
echo "$unblocked IPs débloquées\n";
// 5. Supprimer les anciennes IPs débloquées (optionnel, garder 180 jours d'historique)
echo "- Suppression des anciennes IPs débloquées (>180 jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_blocked_ips
WHERE unblocked_at IS NOT NULL
AND unblocked_at < DATE_SUB(NOW(), INTERVAL 180 DAY)
');
$stmt->execute();
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 6. Optimiser les tables (optionnel, peut être long sur de grosses tables)
if ($totalDeleted > 1000) {
echo "- Optimisation des tables...\n";
$tables = [
'sec_performance_metrics',
'sec_failed_login_attempts',
'sec_alerts',
'sec_blocked_ips'
];
foreach ($tables as $table) {
try {
$db->exec("OPTIMIZE TABLE $table");
echo " → Table $table optimisée\n";
} catch (Exception $e) {
echo " ⚠ Impossible d'optimiser $table: " . $e->getMessage() . "\n";
}
}
}
// 7. Statistiques finales
echo "\n=== RÉSUMÉ ===\n";
echo "Total supprimé: $totalDeleted lignes\n";
echo "IPs débloquées: $unblocked\n";
// Obtenir les statistiques actuelles
$stats = [];
$tables = [
'sec_alerts' => "SELECT COUNT(*) as total, SUM(resolved = 0) as active FROM sec_alerts",
'sec_performance_metrics' => "SELECT COUNT(*) as total FROM sec_performance_metrics",
'sec_failed_login_attempts' => "SELECT COUNT(*) as total FROM sec_failed_login_attempts",
'sec_blocked_ips' => "SELECT COUNT(*) as total, SUM(permanent = 1) as permanent FROM sec_blocked_ips WHERE unblocked_at IS NULL"
];
echo "\nÉtat actuel des tables:\n";
foreach ($tables as $table => $query) {
$result = $db->query($query)->fetch(PDO::FETCH_ASSOC);
if ($table === 'sec_alerts') {
echo "- $table: {$result['total']} total, {$result['active']} actives\n";
} elseif ($table === 'sec_blocked_ips') {
$permanent = $result['permanent'] ?? 0;
echo "- $table: {$result['total']} bloquées, $permanent permanentes\n";
} else {
echo "- $table: {$result['total']} enregistrements\n";
}
}
echo "\n[" . date('Y-m-d H:i:s') . "] Nettoyage terminé avec succès\n";
} catch (Exception $e) {
echo "\n❌ ERREUR: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
exit(1);
}

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env php
<?php
/**
* Script d'initialisation des tables de sécurité
* Crée les tables si elles n'existent pas
*/
declare(strict_types=1);
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
$db = Database::getInstance();
echo "\n========================================\n";
echo " CRÉATION DES TABLES DE SÉCURITÉ\n";
echo "========================================\n\n";
try {
// Désactiver temporairement le mode strict pour les clés étrangères
$db->exec("SET FOREIGN_KEY_CHECKS = 0");
// 1. Table des alertes
echo "1. Création de la table sec_alerts...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_alerts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`alert_type` VARCHAR(50) NOT NULL COMMENT 'Type d\'alerte (BRUTE_FORCE, SQL_ERROR, etc.)',
`alert_level` ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL', 'SECURITY') NOT NULL DEFAULT 'INFO',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP source',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté ou utilisé',
`endpoint` VARCHAR(255) DEFAULT NULL COMMENT 'Endpoint API concerné',
`method` VARCHAR(10) DEFAULT NULL COMMENT 'Méthode HTTP',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels en JSON',
`occurrences` INT(11) DEFAULT 1 COMMENT 'Nombre d\'occurrences',
`first_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`email_sent` TINYINT(1) DEFAULT 0 COMMENT 'Email d\'alerte envoyé',
`email_sent_at` TIMESTAMP NULL DEFAULT NULL,
`resolved` TINYINT(1) DEFAULT 0 COMMENT 'Alerte résolue',
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
`resolved_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID admin qui a résolu',
`notes` TEXT DEFAULT NULL COMMENT 'Notes de résolution',
KEY `idx_ip` (`ip_address`),
KEY `idx_type_time` (`alert_type`, `last_seen`),
KEY `idx_level` (`alert_level`),
KEY `idx_resolved` (`resolved`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Alertes de sécurité et monitoring'
");
echo " ✓ Table sec_alerts créée\n";
// 2. Table des métriques de performance (SANS PARTITIONNEMENT)
echo "2. Création de la table sec_performance_metrics...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_performance_metrics` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`endpoint` VARCHAR(255) NOT NULL COMMENT 'Endpoint API',
`method` VARCHAR(10) NOT NULL COMMENT 'Méthode HTTP',
`response_time_ms` INT(11) NOT NULL COMMENT 'Temps de réponse total en ms',
`db_time_ms` INT(11) DEFAULT 0 COMMENT 'Temps cumulé des requêtes DB en ms',
`db_queries_count` INT(11) DEFAULT 0 COMMENT 'Nombre de requêtes DB',
`memory_peak_mb` FLOAT DEFAULT NULL COMMENT 'Pic mémoire en MB',
`memory_start_mb` FLOAT DEFAULT NULL COMMENT 'Mémoire au début en MB',
`http_status` INT(11) DEFAULT NULL COMMENT 'Code HTTP de réponse',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent complet',
`request_size` INT(11) DEFAULT NULL COMMENT 'Taille de la requête en octets',
`response_size` INT(11) DEFAULT NULL COMMENT 'Taille de la réponse en octets',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_endpoint_time` (`endpoint`, `created_at`),
KEY `idx_response_time` (`response_time_ms`),
KEY `idx_created` (`created_at`),
KEY `idx_status` (`http_status`),
KEY `idx_user` (`user_id`),
KEY `idx_date_endpoint` (`created_at`, `endpoint`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Métriques de performance des requêtes'
");
echo " ✓ Table sec_performance_metrics créée\n";
// 3. Table des tentatives de login échouées
echo "3. Création de la table sec_failed_login_attempts...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_failed_login_attempts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté',
`encrypted_username` VARCHAR(255) DEFAULT NULL COMMENT 'Username chiffré si trouvé',
`ip_address` VARCHAR(45) NOT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent',
`attempt_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`error_type` VARCHAR(50) DEFAULT NULL COMMENT 'Type d\'erreur (invalid_password, user_not_found, etc.)',
`country_code` VARCHAR(2) DEFAULT NULL COMMENT 'Code pays de l\'IP (si géoloc activée)',
KEY `idx_ip_time` (`ip_address`, `attempt_time`),
KEY `idx_username` (`username`),
KEY `idx_encrypted_username` (`encrypted_username`),
KEY `idx_time` (`attempt_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tentatives de connexion échouées'
");
echo " ✓ Table sec_failed_login_attempts créée\n";
// 4. Table des IPs bloquées
echo "4. Création de la table sec_blocked_ips...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_blocked_ips` (
`ip_address` VARCHAR(45) NOT NULL PRIMARY KEY COMMENT 'Adresse IP bloquée',
`reason` VARCHAR(255) NOT NULL COMMENT 'Raison du blocage',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels',
`blocked_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`blocked_until` TIMESTAMP NOT NULL COMMENT 'Bloqué jusqu\'à',
`blocked_by` VARCHAR(50) DEFAULT 'system' COMMENT 'Qui a bloqué (system ou user ID)',
`permanent` TINYINT(1) DEFAULT 0 COMMENT 'Blocage permanent',
`unblocked_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de déblocage effectif',
`unblocked_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'Qui a débloqué',
`block_count` INT(11) DEFAULT 1 COMMENT 'Nombre de fois bloquée',
KEY `idx_blocked_until` (`blocked_until`),
KEY `idx_permanent` (`permanent`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IPs bloquées temporairement ou définitivement'
");
echo " ✓ Table sec_blocked_ips créée\n";
// 5. Créer les vues
echo "5. Création des vues...\n";
// Vue pour les alertes actives
$db->exec("
CREATE OR REPLACE VIEW sec_active_alerts AS
SELECT
a.*,
u.encrypted_name as user_name,
r.encrypted_name as resolver_name
FROM sec_alerts a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN users r ON a.resolved_by = r.id
WHERE a.resolved = 0
OR (a.resolved = 1 AND a.resolved_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR))
ORDER BY
CASE a.alert_level
WHEN 'SECURITY' THEN 1
WHEN 'CRITICAL' THEN 2
WHEN 'ERROR' THEN 3
WHEN 'WARNING' THEN 4
WHEN 'INFO' THEN 5
END,
a.last_seen DESC
");
echo " ✓ Vue sec_active_alerts créée\n";
// Vue pour les IPs suspectes
$db->exec("
CREATE OR REPLACE VIEW sec_suspicious_ips AS
SELECT
ip_address,
COUNT(*) as total_attempts,
COUNT(DISTINCT username) as unique_usernames,
MIN(attempt_time) as first_attempt,
MAX(attempt_time) as last_attempt,
TIMESTAMPDIFF(MINUTE, MIN(attempt_time), MAX(attempt_time)) as timespan_minutes
FROM sec_failed_login_attempts
WHERE attempt_time >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
GROUP BY ip_address
HAVING total_attempts >= 5
OR unique_usernames >= 3
ORDER BY total_attempts DESC
");
echo " ✓ Vue sec_suspicious_ips créée\n";
// 6. Créer les index additionnels
echo "6. Création des index additionnels...\n";
// Index pour les requêtes fréquentes
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_metrics_recent ON sec_performance_metrics(created_at DESC, endpoint)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_alerts_recent ON sec_alerts(last_seen DESC, alert_level)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_failed_recent ON sec_failed_login_attempts(attempt_time DESC, ip_address)");
echo " ✓ Index créés\n";
// 7. Créer la procédure de nettoyage
echo "7. Création de la procédure de nettoyage...\n";
$db->exec("DROP PROCEDURE IF EXISTS sec_cleanup_old_data");
$db->exec("
CREATE PROCEDURE sec_cleanup_old_data(IN days_to_keep INT)
BEGIN
-- Nettoyer les métriques de performance
DELETE FROM sec_performance_metrics
WHERE created_at < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Nettoyer les tentatives de login
DELETE FROM sec_failed_login_attempts
WHERE attempt_time < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Nettoyer les alertes résolues
DELETE FROM sec_alerts
WHERE resolved = 1
AND resolved_at < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Retourner le nombre de lignes supprimées
SELECT ROW_COUNT() as deleted_rows;
END
");
echo " ✓ Procédure sec_cleanup_old_data créée\n";
// Réactiver les clés étrangères
$db->exec("SET FOREIGN_KEY_CHECKS = 1");
// 8. Vérifier que tout est créé
echo "\n8. Vérification finale...\n";
$tables = ['sec_alerts', 'sec_performance_metrics', 'sec_failed_login_attempts', 'sec_blocked_ips'];
$allOk = true;
foreach ($tables as $table) {
$stmt = $db->query("SELECT COUNT(*) as count FROM $table");
if ($stmt) {
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo " ✓ Table $table : OK ({$result['count']} enregistrements)\n";
} else {
echo " ✗ Table $table : ERREUR\n";
$allOk = false;
}
}
if ($allOk) {
echo "\n========================================\n";
echo "✅ TOUTES LES TABLES ONT ÉTÉ CRÉÉES AVEC SUCCÈS\n";
echo "========================================\n\n";
echo "Le système de sécurité est maintenant prêt à être utilisé.\n";
echo "Vous pouvez tester avec : php test_security.php\n\n";
} else {
echo "\n⚠️ Certaines tables n'ont pas pu être créées.\n";
echo "Vérifiez les erreurs ci-dessus.\n\n";
}
} catch (PDOException $e) {
echo "\n❌ ERREUR SQL : " . $e->getMessage() . "\n\n";
echo "Code d'erreur : " . $e->getCode() . "\n";
echo "Vérifiez les permissions et la configuration de la base de données.\n\n";
exit(1);
} catch (Exception $e) {
echo "\n❌ ERREUR : " . $e->getMessage() . "\n\n";
exit(1);
}

View File

@@ -0,0 +1,157 @@
-- Script de création des tables pour le module Chat
-- Date : 2025-01-17
-- Version : 1.0
-- Tables préfixées "chat_" pour le module de messagerie
-- ============================================
-- SUPPRESSION DES TABLES EXISTANTES
-- ============================================
-- Attention : Ceci supprimera toutes les données existantes du chat !
-- Désactiver temporairement les contraintes de clés étrangères
SET FOREIGN_KEY_CHECKS = 0;
-- Supprimer la vue si elle existe
DROP VIEW IF EXISTS chat_rooms_with_last_message;
-- Supprimer les tables dans l'ordre inverse des dépendances
DROP TABLE IF EXISTS `chat_read_receipts`;
DROP TABLE IF EXISTS `chat_participants`;
DROP TABLE IF EXISTS `chat_messages`;
DROP TABLE IF EXISTS `chat_rooms`;
-- Supprimer toute autre table commençant par chat_ qui pourrait exister
-- Note : Cette procédure supprime dynamiquement toutes les tables avec le préfixe chat_
DELIMITER $$
DROP PROCEDURE IF EXISTS drop_chat_tables$$
CREATE PROCEDURE drop_chat_tables()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE tableName VARCHAR(255);
DECLARE cur CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'chat_%';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tableName;
IF done THEN
LEAVE read_loop;
END IF;
SET @sql = CONCAT('DROP TABLE IF EXISTS `', tableName, '`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
-- Exécuter la procédure
CALL drop_chat_tables();
-- Supprimer la procédure après utilisation
DROP PROCEDURE IF EXISTS drop_chat_tables;
-- Réactiver les contraintes de clés étrangères
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================
-- CRÉATION DES NOUVELLES TABLES
-- ============================================
-- Table des salles de conversation
CREATE TABLE IF NOT EXISTS `chat_rooms` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID de la salle',
`title` VARCHAR(255) DEFAULT NULL COMMENT 'Titre de la conversation',
`type` ENUM('private', 'group', 'broadcast') NOT NULL DEFAULT 'private' COMMENT 'Type de conversation',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création',
`created_by` INT(11) UNSIGNED NOT NULL COMMENT 'ID du créateur',
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Dernière modification',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Conversation active',
KEY `idx_created_by` (`created_by`),
KEY `idx_type` (`type`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_chat_rooms_creator` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Salles de conversation';
-- Table des messages
CREATE TABLE IF NOT EXISTS `chat_messages` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID du message',
`room_id` VARCHAR(36) NOT NULL COMMENT 'ID de la salle',
`content` TEXT NOT NULL COMMENT 'Contenu du message',
`sender_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'expéditeur',
`sent_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date d\'envoi',
`edited_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de modification',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Message supprimé',
KEY `idx_room_id` (`room_id`),
KEY `idx_sender_id` (`sender_id`),
KEY `idx_sent_at` (`sent_at`),
KEY `idx_room_sent` (`room_id`, `sent_at`),
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`room_id`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_messages_sender` FOREIGN KEY (`sender_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Messages du chat';
-- Table des participants
CREATE TABLE IF NOT EXISTS `chat_participants` (
`room_id` VARCHAR(36) NOT NULL COMMENT 'ID de la salle',
`user_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'utilisateur',
`role` INT(11) DEFAULT NULL COMMENT 'Rôle de l\'utilisateur (fk_role)',
`entite_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID de l\'entité',
`joined_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date d\'adhésion',
`left_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de départ',
`is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Admin de la salle',
`last_read_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Dernière lecture',
PRIMARY KEY (`room_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_entite_id` (`entite_id`),
KEY `idx_joined_at` (`joined_at`),
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`room_id`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_participants_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_participants_entite` FOREIGN KEY (`entite_id`) REFERENCES `entites` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Participants aux conversations';
-- Table des accusés de lecture
CREATE TABLE IF NOT EXISTS `chat_read_receipts` (
`message_id` VARCHAR(36) NOT NULL COMMENT 'ID du message',
`user_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'utilisateur',
`read_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de lecture',
PRIMARY KEY (`message_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_read_at` (`read_at`),
CONSTRAINT `fk_chat_read_message` FOREIGN KEY (`message_id`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_read_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Accusés de lecture';
-- Index supplémentaires pour les performances
CREATE INDEX idx_chat_active_rooms ON chat_rooms(is_active, created_at DESC);
CREATE INDEX idx_chat_user_rooms ON chat_participants(user_id, left_at, joined_at DESC);
CREATE INDEX idx_chat_unread ON chat_messages(room_id, sent_at) WHERE id NOT IN (SELECT message_id FROM chat_read_receipts);
-- Vue pour faciliter la récupération des conversations avec le dernier message
CREATE OR REPLACE VIEW chat_rooms_with_last_message AS
SELECT
r.*,
m.content as last_message_content,
m.sender_id as last_message_sender,
m.sent_at as last_message_at,
u.encrypted_name as last_message_sender_name
FROM chat_rooms r
LEFT JOIN (
SELECT m1.*
FROM chat_messages m1
INNER JOIN (
SELECT room_id, MAX(sent_at) as max_sent_at
FROM chat_messages
WHERE is_deleted = 0
GROUP BY room_id
) m2 ON m1.room_id = m2.room_id AND m1.sent_at = m2.max_sent_at
) m ON r.id = m.room_id
LEFT JOIN users u ON m.sender_id = u.id
WHERE r.is_active = 1;

View File

@@ -0,0 +1,123 @@
-- Script de création des tables pour le module Security & Monitoring
-- Date : 2025-01-17
-- Version : 1.0
-- Préfixe : sec_ (security)
-- ============================================
-- SUPPRESSION DES TABLES EXISTANTES (OPTIONNEL)
-- ============================================
-- Décommenter si vous voulez recréer les tables
-- DROP TABLE IF EXISTS `sec_blocked_ips`;
-- DROP TABLE IF EXISTS `sec_failed_login_attempts`;
-- DROP TABLE IF EXISTS `sec_performance_metrics`;
-- DROP TABLE IF EXISTS `sec_alerts`;
-- ============================================
-- CRÉATION DES TABLES DE SÉCURITÉ ET MONITORING
-- ============================================
-- Table principale des alertes de sécurité
CREATE TABLE IF NOT EXISTS `sec_alerts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`alert_type` VARCHAR(50) NOT NULL COMMENT 'Type d\'alerte (BRUTE_FORCE, SQL_ERROR, etc.)',
`alert_level` ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL', 'SECURITY') NOT NULL DEFAULT 'INFO',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP source',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté ou utilisé',
`endpoint` VARCHAR(255) DEFAULT NULL COMMENT 'Endpoint API concerné',
`method` VARCHAR(10) DEFAULT NULL COMMENT 'Méthode HTTP',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels en JSON',
`occurrences` INT(11) DEFAULT 1 COMMENT 'Nombre d\'occurrences',
`first_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`email_sent` TINYINT(1) DEFAULT 0 COMMENT 'Email d\'alerte envoyé',
`email_sent_at` TIMESTAMP NULL DEFAULT NULL,
`resolved` TINYINT(1) DEFAULT 0 COMMENT 'Alerte résolue',
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
`resolved_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID admin qui a résolu',
`notes` TEXT DEFAULT NULL COMMENT 'Notes de résolution',
KEY `idx_ip` (`ip_address`),
KEY `idx_type_time` (`alert_type`, `last_seen`),
KEY `idx_level` (`alert_level`),
KEY `idx_resolved` (`resolved`),
KEY `idx_user` (`user_id`),
CONSTRAINT `fk_sec_alerts_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_sec_alerts_resolver` FOREIGN KEY (`resolved_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Alertes de sécurité et monitoring';
-- Table des métriques de performance
CREATE TABLE IF NOT EXISTS `sec_performance_metrics` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`endpoint` VARCHAR(255) NOT NULL COMMENT 'Endpoint API',
`method` VARCHAR(10) NOT NULL COMMENT 'Méthode HTTP',
`response_time_ms` INT(11) NOT NULL COMMENT 'Temps de réponse total en ms',
`db_time_ms` INT(11) DEFAULT 0 COMMENT 'Temps cumulé des requêtes DB en ms',
`db_queries_count` INT(11) DEFAULT 0 COMMENT 'Nombre de requêtes DB',
`memory_peak_mb` FLOAT DEFAULT NULL COMMENT 'Pic mémoire en MB',
`memory_start_mb` FLOAT DEFAULT NULL COMMENT 'Mémoire au début en MB',
`http_status` INT(11) DEFAULT NULL COMMENT 'Code HTTP de réponse',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent complet',
`request_size` INT(11) DEFAULT NULL COMMENT 'Taille de la requête en octets',
`response_size` INT(11) DEFAULT NULL COMMENT 'Taille de la réponse en octets',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_endpoint_time` (`endpoint`, `created_at`),
KEY `idx_response_time` (`response_time_ms`),
KEY `idx_created` (`created_at`),
KEY `idx_status` (`http_status`),
KEY `idx_user` (`user_id`),
KEY `idx_date_endpoint` (`created_at`, `endpoint`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Métriques de performance des requêtes';
-- Table des tentatives de login échouées
CREATE TABLE IF NOT EXISTS `sec_failed_login_attempts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté',
`encrypted_username` VARCHAR(255) DEFAULT NULL COMMENT 'Username chiffré si trouvé',
`ip_address` VARCHAR(45) NOT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent',
`attempt_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`error_type` VARCHAR(50) DEFAULT NULL COMMENT 'Type d\'erreur (invalid_password, user_not_found, etc.)',
`country_code` VARCHAR(2) DEFAULT NULL COMMENT 'Code pays de l\'IP (si géoloc activée)',
KEY `idx_ip_time` (`ip_address`, `attempt_time`),
KEY `idx_username` (`username`),
KEY `idx_encrypted_username` (`encrypted_username`),
KEY `idx_time` (`attempt_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tentatives de connexion échouées';
-- Table des IPs bloquées
CREATE TABLE IF NOT EXISTS `sec_blocked_ips` (
`ip_address` VARCHAR(45) NOT NULL PRIMARY KEY COMMENT 'Adresse IP bloquée',
`reason` VARCHAR(255) NOT NULL COMMENT 'Raison du blocage',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels',
`blocked_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`blocked_until` TIMESTAMP NOT NULL COMMENT 'Bloqué jusqu\'à',
`blocked_by` VARCHAR(50) DEFAULT 'system' COMMENT 'Qui a bloqué (system ou user ID)',
`permanent` TINYINT(1) DEFAULT 0 COMMENT 'Blocage permanent',
`unblocked_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de déblocage effectif',
`unblocked_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'Qui a débloqué',
`block_count` INT(11) DEFAULT 1 COMMENT 'Nombre de fois bloquée',
KEY `idx_blocked_until` (`blocked_until`),
KEY `idx_permanent` (`permanent`),
CONSTRAINT `fk_sec_blocked_unblocked_by` FOREIGN KEY (`unblocked_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IPs bloquées temporairement ou définitivement';
-- ============================================
-- INDEX ADDITIONNELS POUR PERFORMANCES
-- ============================================
-- Index pour requêtes de monitoring fréquentes
CREATE INDEX idx_sec_metrics_recent ON sec_performance_metrics(created_at DESC, endpoint);
CREATE INDEX idx_sec_alerts_recent ON sec_alerts(last_seen DESC, alert_level);
CREATE INDEX idx_sec_failed_recent ON sec_failed_login_attempts(attempt_time DESC, ip_address);
-- ============================================
-- FIN DU SCRIPT
-- ============================================
-- Note: La purge des données anciennes doit être gérée par:
-- 1. Un cron qui appelle l'endpoint API /api/admin/cleanup
-- 2. Ou directement via les méthodes cleanup des services PHP

View File

@@ -0,0 +1,35 @@
-- Migration pour supporter les usernames UTF-8 avec jusqu'à 30 caractères
-- Date : 2025-01-17
-- Objectif : Permettre des usernames plus souples (émojis, accents, espaces, etc.)
-- IMPORTANT : Faire une sauvegarde avant d'exécuter ce script !
-- mysqldump -u root -p geo_app > backup_geo_app_$(date +%Y%m%d).sql
-- Augmenter la taille de la colonne encrypted_user_name pour supporter
-- les usernames UTF-8 de 30 caractères maximum une fois chiffrés
-- Un username de 30 caractères UTF-8 peut faire jusqu'à 120 octets
-- Après chiffrement AES-256-CBC + base64, cela peut atteindre ~200 caractères
ALTER TABLE `users`
MODIFY COLUMN `encrypted_user_name` varchar(255) DEFAULT ''
COMMENT 'Username chiffré - Supporte UTF-8 30 caractères maximum';
-- Vérifier que la modification a bien été appliquée
SELECT
COLUMN_NAME,
COLUMN_TYPE,
CHARACTER_MAXIMUM_LENGTH,
COLUMN_COMMENT
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'encrypted_user_name';
-- Note : Les nouvelles règles de validation des usernames sont :
-- - Minimum : 8 caractères UTF-8
-- - Maximum : 30 caractères UTF-8
-- - Accepte TOUS les caractères (lettres, chiffres, espaces, émojis, accents, etc.)
-- - Trim automatique des espaces en début/fin
-- - Unicité vérifiée dans toute la base

View File

@@ -0,0 +1,875 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
use PDO;
use PDOException;
use Database;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
class ChatController {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
/**
* GET /api/chat/rooms
* Liste des conversations filtrées par rôle et entité
*/
public function getRooms(): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
$entityId = Session::getEntityId();
// Récupérer le rôle de l'utilisateur
$userRole = $this->getUserRole($userId);
// Construction de la requête selon le rôle
$sql = '
SELECT DISTINCT
r.id,
r.title,
r.type,
r.created_at,
r.created_by,
r.updated_at,
-- Dernier message
(SELECT m.content
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message,
(SELECT m.sent_at
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message_at,
-- Nombre de messages non lus
(SELECT COUNT(*)
FROM chat_messages m
WHERE m.room_id = r.id
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
AND m.sender_id != :user_id_count) as unread_count,
-- Participants
(SELECT COUNT(*)
FROM chat_participants cp
WHERE cp.room_id = r.id
AND cp.left_at IS NULL) as participant_count
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE r.is_active = 1
AND p.user_id = :user_id
AND p.left_at IS NULL
';
// Filtrage supplémentaire selon le rôle
if ($userRole == 1) {
// Utilisateur simple : seulement ses conversations privées et de groupe
$sql .= ' AND r.type IN ("private", "group")';
} elseif ($userRole == 2) {
// Admin d'entité : toutes les conversations de son entité
$sql .= ' AND (p.entite_id = :entity_id OR r.type = "broadcast")';
}
// Rôle > 2 : accès à toutes les conversations
$sql .= ' ORDER BY COALESCE(last_message_at, r.created_at) DESC';
$stmt = $this->db->prepare($sql);
$params = [
'user_id' => $userId,
'user_id_count' => $userId
];
if ($userRole == 2) {
$params['entity_id'] = $entityId;
}
$stmt->execute($params);
$rooms = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Pour chaque room, récupérer les participants
foreach ($rooms as &$room) {
$room['participants'] = $this->getRoomParticipants($room['id']);
}
LogService::log('Récupération des conversations', [
'level' => 'debug',
'user_id' => $userId,
'room_count' => count($rooms)
]);
Response::json([
'status' => 'success',
'rooms' => $rooms
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des conversations', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms
* Créer une nouvelle conversation
*/
public function createRoom(): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
// Validation des données
if (!isset($data['type']) || !in_array($data['type'], ['private', 'group', 'broadcast'])) {
Response::json([
'status' => 'error',
'message' => 'Type de conversation invalide'
], 400);
return;
}
// Vérification des permissions pour broadcast
if ($data['type'] === 'broadcast' && $userRole < 2) {
Response::json([
'status' => 'error',
'message' => 'Permissions insuffisantes pour créer une diffusion'
], 403);
return;
}
// Validation des participants
if (!isset($data['participants']) || !is_array($data['participants']) || empty($data['participants'])) {
Response::json([
'status' => 'error',
'message' => 'Au moins un participant requis'
], 400);
return;
}
// Pour une conversation privée, limiter à 2 participants (incluant le créateur)
if ($data['type'] === 'private' && count($data['participants']) > 1) {
Response::json([
'status' => 'error',
'message' => 'Une conversation privée ne peut avoir que 2 participants'
], 400);
return;
}
// Vérifier que tous les participants existent et sont accessibles
$participantIds = array_map('intval', $data['participants']);
if (!in_array($userId, $participantIds)) {
$participantIds[] = $userId; // Ajouter le créateur
}
// Vérifier l'existence d'une conversation privée existante
if ($data['type'] === 'private' && count($participantIds) === 2) {
$existingRoom = $this->findExistingPrivateRoom($participantIds[0], $participantIds[1]);
if ($existingRoom) {
Response::json([
'status' => 'success',
'room' => $existingRoom,
'existing' => true
]);
return;
}
}
// Générer un UUID pour la room
$roomId = $this->generateUUID();
// Titre de la conversation
$title = $data['title'] ?? null;
if (!$title && $data['type'] === 'private') {
// Pour une conversation privée, pas de titre par défaut
$title = null;
}
$this->db->beginTransaction();
try {
// Créer la room
$stmt = $this->db->prepare('
INSERT INTO chat_rooms (id, title, type, created_by, created_at)
VALUES (:id, :title, :type, :created_by, NOW())
');
$stmt->execute([
'id' => $roomId,
'title' => $title,
'type' => $data['type'],
'created_by' => $userId
]);
// Ajouter les participants
$participantStmt = $this->db->prepare('
INSERT INTO chat_participants (room_id, user_id, role, entite_id, is_admin)
VALUES (:room_id, :user_id, :role, :entite_id, :is_admin)
');
foreach ($participantIds as $participantId) {
$participantData = $this->getUserData($participantId);
if (!$participantData) {
throw new \Exception("Participant invalide: $participantId");
}
$participantStmt->execute([
'room_id' => $roomId,
'user_id' => $participantId,
'role' => $participantData['fk_role'],
'entite_id' => $participantData['fk_entite'],
'is_admin' => ($participantId === $userId) ? 1 : 0
]);
}
// Si un message initial est fourni, l'envoyer
if (isset($data['initial_message']) && !empty($data['initial_message'])) {
$messageId = $this->generateUUID();
$msgStmt = $this->db->prepare('
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
VALUES (:id, :room_id, :content, :sender_id, NOW())
');
$msgStmt->execute([
'id' => $messageId,
'room_id' => $roomId,
'content' => $data['initial_message'],
'sender_id' => $userId
]);
}
$this->db->commit();
LogService::log('Conversation créée', [
'level' => 'info',
'room_id' => $roomId,
'type' => $data['type'],
'created_by' => $userId,
'participant_count' => count($participantIds)
]);
// Récupérer la room créée avec ses détails
$room = $this->getRoomDetails($roomId);
Response::json([
'status' => 'success',
'room' => $room
], 201);
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
} catch (PDOException $e) {
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
} catch (\Exception $e) {
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 400);
}
}
/**
* GET /api/chat/rooms/{id}/messages
* Récupérer les messages d'une conversation
*/
public function getRoomMessages(string $roomId): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Paramètres de pagination
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
$before = $_GET['before'] ?? null; // Message ID pour pagination
$sql = '
SELECT
m.id,
m.content,
m.sender_id,
m.sent_at,
m.edited_at,
m.is_deleted,
u.encrypted_name as sender_name,
u.first_name as sender_first_name,
-- Statut de lecture
(SELECT COUNT(*)
FROM chat_read_receipts r
WHERE r.message_id = m.id) as read_count,
(SELECT COUNT(*)
FROM chat_read_receipts r
WHERE r.message_id = m.id
AND r.user_id = :user_id) as is_read
FROM chat_messages m
INNER JOIN users u ON m.sender_id = u.id
WHERE m.room_id = :room_id
';
$params = [
'room_id' => $roomId,
'user_id' => $userId
];
if ($before) {
$sql .= ' AND m.sent_at < (SELECT sent_at FROM chat_messages WHERE id = :before)';
$params['before'] = $before;
}
$sql .= ' ORDER BY m.sent_at DESC LIMIT :limit';
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
if ($key === 'limit') {
$stmt->bindValue($key, $value, PDO::PARAM_INT);
} else {
$stmt->bindValue($key, $value);
}
}
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Déchiffrer les noms
foreach ($messages as &$message) {
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_read'] = (bool)$message['is_read'];
$message['is_mine'] = ($message['sender_id'] == $userId);
}
// Inverser pour avoir l'ordre chronologique
$messages = array_reverse($messages);
// Mettre à jour last_read_at pour ce participant
$this->updateLastRead($roomId, $userId);
Response::json([
'status' => 'success',
'messages' => $messages,
'has_more' => count($messages) === $limit
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des messages', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms/{id}/messages
* Envoyer un message dans une conversation
*/
public function sendMessage(string $roomId): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Validation du contenu
if (!isset($data['content']) || empty(trim($data['content']))) {
Response::json([
'status' => 'error',
'message' => 'Le message ne peut pas être vide'
], 400);
return;
}
$content = trim($data['content']);
// Limiter la longueur du message
if (mb_strlen($content, 'UTF-8') > 5000) {
Response::json([
'status' => 'error',
'message' => 'Message trop long (max 5000 caractères)'
], 400);
return;
}
$messageId = $this->generateUUID();
// Insérer le message
$stmt = $this->db->prepare('
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
VALUES (:id, :room_id, :content, :sender_id, NOW())
');
$stmt->execute([
'id' => $messageId,
'room_id' => $roomId,
'content' => $content,
'sender_id' => $userId
]);
// Mettre à jour la date de dernière modification de la room
$updateStmt = $this->db->prepare('
UPDATE chat_rooms
SET updated_at = NOW()
WHERE id = :room_id
');
$updateStmt->execute(['room_id' => $roomId]);
// Récupérer le message créé avec les infos du sender
$msgStmt = $this->db->prepare('
SELECT
m.id,
m.content,
m.sender_id,
m.sent_at,
u.encrypted_name as sender_name,
u.first_name as sender_first_name
FROM chat_messages m
INNER JOIN users u ON m.sender_id = u.id
WHERE m.id = :id
');
$msgStmt->execute(['id' => $messageId]);
$message = $msgStmt->fetch(PDO::FETCH_ASSOC);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_mine'] = true;
$message['is_read'] = false;
$message['read_count'] = 0;
LogService::log('Message envoyé', [
'level' => 'debug',
'room_id' => $roomId,
'message_id' => $messageId,
'sender_id' => $userId
]);
Response::json([
'status' => 'success',
'message' => $message
], 201);
} catch (PDOException $e) {
LogService::log('Erreur lors de l\'envoi du message', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms/{id}/read
* Marquer les messages comme lus
*/
public function markAsRead(string $roomId): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Si des message_ids spécifiques sont fournis
if (isset($data['message_ids']) && is_array($data['message_ids'])) {
$messageIds = $data['message_ids'];
// Marquer ces messages spécifiques comme lus
$stmt = $this->db->prepare('
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
VALUES (:message_id, :user_id, NOW())
');
foreach ($messageIds as $messageId) {
$stmt->execute([
'message_id' => $messageId,
'user_id' => $userId
]);
}
} else {
// Marquer tous les messages non lus de la room comme lus
$stmt = $this->db->prepare('
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
SELECT m.id, :user_id, NOW()
FROM chat_messages m
WHERE m.room_id = :room_id
AND m.id NOT IN (
SELECT message_id
FROM chat_read_receipts
WHERE user_id = :user_id_check
)
');
$stmt->execute([
'user_id' => $userId,
'user_id_check' => $userId,
'room_id' => $roomId
]);
}
// Mettre à jour last_read_at
$this->updateLastRead($roomId, $userId);
// Compter les messages non lus restants
$countStmt = $this->db->prepare('
SELECT COUNT(*) as unread_count
FROM chat_messages m
WHERE m.room_id = :room_id
AND m.sender_id != :user_id
AND m.id NOT IN (
SELECT message_id
FROM chat_read_receipts
WHERE user_id = :user_id_check
)
');
$countStmt->execute([
'room_id' => $roomId,
'user_id' => $userId,
'user_id_check' => $userId
]);
$result = $countStmt->fetch(PDO::FETCH_ASSOC);
Response::json([
'status' => 'success',
'unread_count' => (int)$result['unread_count']
]);
} catch (PDOException $e) {
LogService::log('Erreur lors du marquage comme lu', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* GET /api/chat/recipients
* Liste des destinataires possibles selon le rôle
*/
public function getRecipients(): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
$sql = '
SELECT
u.id,
u.encrypted_name as name,
u.first_name,
u.sect_name,
u.fk_role as role,
u.fk_entite as entite_id,
e.encrypted_name as entite_name
FROM users u
LEFT JOIN entites e ON u.fk_entite = e.id
WHERE u.chk_active = 1
AND u.id != :user_id
';
$params = ['user_id' => $userId];
// Filtrage selon le rôle
if ($userRole == 1) {
// Utilisateur simple : seulement les utilisateurs de son entité
$sql .= ' AND u.fk_entite = :entity_id';
$params['entity_id'] = $entityId;
} elseif ($userRole == 2) {
// Admin d'entité :
// - Tous les membres actifs de son amicale (même entité)
// - Les super-admins (fk_role=9) de l'entité 1
$sql .= ' AND (
u.fk_entite = :entity_id
OR (u.fk_role = 9 AND u.fk_entite = 1)
)';
$params['entity_id'] = $entityId;
} elseif ($userRole == 9) {
// Super-administrateur :
// - Seulement les administrateurs actifs des amicales (fk_role=2)
// - Et les autres super-admins (fk_role=9)
$sql .= ' AND (u.fk_role = 2 OR u.fk_role = 9)';
}
// Autres rôles (3-8) : pas de filtrage spécifique pour le moment
$sql .= ' ORDER BY u.fk_entite, u.encrypted_name';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$recipients = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Déchiffrer les données et organiser par entité
$recipientsByEntity = [];
$recipientsDecrypted = [];
foreach ($recipients as &$recipient) {
// Déchiffrer le nom
$recipient['name'] = ApiService::decryptData($recipient['name']);
// Déchiffrer le nom de l'entité
$entiteName = $recipient['entite_name'] ?
ApiService::decryptData($recipient['entite_name']) :
'Sans entité';
// Créer une copie pour recipients_by_entity
$recipientCopy = $recipient;
unset($recipientCopy['entite_name']);
// Organiser par entité
if (!isset($recipientsByEntity[$entiteName])) {
$recipientsByEntity[$entiteName] = [];
}
$recipientsByEntity[$entiteName][] = $recipientCopy;
// Remplacer entite_name chiffré par la version déchiffrée
$recipient['entite_name'] = $entiteName;
// Ajouter à la liste déchiffrée
$recipientsDecrypted[] = $recipient;
}
Response::json([
'status' => 'success',
'recipients' => $recipientsDecrypted,
'recipients_by_entity' => $recipientsByEntity
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des destinataires', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
// ===== Méthodes utilitaires privées =====
/**
* Récupérer le rôle d'un utilisateur
*/
private function getUserRole(int $userId): int {
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? (int)$result['fk_role'] : 1;
}
/**
* Récupérer les données d'un utilisateur
*/
private function getUserData(int $userId): ?array {
$stmt = $this->db->prepare('
SELECT id, fk_role, fk_entite, encrypted_name, first_name
FROM users
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ?: null;
}
/**
* Vérifier si un utilisateur est dans une room
*/
private function isUserInRoom(int $userId, string $roomId): bool {
$stmt = $this->db->prepare('
SELECT COUNT(*) as count
FROM chat_participants
WHERE room_id = ?
AND user_id = ?
AND left_at IS NULL
');
$stmt->execute([$roomId, $userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result && $result['count'] > 0;
}
/**
* Récupérer les participants d'une room
*/
private function getRoomParticipants(string $roomId): array {
$stmt = $this->db->prepare('
SELECT
p.user_id,
p.is_admin,
u.encrypted_name as name,
u.first_name
FROM chat_participants p
INNER JOIN users u ON p.user_id = u.id
WHERE p.room_id = ?
AND p.left_at IS NULL
');
$stmt->execute([$roomId]);
$participants = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($participants as &$participant) {
$participant['name'] = ApiService::decryptData($participant['name']);
}
return $participants;
}
/**
* Récupérer les détails d'une room
*/
private function getRoomDetails(string $roomId): array {
$stmt = $this->db->prepare('
SELECT
r.id,
r.title,
r.type,
r.created_at,
r.created_by,
r.updated_at
FROM chat_rooms r
WHERE r.id = ?
');
$stmt->execute([$roomId]);
$room = $stmt->fetch(PDO::FETCH_ASSOC);
if ($room) {
$room['participants'] = $this->getRoomParticipants($roomId);
}
return $room;
}
/**
* Trouver une conversation privée existante entre deux utilisateurs
*/
private function findExistingPrivateRoom(int $user1, int $user2): ?array {
$stmt = $this->db->prepare('
SELECT r.*
FROM chat_rooms r
WHERE r.type = "private"
AND r.is_active = 1
AND EXISTS (
SELECT 1 FROM chat_participants p1
WHERE p1.room_id = r.id
AND p1.user_id = ?
AND p1.left_at IS NULL
)
AND EXISTS (
SELECT 1 FROM chat_participants p2
WHERE p2.room_id = r.id
AND p2.user_id = ?
AND p2.left_at IS NULL
)
AND (
SELECT COUNT(*)
FROM chat_participants p
WHERE p.room_id = r.id
AND p.left_at IS NULL
) = 2
');
$stmt->execute([$user1, $user2]);
$room = $stmt->fetch(PDO::FETCH_ASSOC);
if ($room) {
$room['participants'] = $this->getRoomParticipants($room['id']);
return $room;
}
return null;
}
/**
* Mettre à jour la date de dernière lecture
*/
private function updateLastRead(string $roomId, int $userId): void {
$stmt = $this->db->prepare('
UPDATE chat_participants
SET last_read_at = NOW()
WHERE room_id = ?
AND user_id = ?
');
$stmt->execute([$roomId, $userId]);
}
/**
* Générer un UUID v4
*/
private function generateUUID(): string {
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}

View File

@@ -20,6 +20,9 @@ use ApiService;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/EntiteController.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
use App\Services\Security\SecurityMonitor;
class LoginController {
private PDO $db;
@@ -76,6 +79,11 @@ class LoginController {
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// Enregistrer la tentative échouée
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'user_not_found', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : utilisateur non trouvé', [
'level' => 'warning',
'username' => $username
@@ -88,6 +96,11 @@ class LoginController {
$passwordValid = password_verify($data['password'], $user['user_pass_hash']);
if (!$passwordValid) {
// Enregistrer la tentative échouée
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'invalid_password', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : mot de passe incorrect', [
'level' => 'warning',
'username' => $username
@@ -769,6 +782,88 @@ class LoginController {
$response['regions'] = $regionsData;
}
// Ajout des informations du module chat
$chatData = [];
// Récupérer le nombre total de conversations de l'utilisateur
$roomCountStmt = $this->db->prepare('
SELECT COUNT(DISTINCT r.id) as total_rooms
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
');
$roomCountStmt->execute(['user_id' => $user['id']]);
$roomCount = $roomCountStmt->fetch(PDO::FETCH_ASSOC);
$chatData['total_rooms'] = (int)($roomCount['total_rooms'] ?? 0);
// Récupérer le nombre de messages non lus
$unreadStmt = $this->db->prepare('
SELECT COUNT(*) as unread_count
FROM chat_messages m
INNER JOIN chat_participants p ON m.room_id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND m.sender_id != :sender_id
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
AND m.is_deleted = 0
');
$unreadStmt->execute([
'user_id' => $user['id'],
'sender_id' => $user['id']
]);
$unreadResult = $unreadStmt->fetch(PDO::FETCH_ASSOC);
$chatData['unread_messages'] = (int)($unreadResult['unread_count'] ?? 0);
// Récupérer la dernière conversation active (optionnel, pour affichage rapide)
$lastRoomStmt = $this->db->prepare('
SELECT
r.id,
r.title,
r.type,
(SELECT m.content
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message,
(SELECT m.sent_at
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message_at
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
ORDER BY COALESCE(
(SELECT MAX(m.sent_at) FROM chat_messages m WHERE m.room_id = r.id),
r.created_at
) DESC
LIMIT 1
');
$lastRoomStmt->execute(['user_id' => $user['id']]);
$lastRoom = $lastRoomStmt->fetch(PDO::FETCH_ASSOC);
if ($lastRoom) {
$chatData['last_active_room'] = [
'id' => $lastRoom['id'],
'title' => $lastRoom['title'],
'type' => $lastRoom['type'],
'last_message' => $lastRoom['last_message'],
'last_message_at' => $lastRoom['last_message_at']
];
}
// Indicateur si le chat est disponible pour cet utilisateur
$chatData['chat_enabled'] = true; // Peut être conditionné selon le rôle ou l'entité
// Ajouter les données du chat à la réponse
$response['chat'] = $chatData;
// Envoi de la réponse
Response::json($response);
} catch (PDOException $e) {

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/ReceiptService.php';
use PDO;
use PDOException;
@@ -551,10 +552,44 @@ class PassageController {
'operationId' => $operationId
]);
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
$receiptGenerated = false;
if (isset($data['fk_type']) && (int)$data['fk_type'] === 1) {
// Vérifier si un email a été fourni
$hasEmail = false;
if (!empty($data['email'])) {
$hasEmail = filter_var($data['email'], FILTER_VALIDATE_EMAIL) !== false;
} elseif (!empty($encryptedEmail)) {
// L'email a déjà été validé lors du chiffrement
$hasEmail = true;
}
if ($hasEmail) {
try {
$receiptService = new \App\Services\ReceiptService();
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
if ($receiptGenerated) {
LogService::log('Reçu généré automatiquement pour le passage', [
'level' => 'info',
'passageId' => $passageId
]);
}
} catch (Exception $e) {
LogService::log('Erreur lors de la génération automatique du reçu', [
'level' => 'warning',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
}
}
}
Response::json([
'status' => 'success',
'message' => 'Passage créé avec succès',
'passage_id' => $passageId
'passage_id' => $passageId,
'receipt_generated' => $receiptGenerated
], 201);
} catch (Exception $e) {
LogService::log('Erreur lors de la création du passage', [
@@ -705,9 +740,52 @@ class PassageController {
'passageId' => $passageId
]);
// Vérifier si un reçu doit être généré après la mise à jour
$receiptGenerated = false;
// Récupérer les données actualisées du passage
$stmt = $this->db->prepare('SELECT fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
$stmt->execute([$passageId]);
$updatedPassage = $stmt->fetch(PDO::FETCH_ASSOC);
if ($updatedPassage) {
// Générer un reçu si :
// - C'est un don (fk_type = 1)
// - Il y a un email valide
// - Il n'y a pas encore de reçu (nom_recu est vide ou null)
if ((int)$updatedPassage['fk_type'] === 1 &&
!empty($updatedPassage['encrypted_email']) &&
empty($updatedPassage['nom_recu'])) {
// Vérifier que l'email est valide en le déchiffrant
try {
$email = ApiService::decryptSearchableData($updatedPassage['encrypted_email']);
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$receiptService = new \App\Services\ReceiptService();
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
if ($receiptGenerated) {
LogService::log('Reçu généré automatiquement après mise à jour du passage', [
'level' => 'info',
'passageId' => $passageId
]);
}
}
} catch (Exception $e) {
LogService::log('Erreur lors de la génération automatique du reçu après mise à jour', [
'level' => 'warning',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
}
}
}
Response::json([
'status' => 'success',
'message' => 'Passage mis à jour avec succès'
'message' => 'Passage mis à jour avec succès',
'receipt_generated' => $receiptGenerated
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de la mise à jour du passage', [
@@ -800,4 +878,150 @@ class PassageController {
], 500);
}
}
/**
* Récupère le reçu PDF d'un passage
*
* @param string $id ID du passage
* @return void
*/
public function getReceipt(string $id): void {
try {
$userId = Session::getUserId();
if (!$userId) {
Response::json([
'status' => 'error',
'message' => 'Vous devez être connecté pour effectuer cette action'
], 401);
return;
}
$passageId = (int)$id;
// Vérifier que le passage existe et que l'utilisateur y a accès
$entiteId = $this->getUserEntiteId($userId);
if (!$entiteId) {
Response::json([
'status' => 'error',
'message' => 'Entité non trouvée pour cet utilisateur'
], 404);
return;
}
// Récupérer les informations du passage et du reçu
$stmt = $this->db->prepare('
SELECT p.id, p.nom_recu, p.date_creat_recu, p.fk_operation, o.fk_entite
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND o.fk_entite = ? AND p.chk_active = 1
');
$stmt->execute([$passageId, $entiteId]);
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$passage) {
Response::json([
'status' => 'error',
'message' => 'Passage non trouvé ou accès non autorisé'
], 404);
return;
}
if (empty($passage['nom_recu'])) {
Response::json([
'status' => 'error',
'message' => 'Aucun reçu disponible pour ce passage'
], 404);
return;
}
// Récupérer le fichier depuis la table medias
$stmt = $this->db->prepare('
SELECT file_path, mime_type, file_size, fichier
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['passage', $passageId, 'recu']);
$media = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$media) {
// Si pas trouvé dans medias, essayer de construire le chemin
$filePath = __DIR__ . '/../../uploads/entites/' . $passage['fk_entite'] .
'/recus/' . $passage['fk_operation'] . '/' . $passage['nom_recu'];
if (!file_exists($filePath)) {
Response::json([
'status' => 'error',
'message' => 'Fichier reçu introuvable'
], 404);
return;
}
$media = [
'file_path' => $filePath,
'mime_type' => 'application/pdf',
'fichier' => $passage['nom_recu'],
'file_size' => filesize($filePath)
];
}
// Vérifier que le fichier existe
if (!file_exists($media['file_path'])) {
Response::json([
'status' => 'error',
'message' => 'Fichier reçu introuvable sur le serveur'
], 404);
return;
}
// Lire le contenu du fichier
$pdfContent = file_get_contents($media['file_path']);
if ($pdfContent === false) {
Response::json([
'status' => 'error',
'message' => 'Impossible de lire le fichier reçu'
], 500);
return;
}
// Option 1: Retourner le PDF directement (pour téléchargement)
if (isset($_GET['download']) && $_GET['download'] === 'true') {
header('Content-Type: ' . $media['mime_type']);
header('Content-Disposition: attachment; filename="' . $media['fichier'] . '"');
header('Content-Length: ' . $media['file_size']);
header('Cache-Control: no-cache, must-revalidate');
echo $pdfContent;
exit;
}
// Option 2: Retourner le PDF en base64 dans JSON (pour Flutter)
$base64 = base64_encode($pdfContent);
Response::json([
'status' => 'success',
'receipt' => [
'passage_id' => $passageId,
'file_name' => $media['fichier'],
'mime_type' => $media['mime_type'],
'file_size' => $media['file_size'],
'created_at' => $passage['date_creat_recu'],
'data_base64' => $base64
]
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération du reçu', [
'level' => 'error',
'error' => $e->getMessage(),
'passageId' => $id,
'userId' => $userId ?? null
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération du reçu'
], 500);
}
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/Security/AlertService.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/../Services/Security/IPBlocker.php';
require_once __DIR__ . '/../Services/Security/EmailThrottler.php';
use App\Services\Security\AlertService;
use App\Services\Security\SecurityMonitor;
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\IPBlocker;
use App\Services\Security\EmailThrottler;
use Database;
use Session;
use Response;
use Request;
class SecurityController {
/**
* Obtenir les métriques de performance
* GET /api/admin/metrics
*/
public function getMetrics(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$endpoint = Request::getQuery('endpoint');
$hours = (int)(Request::getQuery('hours') ?? 24);
$stats = PerformanceMonitor::getStats($endpoint, $hours);
Response::json([
'success' => true,
'data' => $stats
]);
}
/**
* Obtenir les alertes actives
* GET /api/admin/alerts
*/
public function getAlerts(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$limit = (int)(Request::getQuery('limit') ?? 50);
$alerts = AlertService::getActiveAlerts($limit);
Response::json([
'success' => true,
'data' => $alerts
]);
}
/**
* Résoudre une alerte
* POST /api/admin/alerts/:id/resolve
*/
public function resolveAlert(string $id): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
$notes = $data['notes'] ?? '';
$userId = Session::getUserId();
$success = AlertService::resolve((int)$id, $userId, $notes);
Response::json([
'success' => $success,
'message' => $success ? 'Alerte résolue' : 'Erreur lors de la résolution'
]);
}
/**
* Obtenir les IPs bloquées
* GET /api/admin/blocked-ips
*/
public function getBlockedIPs(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$activeOnly = Request::getQuery('active_only') !== 'false';
$ips = IPBlocker::getBlockedIPs($activeOnly);
Response::json([
'success' => true,
'data' => $ips
]);
}
/**
* Débloquer une IP
* POST /api/admin/unblock-ip
*/
public function unblockIP(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
if (!isset($data['ip'])) {
Response::json(['error' => 'IP address required'], 400);
return;
}
$userId = Session::getUserId();
$success = IPBlocker::unblock($data['ip'], $userId);
Response::json([
'success' => $success,
'message' => $success ? 'IP débloquée' : 'Erreur lors du déblocage'
]);
}
/**
* Bloquer une IP manuellement
* POST /api/admin/block-ip
*/
public function blockIP(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
if (!isset($data['ip'])) {
Response::json(['error' => 'IP address required'], 400);
return;
}
$reason = $data['reason'] ?? 'Blocked by admin';
$duration = (int)($data['duration'] ?? 3600);
$permanent = $data['permanent'] ?? false;
if ($permanent) {
$success = IPBlocker::blockPermanent($data['ip'], $reason, 'admin_' . Session::getUserId());
} else {
$success = IPBlocker::block($data['ip'], $duration, $reason, 'admin_' . Session::getUserId());
}
Response::json([
'success' => $success,
'message' => $success ? 'IP bloquée' : 'Erreur lors du blocage'
]);
}
/**
* Obtenir le rapport de sécurité
* GET /api/admin/security-report
*/
public function getSecurityReport(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
// Compiler le rapport
$report = [
'security_stats' => SecurityMonitor::getSecurityStats(),
'performance_stats' => PerformanceMonitor::getStats(null, 24),
'blocked_ips_stats' => IPBlocker::getStats(),
'email_throttle_stats' => (new EmailThrottler())->getStats(),
'recent_alerts' => AlertService::getActiveAlerts(10)
];
Response::json([
'success' => true,
'data' => $report,
'generated_at' => date('Y-m-d H:i:s')
]);
}
/**
* Nettoyer les anciennes données
* POST /api/admin/cleanup
*/
public function cleanup(): void {
// Vérifier l'authentification et les droits super admin
if (!Session::isLoggedIn() || Session::getRole() < 9) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
$daysToKeep = (int)($data['days_to_keep'] ?? 30);
// Nettoyer les métriques de performance
$cleanedMetrics = PerformanceMonitor::cleanup($daysToKeep);
// Nettoyer les IPs expirées
$cleanedIPs = IPBlocker::cleanupExpired();
Response::json([
'success' => true,
'message' => 'Nettoyage effectué',
'cleaned' => [
'performance_metrics' => $cleanedMetrics,
'expired_ips' => $cleanedIPs
]
]);
}
/**
* Tester les alertes email
* POST /api/admin/test-alert
*/
public function testAlert(): void {
// Vérifier l'authentification et les droits super admin
if (!Session::isLoggedIn() || Session::getRole() < 9) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
// Déclencher une alerte de test
AlertService::trigger('TEST_ALERT', [
'message' => 'Ceci est une alerte de test déclenchée manuellement',
'triggered_by' => Session::getUsername(),
'timestamp' => date('Y-m-d H:i:s')
], 'INFO');
Response::json([
'success' => true,
'message' => 'Alerte de test envoyée'
]);
}
}

View File

@@ -214,11 +214,41 @@ class UserController {
$data = Request::getJson();
$currentUserId = Session::getUserId();
// Log de début de création avec les données reçues (sans données sensibles)
LogService::log('Tentative de création d\'utilisateur', [
'level' => 'debug',
'createdBy' => $currentUserId,
'fields_received' => array_keys($data ?? []),
'has_email' => isset($data['email']),
'has_name' => isset($data['name']),
'has_username' => isset($data['username']),
'has_password' => isset($data['password'])
]);
// Validation des données requises
if (!isset($data['email'], $data['name'])) {
if (!isset($data['email']) || empty(trim($data['email']))) {
LogService::log('Erreur création utilisateur : Email manquant', [
'level' => 'warning',
'createdBy' => $currentUserId
]);
Response::json([
'status' => 'error',
'message' => 'Email et nom requis'
'message' => 'Email requis',
'field' => 'email'
], 400);
return;
}
if (!isset($data['name']) || empty(trim($data['name']))) {
LogService::log('Erreur création utilisateur : Nom manquant', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $data['email'] ?? 'non fourni'
]);
Response::json([
'status' => 'error',
'message' => 'Nom requis',
'field' => 'name'
], 400);
return;
}
@@ -260,9 +290,16 @@ class UserController {
// Validation de l'email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Erreur création utilisateur : Format email invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email
]);
Response::json([
'status' => 'error',
'message' => 'Format d\'email invalide'
'message' => 'Format d\'email invalide',
'field' => 'email',
'value' => $email
], 400);
return;
}
@@ -290,20 +327,56 @@ class UserController {
if ($chkUsernameManuel === 1) {
// Username manuel obligatoire
if (!isset($data['username']) || empty(trim($data['username']))) {
LogService::log('Erreur création utilisateur : Username manuel requis mais non fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'entite_id' => $entiteId,
'chk_username_manuel' => $chkUsernameManuel
]);
Response::json([
'status' => 'error',
'message' => 'Le nom d\'utilisateur est requis pour cette entité'
'message' => 'Identifiant requis',
'field' => 'username',
'details' => 'Saisie manuelle obligatoire pour cette entité'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Trim du username mais on garde la casse originale (plus de lowercase forcé)
$username = trim($data['username']);
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
// Validation ultra-souple : seulement la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
LogService::log('Erreur création utilisateur : Username trop court', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Format du nom d\'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)'
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères'
], 400);
return;
}
if ($usernameLength > 30) {
LogService::log('Erreur création utilisateur : Username trop long', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères'
], 400);
return;
}
@@ -316,7 +389,8 @@ class UserController {
if ($checkUsernameStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Ce nom d\'utilisateur est déjà utilisé dans GeoSector'
'message' => 'Identifiant déjà utilisé',
'field' => 'username'
], 409);
return;
}
@@ -338,9 +412,18 @@ class UserController {
if ($chkMdpManuel === 1) {
// Mot de passe manuel obligatoire
if (!isset($data['password']) || empty($data['password'])) {
LogService::log('Erreur création utilisateur : Mot de passe manuel requis mais non fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'entite_id' => $entiteId,
'chk_mdp_manuel' => $chkMdpManuel
]);
Response::json([
'status' => 'error',
'message' => 'Le mot de passe est requis pour cette entité'
'message' => 'Mot de passe requis',
'field' => 'password',
'details' => 'Saisie manuelle obligatoire pour cette entité'
], 400);
return;
}
@@ -927,22 +1010,60 @@ class UserController {
try {
$data = Request::getJson();
// Log de la requête
LogService::log('Vérification de disponibilité username', [
'level' => 'debug',
'checkedBy' => Session::getUserId(),
'has_username' => isset($data['username'])
]);
// Validation de la présence du username
if (!isset($data['username']) || empty(trim($data['username']))) {
LogService::log('Erreur vérification username : Username manquant', [
'level' => 'warning',
'checkedBy' => Session::getUserId()
]);
Response::json([
'status' => 'error',
'message' => 'Username requis pour la vérification'
'message' => 'Identifiant requis',
'field' => 'username'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Trim du username mais on garde la casse originale
$username = trim($data['username']);
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
// Validation ultra-souple : seulement la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
LogService::log('Erreur vérification username : Username trop court', [
'level' => 'warning',
'checkedBy' => Session::getUserId(),
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Format invalide : 10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères',
'available' => false
], 400);
return;
}
if ($usernameLength > 30) {
LogService::log('Erreur vérification username : Username trop long', [
'level' => 'warning',
'checkedBy' => Session::getUserId(),
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères',
'available' => false
], 400);
return;

View File

@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/MonitoredDatabase.php';
require_once __DIR__ . '/../Services/Security/AlertService.php';
use App\Services\Security\AlertService;
class Database {
private static ?PDO $instance = null;
private static array $config;
@@ -23,13 +28,22 @@ class Database {
PDO::ATTR_EMULATE_PREPARES => false,
];
self::$instance = new PDO(
// Utiliser MonitoredDatabase pour le monitoring
self::$instance = new MonitoredDatabase(
$dsn,
self::$config['username'],
self::$config['password'],
$options
);
} catch (PDOException $e) {
// Créer une alerte pour la connexion échouée
AlertService::trigger('DB_CONNECTION', [
'error' => $e->getMessage(),
'host' => self::$config['host'],
'database' => self::$config['name'],
'message' => 'Échec de connexion à la base de données'
], 'CRITICAL');
throw new RuntimeException("Database connection failed: " . $e->getMessage());
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\SecurityMonitor;
/**
* Classe PDO étendue avec monitoring de sécurité et performance
*/
class MonitoredDatabase extends PDO {
/**
* Préparer une requête avec monitoring
*/
public function prepare($statement, $options = []): PDOStatement|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
$stmt = parent::prepare($statement, $options);
// Retourner un statement monitored
if ($stmt !== false) {
return new MonitoredStatement($stmt, $statement);
}
return false;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
/**
* Exécuter une requête directement avec monitoring
*/
public function exec($statement): int|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
$result = parent::exec($statement);
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
/**
* Query avec monitoring
*/
public function query($statement, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, ...$args): PDOStatement|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
$result = parent::query($statement, $mode, ...$args);
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
}
/**
* PDOStatement étendu avec monitoring
*/
class MonitoredStatement extends PDOStatement {
private PDOStatement $stmt;
private string $query;
public function __construct(PDOStatement $stmt, string $query) {
$this->stmt = $stmt;
$this->query = $query;
}
/**
* Exécuter avec monitoring
*/
public function execute($params = null): bool {
// Démarrer le chronométrage (si pas déjà fait dans prepare)
PerformanceMonitor::startDbQuery($this->query);
try {
$result = $this->stmt->execute($params);
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $this->query);
// Re-lancer l'exception
throw $e;
}
}
/**
* Proxy vers le statement original pour toutes les autres méthodes
*/
public function __call($method, $args) {
return call_user_func_array([$this->stmt, $method], $args);
}
public function fetch($mode = PDO::FETCH_DEFAULT, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0): mixed {
return $this->stmt->fetch($mode, $cursorOrientation, $cursorOffset);
}
public function fetchAll($mode = PDO::FETCH_DEFAULT, ...$args): array {
return $this->stmt->fetchAll($mode, ...$args);
}
public function fetchColumn($column = 0): mixed {
return $this->stmt->fetchColumn($column);
}
public function rowCount(): int {
return $this->stmt->rowCount();
}
public function bindParam($param, &$var, $type = PDO::PARAM_STR, $maxLength = null, $driverOptions = null): bool {
return $this->stmt->bindParam($param, $var, $type, $maxLength, $driverOptions);
}
public function bindValue($param, $value, $type = PDO::PARAM_STR): bool {
return $this->stmt->bindValue($param, $value, $type);
}
public function closeCursor(): bool {
return $this->stmt->closeCursor();
}
public function errorCode(): ?string {
return $this->stmt->errorCode();
}
public function errorInfo(): array {
return $this->stmt->errorInfo();
}
}

View File

@@ -34,13 +34,14 @@ class Router {
$this->post('log', ['LogController', 'index']);
// Routes privées utilisateurs
// IMPORTANT: Les routes spécifiques doivent être déclarées AVANT les routes avec paramètres
$this->post('users/check-username', ['UserController', 'checkUsername']); // Déplacé avant les routes avec :id
$this->get('users', ['UserController', 'getUsers']);
$this->get('users/:id', ['UserController', 'getUserById']);
$this->post('users', ['UserController', 'createUser']);
$this->put('users/:id', ['UserController', 'updateUser']);
$this->delete('users/:id', ['UserController', 'deleteUser']);
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
$this->post('users/check-username', ['UserController', 'checkUsername']);
$this->post('logout', ['LoginController', 'logout']);
// Routes entités
@@ -69,6 +70,7 @@ class Router {
// Routes passages
$this->get('passages', ['PassageController', 'getPassages']);
$this->get('passages/:id', ['PassageController', 'getPassageById']);
$this->get('passages/:id/receipt', ['PassageController', 'getReceipt']);
$this->get('passages/operation/:operation_id', ['PassageController', 'getPassagesByOperation']);
$this->post('passages', ['PassageController', 'createPassage']);
$this->put('passages/:id', ['PassageController', 'updatePassage']);
@@ -97,6 +99,25 @@ class Router {
$this->post('password/check', ['PasswordController', 'checkStrength']);
$this->post('password/compromised', ['PasswordController', 'checkCompromised']);
$this->get('password/generate', ['PasswordController', 'generate']);
// Routes du module Chat
$this->get('chat/rooms', ['ChatController', 'getRooms']);
$this->post('chat/rooms', ['ChatController', 'createRoom']);
$this->get('chat/rooms/:id/messages', ['ChatController', 'getRoomMessages']);
$this->post('chat/rooms/:id/messages', ['ChatController', 'sendMessage']);
$this->post('chat/rooms/:id/read', ['ChatController', 'markAsRead']);
$this->get('chat/recipients', ['ChatController', 'getRecipients']);
// Routes du module Sécurité (Admin uniquement)
$this->get('admin/metrics', ['SecurityController', 'getMetrics']);
$this->get('admin/alerts', ['SecurityController', 'getAlerts']);
$this->post('admin/alerts/:id/resolve', ['SecurityController', 'resolveAlert']);
$this->get('admin/blocked-ips', ['SecurityController', 'getBlockedIPs']);
$this->post('admin/unblock-ip', ['SecurityController', 'unblockIP']);
$this->post('admin/block-ip', ['SecurityController', 'blockIP']);
$this->get('admin/security-report', ['SecurityController', 'getSecurityReport']);
$this->post('admin/cleanup', ['SecurityController', 'cleanup']);
$this->post('admin/test-alert', ['SecurityController', 'testAlert']);
}
public function handle(): void {

View File

@@ -0,0 +1,622 @@
<?php
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/LogService.php';
require_once __DIR__ . '/ApiService.php';
require_once __DIR__ . '/FileService.php';
use PDO;
use Database;
use LogService;
use ApiService;
use FileService;
use Exception;
use DateTime;
/**
* Service de gestion des reçus pour les passages de type don (fk_type=1)
* Optimisé pour générer des PDF très légers (< 20KB)
*/
class ReceiptService {
private PDO $db;
private FileService $fileService;
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
public function __construct() {
$this->db = Database::getInstance();
$this->fileService = new FileService();
}
/**
* Génère un reçu pour un passage de type don avec email valide
*
* @param int $passageId ID du passage
* @return bool True si le reçu a été généré avec succès
*/
public function generateReceiptForPassage(int $passageId): bool {
try {
// Récupérer les données du passage
$passageData = $this->getPassageData($passageId);
if (!$passageData) {
LogService::log('Passage non trouvé pour génération de reçu', [
'level' => 'warning',
'passageId' => $passageId
]);
return false;
}
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
if ((int)$passageData['fk_type'] !== 1) {
return false; // Pas un don, pas de reçu
}
// Déchiffrer et vérifier l'email
$email = '';
if (!empty($passageData['encrypted_email'])) {
$email = ApiService::decryptSearchableData($passageData['encrypted_email']);
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Email invalide ou manquant pour le reçu', [
'level' => 'info',
'passageId' => $passageId
]);
return false;
}
// Récupérer les données de l'opération
$operationData = $this->getOperationData($passageData['fk_operation']);
if (!$operationData) {
return false;
}
// Récupérer les données de l'entité
$entiteData = $this->getEntiteData($operationData['fk_entite']);
if (!$entiteData) {
return false;
}
// Récupérer le logo de l'entité
$logoPath = $this->getEntiteLogo($operationData['fk_entite']);
// Préparer les données pour la génération du PDF
$receiptData = $this->prepareReceiptData($passageData, $operationData, $entiteData, $email);
// Générer le PDF optimisé
$pdfContent = $this->generateOptimizedPDF($receiptData, $logoPath);
// Créer le répertoire de stockage
$uploadPath = "/entites/{$operationData['fk_entite']}/recus/{$operationData['id']}";
$fullPath = $this->fileService->createDirectory($operationData['fk_entite'], $uploadPath);
// Nom du fichier
$fileName = 'recu_' . $passageId . '.pdf';
$filePath = $fullPath . '/' . $fileName;
// Sauvegarder le fichier
if (file_put_contents($filePath, $pdfContent) === false) {
throw new Exception('Impossible de sauvegarder le fichier PDF');
}
// Appliquer les permissions
$this->fileService->setFilePermissions($filePath);
// Enregistrer dans la table medias
$mediaId = $this->saveToMedias(
$operationData['fk_entite'],
$operationData['id'],
$passageId,
$fileName,
$filePath,
strlen($pdfContent)
);
// Mettre à jour le passage avec les infos du reçu
$this->updatePassageReceipt($passageId, $fileName);
// Ajouter à la queue d'email
$this->queueReceiptEmail($passageId, $email, $receiptData, $pdfContent);
LogService::log('Reçu généré avec succès', [
'level' => 'info',
'passageId' => $passageId,
'mediaId' => $mediaId,
'fileName' => $fileName,
'fileSize' => strlen($pdfContent)
]);
return true;
} catch (Exception $e) {
LogService::log('Erreur lors de la génération du reçu', [
'level' => 'error',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
return false;
}
}
/**
* Génère un PDF ultra-optimisé (< 20KB)
* Utilise le format PDF natif pour minimiser la taille
*/
private function generateOptimizedPDF(array $data, ?string $logoPath): string {
// Début du PDF
$pdf = "%PDF-1.3\n";
$objects = [];
$xref = [];
// Object 1 - Catalog
$objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
// Object 2 - Pages
$objects[2] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
// Object 3 - Page
$objects[3] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
// Object 4 - Font (Helvetica)
$objects[4] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
// Contenu de la page (texte du reçu)
$content = $this->generatePDFContent($data);
// Object 5 - Content stream
$contentLength = strlen($content);
$objects[5] = "5 0 obj\n<< /Length $contentLength >>\nstream\n$content\nendstream\nendobj\n";
// Construction du PDF final
$offset = strlen($pdf);
foreach ($objects as $obj) {
$xref[] = $offset;
$pdf .= $obj;
$offset += strlen($obj);
}
// Table xref
$pdf .= "xref\n";
$pdf .= "0 " . (count($objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
foreach ($xref as $off) {
$pdf .= sprintf("%010d 00000 n \n", $off);
}
// Trailer
$pdf .= "trailer\n";
$pdf .= "<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n";
$pdf .= "startxref\n";
$pdf .= "$offset\n";
$pdf .= "%%EOF\n";
return $pdf;
}
/**
* Génère le contenu textuel du reçu pour le PDF
*/
private function generatePDFContent(array $data): string {
$content = "BT\n";
$content .= "/F1 12 Tf\n";
$y = 750;
// En-tête
$content .= "50 $y Td\n";
$content .= "(" . $this->escapeString($data['entite_name']) . ") Tj\n";
$y -= 20;
if (!empty($data['entite_address'])) {
$content .= "0 -20 Td\n";
$content .= "(" . $this->escapeString($data['entite_address']) . ") Tj\n";
$y -= 20;
}
// Titre du reçu
$y -= 40;
$content .= "/F1 16 Tf\n";
$content .= "0 -40 Td\n";
$content .= "(RECU DE DON N° " . $data['receipt_number'] . ") Tj\n";
$content .= "/F1 10 Tf\n";
$content .= "0 -15 Td\n";
$content .= "(Article 200 du Code General des Impots) Tj\n";
// Informations du donateur
$y -= 60;
$content .= "/F1 12 Tf\n";
$content .= "0 -45 Td\n";
$content .= "(DONATEUR) Tj\n";
$content .= "/F1 11 Tf\n";
$content .= "0 -20 Td\n";
$content .= "(Nom : " . $this->escapeString($data['donor_name']) . ") Tj\n";
if (!empty($data['donor_address'])) {
$content .= "0 -15 Td\n";
$content .= "(Adresse : " . $this->escapeString($data['donor_address']) . ") Tj\n";
}
if (!empty($data['donor_email'])) {
$content .= "0 -15 Td\n";
$content .= "(Email : " . $this->escapeString($data['donor_email']) . ") Tj\n";
}
// Détails du don
$content .= "0 -30 Td\n";
$content .= "/F1 12 Tf\n";
$content .= "(DETAILS DU DON) Tj\n";
$content .= "/F1 11 Tf\n";
$content .= "0 -20 Td\n";
$content .= "(Date : " . $data['donation_date'] . ") Tj\n";
$content .= "0 -15 Td\n";
$content .= "(Montant : " . $data['amount'] . " EUR) Tj\n";
$content .= "0 -15 Td\n";
$content .= "(Mode de reglement : " . $this->escapeString($data['payment_method']) . ") Tj\n";
if (!empty($data['operation_name'])) {
$content .= "0 -15 Td\n";
$content .= "(Campagne : " . $this->escapeString($data['operation_name']) . ") Tj\n";
}
// Mention légale
$content .= "/F1 9 Tf\n";
$content .= "0 -40 Td\n";
$content .= "(Reduction d'impot egale a 66% du montant verse dans la limite de 20% du revenu imposable) Tj\n";
// Date et signature
$content .= "/F1 11 Tf\n";
$content .= "0 -30 Td\n";
$content .= "(Fait a " . $this->escapeString($data['entite_city']) . ", le " . $data['signature_date'] . ") Tj\n";
$content .= "0 -20 Td\n";
$content .= "(Le President) Tj\n";
$content .= "ET\n";
return $content;
}
/**
* Échappe les caractères spéciaux pour le PDF
*/
private function escapeString(string $str): string {
// Échapper les caractères spéciaux PDF
$str = str_replace('\\', '\\\\', $str);
$str = str_replace('(', '\\(', $str);
$str = str_replace(')', '\\)', $str);
// Remplacer manuellement les caractères accentués les plus courants
$accents = [
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
'Ñ' => 'N', 'ñ' => 'n',
'Ç' => 'C', 'ç' => 'c',
'Œ' => 'OE', 'œ' => 'oe',
'Æ' => 'AE', 'æ' => 'ae'
];
$str = strtr($str, $accents);
// Supprimer tout caractère non-ASCII restant
$str = preg_replace('/[^\x20-\x7E]/', '', $str);
return $str;
}
/**
* Récupère les données du passage
*/
private function getPassageData(int $passageId): ?array {
$stmt = $this->db->prepare('
SELECT p.*,
u.encrypted_name as user_encrypted_name,
u.encrypted_email as user_encrypted_email,
u.encrypted_phone as user_encrypted_phone
FROM ope_pass p
LEFT JOIN users u ON p.fk_user = u.id
WHERE p.id = ? AND p.chk_active = 1
');
$stmt->execute([$passageId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'opération
*/
private function getOperationData(int $operationId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM operations
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$operationId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'entité
*/
private function getEntiteData(int $entiteId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM entites
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$entiteId]);
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
if ($entite) {
// Déchiffrer les données
if (!empty($entite['encrypted_name'])) {
$entite['name'] = ApiService::decryptData($entite['encrypted_name']);
}
if (!empty($entite['encrypted_email'])) {
$entite['email'] = ApiService::decryptSearchableData($entite['encrypted_email']);
}
if (!empty($entite['encrypted_phone'])) {
$entite['phone'] = ApiService::decryptData($entite['encrypted_phone']);
}
}
return $entite ?: null;
}
/**
* Récupère le chemin du logo de l'entité
*/
private function getEntiteLogo(int $entiteId): ?string {
$stmt = $this->db->prepare('
SELECT file_path FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['entite', $entiteId, 'logo']);
$logo = $stmt->fetch(PDO::FETCH_ASSOC);
if ($logo && !empty($logo['file_path']) && file_exists($logo['file_path'])) {
return $logo['file_path'];
}
// Utiliser le logo par défaut si disponible
if (file_exists(self::DEFAULT_LOGO_PATH)) {
return self::DEFAULT_LOGO_PATH;
}
return null;
}
/**
* Prépare les données pour le reçu
*/
private function prepareReceiptData(array $passage, array $operation, array $entite, string $email): array {
// Déchiffrer le nom du donateur
$donorName = '';
if (!empty($passage['encrypted_name'])) {
$donorName = ApiService::decryptData($passage['encrypted_name']);
} elseif (!empty($passage['user_encrypted_name'])) {
$donorName = ApiService::decryptData($passage['user_encrypted_name']);
}
// Construire l'adresse du donateur
$donorAddress = [];
if (!empty($passage['numero'])) $donorAddress[] = $passage['numero'];
if (!empty($passage['rue'])) $donorAddress[] = $passage['rue'];
if (!empty($passage['rue_bis'])) $donorAddress[] = $passage['rue_bis'];
if (!empty($passage['ville'])) $donorAddress[] = $passage['ville'];
// Date du don
$donationDate = '';
if (!empty($passage['passed_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['passed_at']));
} elseif (!empty($passage['created_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['created_at']));
}
// Mode de règlement
$paymentMethod = $this->getPaymentMethodLabel((int)($passage['fk_type_reglement'] ?? 1));
// Adresse de l'entité
$entiteAddress = [];
if (!empty($entite['adresse1'])) $entiteAddress[] = $entite['adresse1'];
if (!empty($entite['adresse2'])) $entiteAddress[] = $entite['adresse2'];
if (!empty($entite['code_postal']) || !empty($entite['ville'])) {
$entiteAddress[] = trim($entite['code_postal'] . ' ' . $entite['ville']);
}
return [
'receipt_number' => $passage['id'],
'entite_name' => $entite['name'] ?? 'Amicale des Sapeurs-Pompiers',
'entite_address' => implode(' ', $entiteAddress),
'entite_city' => $entite['ville'] ?? '',
'entite_email' => $entite['email'] ?? '',
'entite_phone' => $entite['phone'] ?? '',
'donor_name' => $donorName,
'donor_address' => implode(' ', $donorAddress),
'donor_email' => $email,
'donation_date' => $donationDate,
'amount' => number_format((float)($passage['montant'] ?? 0), 2, ',', ' '),
'payment_method' => $paymentMethod,
'operation_name' => $operation['libelle'] ?? '',
'signature_date' => date('d/m/Y')
];
}
/**
* Retourne le libellé du mode de règlement
*/
private function getPaymentMethodLabel(int $typeReglement): string {
$stmt = $this->db->prepare('SELECT libelle FROM x_types_reglements WHERE id = ?');
$stmt->execute([$typeReglement]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? $result['libelle'] : 'Espèces';
}
/**
* Enregistre le fichier dans la table medias
*/
private function saveToMedias(int $entiteId, int $operationId, int $passageId, string $fileName, string $filePath, int $fileSize): int {
$stmt = $this->db->prepare('
INSERT INTO medias (
support, support_id, fichier, file_type, file_category,
file_size, mime_type, original_name, fk_entite, fk_operation,
file_path, description, created_at, fk_user_creat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
'passage', // support
$passageId, // support_id
$fileName, // fichier
'pdf', // file_type
'recu', // file_category
$fileSize, // file_size
'application/pdf', // mime_type
$fileName, // original_name
$entiteId, // fk_entite
$operationId, // fk_operation
$filePath, // file_path
'Reçu de don', // description
0 // fk_user_creat (système)
]);
return (int)$this->db->lastInsertId();
}
/**
* Met à jour le passage avec les informations du reçu
*/
private function updatePassageReceipt(int $passageId, string $fileName): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET nom_recu = ?, date_creat_recu = NOW()
WHERE id = ?
');
$stmt->execute([$fileName, $passageId]);
}
/**
* Ajoute le reçu à la queue d'email
*/
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfContent): void {
// Préparer le sujet
$subject = "Votre reçu de don N°" . $receiptData['receipt_number'];
// Préparer le corps de l'email
$body = $this->generateEmailBody($receiptData);
// Préparer les headers avec pièce jointe
$boundary = md5((string)time());
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
// Corps complet avec pièce jointe
$fullBody = "--$boundary\r\n";
$fullBody .= "Content-Type: text/html; charset=UTF-8\r\n";
$fullBody .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$fullBody .= $body . "\r\n\r\n";
// Pièce jointe PDF
$fullBody .= "--$boundary\r\n";
$fullBody .= "Content-Type: application/pdf; name=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n";
$fullBody .= "Content-Transfer-Encoding: base64\r\n";
$fullBody .= "Content-Disposition: attachment; filename=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n\r\n";
$fullBody .= chunk_split(base64_encode($pdfContent)) . "\r\n";
$fullBody .= "--$boundary--";
// Insérer dans la queue
$stmt = $this->db->prepare('
INSERT INTO email_queue (
fk_pass, to_email, subject, body, headers, created_at, status
) VALUES (?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
$passageId,
$email,
$subject,
$fullBody,
$headers,
'pending'
]);
}
/**
* Génère le corps HTML de l'email
*/
private function generateEmailBody(array $data): string {
// Convertir toutes les valeurs en string pour htmlspecialchars
$safeData = array_map(function($value) {
return is_string($value) ? $value : (string)$value;
}, $data);
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f4f4f4; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { background-color: #f4f4f4; padding: 10px; text-align: center; font-size: 12px; }
.amount { font-size: 24px; font-weight: bold; color: #2c5aa0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>' . htmlspecialchars($safeData['entite_name']) . '</h2>
</div>
<div class="content">
<p>Bonjour ' . htmlspecialchars($safeData['donor_name']) . ',</p>
<p>Nous vous remercions chaleureusement pour votre don de <span class="amount">' .
htmlspecialchars($safeData['amount']) . ' €</span> effectué le ' .
htmlspecialchars($safeData['donation_date']) . '.</p>
<p>Vous trouverez ci-joint votre reçu fiscal N°' . htmlspecialchars($safeData['receipt_number']) .
' qui vous permettra de bénéficier d\'une réduction d\'impôt égale à 66% du montant de votre don.</p>
<p>Votre soutien est précieux pour nous permettre de poursuivre nos actions.</p>
<p>Cordialement,<br>
L\'équipe de ' . htmlspecialchars($safeData['entite_name']) . '</p>
</div>
<div class="footer">
<p>Conservez ce reçu pour votre déclaration fiscale</p>
<p>' . htmlspecialchars($safeData['entite_name']) . '<br>
' . htmlspecialchars($safeData['entite_address']) . '<br>
' . htmlspecialchars($safeData['entite_email']) . '</p>
</div>
</div>
</body>
</html>';
return $html;
}
/**
* Met à jour la date d'envoi du reçu
*/
public function markReceiptAsSent(int $passageId): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET date_sent_recu = NOW(), chk_email_sent = 1
WHERE id = ?
');
$stmt->execute([$passageId]);
}
}

254
api/test_security.php Normal file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour le système de sécurité et monitoring
* Usage: php test_security.php
*/
declare(strict_types=1);
// Configuration
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/src/Config/AppConfig.php';
require_once __DIR__ . '/src/Core/Database.php';
// Services de sécurité
require_once __DIR__ . '/src/Services/Security/AlertService.php';
require_once __DIR__ . '/src/Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/src/Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/src/Services/Security/IPBlocker.php';
require_once __DIR__ . '/src/Services/Security/EmailThrottler.php';
use App\Services\Security\AlertService;
use App\Services\Security\SecurityMonitor;
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\IPBlocker;
use App\Services\Security\EmailThrottler;
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
echo "\n========================================\n";
echo " TEST DU SYSTÈME DE SÉCURITÉ\n";
echo "========================================\n\n";
// Test 1: Vérifier les tables
echo "1. Vérification des tables de sécurité...\n";
try {
$db = Database::getInstance();
$tables = [
'sec_alerts',
'sec_performance_metrics',
'sec_failed_login_attempts',
'sec_blocked_ips'
];
foreach ($tables as $table) {
$stmt = $db->query("SELECT COUNT(*) as count FROM $table");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo " ✓ Table $table : {$result['count']} enregistrements\n";
}
echo " [OK] Toutes les tables existent\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n";
echo " Assurez-vous d'avoir exécuté le script SQL de création des tables.\n";
exit(1);
}
// Test 2: Test du monitoring de performance
echo "2. Test du monitoring de performance...\n";
try {
// Simuler une requête
PerformanceMonitor::startRequest();
// Simuler une requête DB
PerformanceMonitor::startDbQuery("SELECT * FROM users WHERE id = 1");
usleep(50000); // 50ms
PerformanceMonitor::endDbQuery();
// Terminer la requête
PerformanceMonitor::endRequest('/api/test', 'GET', 200);
echo " ✓ Monitoring de performance fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 3: Test de détection SQL Injection
echo "3. Test de détection d'injection SQL...\n";
$testQueries = [
"normal_query" => true,
"'; DROP TABLE users; --" => false,
"1' OR '1'='1" => false,
"admin' UNION SELECT * FROM users--" => false
];
foreach ($testQueries as $query => $shouldPass) {
$result = SecurityMonitor::checkSQLInjection($query);
$status = ($result === $shouldPass) ? '✓' : '✗';
$expected = $shouldPass ? 'autorisé' : 'bloqué';
echo " $status '$query' : $expected\n";
}
echo "\n";
// Test 4: Test du blocage d'IP
echo "4. Test du système de blocage d'IP...\n";
try {
$testIP = '192.168.1.99';
// Vérifier que l'IP n'est pas bloquée
if (!IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP non bloquée initialement\n";
}
// Bloquer l'IP temporairement (10 secondes)
IPBlocker::block($testIP, 10, 'Test temporaire');
// Vérifier qu'elle est bloquée
if (IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP bloquée avec succès\n";
}
// Débloquer l'IP
IPBlocker::unblock($testIP);
// Vérifier qu'elle est débloquée
if (!IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP débloquée avec succès\n";
}
echo " [OK] Système de blocage IP fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 5: Test des alertes
echo "5. Test du système d'alertes...\n";
try {
// Créer une alerte de test
AlertService::trigger('TEST', [
'message' => 'Ceci est une alerte de test',
'test_script' => true
], 'INFO');
echo " ✓ Alerte créée avec succès\n";
// Récupérer les alertes actives
$alerts = AlertService::getActiveAlerts(1);
if (!empty($alerts)) {
echo "" . count($alerts) . " alerte(s) active(s) trouvée(s)\n";
}
echo " [OK] Système d'alertes fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 6: Test de brute force
echo "6. Simulation d'attaque brute force...\n";
try {
$attackerIP = '10.0.0.1';
// Simuler plusieurs tentatives échouées
for ($i = 1; $i <= 6; $i++) {
SecurityMonitor::recordFailedLogin(
$attackerIP,
'user' . $i,
'invalid_password',
'Mozilla/5.0 Test'
);
echo " - Tentative $i enregistrée\n";
}
// Vérifier la détection
$canLogin = SecurityMonitor::checkBruteForce($attackerIP, 'testuser');
if (!$canLogin) {
echo " ✓ Attaque brute force détectée et bloquée\n";
} else {
echo " ✗ L'attaque aurait dû être détectée\n";
}
// Nettoyer
$db->exec("DELETE FROM sec_failed_login_attempts WHERE ip_address = '$attackerIP'");
IPBlocker::unblock($attackerIP);
echo " [OK] Détection brute force fonctionnelle\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 7: Test du throttling email
echo "7. Test du throttling d'emails...\n";
try {
$throttler = new EmailThrottler();
// Premier email devrait passer
if ($throttler->canSend('TEST_TYPE', 60)) {
echo " ✓ Premier email autorisé\n";
$throttler->recordSent('TEST_TYPE');
}
// Deuxième email immédiat devrait être bloqué
if (!$throttler->canSend('TEST_TYPE', 60)) {
echo " ✓ Deuxième email bloqué (throttling)\n";
}
// Obtenir les stats
$stats = $throttler->getStats();
echo " ✓ Stats throttling : {$stats['hourly']['sent']} emails/heure\n";
echo " [OK] Throttling email fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 8: Statistiques globales
echo "8. Récupération des statistiques...\n";
try {
// Stats de sécurité
$securityStats = SecurityMonitor::getSecurityStats();
echo " ✓ Tentatives de login échouées (24h) : " .
($securityStats['failed_logins']['total_attempts'] ?? 0) . "\n";
// Stats de performance
$perfStats = PerformanceMonitor::getStats(null, 24);
echo " ✓ Requêtes totales (24h) : " .
($perfStats['global']['total_requests'] ?? 0) . "\n";
// Stats de blocage IP
$ipStats = IPBlocker::getStats();
echo " ✓ IPs bloquées actives : " .
(($ipStats['totals']['temporary'] ?? 0) + ($ipStats['totals']['permanent'] ?? 0)) . "\n";
echo " [OK] Statistiques disponibles\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Résumé
echo "========================================\n";
echo " RÉSUMÉ DES TESTS\n";
echo "========================================\n\n";
echo "✓ Tables de sécurité créées\n";
echo "✓ Monitoring de performance actif\n";
echo "✓ Détection SQL injection fonctionnelle\n";
echo "✓ Blocage d'IP opérationnel\n";
echo "✓ Système d'alertes configuré\n";
echo "✓ Détection brute force active\n";
echo "✓ Throttling email en place\n";
echo "✓ Statistiques disponibles\n\n";
echo "🔒 Le système de sécurité est opérationnel !\n\n";
echo "PROCHAINES ÉTAPES :\n";
echo "1. Configurer l'email dans AppConfig pour recevoir les alertes\n";
echo "2. Personnaliser les seuils dans les constantes des services\n";
echo "3. Tester avec de vraies requêtes API\n";
echo "4. Surveiller les logs et ajuster les règles\n\n";

150
api/tests/test_user_creation.php Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour la création d'utilisateurs
* Teste les différents cas qui peuvent causer une erreur 400
*/
// Configuration
$apiUrl = 'http://localhost/api';
$sessionId = ''; // À remplir avec un session_id valide
// Couleurs pour la sortie
$red = "\033[31m";
$green = "\033[32m";
$yellow = "\033[33m";
$blue = "\033[34m";
$reset = "\033[0m";
function testRequest($endpoint, $method, $data = null, $sessionId = '') {
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($sessionId) {
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $sessionId
]);
} else {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
}
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => json_decode($response, true) ?: $response
];
}
echo $blue . "=== Test de création d'utilisateur et vérification username ===" . $reset . "\n\n";
// Test 1: Vérification username sans données
echo $yellow . "Test 1: POST /api/users/check-username sans données" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', [], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 2: Vérification username avec format invalide (trop court)
echo $yellow . "Test 2: POST /api/users/check-username avec username trop court" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => 'abc'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Format attendu: " . ($result['body']['format'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 3: Vérification username avec format invalide (commence par un chiffre)
echo $yellow . "Test 3: POST /api/users/check-username avec username commençant par un chiffre" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => '123test.user'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Valeur testée: " . ($result['body']['value'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 4: Vérification username valide
echo $yellow . "Test 4: POST /api/users/check-username avec username valide" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => 'test.user123'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 200 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Disponible: " . ($result['body']['available'] ? 'Oui' : 'Non') . "\n";
echo "Message: " . $result['body']['message'] . "\n";
}
echo "\n";
// Test 5: Création utilisateur sans email
echo $yellow . "Test 5: POST /api/users sans email" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'name' => 'Test User',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 6: Création utilisateur sans nom
echo $yellow . "Test 6: POST /api/users sans nom" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'test@example.com',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 7: Création utilisateur avec email invalide
echo $yellow . "Test 7: POST /api/users avec email invalide" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'invalid-email',
'name' => 'Test User',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
echo "Valeur testée: " . ($result['body']['value'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 8: Création utilisateur pour entité avec username manuel mais sans username
echo $yellow . "Test 8: POST /api/users pour entité avec chk_username_manuel=1 sans username" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'test@example.com',
'name' => 'Test User',
'fk_entite' => 5 // Supposons que l'entité 5 a chk_username_manuel=1
], $sessionId);
echo "Code HTTP: " . $result['code'] . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
echo "Reason: " . ($result['body']['reason'] ?? 'non spécifié') . "\n";
}
echo "\n";
echo $blue . "=== Fin des tests ===" . $reset . "\n";
echo "\nRappel: Pour que ces tests fonctionnent, vous devez:\n";
echo "1. Avoir un serveur local en cours d'exécution\n";
echo "2. Remplir la variable \$sessionId avec un token de session valide\n";
echo "3. Vérifier les logs dans /logs/ pour voir les détails\n";

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour la validation ultra-souple des usernames
* Teste les nouvelles règles : 8-30 caractères UTF-8, tous caractères acceptés
*/
// Configuration
$apiUrl = 'http://localhost/api';
$sessionId = ''; // À remplir avec un session_id valide
// Couleurs pour la sortie
$red = "\033[31m";
$green = "\033[32m";
$yellow = "\033[33m";
$blue = "\033[34m";
$reset = "\033[0m";
function testRequest($endpoint, $method, $data = null, $sessionId = '') {
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($sessionId) {
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json; charset=UTF-8',
'Authorization: Bearer ' . $sessionId
]);
} else {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=UTF-8']);
}
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => json_decode($response, true) ?: $response
];
}
echo $blue . "=== Test de validation ultra-souple des usernames ===" . $reset . "\n";
echo "Nouvelles règles : 8-30 caractères UTF-8, tous caractères acceptés\n\n";
// Cas qui doivent ÉCHOUER (trop court ou trop long)
$testsFail = [
['username' => 'abc', 'desc' => 'Trop court (3 car.)'],
['username' => '1234567', 'desc' => 'Trop court (7 car.)'],
['username' => str_repeat('a', 31), 'desc' => 'Trop long (31 car.)'],
['username' => str_repeat('😀', 31), 'desc' => 'Trop long (31 émojis)'],
['username' => ' ', 'desc' => 'Espaces seulement (devient vide après trim)'],
];
// Cas qui doivent RÉUSSIR
$testsSuccess = [
['username' => 'Jean-Pierre', 'desc' => 'Nom avec tiret et majuscules'],
['username' => '12345678', 'desc' => 'Chiffres seulement (8 car.)'],
['username' => 'user@company', 'desc' => 'Avec arobase'],
['username' => 'Marie 2024', 'desc' => 'Avec espace'],
['username' => 'josé.garcía', 'desc' => 'Avec accents'],
['username' => '用户2024', 'desc' => 'Caractères chinois'],
['username' => 'مستخدم123', 'desc' => 'Caractères arabes'],
['username' => 'Admin_#123!', 'desc' => 'Caractères spéciaux'],
['username' => '😀🎉Party2024', 'desc' => 'Avec émojis'],
['username' => 'Пользователь', 'desc' => 'Cyrillique'],
['username' => ' espacé ', 'desc' => 'Espaces (seront trimés)'],
['username' => 'a' . str_repeat('b', 28) . 'c', 'desc' => 'Exactement 30 car.'],
];
echo $yellow . "Tests qui doivent ÉCHOUER :" . $reset . "\n\n";
foreach ($testsFail as $index => $test) {
echo "Test " . ($index + 1) . ": " . $test['desc'] . "\n";
echo "Username: \"" . $test['username'] . "\"\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => $test['username']], $sessionId);
$isExpectedFailure = ($result['code'] == 400);
echo "Code HTTP: " . ($isExpectedFailure ? $green : $red) . $result['code'] . $reset;
echo " - " . ($isExpectedFailure ? $green . "✓ OK" : $red . "✗ ERREUR") . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
if (isset($result['body']['details'])) {
echo "Détails: " . $result['body']['details'] . "\n";
}
}
echo "\n";
}
echo $yellow . "Tests qui doivent RÉUSSIR :" . $reset . "\n\n";
foreach ($testsSuccess as $index => $test) {
echo "Test " . ($index + 1) . ": " . $test['desc'] . "\n";
echo "Username: \"" . $test['username'] . "\"\n";
echo "Longueur: " . mb_strlen(trim($test['username']), 'UTF-8') . " caractères\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => $test['username']], $sessionId);
$isExpectedSuccess = ($result['code'] == 200);
echo "Code HTTP: " . ($isExpectedSuccess ? $green : $red) . $result['code'] . $reset;
echo " - " . ($isExpectedSuccess ? $green . "✓ OK" : $red . "✗ ERREUR") . $reset . "\n";
if (is_array($result['body'])) {
if ($result['code'] == 200) {
echo "Disponible: " . ($result['body']['available'] ? $green . "Oui" : $yellow . "Non (déjà pris)") . $reset . "\n";
} else {
echo "Message: " . $result['body']['message'] . "\n";
if (isset($result['body']['details'])) {
echo "Détails: " . $result['body']['details'] . "\n";
}
}
}
echo "\n";
}
echo $blue . "=== Résumé des nouvelles règles ===" . $reset . "\n";
echo "✓ Minimum : 8 caractères UTF-8\n";
echo "✓ Maximum : 30 caractères UTF-8\n";
echo "✓ Tous caractères acceptés (lettres, chiffres, espaces, émojis, accents, etc.)\n";
echo "✓ Trim automatique des espaces début/fin\n";
echo "✓ Sensible à la casse (Jean ≠ jean)\n";
echo "✓ Unicité vérifiée dans toute la base\n\n";
echo $yellow . "Note: N'oubliez pas d'exécuter le script SQL de migration !" . $reset . "\n";
echo "scripts/sql/migration_username_utf8_support.sql\n";