Aller au contenu

Sécurité

Cette page décrit ce que le code de BCard fait réellement en matière d'authentification, d'autorisation et de traçabilité : pare-feux et rôles tels que déclarés dans config/packages/security.yaml, authentification web par formulaire et par connexion sociale, authentification par jeton JWT pour l'API, vérification d'e-mail, protection anti-robot, journal d'audit et suppression de compte. Toute affirmation ci-dessous provient de la lecture du code cité ; les points restés incertains sont signalés comme tels.


Pare-feux

config/packages/security.yaml déclare quatre pare-feux, évalués dans cet ordre :

Pare-feu Motif d'URL (pattern) État Comportement
login ^/api/login_check stateless: true Authentification par json_login, gérée par les handlers lexik_jwt_authentication.handler.authentication_success / authentication_failure
api ^/api stateless: false Authentification JWT (jwt: ~, fournie par LexikJWTAuthenticationBundle)
dev ^/(_(profiler|wdt)|css|images|js)/ security: false : aucune sécurité appliquée sur les assets et les outils de debug Symfony
main (reste des URL, pare-feu par défaut) lazy: true form_login avec login_path/check_path sur app_login, default_target_path: /register ; authentificateurs personnalisés UserAuthenticator, GoogleAuthenticator, FacebookAuthenticator ; logout sur app_logout (cible /) ; remember_me avec une durée de vie de 604800 secondes (7 jours) et le chemin /

Le pare-feu api a stateless: false : la valeur écrite dans le fichier, à distinguer d'un pare-feu API classique qui serait stateless: true.

Autorisations d'accès (access_control, la première règle qui correspond s'applique) :

