<?php
namespace App\Controller;
use App\Entity\OAuth2ClientProfile;
use App\Entity\OAuth2UserConsent;
use App\Entity\PasswordReset;
use App\Entity\User;
use App\Form\ForgotPasswordType;
use App\Form\LoginType;
use App\Form\PasswordResetType;
use App\Service\ItmConnectApiService;
use App\Service\OAuth2Authorize;
use App\Service\SiteConfig;
use DateTime;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Kreait\Firebase\Contract\Messaging;
use Psr\Log\LoggerInterface;
class SecurityController extends AbstractController
{
public function __construct(
private ItmConnectApiService $itemconnect,
private EntityManagerInterface $em,
private OAuth2Authorize $oauth2Authorize,
private MailerInterface $mailer,
private SiteConfig $siteConfig,
private Messaging $messaging,
private LoggerInterface $logger,
) {}
/**
* @Route("/login", name="app_login")
*/
public function login(AuthenticationUtils $authenticationUtils, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
if ($user) {
if ($user->canAccessBO()) {
return $this->redirectToRoute('admin');
}
return $this->redirectToRoute('home');
}
$form = $this->createForm(LoginType::class);
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'form' => $form->createView(),
'error' => $error,
]);
}
/**
* @Route("/forgot-password", name="app_password_request")
*/
function forgot_password(Request $request): Response
{
$form = $this->createForm(ForgotPasswordType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->getData('email');
$user = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
if ($user) {
// Create a password reset entry
$passwordReset = new PasswordReset();
$passwordReset->setEmail($user->getEmail());
// $passwordReset->setToken(md5(rand()));
// Generate a secure random token
$passwordReset->setToken(bin2hex(random_bytes(32)));
$passwordReset->setCreatedAt(new \DateTime('now'));
$this->em->persist($passwordReset);
$this->em->flush();
// Generate the password reset link
$reset_link = $this->generateUrl('app_reset_password', ['token' => $passwordReset->getToken()], UrlGeneratorInterface::ABSOLUTE_URL);
// Build the password reset email using a Twig HTML template
$email = (new Email())
->from(new Address($this->siteConfig->getContactEmail(), 'Support Elvinck'))
->to($user->getEmail())
->subject('Réinitialisation de votre mot de passe')
->html(
$html = $this->renderView('emails/reset_password.html.twig', [
'reset_link' => $reset_link
])
);
// Send the email via the mailer service
$this->mailer->send($email);
// Log the password reset request with user email and IP
$this->logger->info('[PWD_RESET] Password reset email sent.', [
'email' => $user->getEmail(),
'ip' => $request->getClientIp(),
'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
]);
}
$this->addFlash(
'notice',
'Si un compte est associé à cette adresse, un lien de réinitialisation vous a été envoyé par e-mail.'
);
}
return $this->render('security/forgot-password.html.twig', [
'form' => $form->createView()
]);
}
/**
* @Route("/reset-password", name="app_reset_password")
*/
function reset_password(Request $request, UserPasswordHasherInterface $passwordHasher): Response
{
$form = $this->createForm(PasswordResetType::class);
$form->handleRequest($request);
$session = $request->getSession();
$token = $request->query->get('token');
// Store token in session and redirect to clean the URL (hide token)
if ($token) {
$session->set('reset_token', $token);
return $this->redirectToRoute('app_reset_password');
}
// Retrieve token from session
$token = $session->get('reset_token');
$passwordReset = $this->em->getRepository(PasswordReset::class)->findOneBy(['token' => $token]);
// token does not exist or already used
if (!$passwordReset) {
$session->remove('reset_token');
// Log an invalid or expired token access attempt
$this->logger->warning('[PWD_RESET] Attempt to reset password with invalid or expired token.', [
'token' => $token,
'ip' => $request->getClientIp(),
'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
]);
$this->addFlash('error','Ce lien de réinitilisation a déjà été utilisé ou est non valide. Merci de refaire une nouvelle demande.');
return $this->redirectToRoute('app_password_request');
} elseif ($passwordReset->getCreatedAt() < new \DateTime('-24 hours')) {
// token is expired
$this->em->remove($passwordReset);
$this->em->flush();
$session->remove('reset_token');
// Log an expired token access attempt
$this->logger->warning('[PWD_RESET] Expired password reset token.', [
'token' => $token,
'ip' => $request->getClientIp(),
'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
]);
$this->addFlash('error', 'Ce lien de réinitialisation a expiré. Merci de refaire une nouvelle demande.');
return $this->redirectToRoute('app_password_request');
}
// valid token and valid form submission
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->em->getRepository(User::class)->findOneBy(['email' => $passwordReset->getEmail()]);
if ($user) {
$datas = $form->getData();
$hashedPassword = $passwordHasher->hashPassword(
$user,
$datas['password']
);
$user->setPassword($hashedPassword);
$this->em->persist($user);
$this->em->remove($passwordReset);
$this->em->flush();
// Log successful password reset with associated user email and IP
$this->logger->info('[PWD_RESET] Password was successfully reset.', [
'email' => $user->getEmail(),
'ip' => $request->getClientIp(),
'datetime' => (new \DateTime())->format('Y-m-d H:i:s'),
]);
return $this->redirectToRoute('app_login');
}
}
return $this->render('security/reset-password.html.twig', [
'form' => $form->createView()
]);
}
/**
* @Route("/logout-itmconnect", name="app_logout_itmconnect")
*/
public function logoutItmconnect()
{
/** @var User $user */
$user = $this->getUser();
if ($user != null && $user->getIsItmConnect()) {
$refreshToken = $user->getRefreshToken();
$this->itemconnect->itmLogout($refreshToken);
}
return $this->redirectToRoute('app_logout');
}
/**
* @Route("/logout", name="app_logout")
*/
public function logout(Request $request)
{
$session = $request->getSession();
if ($session->has('customReferrer')) return $this->redirectToRoute($session->get('customReferrer'));
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
/**
* @Route("/before-authorize", name="before_oauth2_authorize")
*/
public function beforeAuthorize(TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$url = $this->generateUrl('custom_oauth2_authorize', $request->query->all());
$response = new RedirectResponse($url);
if ($user) {
if ($user->getIsItmConnect() !== null && $user->getIsItmConnect()) {
$refreshToken = $user->getRefreshToken();
$this->itemconnect->itmLogout($refreshToken);
}
$tokenStorage->setToken(null);
$response->headers->clearCookie('REMEMBERME');
}
$session = $request->getSession();
$session->set('customReferrer', $url);
return $response;
}
/**
* @Route("/authorize2", name="custom_oauth2_authorize")
*/
public function customAuthorize(Request $request): Response
{
$session = $request->getSession();
if (!$session->has('customReferrer')) $session->set('customReferrer', $request->getRequestUri());
return $this->oauth2Authorize->indexAction($request);
}
/**
* @Route("/consent", name="app_consent")
*/
public function consent(Request $request): Response
{
$clientId = $request->query->get('client_id');
if (!$clientId || !ctype_alnum($clientId) || !$this->getUser()) {
return $this->redirectToRoute('index');
}
$appClient = $this->em->getRepository(Client::class)->findOneBy(['identifier' => $clientId]);
if (!$appClient) {
return $this->redirectToRoute('index');
}
$appProfile = $this->em->getRepository(OAuth2ClientProfile::class)->findOneBy(['client' => $appClient]);
$appName = $appProfile->getName();
// Get the client scopes
$requestedScopes = explode(' ', $request->query->get('scope'));
// Get the client scopes in the database
$clientScopes = $appClient->getScopes();
// Check all requested scopes are in the client scopes
if (count(array_diff($requestedScopes, $clientScopes)) > 0) {
return $this->redirectToRoute('index');
}
// Check if the user has already consented to the scopes
/** @var User $user */
$user = $this->getUser();
$userConsents = $user->getOAuth2UserConsents()->filter(
fn(OAuth2UserConsent $consent) => $consent->getClient() === $appClient
)->first() ?: null;
$userScopes = $userConsents?->getScopes() ?? [];
$hasExistingScopes = count($userScopes) > 0;
// If user has already consented to the scopes, give consent
if (count(array_diff($requestedScopes, $userScopes)) === 0) {
$request->getSession()->set('consent_granted', true);
return $this->redirectToRoute('custom_oauth2_authorize', $request->query->all());
}
// Remove the scopes to which the user has already consented
$requestedScopes = array_diff($requestedScopes, $userScopes);
// Map the requested scopes to scope names
$scopeNames = [
'profile' => 'Your profile',
'email' => 'Your email address'
];
// Get all the scope names in the requested scopes.
$requestedScopeNames = array_map(fn($scope) => $scopeNames[$scope], $requestedScopes);
$existingScopes = array_map(fn($scope) => $scopeNames[$scope], $userScopes);
$request->getSession()->set('consent_granted', true);
// Add the requested scopes to the user's scopes
$consents = $userConsents ?? new OAuth2UserConsent();
$consents->setScopes(array_merge($requestedScopes, $userScopes));
$consents->setClient($appClient);
$consents->setCreated(new \DateTimeImmutable());
$consents->setExpires(new \DateTimeImmutable('+30 days'));
$consents->setIpAddress($request->getClientIp());
$user->addOAuth2UserConsent($consents);
$this->em->persist($consents);
$this->em->flush();
return $this->redirectToRoute('custom_oauth2_authorize', $request->query->all());
}
/**
* @Route("/.well-known/jwks.json", name="app_jwks")
*/
public function jwks(): Response
{
// Load the public key from the filesystem and use OpenSSL to parse it.
$kernelDirectory = $this->getParameter('kernel.project_dir');
$publicKey = openssl_pkey_get_public(file_get_contents($kernelDirectory . '/var/keys/public.key'));
$details = openssl_pkey_get_details($publicKey);
$jwks = [
'keys' => [
[
'kty' => 'RSA',
'alg' => 'RS256',
'use' => 'sig',
'kid' => '1',
'n' => strtr(rtrim(base64_encode($details['rsa']['n']), '='), '+/', '-_'),
'e' => strtr(rtrim(base64_encode($details['rsa']['e']), '='), '+/', '-_'),
],
],
];
return $this->json($jwks);
}
/**
* @Route("/subscribe-topic", name="app_subscribe_topic")
*/
public function subscribeTopic(Request $request): Response
{
$body = json_decode($request->getContent(), true);
if (isset($body['token'])) {
$topics = $this->messaging->subscribeToTopic("global-fr", $body['token']);
return $this->json(["status" => "Success", "data" => $topics], 200);
}
return $this->json(["status" => "Error", "data" => "No token received to subscribe (expecting a body with token property)"], 500);
}
}