Files
ludikevent_crm/src/Controller/HomeController.php
Serreau Jovann 2c43d8f0ce fix: forcer session save et retry automatique pour SSO invalid state
Sauvegarde explicite de la session avant la redirection OAuth pour
garantir la persistance du state parameter. Retry automatique du
flow SSO en cas d'InvalidStateAuthenticationException.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:03:23 +01:00

194 lines
8.3 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class HomeController extends AbstractController
{
#[Route('/intranet/connect/keycloak', name: 'connect_keycloak_start')]
public function connect(ClientRegistry $clientRegistry, Request $request): Response
{
$response = $clientRegistry
->getClient('keycloak')
->redirect(['email', 'profile', 'openid'], []);
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$request->getSession()->save();
return $response;
}
#[Route('/intranet/oauth/sso', name: 'connect_keycloak_check')]
public function connectCheck(Request $request): Response
{
// This method stays empty; the authenticator will intercept it!
return new Response();
}
#[Route(path: '/intranet', name: 'app_home', options: ['sitemap' => false], methods: ['GET','POST'])]
public function index(AuthenticationUtils $authenticationUtils): Response
{
if($this->getUser()){
return $this->redirectToRoute('app_crm');
}
return $this->render('home.twig',[
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route(path: '/intranet/logout', name: 'app_logout', options: ['sitemap' => false], methods: ['GET','POST'])]
public function logout(): Response
{
}
#[Route(path: '/intranet/mot-de-passe-oublie', name: 'app_forgot_password', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPassword(Request $request,EventDispatcherInterface $eventDispatcher): Response
{
$requestPasswordRequest = new ResetPasswordEvent();
$form = $this->createForm(RequestPasswordRequestType::class,$requestPasswordRequest);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$eventDispatcher->dispatch($requestPasswordRequest);
return $this->redirectToRoute('app_forgot_password_sent');
}
return $this->render('security/forgot_password.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/intranet/mot-de-passe-oublie/sent', name: 'app_forgot_password_sent', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPasswordSent(Request $request,EventDispatcherInterface $eventDispatcher): Response
{
return $this->render('security/forgot_password_success.twig', [
]);
}
#[Route(path: '/intranet/mot-de-passe-oublie/{id}/{token}', name: 'app_forgot_password_confirm', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPasswordConfirm(UserPasswordHasherInterface $userPasswordHasher,EventDispatcherInterface $eventDispatcher,Request $request,EntityManagerInterface $entityManager,string $id,string $token): Response
{
$errorMessage = "Requête non valide.";
if (!is_numeric($id)) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$account = $entityManager->getRepository(Account::class)->find((int)$id);
if (!$account instanceof Account) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$requestToken = $entityManager->getRepository(AccountResetPasswordRequest::class)->findOneBy([
'Account' => $account, // Assurez-vous que 'Account' est le nom correct de la propriété/colonne dans votre entité AccountResetPasswordRequest.
'token' => $token
]);
if (!$requestToken instanceof AccountResetPasswordRequest) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$now = new \DateTimeImmutable();
if ($requestToken->getExpiresAt() < $now) {
$this->addFlash("error", "Le lien de réinitialisation de mot de passe a expiré.");
return $this->redirectToRoute('app_forgot_password');
}
$event = new ResetPasswordConfirmEvent();
$form = $this->createForm(RequestPasswordConfirmType::class,$event);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$account->setPassword($userPasswordHasher->hashPassword($account,$event->getPassword()));
$entityManager->persist($account);
$entityManager->flush();
$this->addFlash("success", "Votre mot de passe a été mis à jour avec succès.");
return $this->redirectToRoute('app_home');
}
return $this->render('security/forgot-password-confirm.twig', [
'form' => $form->createView(),
'noIndex' => true,
'id' => $id,
'token' => $token,
'account' => $account,
]);
}
#[Route('/unscribe/{email}', name: 'app_unscribe', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function appUnscribe(
string $email,
Request $request,
EntityManagerInterface $entityManager,
AppLogger $appLogger
): Response {
// 1. Décodage de l'email (au cas où il y aurait des caractères spéciaux)
$email = urldecode($email);
// 3. Gestion du POST (Désinscription en un clic via le client mail)
if ($request->isMethod('POST')) {
$appLogger->record('UNSUBSCRIBE', sprintf("Désinscription automatique (One-Click) de : %s", $email));
return new JsonResponse(['status' => 'success'], Response::HTTP_OK);
}
$appLogger->record('UNSUBSCRIBE', sprintf("Désinscription manuelle de : %s", $email));
return $this->render('security/unscribe_success.twig', [
'email' => $email
]);
}
// Remplace par ton DSN exact pour la vérification
private const ALLOWED_DSN = 'https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24';
private const SENTRY_HOST = 'sentry.esy-web.dev';
private const PROJECT_ID = '24';
#[Route('/sentry-tunnel', name: 'sentry_tunnel', methods: ['POST'])]
public function tunnel(Request $request, HttpClientInterface $httpClient): Response
{
try {
$envelope = $request->getContent();
$pieces = explode("\n", $envelope);
// La première ligne de l'enveloppe Sentry contient le header en JSON
$header = json_decode($pieces[0], true);
// --- SÉCURITÉ : On vérifie que le DSN correspond bien au tien ---
if (isset($header['dsn']) && $header['dsn'] !== self::ALLOWED_DSN) {
return new Response('Invalid DSN', 401);
}
// Construction de l'URL finale vers ton instance Sentry
$url = "https://" . self::SENTRY_HOST . "/api/" . self::PROJECT_ID . "/envelope/";
// On renvoie l'enveloppe à Sentry
$httpClient->request('POST', $url, [
'body' => $envelope,
'headers' => [
'Content-Type' => 'text/plain;charset=UTF-8',
],
]);
return new Response('', 204);
} catch (\Exception $e) {
// En cas d'erreur du tunnel, on reste discret pour ne pas polluer la console client
return new Response('Tunnel Error', 500);
}
}
}