Chemin Rôle exigé Méthodes
^/\.well-known/ PUBLIC_ACCESS toutes
^/company PUBLIC_ACCESS toutes
/api/auth/change-password IS_AUTHENTICATED toutes
/api/auth/me IS_AUTHENTICATED toutes
^/api/auth/ PUBLIC_ACCESS toutes
^/api/docs PUBLIC_ACCESS toutes
^/api/cards PUBLIC_ACCESS GET uniquement
^/api/business-cards/public PUBLIC_ACCESS GET uniquement
^/api/admin ROLE_ADMIN toutes
^/api IS_AUTHENTICATED toutes (règle de repli pour le reste de l'API)

Plusieurs lignes access_control restent commentées dans le fichier (routes /api/login, /api/auth/login, /api/auth/register, /api/auth/token/refresh, /api/auth/token/revoke, /api/auth/forgot-password, ainsi que des règles génériques ^/admin et ^/profile) : elles ne produisent aucun effet tant qu'elles ne sont pas décommentées.


Hiérarchie des rôles

Aucune clé role_hierarchy n'est déclarée dans config/packages/security.yaml. Il n'existe donc pas de hiérarchie de rôles configurée au niveau du composant Security : les rôles portés par User::getRoles() sont pris tels quels, sans expansion automatique (par exemple, ROLE_ADMIN n'hérite pas implicitement de ROLE_USER par ce mécanisme). Les contrôleurs qui distinguent plusieurs rôles administratifs le font en dur dans leur code, par exemple App\Controller\admin\AuditLogController::index(), qui autorise explicitement ROLE_ADMIN, ROLE_OPERATOR ou ROLE_SUPPORT.


Authentification web

Le pare-feu main liste trois authentificateurs personnalisés, tous dans src/Security/ :

  • UserAuthenticator — authentification par formulaire (e-mail/mot de passe). Étend AbstractLoginFormAuthenticator ; route de connexion app_login (constante LOGIN_ROUTE). authenticate() construit un Passport avec UserBadge, PasswordCredentials, CsrfTokenBadge et RememberMeBadge à partir des champs email, password et _csrf_token du payload. Après succès (onAuthenticationSuccess()) : redirection vers app_verify_code si le compte n'est pas vérifié (l'identifiant utilisateur est mémorisé en session) ; vers app_login avec un message flash d'erreur si le compte n'est pas activé ; sinon vers le chemin ciblé avant la redirection au login (TargetPathTrait), ou vers app_dashboard par défaut.
  • GoogleAuthenticator et FacebookAuthenticator — voir Connexion sociale ci-dessous ; leur logique de redirection après succès est identique à celle de UserAuthenticator (vérification, activation, puis app_dashboard).

Authentification par jeton JWT

L'API (pare-feu api, préfixe /api) est protégée par lexik/jwt-authentication-bundle.

Délivrance

Le pare-feu login (^/api/login_check) authentifie par json_login : succès et échec sont délégués aux handlers du bundle Lexik. config/packages/lexik_jwt_authentication.yaml fixe token_ttl: 3600 (secondes), soit une heure de validité pour le jeton d'accès émis.

L'événement lexik_jwt_authentication.on_authentication_success est écouté par App\EventSubscriber\JWTAuthenticatedSuccessListener, qui enrichit la charge utile JSON de réponse avec l'identifiant, le nom, l'e-mail et les rôles de l'utilisateur authentifié, ainsi qu'une valeur fixe expiredIn à 3600 (recopiée en dur, indépendamment du token_ttl réel configuré).

Rafraîchissement

L'entité App\Entity\RefreshToken et le service App\Service\RefreshTokenService gèrent des jetons de rafraîchissement distincts du JWT, destinés à l'API mobile :

  • createRefreshToken(User $user) révoque d'abord tous les jetons existants de l'utilisateur (revokeUserRefreshTokens()), puis crée un nouveau jeton — valeur aléatoire de 64 caractères hexadécimaux (bin2hex(random_bytes(32))) — avec une durée de validité par défaut de 2592000 secondes (30 jours), configurable via le paramètre $ttl du constructeur du service. Un seul jeton reste donc valide à la fois par utilisateur : une nouvelle connexion invalide les sessions API précédentes.
  • Le rattachement à l'utilisateur se fait par son adresse e-mail, stockée dans le champ username du jeton — pas par la relation Doctrine ManyToOne que porte l'entité : changer l'e-mail d'un utilisateur invalide donc de fait ses jetons existants.
  • validateRefreshToken(string $refreshTokenString) recherche le jeton par sa valeur, contrôle qu'il n'est pas expiré (et le supprime immédiatement s'il l'est), puis résout l'utilisateur par l'e-mail stocké.

Révocation

  • revokeRefreshToken(string $refreshTokenString) supprime définitivement un jeton donné (typiquement à la déconnexion).
  • revokeUserRefreshTokens(User $user) supprime tous les jetons d'un utilisateur donné ; appelé notamment en préalable de createRefreshToken().
  • cleanExpiredTokens() exécute un DELETE DQL en masse sur les jetons dont la date de validité est dépassée. Cette méthode n'a aucun appelant dans src/ : aucune commande, aucun contrôleur, aucun service planifié ne l'invoque. Les jetons de rafraîchissement expirés s'accumulent donc indéfiniment en base tant qu'une purge (commande planifiée ou tâche cron) n'est pas mise en place.

Connexion sociale

Le pare-feu main déclare deux authentificateurs OAuth2 basés sur knpuniversity/oauth2-client-bundle (configuration : config/packages/knpu_oauth2_client.yaml) :

  • GoogleAuthenticator — actif sur la route connect_google_check (supports() compare _route). authenticate() récupère le jeton d'accès Google, résout l'utilisateur par e-mail, et en crée un nouveau (ROLE_USER, verified: true, mot de passe vide) s'il n'existe pas encore.
  • FacebookAuthenticator — même logique, sur la route connect_facebook_check.

Routes déclarées dans config/routes.yaml :

Route Chemin
connect_google_start /connect/google
connect_google_check /connect/oauth/google/check
connect_facebook_start /connect/facebook
connect_facebook_check /connect/oauth/facebook/check

Les URL de rappel (connect_google_check et connect_facebook_check ci-dessus) sont celles à déclarer dans la configuration développeur de chaque fournisseur (Google Cloud Console, Meta for Developers), préfixées par l'URL publique de l'environnement. Les identifiants client (OAUTH_GOOGLE_CLIENT_ID/SECRET, OAUTH_FACEBOOK_CLIENT_ID/SECRET) sont documentés dans Configuration.


Vérification de l'adresse e-mail

Deux mécanismes existent dans le code, mais un seul est réellement en service.

  • App\Service\EmailVerificationService — vérification par code numérique à six chiffres (generateVerificationCode(), tiré via random_int), c'est le mécanisme effectivement utilisé. sendVerificationCode() fixe l'expiration du code à 15 minutes et l'envoie par e-mail (template email/verification_code.html.twig) ; tout code précédemment émis est écrasé. verifyCode() marque le compte vérifié et activé si le code correspond et n'est pas expiré. canResendCode() limite le renvoi à un intervalle de 2 minutes après le dernier envoi. sendVerificationCode() est appelée depuis trois parcours : UserController::register() (inscription individuelle), CompanyRegistrationController (inscription entreprise) et Controller\api\AuthApiController (inscription et renvoi de code côté API).
  • App\Security\EmailVerifier — s'appuie sur symfonycasts/verify-email-bundle (VerifyEmailHelperInterface::generateSignature() / validateEmailConfirmationFromRequest()) pour générer et valider une URL signée de confirmation. Aucun fichier config/packages/verify_email.yaml n'existe dans le projet et la durée de validité n'est pas surchargée dans le code : la valeur qui s'appliquerait est celle par défaut du bundle, lue dans vendor/symfonycasts/verify-email-bundle/src/DependencyInjection/Configuration.php, soit 3600 secondes (une heure). Mais ce chemin d'émission est du code mort en pratique : sendEmailConfirmation() n'est appelé que par la méthode privée UserController::sendConfirmationEmail(), et cette méthode n'est elle-même invoquée nulle part dans src/ (vérifié par recherche de sendConfirmationEmail( dans tout le dossier). Seule la validation, handleEmailConfirmation(), reste câblée à la route app_verify_email, mais cette route n'est jamais atteinte puisqu'aucun lien signé n'est jamais envoyé.

Protection anti-robot

App\Service\RecaptchaService transporte les deux clés reCAPTCHA v3 injectées depuis la configuration du conteneur (config/packages/karser_recaptcha3.yaml, bundle karser/karser-recaptcha3-bundle, score_threshold: 0.5, enabled: true) : la clé publique (getSiteKey()), destinée aux gabarits qui insèrent le widget côté navigateur, et la clé secrète (getSecretKey()), réservée à la vérification serveur.

Le seul formulaire du code qui intègre le champ reCAPTCHA (Recaptcha3Type, contrainte Recaptcha3) est App\Form\RegistrationFormType, sur le champ captcha (action register). App\Controller\UserController consomme Recaptcha3Validator pour lire la dernière réponse et son score lors du traitement de ce formulaire.


Journal d'audit

App\Service\AuditLogger est l'unique point d'écriture de l'entité App\Entity\AuditLog (le docblock de l'entité indique qu'aucun autre code du projet ne l'instancie) :

  • logUser(User $targetUser, string $actionType, ?User $actorUser = null, array $metadata = []) — l'acteur est celui fourni explicitement, sinon l'utilisateur authentifié résolu via le composant Security (null si aucun, journalisé comme « Système »). Persiste et flushe immédiatement une entrée AuditLog.
  • logVirtualCard(BusinessCardAize $businessCard, ...) — délègue à logUser() en ciblant le propriétaire de la carte, avec la métadonnée virtual_card_id ajoutée automatiquement.

