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). ÉtendAbstractLoginFormAuthenticator; route de connexionapp_login(constanteLOGIN_ROUTE).authenticate()construit unPassportavecUserBadge,PasswordCredentials,CsrfTokenBadgeetRememberMeBadgeà partir des champsemail,passwordet_csrf_tokendu payload. Après succès (onAuthenticationSuccess()) : redirection versapp_verify_codesi le compte n'est pas vérifié (l'identifiant utilisateur est mémorisé en session) ; versapp_loginavec un message flash d'erreur si le compte n'est pas activé ; sinon vers le chemin ciblé avant la redirection au login (TargetPathTrait), ou versapp_dashboardpar défaut.GoogleAuthenticatoretFacebookAuthenticator— voir Connexion sociale ci-dessous ; leur logique de redirection après succès est identique à celle deUserAuthenticator(vérification, activation, puisapp_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 de2592000secondes (30 jours), configurable via le paramètre$ttldu 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
usernamedu jeton — pas par la relation DoctrineManyToOneque 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 decreateRefreshToken().cleanExpiredTokens()exécute unDELETEDQL en masse sur les jetons dont la date de validité est dépassée. Cette méthode n'a aucun appelant danssrc/: 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 routeconnect_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 routeconnect_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é viarandom_int), c'est le mécanisme effectivement utilisé.sendVerificationCode()fixe l'expiration du code à 15 minutes et l'envoie par e-mail (templateemail/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) etController\api\AuthApiController(inscription et renvoi de code côté API).App\Security\EmailVerifier— s'appuie sursymfonycasts/verify-email-bundle(VerifyEmailHelperInterface::generateSignature()/validateEmailConfirmationFromRequest()) pour générer et valider une URL signée de confirmation. Aucun fichierconfig/packages/verify_email.yamln'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 dansvendor/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éeUserController::sendConfirmationEmail(), et cette méthode n'est elle-même invoquée nulle part danssrc/(vérifié par recherche desendConfirmationEmail(dans tout le dossier). Seule la validation,handleEmailConfirmation(), reste câblée à la routeapp_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 (nullsi aucun, journalisé comme « Système »). Persiste et flushe immédiatement une entréeAuditLog.logVirtualCard(BusinessCardAize $businessCard, ...)— délègue àlogUser()en ciblant le propriétaire de la carte, avec la métadonnéevirtual_card_idajouté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_SUPPORTouROLE_OPERATOR(\LogicException). - Dans une transaction Doctrine unique : écrit une entrée d'audit
AuditLog::ACTION_USER_DELETEDconservant l'e-mail et le nom d'origine, appelleUser::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 enwarning, 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 niveauerror, 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.yamlcontient 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\ApiExceptionSubscriberexpose toujours le message brut de l'exception, sans distinction entre développement et production. L'abonné écouteKernelEvents::EXCEPTIONet construit une réponse JSON dès que le chemin de la requête commence par/api, ou dès que l'en-têteAcceptde la requête contientapplication/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 danssrc/, et aucune purge planifiée n'a été trouvée pourAuditLog. Une tâche de purge périodique (commande + planification externe) reste à mettre en place pour les deux. - Les variables
KEYCLOAK_*présentes dans.env.localne 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).