<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Form\RequestAccessFormType;
use App\Form\SigninRegistrationFormType;
use App\Repository\ClientRepository;
use App\Repository\UserRepository;
use App\Service\EmailSenderService;
use App\Service\MagicLinkService;
use App\Service\RecaptchaService;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use MobileDetectBundle\DeviceDetector\MobileDetectorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
private $userRepository;
private $clientRepository;
private $emailSenderService;
private $magicLinkService;
private $logger;
public function __construct(
UserRepository $userRepository,
ClientRepository $clientRepository,
EmailSenderService $emailSenderService,
MagicLinkService $magicLinkService,
LoggerInterface $logger
) {
$this->userRepository = $userRepository;
$this->clientRepository = $clientRepository;
$this->emailSenderService = $emailSenderService;
$this->magicLinkService = $magicLinkService;
$this->logger = $logger;
}
/**
* Page d'accueil et connexion classique (email/password)
*
* @Route("/", name="home")
* @Route("/login", name="app_login")
*/
public function home(AuthenticationUtils $authenticationUtils, Request $request, MobileDetectorInterface $mobileDetector, SessionInterface $session): Response
{
// Si l'utilisateur est déjà authentifié, rediriger vers le dashboard
if ($this->getUser()) {
return $this->redirectToRoute('suggestion.index');
}
$isInactive = ($request->query->get('isInactive') != null) ? 1 : 0;
$error = $authenticationUtils->getLastAuthenticationError();
$prefilledEmail = $request->query->get('email', '');
$isRedirected = ($request->query->get('redirected') == '1');
$loginErrorType = $request->query->get('login_error', null);
$showCreateAccountInfo = $request->query->get('create_account') === '1';
$clientCreateAccountUrl = null;
$hideLoginActions = false;
$showMagicLinkConfirmation = ($request->query->get('magic_link_sent') === '1' && $prefilledEmail !== '');
$magicLinkType = $request->query->get('magic_link_type', 'connexion');
$showRequestAccessForm = ($request->query->get('show') === 'demander-mon-acces');
$requestAccessForm = $this->createForm(RequestAccessFormType::class);
$requestAccessForm->handleRequest($request);
$requestAccessEligible = $request->query->get('eligible');
$requestAccessSigninUrl = null;
if ($showRequestAccessForm && $requestAccessEligible === '1') {
$requestAccessSigninUrl = $session->get('request_access_signin_url');
}
// Si un token est présent, récupérer l'email de l'utilisateur correspondant
$token = $request->query->get('token', '');
if (!empty($token) && empty($prefilledEmail)) {
$user = $this->userRepository->findOneBy(['token' => $token]);
// ✅ Vérifications de sécurité (Option 3)
if ($user) {
// Vérifier que le compte est actif
if (!$user->isIsActive()) {
$this->addFlash('error', 'Ce compte est inactif.');
return $this->redirectToRoute('app_login');
}
$prefilledEmail = $user->getEmail();
}
}
// Vérifier si l'utilisateur (si email pré-rempli) n'a pas de mot de passe
// Ce sont les utilisateurs ANCIENS créés avant l'implémentation du système de mot de passe
$userHasNoPassword = false;
if (!empty($prefilledEmail)) {
$user = $this->userRepository->findOneBy(['email' => $prefilledEmail]);
if ($user && empty($user->getPassword())) {
$userHasNoPassword = true;
}
}
// Scénario "compte inexistant/non activé" + entreprise détectée via domain_restriction
if ($loginErrorType === 'account_missing_client' && !empty($prefilledEmail)) {
$client = $this->clientRepository->findClientByEmailDomain($prefilledEmail);
if ($client) {
$clientCreateAccountUrl = $this->generateUrl('client.signin', [
'slug' => $client->getSlug(),
'token' => $client->getTokenCooptor(),
]);
$hideLoginActions = true;
} else {
// Fallback: si aucun client finalement trouvé, on bascule sur le message "RH"
$loginErrorType = 'account_missing_no_client';
}
}
return $this->render('page/index.html.twig', [
'error' => $error,
'isInactive' => $isInactive,
'prefilledEmail' => $prefilledEmail,
'isRedirected' => $isRedirected,
'userHasNoPassword' => $userHasNoPassword,
'login_error_type' => $loginErrorType,
'show_create_account_info' => $showCreateAccountInfo,
'client_create_account_url' => $clientCreateAccountUrl,
'hide_login_actions' => $hideLoginActions,
'is_mobile' => $mobileDetector->isMobile(),
'show_magic_link_confirmation' => $showMagicLinkConfirmation,
'magic_link_email' => $showMagicLinkConfirmation ? $prefilledEmail : null,
'magic_link_type' => $showMagicLinkConfirmation ? $magicLinkType : null,
'show_request_access_form' => $showRequestAccessForm,
'request_access_form' => $requestAccessForm->createView(),
'request_access_eligible' => $requestAccessEligible,
'request_access_signin_url' => $requestAccessSigninUrl,
]);
}
/**
* Page "Demander mon accès" : formulaire de vérification d'éligibilité par email professionnel.
* Redirige vers la page d'accueil avec le formulaire affiché (?show=demander-mon-acces).
*/
public function requestAccess(Request $request, SessionInterface $session): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('suggestion.index');
}
if ($request->isMethod('GET')) {
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
$form = $this->createForm(RequestAccessFormType::class);
$form->handleRequest($request);
$eligible = null;
$signinUrl = null;
if ($form->isSubmitted() && $form->isValid()) {
$email = (string) $form->get('email')->getData();
$email = strtolower(trim($email));
// Si l'utilisateur a déjà un compte, on envoie un lien de connexion (Magic Link)
// plutôt que de le rediriger vers le flux "inscription" (évite la confusion UX).
$existingUser = $this->userRepository->findOneByLoginEmail($email);
if ($existingUser !== null) {
if (!$existingUser->isIsActive()) {
$this->addFlash('error', 'Ce compte est inactif.');
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
try {
$magicToken = $this->magicLinkService->generateForExistingUser($existingUser);
$magicLinkUrl = $this->generateUrl('app_magic_link_verify', ['token' => $magicToken], UrlGeneratorInterface::ABSOLUTE_URL);
$firstname = trim((string) $existingUser->getFirstname());
$this->emailSenderService->sendTemplatedEmail(
$email,
'Connexion à votre espace Bambboo',
'emails/magic_link.html.twig',
['firstname' => $firstname, 'magic_link_url' => $magicLinkUrl]
);
} catch (\Throwable $e) {
$this->logger->error('requestAccess: envoi Magic Link échoué', ['email' => $email, 'exception' => $e->getMessage()]);
$this->addFlash('error', 'L\'envoi du lien de connexion a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
return $this->redirectToRoute('home', [
'email' => $email,
'magic_link_sent' => '1',
'magic_link_type' => 'connexion',
]);
}
$client = $this->clientRepository->findClientByEmailDomain($email);
$eligible = ($client !== null);
if ($client !== null) {
$slug = $client->getSlug();
$tokenCooptor = $client->getTokenCooptor();
$signinUrl = $this->generateUrl('client.signin', ['slug' => $slug, 'token' => $tokenCooptor], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
$session->set('request_access_signin_url', $signinUrl);
$session->set('request_access_email', $email);
}
} elseif ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', 'Veuillez saisir une adresse email professionnelle valide.');
}
return $this->redirectToRoute('home', [
'show' => 'demander-mon-acces',
'eligible' => $eligible === true ? '1' : ($eligible === false ? '0' : null),
]);
}
/**
* Renvoie l'email de lien d'inscription (après succès "Demander mon accès").
*/
public function requestAccessResend(Request $request, SessionInterface $session): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('suggestion.index');
}
$token = $request->request->get('_csrf_token', '');
if (!$this->isCsrfTokenValid('request_access_resend', $token)) {
$this->addFlash('error', 'Token de sécurité invalide.');
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
$email = $session->get('request_access_email');
$signinUrl = $session->get('request_access_signin_url');
if ($email === null || $signinUrl === null) {
$this->addFlash('warning', 'Session expirée. Veuillez vérifier à nouveau votre éligibilité.');
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
$user = $this->userRepository->findOneByLoginEmail($email);
$firstname = $user !== null ? trim((string) $user->getFirstname()) : '';
try {
$this->sendRequestAccessEmail($email, $firstname, $signinUrl);
$this->addFlash('success', 'L\'email a été renvoyé.');
} catch (TransportExceptionInterface $e) {
$this->logger->error('Request access resend email failed', ['email' => $email, 'exception' => $e->getMessage()]);
$this->addFlash('error', 'Impossible de renvoyer l\'email. Veuillez réessayer plus tard.');
}
return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
}
/**
* Demande d'envoi d'un Magic Link depuis la page d'accueil (POST).
* Si le compte existe : envoi du lien de connexion.
* Si le compte n'existe pas et que le domaine est reconnu : création du contexte pending signup et envoi du lien.
*/
public function requestMagicLink(Request $request): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('suggestion.index');
}
$email = trim((string) $request->request->get('email', ''));
if ($email === '') {
$this->addFlash('error', 'Veuillez saisir votre adresse email.');
return $this->redirectToRoute('home');
}
if (!$this->isCsrfTokenValid('request_magic_link', $request->request->get('_csrf_token', ''))) {
$this->addFlash('error', 'Token de sécurité invalide.');
return $this->redirectToRoute('home');
}
$email = strtolower($email);
$user = $this->userRepository->findOneBy(['email' => $email]);
if ($user !== null) {
if (!$user->isIsActive()) {
return $this->redirectToRoute('home', ['email' => $email, 'magic_link_sent' => '1', 'magic_link_type' => 'connexion']);
}
try {
$magicToken = $this->magicLinkService->generateForExistingUser($user);
$magicLinkUrl = $this->generateUrl('app_magic_link_verify', ['token' => $magicToken], UrlGeneratorInterface::ABSOLUTE_URL);
$firstname = trim((string) $user->getFirstname());
$this->emailSenderService->sendTemplatedEmail(
$email,
'Connexion à votre espace Bambboo',
'emails/magic_link.html.twig',
['firstname' => $firstname, 'magic_link_url' => $magicLinkUrl]
);
} catch (\Throwable $e) {
$this->logger->error('requestMagicLink: envoi échoué', ['email' => $email, 'exception' => $e->getMessage()]);
$this->addFlash('error', 'L\'envoi du lien de connexion a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
return $this->redirectToRoute('home', ['email' => $email]);
}
return $this->redirectToRoute('home', ['email' => $email, 'magic_link_sent' => '1', 'magic_link_type' => 'connexion']);
}
$client = $this->clientRepository->findClientByEmailDomain($email);
if ($client !== null) {
try {
$magicToken = $this->magicLinkService->generateForPendingSignup($email, $client, 'ROLE_COOPTOR');
$magicLinkUrl = $this->generateUrl('app_magic_link_verify', ['token' => $magicToken], UrlGeneratorInterface::ABSOLUTE_URL);
$firstname = '';
$this->emailSenderService->sendTemplatedEmail(
$email,
'Vérifiez votre email pour activer votre compte Bambboo',
'emails/signup_verify_email.html.twig',
['firstname' => $firstname, 'magic_link_url' => $magicLinkUrl]
);
} catch (\Throwable $e) {
$this->logger->error('requestMagicLink: pending signup envoi échoué', ['email' => $email, 'exception' => $e->getMessage()]);
$this->addFlash('error', 'L\'envoi du lien d\'activation a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
return $this->redirectToRoute('home', ['email' => $email]);
}
return $this->redirectToRoute('home', ['email' => $email, 'magic_link_sent' => '1', 'magic_link_type' => 'inscription']);
}
return $this->redirectToRoute('home', ['email' => $email, 'login_error' => 'domain_not_partner']);
}
/**
* Envoie l'email contenant le lien d'inscription (demander mon accès).
*
* @param string $email Destinataire
* @param string $firstname Prénom pour la salutation
* @param string $signinUrl URL absolue du formulaire d'inscription client
*/
private function sendRequestAccessEmail(string $email, string $firstname, string $signinUrl): void
{
$this->emailSenderService->sendTemplatedEmail(
$email,
'Accéder à mon espace Bambboo',
'emails/request_access_link.html.twig',
[
'firstname' => $firstname,
'signin_url' => $signinUrl,
]
);
}
/**
* Extrait un prénom pour la salutation à partir de la partie locale de l'email.
*/
private function extractFirstnameFromEmail(string $email): string
{
$pos = strpos($email, '@');
if ($pos === false || $pos === 0) {
return '';
}
$local = substr($email, 0, $pos);
$local = preg_replace('/[^a-zA-ZÀ-ÿ]/', '', $local);
return $local !== '' ? ucfirst(strtolower($local)) : '';
}
/**
* Page d'inscription depuis un lien client (slug/token)
*
* @Route("/{slug}/{token}", name="client.signin")
*/
public function signin(
string $slug,
string $token,
ClientRepository $clientRepository,
AuthenticationUtils $authenticationUtils,
SessionInterface $session,
Request $request,
UserRepository $userRepository,
RecaptchaService $recaptchaService,
MobileDetectorInterface $mobileDetector
): Response {
// Si l'utilisateur est déjà authentifié, on exécute directement la logique post-login
if ($this->getUser()) {
return $this->forward(PostLoginController::class . '::handlePostLogin');
}
$session->remove('linkedin_cookies');
$client = $clientRepository->findOneBy(['slug' => $slug]);
$session->set('origin', $request->getRequestUri());
if (!$client) {
return $this->redirectToRoute('home');
}
$canSignIn = 1;
$role = [];
if ($token === $client->getTokenRecruiter()) {
$role = ["ROLE_RECRUITER"];
$maxUser = $client->getMaxRecruiter();
if ($maxUser) {
$coworkers = count($userRepository->getCoworkers($client));
if ($coworkers >= $maxUser) {
$canSignIn = 0;
}
}
} elseif ($token === $client->getTokenCooptor()) {
$role = ["ROLE_COOPTOR"];
$maxUser = $client->getMaxCooptor();
if ($maxUser) {
$coworkers = count($userRepository->getCoworkers($client));
if ($coworkers >= $maxUser) {
$canSignIn = 0;
}
}
} else {
return $this->redirectToRoute('home');
}
$session->set('client_id', $client->getId());
$session->set('role', $role);
$error = $authenticationUtils->getLastAuthenticationError();
$isMobile = $mobileDetector->isMobile();
// Créer le formulaire d'inscription
$user = new User();
$form = $this->createForm(SigninRegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$submittedEmail = null;
try {
$submittedEmail = $form->get('email')->getData();
} catch (\Throwable $e) {
$submittedEmail = null;
}
$this->logger->info('SIGNUP: formulaire soumis', [
'route' => 'client.signin',
'method' => $request->getMethod(),
'slug' => $slug,
'is_mobile' => $isMobile,
'email' => $submittedEmail,
]);
}
// Si l'utilisateur existe déjà : envoyer un Magic Link de connexion
if ($form->isSubmitted()) {
$email = $form->get('email')->getData();
$email = is_string($email) ? strtolower(trim($email)) : '';
if ($email !== '') {
$existingUser = $userRepository->findOneByLoginEmail($email);
if ($existingUser) {
$this->logger->info('SIGNUP: email déjà existant, envoi Magic Link', [
'email' => $email,
'existing_user_id' => $existingUser->getId(),
'is_mobile' => $isMobile,
]);
if (!$existingUser->isIsActive()) {
$recaptchaSiteKey = $this->getRecaptchaSiteKey();
return $this->render('client/signin.html.twig', [
'client' => $client,
'error' => $error,
'canSignIn' => $canSignIn,
'form' => $form->createView(),
'recaptcha_site_key' => $recaptchaSiteKey,
'magic_link_sent' => false,
]);
}
try {
$magicToken = $this->magicLinkService->generateForExistingUser($existingUser);
$magicLinkUrl = $this->generateUrl('app_magic_link_verify', ['token' => $magicToken], UrlGeneratorInterface::ABSOLUTE_URL);
$firstname = trim((string) $existingUser->getFirstname());
$this->emailSenderService->sendTemplatedEmail(
$email,
'Connexion à votre espace Bambboo',
'emails/magic_link.html.twig',
['firstname' => $firstname, 'magic_link_url' => $magicLinkUrl]
);
} catch (\Throwable $e) {
$this->logger->error('SIGNUP: envoi Magic Link échoué', ['email' => $email, 'exception' => $e->getMessage()]);
}
$recaptchaSiteKey = $this->getRecaptchaSiteKey();
return $this->render('client/signin.html.twig', [
'client' => $client,
'error' => $error,
'canSignIn' => $canSignIn,
'form' => $form->createView(),
'recaptcha_site_key' => $recaptchaSiteKey,
'magic_link_sent' => true,
'magic_link_email' => $email,
'magic_link_type' => 'connexion',
]);
}
}
}
if ($form->isSubmitted() && !$form->isValid()) {
$errors = [];
foreach ($form->getErrors(true) as $errorItem) {
$origin = $errorItem->getOrigin();
$errors[] = [
'field' => $origin ? $origin->getName() : null,
'message' => $errorItem->getMessage(),
];
}
$this->logger->warning('SIGNUP: formulaire invalide', [
'is_mobile' => $isMobile,
'email' => $form->has('email') ? $form->get('email')->getData() : null,
'errors' => $errors,
]);
}
if ($form->isSubmitted() && $form->isValid()) {
// Validation reCAPTCHA Enterprise uniquement si l'utilisateur n'est pas connecté
if (!$this->getUser()) {
$recaptchaToken = $form->get('recaptcha_token')->getData();
$recaptchaResult = $recaptchaService->verify(
$recaptchaToken ?? '',
$request->getClientIp(),
0.3 // Score minimum requis (abaissé pour être plus souple)
);
// En mode souple, on ne bloque jamais l'inscription même si le reCAPTCHA échoue
if (!$recaptchaResult['success']) {
// Log pour monitoring
}
}
$email = $form->get('email')->getData();
$email = is_string($email) ? strtolower(trim($email)) : '';
$roleValue = $role[0] ?? 'ROLE_COOPTOR';
try {
$magicToken = $this->magicLinkService->generateForPendingSignup($email, $client, $roleValue);
$magicLinkUrl = $this->generateUrl('app_magic_link_verify', ['token' => $magicToken], UrlGeneratorInterface::ABSOLUTE_URL);
$firstname = '';
$this->emailSenderService->sendTemplatedEmail(
$email,
'Vérifiez votre email pour activer votre compte Bambboo',
'emails/signup_verify_email.html.twig',
['firstname' => $firstname, 'magic_link_url' => $magicLinkUrl]
);
} catch (\Throwable $e) {
$this->logger->error('SIGNUP: envoi Magic Link pending signup échoué', ['email' => $email, 'exception' => $e->getMessage()]);
$recaptchaSiteKey = $this->getRecaptchaSiteKey();
return $this->render('client/signin.html.twig', [
'client' => $client,
'error' => $error,
'canSignIn' => $canSignIn,
'form' => $form->createView(),
'recaptcha_site_key' => $recaptchaSiteKey,
'magic_link_sent' => false,
]);
}
$recaptchaSiteKey = $this->getRecaptchaSiteKey();
return $this->render('client/signin.html.twig', [
'client' => $client,
'error' => $error,
'canSignIn' => $canSignIn,
'form' => $form->createView(),
'recaptcha_site_key' => $recaptchaSiteKey,
'magic_link_sent' => true,
'magic_link_email' => $email,
'magic_link_type' => 'inscription',
]);
}
$recaptchaSiteKey = $this->getRecaptchaSiteKey();
return $this->render('client/signin.html.twig', [
'client' => $client,
'error' => $error,
'canSignIn' => $canSignIn,
'form' => $form->createView(),
'recaptcha_site_key' => $recaptchaSiteKey,
'magic_link_sent' => false,
]);
}
/**
* Retourne la clé reCAPTCHA Enterprise si configurée.
*/
private function getRecaptchaSiteKey(): string
{
if ($this->getUser()) {
return '';
}
try {
return (string) ($this->getParameter('app.recaptcha_enterprise_site_key') ?? '');
} catch (\Exception $e) {
return '';
}
}
/**
* @Route("/logout", name="app_logout")
*/
public function logout(): void
{
// Ce code ne sera jamais exécuté, Symfony intercepte cette route pour gérer la déconnexion
throw new \LogicException('Logout route');
}
}