Actions tracées, via les constantes AuditLog::ACTION_* : création, modification, activation, désactivation et suppression de compte utilisateur ; création et activation d'abonnement ; changement de statut de commande ; création, modification et suppression de carte de visite virtuelle.

Consultation en lecture seule via App\Controller\admin\AuditLogController (préfixe de route /admin/audit-logs), qui exige IS_AUTHENTICATED_FULLY et, en plus, l'un des rôles ROLE_ADMIN, ROLE_OPERATOR ou ROLE_SUPPORT (contrôle explicite dans la méthode index(), au-delà de ce que la hiérarchie des rôles pourrait laisser supposer — voir Hiérarchie des rôles). La liste est paginée (10 entrées par page, KnpPaginator) et filtrable par recherche libre et par type d'action.

Le docblock de l'entité AuditLog précise qu'aucune tâche planifiée de purge n'a été trouvée dans le projet : les entrées du journal d'audit sont conservées indéfiniment, comme les jetons de rafraîchissement expirés (voir Révocation ci-dessus).


Suppression de compte

App\Service\AccountDeletionService::deleteAccount(User $user) :

  • Refuse la suppression si le compte porte l'un des rôles ROLE_ADMIN, ROLE_SUPPORT ou ROLE_OPERATOR (\LogicException).
  • Dans une transaction Doctrine unique : écrit une entrée d'audit AuditLog::ACTION_USER_DELETED conservant l'e-mail et le nom d'origine, appelle User::anonymize(), supprime définitivement les cartes de visite virtuelles de l'utilisateur (en détachant d'abord l'employé associé) et ses contacts, puis anonymise sans les supprimer ses commandes (nom, prénom, e-mail, téléphone, adresse de livraison et notes remplacés par des valeurs neutres, conservées à des fins comptables).
  • Après validation de la transaction, envoie un e-mail de confirmation à l'ancienne adresse de l'utilisateur et un e-mail d'information à chaque compte portant ROLE_ADMIN (échecs d'envoi journalisés en warning, sans annuler la suppression déjà actée) ; invalide ensuite le token de sécurité courant (TokenStorageInterface::setToken(null)), ce qui déconnecte immédiatement l'utilisateur.
  • En cas d'erreur pendant la transaction : rollback(), journalisation en niveau error, puis l'exception est relancée telle quelle.

