Aller au contenu

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 httpOnlyNOUVEAU

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 : attributs VIEW et PARTICIPATE sur une Conversation — accordé si l’utilisateur courant est participant de la conversation.
  • App\Infrastructure\Symfony\Security\Voter\MessageVoter : attribut MESSAGE sur un User cible — 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\Security expose isGranted() (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.