src/Controller/SecurityController.php line 81

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\OAuth2ClientProfile;
  4. use App\Entity\OAuth2UserConsent;
  5. use App\Entity\PasswordReset;
  6. use App\Entity\User;
  7. use App\Form\ForgotPasswordType;
  8. use App\Form\LoginType;
  9. use App\Form\PasswordResetType;
  10. use App\Service\ItmConnectApiService;
  11. use App\Service\OAuth2Authorize;
  12. use App\Service\SiteConfig;
  13. use DateTime;
  14. use DateTimeInterface;
  15. use Doctrine\ORM\EntityManagerInterface;
  16. use League\Bundle\OAuth2ServerBundle\Model\Client;
  17. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  18. use Symfony\Component\HttpFoundation\RedirectResponse;
  19. use Symfony\Component\HttpFoundation\Request;
  20. use Symfony\Component\HttpFoundation\Response;
  21. use Symfony\Component\Mailer\MailerInterface;
  22. use Symfony\Component\Mime\Address;
  23. use Symfony\Component\Mime\Email;
  24. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  25. use Symfony\Component\Routing\Annotation\Route;
  26. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  27. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  28. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  29. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  30. use Kreait\Firebase\Contract\Messaging;
  31. use Psr\Log\LoggerInterface;
  32. class SecurityController extends AbstractController
  33. {
  34.     public function __construct(
  35.         private ItmConnectApiService $itemconnect,
  36.         private EntityManagerInterface $em,
  37.         private OAuth2Authorize $oauth2Authorize,
  38.         private MailerInterface $mailer,
  39.         private SiteConfig $siteConfig,
  40.         private Messaging  $messaging,
  41.         private LoggerInterface $logger,
  42.     ) {}
  43.     /**
  44.      * @Route("/login", name="app_login")
  45.      */
  46.     public function login(AuthenticationUtils $authenticationUtilsRequest $request): Response
  47.     {
  48.         /** @var User $user */
  49.         $user $this->getUser();
  50.         if ($user) {
  51.             if ($user->canAccessBO()) {
  52.                 return $this->redirectToRoute('admin');
  53.             }
  54.             return $this->redirectToRoute('home');
  55.         }
  56.         $form $this->createForm(LoginType::class);
  57.         // get the login error if there is one
  58.         $error $authenticationUtils->getLastAuthenticationError();
  59.         // last username entered by the user
  60.         $lastUsername $authenticationUtils->getLastUsername();
  61.         return $this->render('security/login.html.twig', [
  62.             'last_username' => $lastUsername,
  63.             'form' => $form->createView(),
  64.             'error' => $error,
  65.         ]);
  66.     }
  67.     /**
  68.      * @Route("/forgot-password", name="app_password_request")
  69.      */
  70.     function forgot_password(Request $request): Response
  71.     {
  72.         $form $this->createForm(ForgotPasswordType::class);
  73.         $form->handleRequest($request);
  74.         if ($form->isSubmitted() && $form->isValid()) {
  75.             $email $form->getData('email');
  76.             $user $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
  77.             if ($user) {
  78.                 // Create a password reset entry
  79.                 $passwordReset = new PasswordReset();
  80.                 $passwordReset->setEmail($user->getEmail());
  81.                 // $passwordReset->setToken(md5(rand()));
  82.                 // Generate a secure random token
  83.                 $passwordReset->setToken(bin2hex(random_bytes(32)));
  84.                 $passwordReset->setCreatedAt(new \DateTime('now'));
  85.                 $this->em->persist($passwordReset);
  86.                 $this->em->flush();
  87.                 // Generate the password reset link
  88.                 $reset_link $this->generateUrl('app_reset_password', ['token' => $passwordReset->getToken()], UrlGeneratorInterface::ABSOLUTE_URL);
  89.                 // Build the password reset email using a Twig HTML template
  90.                 $email = (new Email())
  91.                     ->from(new Address($this->siteConfig->getContactEmail(), 'Support Elvinck'))
  92.                     ->to($user->getEmail())
  93.                     ->subject('Réinitialisation de votre mot de passe')
  94.                     ->html(
  95.                         $html $this->renderView('emails/reset_password.html.twig', [
  96.                             'reset_link' => $reset_link
  97.                         ])
  98.                     );
  99.                 // Send the email via the mailer service
  100.                 $this->mailer->send($email);
  101.                 // Log the password reset request with user email and IP
  102.                 $this->logger->info('[PWD_RESET] Password reset email sent.', [
  103.                     'email' => $user->getEmail(),
  104.                     'ip' => $request->getClientIp(),
  105.                     'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
  106.                 ]);
  107.             }
  108.             $this->addFlash(
  109.                 'notice',
  110.                 'Si un compte est associé à cette adresse, un lien de réinitialisation vous a été envoyé par e-mail.'
  111.             );
  112.         }
  113.         return $this->render('security/forgot-password.html.twig', [
  114.             'form' => $form->createView()
  115.         ]);
  116.     }
  117.     /**
  118.      * @Route("/reset-password", name="app_reset_password")
  119.      */
  120.     function reset_password(Request $requestUserPasswordHasherInterface $passwordHasher): Response
  121.     {
  122.         $form $this->createForm(PasswordResetType::class);
  123.         $form->handleRequest($request);
  124.         $session $request->getSession();
  125.         $token $request->query->get('token');
  126.          // Store token in session and redirect to clean the URL (hide token)
  127.         if ($token) {
  128.             $session->set('reset_token'$token);
  129.             return $this->redirectToRoute('app_reset_password');
  130.         }
  131.         // Retrieve token from session
  132.         $token $session->get('reset_token');
  133.         $passwordReset $this->em->getRepository(PasswordReset::class)->findOneBy(['token' => $token]);
  134.         
  135.         // token does not exist or already used
  136.         if (!$passwordReset) {
  137.             $session->remove('reset_token');
  138.             // Log an invalid or expired token access attempt
  139.             $this->logger->warning('[PWD_RESET] Attempt to reset password with invalid or expired token.', [
  140.                 'token' => $token,
  141.                 'ip' => $request->getClientIp(),
  142.                 'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
  143.             ]);
  144.             $this->addFlash('error','Ce lien de réinitilisation a déjà été utilisé ou est non valide. Merci de refaire une nouvelle demande.');
  145.             return $this->redirectToRoute('app_password_request');
  146.             
  147.         } elseif ($passwordReset->getCreatedAt() < new \DateTime('-24 hours')) {
  148.             // token is expired
  149.             $this->em->remove($passwordReset);
  150.             $this->em->flush();
  151.             $session->remove('reset_token');
  152.             // Log an expired token access attempt
  153.             $this->logger->warning('[PWD_RESET] Expired password reset token.', [
  154.                     'token' => $token,
  155.                     'ip' => $request->getClientIp(),
  156.                     'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
  157.             ]);
  158.             $this->addFlash('error''Ce lien de réinitialisation a expiré. Merci de refaire une nouvelle demande.');
  159.             return $this->redirectToRoute('app_password_request');
  160.         }
  161.         //  valid token and valid form submission
  162.         if ($form->isSubmitted() && $form->isValid()) {
  163.             $user $this->em->getRepository(User::class)->findOneBy(['email' => $passwordReset->getEmail()]);
  164.             if ($user) {
  165.                 $datas $form->getData();
  166.                 $hashedPassword $passwordHasher->hashPassword(
  167.                     $user,
  168.                     $datas['password']
  169.                 );
  170.                 
  171.                 $user->setPassword($hashedPassword);
  172.                 $this->em->persist($user);
  173.                 $this->em->remove($passwordReset);
  174.                 $this->em->flush();
  175.                 
  176.                 // Log successful password reset with associated user email and IP
  177.                 $this->logger->info('[PWD_RESET] Password was successfully reset.', [
  178.                     'email' => $user->getEmail(),
  179.                     'ip' => $request->getClientIp(),
  180.                     'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
  181.                 ]);
  182.                 return $this->redirectToRoute('app_login');
  183.             }
  184.         }
  185.        
  186.         return $this->render('security/reset-password.html.twig', [
  187.             'form' => $form->createView()
  188.         ]);
  189.     }
  190.     /**
  191.      * @Route("/logout-itmconnect", name="app_logout_itmconnect")
  192.      */
  193.     public function logoutItmconnect()
  194.     {
  195.         /** @var User $user */
  196.         $user $this->getUser();
  197.         if ($user != null && $user->getIsItmConnect()) {
  198.             $refreshToken $user->getRefreshToken();
  199.             $this->itemconnect->itmLogout($refreshToken);
  200.         }
  201.         return $this->redirectToRoute('app_logout');
  202.     }
  203.     /**
  204.      * @Route("/logout", name="app_logout")
  205.      */
  206.     public function logout(Request $request)
  207.     {
  208.         $session $request->getSession();
  209.         if ($session->has('customReferrer')) return $this->redirectToRoute($session->get('customReferrer'));
  210.         throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
  211.     }
  212.     /**
  213.      * @Route("/before-authorize", name="before_oauth2_authorize")
  214.      */
  215.     public function beforeAuthorize(TokenStorageInterface $tokenStorageEventDispatcherInterface $eventDispatcherRequest $request): Response
  216.     {
  217.         /** @var User $user */
  218.         $user $this->getUser();
  219.         $url $this->generateUrl('custom_oauth2_authorize'$request->query->all());
  220.         $response =  new RedirectResponse($url);
  221.         if ($user) {
  222.             if ($user->getIsItmConnect() !== null && $user->getIsItmConnect()) {
  223.                 $refreshToken $user->getRefreshToken();
  224.                 $this->itemconnect->itmLogout($refreshToken);
  225.             }
  226.             $tokenStorage->setToken(null);
  227.             $response->headers->clearCookie('REMEMBERME');
  228.         }
  229.         $session $request->getSession();
  230.         $session->set('customReferrer'$url);
  231.         return $response;
  232.     }
  233.     /**
  234.      * @Route("/authorize2", name="custom_oauth2_authorize")
  235.      */
  236.     public function customAuthorize(Request $request): Response
  237.     {
  238.         $session $request->getSession();
  239.         if (!$session->has('customReferrer')) $session->set('customReferrer'$request->getRequestUri());
  240.         return $this->oauth2Authorize->indexAction($request);
  241.     }
  242.     /**
  243.      * @Route("/consent", name="app_consent")
  244.      */
  245.     public function consent(Request $request): Response
  246.     {
  247.         $clientId $request->query->get('client_id');
  248.         if (!$clientId || !ctype_alnum($clientId) || !$this->getUser()) {
  249.             return $this->redirectToRoute('index');
  250.         }
  251.         $appClient $this->em->getRepository(Client::class)->findOneBy(['identifier' => $clientId]);
  252.         if (!$appClient) {
  253.             return $this->redirectToRoute('index');
  254.         }
  255.         $appProfile $this->em->getRepository(OAuth2ClientProfile::class)->findOneBy(['client' => $appClient]);
  256.         $appName $appProfile->getName();
  257.         // Get the client scopes
  258.         $requestedScopes explode(' '$request->query->get('scope'));
  259.         // Get the client scopes in the database
  260.         $clientScopes $appClient->getScopes();
  261.         // Check all requested scopes are in the client scopes
  262.         if (count(array_diff($requestedScopes$clientScopes)) > 0) {
  263.             return $this->redirectToRoute('index');
  264.         }
  265.         // Check if the user has already consented to the scopes
  266.         /** @var User $user */
  267.         $user $this->getUser();
  268.         $userConsents $user->getOAuth2UserConsents()->filter(
  269.             fn(OAuth2UserConsent $consent) => $consent->getClient() === $appClient
  270.         )->first() ?: null;
  271.         $userScopes $userConsents?->getScopes() ?? [];
  272.         $hasExistingScopes count($userScopes) > 0;
  273.         // If user has already consented to the scopes, give consent
  274.         if (count(array_diff($requestedScopes$userScopes)) === 0) {
  275.             $request->getSession()->set('consent_granted'true);
  276.             return $this->redirectToRoute('custom_oauth2_authorize'$request->query->all());
  277.         }
  278.         // Remove the scopes to which the user has already consented
  279.         $requestedScopes array_diff($requestedScopes$userScopes);
  280.         // Map the requested scopes to scope names
  281.         $scopeNames = [
  282.             'profile' => 'Your profile',
  283.             'email' => 'Your email address'
  284.         ];
  285.         // Get all the scope names in the requested scopes.
  286.         $requestedScopeNames array_map(fn($scope) => $scopeNames[$scope], $requestedScopes);
  287.         $existingScopes array_map(fn($scope) => $scopeNames[$scope], $userScopes);
  288.         $request->getSession()->set('consent_granted'true);
  289.         // Add the requested scopes to the user's scopes
  290.         $consents $userConsents ?? new OAuth2UserConsent();
  291.         $consents->setScopes(array_merge($requestedScopes$userScopes));
  292.         $consents->setClient($appClient);
  293.         $consents->setCreated(new \DateTimeImmutable());
  294.         $consents->setExpires(new \DateTimeImmutable('+30 days'));
  295.         $consents->setIpAddress($request->getClientIp());
  296.         $user->addOAuth2UserConsent($consents);
  297.         $this->em->persist($consents);
  298.         $this->em->flush();
  299.         return $this->redirectToRoute('custom_oauth2_authorize'$request->query->all());
  300.     }
  301.     /**
  302.      * @Route("/.well-known/jwks.json", name="app_jwks")
  303.      */
  304.     public function jwks(): Response
  305.     {
  306.         // Load the public key from the filesystem and use OpenSSL to parse it.
  307.         $kernelDirectory $this->getParameter('kernel.project_dir');
  308.         $publicKey openssl_pkey_get_public(file_get_contents($kernelDirectory '/var/keys/public.key'));
  309.         $details openssl_pkey_get_details($publicKey);
  310.         $jwks = [
  311.             'keys' => [
  312.                 [
  313.                     'kty' => 'RSA',
  314.                     'alg' => 'RS256',
  315.                     'use' => 'sig',
  316.                     'kid' => '1',
  317.                     'n' => strtr(rtrim(base64_encode($details['rsa']['n']), '='), '+/''-_'),
  318.                     'e' => strtr(rtrim(base64_encode($details['rsa']['e']), '='), '+/''-_'),
  319.                 ],
  320.             ],
  321.         ];
  322.         return $this->json($jwks);
  323.     }
  324.     /**
  325.      * @Route("/subscribe-topic", name="app_subscribe_topic")
  326.      */
  327.     public function subscribeTopic(Request $request): Response
  328.     {
  329.         $body json_decode($request->getContent(), true);
  330.         if (isset($body['token'])) {
  331.             $topics $this->messaging->subscribeToTopic("global-fr"$body['token']);
  332.             return $this->json(["status" => "Success""data" => $topics], 200);
  333.         }
  334.         return $this->json(["status" => "Error""data" => "No token received to subscribe (expecting a body with token property)"], 500);
  335.     }
  336. }