Données effacées : cartes de visite virtuelles, contacts. Données conservées (sous forme anonymisée ou figée) : l'utilisateur lui-même (anonymize()), ses commandes, et l'entrée d'audit de la suppression elle-même — qui garde en clair l'e-mail et le nom d'origine dans ses métadonnées.


Points de vigilance

  • docker-compose.yaml contient des identifiants en clair (utilisateur, mot de passe, mot de passe root MySQL) destinés exclusivement au développement local. Voir Configuration pour la gestion des secrets en production, où les valeurs sont fournies par de vraies variables d'environnement plutôt que par ce fichier.
  • App\EventSubscriber\ApiExceptionSubscriber expose toujours le message brut de l'exception, sans distinction entre développement et production. L'abonné écoute KernelEvents::EXCEPTION et construit une réponse JSON dès que le chemin de la requête commence par /api, ou dès que l'en-tête Accept de la requête contient application/json — cette seconde condition n'est pas limitée au préfixe /api : une requête hors API portant cet en-tête déclenche aussi ce comportement. Le code HTTP renvoyé reprend celui de l'exception s'il est compris entre 400 et 599, sinon 500. La charge utile inclut systématiquement $e->getMessage() et le nom court de la classe de l'exception. Une exception applicative peut donc divulguer une information interne (message d'erreur détaillé, y compris potentiellement des éléments techniques) à un appelant non authentifié, en production comme en développement.
  • Les jetons de rafraîchissement expirés et les entrées du journal d'audit s'accumulent indéfiniment. RefreshTokenService::cleanExpiredTokens() existe mais n'est appelée nulle part dans src/, et aucune purge planifiée n'a été trouvée pour AuditLog. Une tâche de purge périodique (commande + planification externe) reste à mettre en place pour les deux.
  • Les variables KEYCLOAK_* présentes dans .env.local ne sont lues par aucun code du projet. Il n'existe pas d'authentification Keycloak dans BCard : ces variables n'ont aucun effet observable (voir aussi Configuration).