Sécurité - Documentation Technique
🎯 Vue d'ensemble
Teadle implémente un système de sécurité multi-niveaux avec authentification JWT, OAuth 2.0, et codes d'authentification pour garantir la protection des données et l'accès sécurisé.
🔐 Architecture de sécurité
graph TB
subgraph "Frontend"
A[Vue.js App]
B[Axios Client]
C[Pinia Store]
end
subgraph "API Gateway"
D[API Platform]
E[Security Firewall]
F[Rate Limiting]
end
subgraph "Authentication"
G[JWT Token]
H[OAuth Providers]
I[Auth Codes]
J[Password Reset]
K[HttpOnly Cookies]
end
subgraph "Authorization"
L[Role-based Access]
M[Resource-level Permissions]
N[API Endpoint Protection]
end
subgraph "Infrastructure"
O[HTTPS/TLS]
P[CORS Protection]
Q[Input Validation]
R[SQL Injection Protection]
end
A --> B
B --> D
D --> E
E --> G
E --> H
E --> I
E --> J
E --> K
G --> L
H --> L
I --> L
K --> L
L --> M
M --> N
D --> O
D --> P
D --> Q
D --> R
style G fill:#ff6b6b
style H fill:#4ecdc4
style I fill:#45b7d1
style J fill:#96ceb4
style K fill:#feca57
🔑 Authentification
1. JWT (JSON Web Tokens)
Configuration
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1 heure
refresh_token_ttl: 2592000 # 30 jours
user_identity_field: email
token_extractors:
authorization_header:
enabled: true
prefix: Bearer
name: Authorization
Structure du token
{
"header": {
"alg": "RS256",
"typ": "JWT"
},
"payload": {
"iat": 1755113942,
"exp": 1755117542,
"roles": ["ROLE_USER"],
"username": "user@example.com",
"user_id": 123,
"user_type": "trainer"
},
"signature": "..."
}
Gestion des tokens
// JwtTokenManager.php
class JwtTokenManager implements TokenManagerInterface
{
public function __construct(
private JWTTokenManagerInterface $jwtManager,
private RefreshTokenManagerInterface $refreshTokenManager
) {}
public function createToken(UserInterface $user): string
{
$payload = [
'user_id' => $user->getId(),
'user_type' => $user->getType(),
'roles' => $user->getRoles()
];
return $this->jwtManager->create($user, $payload);
}
public function validateToken(string $token): ?UserInterface
{
try {
$payload = $this->jwtManager->parse($token);
return $this->userRepository->find($payload['user_id']);
} catch (JWTDecodeFailureException $e) {
return null;
}
}
}
2. OAuth 2.0
Configuration des providers
# config/packages/knpu_oauth2_client.yaml
knpu_oauth2_client:
clients:
linkedin:
type: linkedin
client_id: '%env(LINKEDIN_CLIENT_ID)%'
client_secret: '%env(LINKEDIN_CLIENT_SECRET)%'
redirect_route: oauth_linkedin_callback
redirect_params: {}
scope: ['r_liteprofile', 'r_emailaddress']
google:
type: google
client_id: '%env(GOOGLE_CLIENT_ID)%'
client_secret: '%env(GOOGLE_CLIENT_SECRET)%'
redirect_route: oauth_google_callback
scope: ['email', 'profile']
microsoft:
type: microsoft
client_id: '%env(MICROSOFT_CLIENT_ID)%'
client_secret: '%env(MICROSOFT_CLIENT_SECRET)%'
redirect_route: oauth_microsoft_callback
scope: ['User.Read', 'email']
Implémentation des providers
// LinkedInOAuthProvider.php
class LinkedInOAuthProvider implements OAuthProviderInterface
{
public function __construct(
private ClientRegistry $clientRegistry,
private UserRepositoryInterface $userRepository
) {}
public function authenticate(string $code): OAuthUserData
{
$client = $this->clientRegistry->getClient('linkedin');
$token = $client->getAccessToken($code);
$user = $client->fetchUserFromToken($token);
return new OAuthUserData(
email: $user->getEmail(),
firstName: $user->getFirstName(),
lastName: $user->getLastName(),
providerId: $user->getId(),
provider: 'linkedin'
);
}
}
3. Authentification avec Cookies httpOnly ⭐ NOUVEAU
Vue d'ensemble
L'authentification avec cookies httpOnly offre une sécurité renforcée en protégeant les tokens JWT contre les attaques XSS. Les tokens ne sont plus accessibles en JavaScript et sont automatiquement envoyés par le navigateur.
Configuration des cookies
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\CookieJwtAuthenticator
Authentificateur personnalisé
// CookieJwtAuthenticator.php
class CookieJwtAuthenticator extends AbstractAuthenticator
{
public function __construct(
private JWTTokenManagerInterface $jwtManager,
private UserRepositoryInterface $userRepository
) {}
public function supports(Request $request): ?bool
{
// Vérifier si le cookie JWT est présent
return $request->cookies->has('JWT_TOKEN');
}
public function authenticate(Request $request): Passport
{
$token = $request->cookies->get('JWT_TOKEN');
if (!$token) {
throw new CustomUserMessageAuthenticationException('JWT Token not found');
}
try {
$payload = $this->jwtManager->parse($token);
$user = $this->userRepository->find($payload['user_id']);
if (!$user) {
throw new CustomUserMessageAuthenticationException('User not found');
}
return new Passport(
new UserBadge($user->getEmail(), fn() => $user),
new CustomCredentials(fn() => true, $token)
);
} catch (JWTDecodeFailureException $e) {
throw new CustomUserMessageAuthenticationException('Invalid JWT token');
}
}
}
Gestionnaire de cookies
// CookieManager.php
class CookieManager
{
public function setJwtCookie(Response $response, string $token, int $expiresIn = 3600): void
{
$cookie = new Cookie(
'JWT_TOKEN',
$token,
time() + $expiresIn,
'/',
null,
true, // secure (HTTPS uniquement)
true, // httpOnly
false, // raw
'Strict' // sameSite
);
$response->headers->setCookie($cookie);
}
public function setRefreshTokenCookie(Response $response, string $refreshToken, int $expiresIn = 2592000): void
{
$cookie = new Cookie(
'REFRESH_TOKEN',
$refreshToken,
time() + $expiresIn,
'/',
null,
true, // secure
true, // httpOnly
false, // raw
'Strict' // sameSite
);
$response->headers->setCookie($cookie);
}
public function clearAuthCookies(Response $response): void
{
// Supprimer le cookie JWT
$response->headers->clearCookie('JWT_TOKEN', '/');
// Supprimer le cookie refresh token
$response->headers->clearCookie('REFRESH_TOKEN', '/');
}
}
Contrôleur d'authentification
// SecurityController.php
class SecurityController extends AbstractController
{
public function __construct(
private CookieManager $cookieManager,
private JWTTokenManagerInterface $jwtManager,
private RefreshTokenManagerInterface $refreshTokenManager
) {}
#[Route('/api/security/login', name: 'api_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
// Authentification avec email/password
$user = $this->authenticateUser($data['email'], $data['password']);
// Créer les tokens
$jwtToken = $this->jwtManager->create($user);
$refreshToken = $this->refreshTokenManager->create($user);
// Créer la réponse
$response = new JsonResponse([
'message' => 'Login successful',
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'type' => $user->getType()
]
]);
// Définir les cookies
$this->cookieManager->setJwtCookie($response, $jwtToken);
$this->cookieManager->setRefreshTokenCookie($response, $refreshToken);
return $response;
}
#[Route('/api/logout', name: 'api_logout', methods: ['POST'])]
public function logout(): JsonResponse
{
$response = new JsonResponse(['message' => 'Logout successful']);
// Supprimer les cookies
$this->cookieManager->clearAuthCookies($response);
return $response;
}
#[Route('/api/token/refresh', name: 'api_refresh', methods: ['POST'])]
public function refreshToken(Request $request): JsonResponse
{
$refreshToken = $request->cookies->get('REFRESH_TOKEN');
if (!$refreshToken) {
throw new BadRequestHttpException('Refresh token not found');
}
try {
$user = $this->refreshTokenManager->getUserFromToken($refreshToken);
$newJwtToken = $this->jwtManager->create($user);
$response = new JsonResponse(['message' => 'Token refreshed']);
$this->cookieManager->setJwtCookie($response, $newJwtToken);
return $response;
} catch (Exception $e) {
throw new BadRequestHttpException('Invalid refresh token');
}
}
Workflow de refresh automatique
Côté Frontend :
// L'intercepteur Axios détecte automatiquement les 401
// et appelle /api/token/refresh avant de réessayer la requête
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
// 1. Appel automatique au refresh
await axios.post('/api/token/refresh', {}, { withCredentials: true })
// 2. Nouveaux cookies définis automatiquement
// 3. Réessayer la requête originale
return api(error.config)
}
return Promise.reject(error)
}
)
Côté Backend :
// 1. Réception de la requête de refresh
// 2. Validation du refresh token depuis les cookies
// 3. Génération d'un nouveau JWT token
// 4. Définition du nouveau cookie JWT
// 5. Retour de la réponse avec le nouveau cookie
}
#### Configuration CORS
```yaml
# config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
origin_regex: true
allow_credentials: true # Crucial pour les cookies httpOnly
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
Avantages du refresh automatique
Sécurité : - ✅ Transparence : L'utilisateur ne voit pas les 401 - ✅ Pas de déconnexion intempestive : Seulement si le refresh échoue - ✅ Protection contre les attaques : Tokens renouvelés automatiquement
Expérience utilisateur : - ✅ UX fluide : Pas d'interruption de session - ✅ Performance : Pas de rechargement de page - ✅ Continuité : Les requêtes échouées sont rejouées automatiquement
Maintenance : - ✅ Code simplifié : Gestion centralisée dans l'intercepteur - ✅ Robustesse : Gestion des requêtes concurrentes - ✅ Débogage : Logs détaillés pour le diagnostic allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] allow_headers: ['Content-Type', 'Authorization'] allow_credentials: true # ⚠️ Crucial pour les cookies max_age: 3600 paths: '^/api/': allow_origin: ['http://localhost:3000', 'https://app.teadle.com'] allow_headers: ['*'] allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTIONS'] allow_credentials: true
#### Avantages de l'authentification par cookies
1. **Protection XSS** : Les tokens ne sont pas accessibles en JavaScript
2. **Gestion automatique** : Le navigateur envoie automatiquement les cookies
3. **Expiration automatique** : Gérée par le navigateur
4. **Secure flag** : Restriction aux connexions HTTPS
5. **SameSite** : Protection contre les attaques CSRF
#### Migration depuis les headers Authorization
**Avant :**
```php
// Lecture depuis le header Authorization
$token = $request->headers->get('Authorization');
$token = str_replace('Bearer ', '', $token);
Après :
// Lecture depuis les cookies
$token = $request->cookies->get('JWT_TOKEN');
Tests
// tests/Security/CookieAuthenticationTest.php
class CookieAuthenticationTest extends WebTestCase
{
public function testLoginWithCookies(): void
{
$client = static::createClient();
$client->request('POST', '/api/security/login', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'password'
]));
$this->assertResponseIsSuccessful();
// Vérifier que les cookies sont présents
$this->assertTrue($client->getResponse()->headers->has('Set-Cookie'));
$cookies = $client->getResponse()->headers->getCookies();
$this->assertCount(2, $cookies); // JWT_TOKEN + REFRESH_TOKEN
foreach ($cookies as $cookie) {
$this->assertTrue($cookie->isHttpOnly());
$this->assertTrue($cookie->isSecure());
$this->assertEquals('Strict', $cookie->getSameSite());
}
}
public function testRefreshToken(): void
{
$client = static::createClient();
// 1. Se connecter pour obtenir les cookies
$client->request('POST', '/api/security/login', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'password'
]));
$this->assertResponseIsSuccessful();
// 2. Appeler l'endpoint de refresh
$client->request('POST', '/api/token/refresh', [], [], [
'CONTENT_TYPE' => 'application/json',
]);
$this->assertResponseIsSuccessful();
// 3. Vérifier qu'un nouveau cookie JWT est défini
$cookies = $client->getResponse()->headers->getCookies();
$jwtCookie = null;
foreach ($cookies as $cookie) {
if ($cookie->getName() === 'JWT_TOKEN') {
$jwtCookie = $cookie;
break;
}
}
$this->assertNotNull($jwtCookie);
$this->assertTrue($jwtCookie->isHttpOnly());
}
public function testRefreshTokenWithoutCookie(): void
{
$client = static::createClient();
// Appeler refresh sans cookie
$client->request('POST', '/api/token/refresh', [], [], [
'CONTENT_TYPE' => 'application/json',
]);
$this->assertResponseStatusCodeSame(400);
}
}
}
}
#### Flow d'authentification OAuth
```mermaid
sequenceDiagram
participant User
participant Frontend
participant Backend
participant OAuth Provider
participant Database
User->>Frontend: Click OAuth Login
Frontend->>Backend: GET /security/oauth/login?provider=linkedin
Backend->>OAuth Provider: Redirect to OAuth
OAuth Provider->>User: Login page
User->>OAuth Provider: Enter credentials
OAuth Provider->>Backend: Callback with code
Backend->>OAuth Provider: Exchange code for token
OAuth Provider->>Backend: Return user data
Backend->>Database: Find or create user
Backend->>Frontend: Return JWT token
Frontend->>User: Redirect to dashboard
3. Codes d'authentification
Génération et envoi
// AuthCodeMailer.php (envoi) + AuthCodeGenerator (génération)
// L'envoi passe par EmailService::sendAuthCodeEmail() pour centraliser les templates
// et garantir le dispatch asynchrone via MessageBus
class AuthCodeMailer
{
public function __construct(
private EmailService $emailService
) {}
public function sendAuthCode(User $user, string $code): void
{
$this->emailService->sendAuthCodeEmail($user->getEmail(), $code);
}
}
Vérification
// AuthCodeVerifyProcessor.php
class AuthCodeVerifyProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$this->validator->validate($data);
$authCode = $this->authCodeRepository->findValidCode(
$data->email,
$data->code,
$data->type
);
if (!$authCode) {
throw new InvalidAuthCodeException('Invalid or expired code');
}
// Marquer comme utilisé
$authCode->markAsUsed();
$this->authCodeRepository->save($authCode);
// Retourner le token JWT
return $this->tokenManager->createToken($authCode->getUser());
}
}
4. Reset de mot de passe
Demande de reset
// RequestPasswordResetCommand.php
// L'envoi d'email passe par EmailService::sendPasswordResetEmail()
readonly class RequestPasswordResetCommand
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordResetRequestRepositoryInterface $passwordResetRequestRepository,
private PasswordResetTokenGenerator $tokenGenerator,
private EmailService $emailService,
) {}
public function process(string $email): void
{
$user = $this->userRepository->findOneByEmail($email);
if (!$user) {
return; // Silent fail pour la sécurité
}
$token = $this->tokenGenerator->generate();
$resetRequest = new PasswordResetRequest(
$user,
$token,
new DateTimeImmutable('+1 hour')
);
$this->passwordResetRequestRepository->create($resetRequest);
$this->emailService->sendPasswordResetEmail(
$user->getEmail(),
$user->getFirstname() ?? '',
$user->getLastname() ?? '',
$resetRequest->getToken()
);
}
}
Validation et reset
// PerformPasswordResetCommand.php
readonly class PerformPasswordResetCommand
{
public function process(string $token, string $newPassword): void
{
$resetRequest = $this->passwordResetRequestRepository->findValidByToken($token);
if (!$resetRequest) {
throw new InvalidTokenException('Invalid or expired token');
}
if ($resetRequest->isUsed()) {
throw new TokenAlreadyUsedException('Token already used');
}
// Mettre à jour le mot de passe
$user = $resetRequest->getUser();
$user->setPassword($this->passwordHasher->hashPassword($user, $newPassword));
// Marquer comme utilisé
$resetRequest->markAsUsed();
$this->userRepository->save($user);
$this->passwordResetRequestRepository->save($resetRequest);
}
}
🛡️ Autorisation
Messagerie (Voters + rate limiting)
App\Infrastructure\Symfony\Security\Voter\ConversationVoter: attributsVIEWetPARTICIPATEsur uneConversation— accordé si l’utilisateur courant est participant de la conversation.App\Infrastructure\Symfony\Security\Voter\MessageVoter: attributMESSAGEsur unUsercible — accordé si les deux utilisateurs ont au moins une adhésion organisationnelle active commune (OrganizationMembershipRepository::findActiveCommonMemberships) et ne sont pas tous deux des formateurs (pas de DM intervenant ↔ intervenant).- Le wrapper
App\Infrastructure\Symfony\Security\SecurityexposeisGranted()(délégation vers le service Symfony) pour les processors. - Rate limit
POST /conversations/{uuid}/messages: limiteur nommémessaging(config/packages/rate_limiter.yaml), fenêtre glissante 20 requêtes / minute par clé utilisateur (UUID user), réponse 429 si dépassement.
1. Rôles et permissions
Hiérarchie des rôles
// User.php
abstract class User implements UserInterface
{
public function getRoles(): array
{
$roles = $this->roles;
// Ajouter le rôle par défaut
$roles[] = 'ROLE_USER';
// Ajouter le rôle spécifique au type
if ($this instanceof Trainer) {
$roles[] = 'ROLE_TRAINER';
} elseif ($this instanceof Organization) {
$roles[] = 'ROLE_ORGANIZATION';
}
return array_unique($roles);
}
}
Configuration des rôles
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_TRAINER, ROLE_ORGANIZATION]
ROLE_TRAINER: [ROLE_USER]
ROLE_ORGANIZATION: [ROLE_USER]
2. Contrôle d'accès
Configuration des routes
# config/packages/security.yaml
security:
access_control:
# Routes publiques
- { path: ^/health/*, roles: PUBLIC_ACCESS }
- { path: ^/security/*, roles: PUBLIC_ACCESS }
- { path: ^/onboarding/*, roles: PUBLIC_ACCESS }
# Routes protégées par défaut (inclut POST /trainer/calendar/oauth/{provider}/calendar/list et …/callback : JWT formateur)
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
Vérification dans les providers
// SecureProvider.php
class SecureProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$user = $this->getUserFromContext($context);
if (!$user) {
throw new AccessDeniedException('Authentication required');
}
// Vérification du type d'utilisateur
if (!$this->hasRequiredRole($user, $operation)) {
throw new AccessDeniedException('Insufficient permissions');
}
// Vérification des permissions métier
if (!$this->hasBusinessPermission($user, $operation)) {
throw new AccessDeniedException('Business rule violation');
}
return $this->dataHandler->handle($user);
}
private function hasRequiredRole(User $user, Operation $operation): bool
{
$requiredRoles = $operation->getSecurity() ?? ['ROLE_USER'];
foreach ($requiredRoles as $role) {
if (in_array($role, $user->getRoles())) {
return true;
}
}
return false;
}
}
3. Permissions métier
Vérification des propriétaires
// BusinessPermissionChecker.php
class BusinessPermissionChecker
{
public function canAccessTrainerData(User $user, Trainer $trainer): bool
{
// Un formateur peut accéder à ses propres données
if ($user instanceof Trainer && $user->getId() === $trainer->getId()) {
return true;
}
// Une organisation peut accéder aux données des formateurs avec qui elle travaille
if ($user instanceof Organization) {
return $this->hasWorkingRelationship($user, $trainer);
}
return false;
}
public function canAccessOrganizationData(User $user, Organization $organization): bool
{
// Une organisation peut accéder à ses propres données
if ($user instanceof Organization && $user->getId() === $organization->getId()) {
return true;
}
// Un formateur peut accéder aux données des organisations avec qui il travaille
if ($user instanceof Trainer) {
return $this->hasWorkingRelationship($user, $organization);
}
return false;
}
}
🔒 Protection des données
1. Validation des entrées
Contraintes de validation
// Request classes avec validation
class PasswordResetRequest
{
public function __construct(
#[Assert\NotBlank(message: 'Email is required')]
#[Assert\Email(message: 'Invalid email format')]
#[Assert\Length(max: 180, maxMessage: 'Email too long')]
public readonly string $email
) {}
}
class PasswordResetPerformRequest
{
public function __construct(
#[Assert\NotBlank(message: 'Token is required')]
public readonly string $token,
#[Assert\NotBlank(message: 'Password is required')]
#[Assert\Length(min: 8, minMessage: 'Password must be at least 8 characters')]
#[Assert\Regex(
pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/',
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character'
)]
public readonly string $password
) {}
}
Validation personnalisée
// Custom validator
class PhoneNumberValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (null === $value || '' === $value) {
return;
}
$phoneUtil = PhoneNumberUtil::getInstance();
try {
$phoneNumber = $phoneUtil->parse($value, 'FR');
if (!$phoneUtil->isValidNumber($phoneNumber)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
} catch (NumberParseException $e) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}
2. Protection contre les attaques
Rate Limiting
# config/packages/nelmio_security.yaml
nelmio_security:
rate_limit:
enabled: true
path: ^/security/
limit: 10
period: 60
Protection CSRF
# config/packages/framework.yaml
framework:
csrf_protection:
enabled: true
Headers de sécurité
# config/packages/nelmio_security.yaml
nelmio_security:
forced_ssl: ~
content_type_nosniff: ~
xss_protection: ~
content_security_policy:
hosts: []
content_types: []
enforce: true
hosts_https: []
content_types_https: []
enforce_https: true
3. Chiffrement des données sensibles
Configuration du chiffrement
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
encrypted_text: App\Infrastructure\Orm\Type\EncryptedTextType
encrypted_json: App\Infrastructure\Orm\Type\EncryptedJsonType
Types de données chiffrées
// EncryptedTextType.php
class EncryptedTextType extends Type
{
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return null;
}
return $this->encryptor->encrypt($value);
}
public function convertToPHPValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return null;
}
return $this->encryptor->decrypt($value);
}
}
📊 Monitoring et audit
1. Logs de sécurité
Configuration des logs
# config/packages/monolog.yaml
monolog:
channels: ['security']
handlers:
security:
type: stream
path: "%kernel.logs_dir%/security.log"
level: info
channels: ["security"]
Événements de sécurité
// SecurityEventSubscriber.php
class SecurityEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private LoggerInterface $securityLogger
) {}
public static function getSubscribedEvents(): array
{
return [
SecurityEvents::INTERACTIVE_LOGIN => 'onInteractiveLogin',
SecurityEvents::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
'lexik_jwt_authentication.on_authentication_success' => 'onJwtSuccess',
'lexik_jwt_authentication.on_authentication_failure' => 'onJwtFailure',
];
}
public function onInteractiveLogin(InteractiveLoginEvent $event): void
{
$user = $event->getAuthenticationToken()->getUser();
$this->securityLogger->info('User login successful', [
'user_id' => $user->getId(),
'email' => $user->getEmail(),
'ip' => $event->getRequest()->getClientIp(),
'user_agent' => $event->getRequest()->headers->get('User-Agent'),
]);
}
public function onAuthenticationFailure(AuthenticationFailureEvent $event): void
{
$this->securityLogger->warning('Authentication failure', [
'exception' => $event->getException()->getMessage(),
'ip' => $event->getRequest()->getClientIp(),
'user_agent' => $event->getRequest()->headers->get('User-Agent'),
]);
}
}
2. Détection d'anomalies
Rate limiting avancé
// AdvancedRateLimiter.php
class AdvancedRateLimiter
{
public function checkRateLimit(string $identifier, string $operation): bool
{
$key = "rate_limit:{$identifier}:{$operation}";
$current = $this->redis->incr($key);
if ($current === 1) {
$this->redis->expire($key, $this->getWindow($operation));
}
$limit = $this->getLimit($operation);
if ($current > $limit) {
$this->securityLogger->warning('Rate limit exceeded', [
'identifier' => $identifier,
'operation' => $operation,
'current' => $current,
'limit' => $limit,
]);
return false;
}
return true;
}
}
3. Audit trail
Trait d'audit
// AuditableTrait.php
trait AuditableTrait
{
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
#[ORM\Column(length: 255, nullable: true)]
private ?string $createdBy = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $updatedBy = null;
#[ORM\PrePersist]
public function setCreatedAt(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->createdBy = $this->getCurrentUser();
}
#[ORM\PreUpdate]
public function setUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
$this->updatedBy = $this->getCurrentUser();
}
}
🚀 Bonnes pratiques
1. Gestion des secrets
# Génération des clés JWT
php bin/console lexik:jwt:generate-keypair
# Variables d'environnement sensibles
JWT_SECRET_KEY="%kernel.project_dir%/config/jwt/private.pem"
JWT_PUBLIC_KEY="%kernel.project_dir%/config/jwt/public.pem"
JWT_PASSPHRASE="your-secure-passphrase"
# Clés OAuth
LINKEDIN_CLIENT_ID="your-linkedin-client-id"
LINKEDIN_CLIENT_SECRET="your-linkedin-client-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
MICROSOFT_CLIENT_ID="your-microsoft-client-id"
MICROSOFT_CLIENT_SECRET="your-microsoft-client-secret"
2. Tests de sécurité
// SecurityTest.php
class SecurityTest extends WebTestCase
{
public function testUnauthenticatedAccess(): void
{
$client = static::createClient();
$client->request('GET', '/dashboard/trainer/income');
$this->assertResponseRedirects('/security/login');
}
public function testCrossUserAccess(): void
{
$client = static::createClient();
// Login as trainer 1
$this->loginAsTrainer($client, 1);
// Try to access trainer 2's data
$client->request('GET', '/trainer/2/profile');
$this->assertResponseStatusCodeSame(403);
}
public function testRateLimiting(): void
{
$client = static::createClient();
// Make multiple requests
for ($i = 0; $i < 15; $i++) {
$client->request('POST', '/security/login', [
'email' => 'test@example.com',
'password' => 'wrong-password'
]);
}
$this->assertResponseStatusCodeSame(429); // Too Many Requests
}
}
Cette architecture de sécurité garantit la protection complète du système Teadle contre les menaces courantes et assure la confidentialité des données utilisateurs.