src/StartPlatz/Bundle/UserBundle/Controller/AuthenticationController.php line 281

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace App\StartPlatz\Bundle\UserBundle\Controller;
  3. use App\StartPlatz\Bundle\LogBundle\Entity\MemberlogRepository;
  4. use App\StartPlatz\Bundle\LogBundle\Service\CronjobLogger;
  5. use App\StartPlatz\Bundle\MemberBundle\Entity\Member;
  6. use App\StartPlatz\Bundle\StartupBundle\Entity\Application;
  7. use App\StartPlatz\Bundle\StartupBundle\Entity\Batch;
  8. use App\StartPlatz\Bundle\UserBundle\Entity\LoginTokenRepository;
  9. use App\StartPlatz\Bundle\UserBundle\Entity\UserRepository;
  10. use App\StartPlatz\Bundle\UserBundle\Form\SetPasswordFormType;
  11. use App\StartPlatz\Bundle\WebsiteBundle\Utility\Utility;
  12. use App\StartPlatz\Bundle\ApiBundle\Service\WebhookDispatcher;
  13. use Doctrine\DBAL\Driver\Connection;
  14. use Doctrine\ORM\EntityManagerInterface;
  15. use Exception;
  16. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  17. use App\StartPlatz\Bundle\MailBundle\Service\MailService;
  18. use Symfony\Component\Routing\Annotation\Route;
  19. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
  20. use App\StartPlatz\Bundle\FeedbackBundle\CallbackService;
  21. use App\StartPlatz\Bundle\UserBundle\LoginService;
  22. use Symfony\Component\Validator\Constraints as Assert;
  23. use Symfony\Component\Validator\Validation;
  24. use App\StartPlatz\Bundle\UserBundle\Entity\User;
  25. use App\StartPlatz\Bundle\UserBundle\Form\LoginFormType;
  26. use App\StartPlatz\Bundle\UserBundle\Form\LostPasswordFormType;
  27. use App\StartPlatz\Bundle\UserBundle\Form\RegistrationFormType;
  28. use App\StartPlatz\Bundle\UserBundle\Security\LoginLink\Token;
  29. use Startplatz\Bundle\WordpressIntegrationBundle\Annotation\WordpressResponse;
  30. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  31. use Symfony\Component\HttpFoundation\RedirectResponse;
  32. use Symfony\Component\HttpFoundation\Request;
  33. use Symfony\Component\HttpFoundation\Response;
  34. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  35. use Symfony\Component\Routing\RouterInterface;
  36. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  37. use Symfony\Component\Security\Core\Security as SymfonySecurity;
  38. class AuthenticationController extends AbstractController
  39. {
  40.     public function __construct(
  41.         private readonly SessionInterface $session,
  42.         private readonly RouterInterface $router,
  43.         private readonly UserPasswordEncoderInterface $encoder,
  44.         private readonly MailService $mailService,
  45.         private readonly CallbackService $callbackService,
  46.         private readonly Connection $connection,
  47.         private readonly WebhookDispatcher $webhookDispatcher,
  48.         private readonly LoginTokenRepository $loginTokenRepository,
  49.         private readonly MemberlogRepository $memberlogRepository,
  50.         private readonly CronjobLogger $cronjobLogger,
  51.         private readonly LoginService $loginService,
  52.         private readonly EntityManagerInterface $entityManager
  53.     ) {
  54.     }
  55.     #[Route('/usfb/{md5}'name'unsubscribe_user_bulkmail')]
  56.     public function unsubscribeUserBulkmailAction($md5)
  57.     {
  58.         $em $this->entityManager;
  59.         if (!$user $em->getRepository(User::class)->findOneBy(['salt' => $md5])) {
  60.             return new Response('sorry, not found ');
  61.         }
  62.         if (!$user->getisOnBlacklist()) {
  63.             $member $em->getRepository(Member::class)->find($user->getMemberId());
  64.             $em->getRepository(Member::class)->addTag('blacklist'$member$user->getEmail(), $logText 'User has unsubscribed');
  65.         }
  66.         $redirect '/x/feed';
  67.         $hash Token::createHash($user->getEmail(), $redirect);
  68.         $this->session->getFlashBag()->add('notice''SUCCESS you have been unsubscribed ');
  69.         return $this->redirect($this->generateUrl('login_email_check', [
  70.             'email' => $user->getEmail(),
  71.             'hash' => $hash,
  72.             'redirect' => $redirect,
  73.         ]));
  74.     }
  75.     #[Route('/authentication/magic-link'name'login_magic_link')]
  76.     public function magicLinkAction(Request $request)
  77.     {
  78.         // Prüfe die Anfragemethode
  79.         if (!$request->isMethod('POST')) {
  80.             return new Response('Invalid request.'405);
  81.         }
  82.         $em $this->entityManager;
  83.         $email strtolower((string) $request->get('email'));
  84.         /** @var Member $member */
  85.         if (!$member $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
  86.             return new Response(''400);
  87.         }
  88.         // Get user for token-based magic link
  89.         $user $this->getUserRepository()->findOneByEmail($email);
  90.         if (!$user) {
  91.             return new Response(''400);
  92.         }
  93.         if ($redirect $request->request->get('targetPath')) {
  94.             if ($redirect[0] != "/") {
  95.                 $redirect "/" $redirect;
  96.             }
  97.         } elseif ($redirect $request->get('targetPath')) {
  98.             ## do nothing
  99.         } else {
  100.             $redirect "/x/home";
  101.         }
  102.         // Create token-based magic link (one-time use, 4 hours validity)
  103.         $loginToken $this->loginTokenRepository->createToken($user240$redirect);
  104.         $loginLink $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
  105.         $loginLink $request->getSchemeAndHttpHost() . $loginLink;
  106.         // Render email from HTML template (full control over content)
  107.         $mailText $this->renderView(
  108.             '@StartPlatzUserBundle/Mails/magicLink.html.twig',
  109.             [
  110.                 'firstName' => $member->getFirstName(),
  111.                 'magicLink' => $loginLink,
  112.             ]
  113.         );
  114.         // Send via n8n webhook (Hetzner server - free & better deliverability)
  115.         $payload = [
  116.             'memberEmail' => $member->getEmail(),
  117.             'mailText' => $mailText,
  118.             'mailSubject' => 'Dein Login-Link / Your login link',
  119.             'fromEmail' => 'info@startplatz.de',
  120.             'fromName' => 'STARTPLATZ',
  121.             'bodyType' => 'html',
  122.         ];
  123.         $this->webhookDispatcher->dispatch('email_notification'$payload'AuthenticationController::magicLinkAction'$member->getId());
  124.         return new Response('SUCCESS Magic Link sent to ' $email);
  125.     }
  126.     #[Route('/authentication/magic-link-defense'name'login_magic_defense_link')]
  127.     /**
  128.      * @IsGranted("ROLE_USER")
  129.      */
  130.     public function magicLinkDefenseAction(Request $request)
  131.     {
  132.         $em $this->entityManager;
  133.         $email strtolower((string) $request->get('email'));
  134.         /** @var Member $member */
  135.         if (!$member $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
  136.             return new Response(''400);
  137.         }
  138.         // Get user for token-based magic link
  139.         $user $this->getUserRepository()->findOneByEmail($email);
  140.         if (!$user) {
  141.             return new Response(''400);
  142.         }
  143.         if ($redirect $request->request->get('targetPath')) {
  144.             if ($redirect[0] != "/") {
  145.                 $redirect "/" $redirect;
  146.             }
  147.         } elseif ($redirect $request->get('targetPath')) {
  148.             ## do nothing
  149.         } else {
  150.             $redirect "/x/home";
  151.         }
  152.         // Create token-based magic link (one-time use, 4 hours validity)
  153.         $loginToken $this->loginTokenRepository->createToken($user240$redirect);
  154.         $loginLink $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
  155.         $loginLink $request->getSchemeAndHttpHost() . $loginLink;
  156.         // Render email from HTML template (full control over content)
  157.         $mailText $this->renderView(
  158.             '@StartPlatzUserBundle/Mails/magicLink.html.twig',
  159.             [
  160.                 'firstName' => $member->getFirstName(),
  161.                 'magicLink' => $loginLink,
  162.             ]
  163.         );
  164.         // Send via n8n webhook (Hetzner server - free & better deliverability)
  165.         $payload = [
  166.             'memberEmail' => $member->getEmail(),
  167.             'mailText' => $mailText,
  168.             'mailSubject' => 'Dein Login-Link / Your login link',
  169.             'fromEmail' => 'info@startplatz.de',
  170.             'fromName' => 'STARTPLATZ',
  171.             'bodyType' => 'html',
  172.         ];
  173.         $this->webhookDispatcher->dispatch('email_notification'$payload'AuthenticationController::magicLinkDefenseAction'$member->getId());
  174.         return new Response('SUCCESS Magic Link sent to ' $email);
  175.     }
  176.     /* Gerrit stash membership 11.4.23
  177.         /**
  178.          * @Route("/x/membership/finalize/{account}/{productNumber}/{customerHash}", name="x_membership_booked")
  179.          *
  180.         public function newMembershipLogin(Request $request, $customerHash, $account, $productNumber = 0)
  181.         {
  182.             if (!$redirect = json_decode(base64_decode($request->get('redirect')))) {
  183.                 $redirect = json_decode(json_encode(array('path' => 'x_membership_first-steps', 'parameters' => array('productNumber' => $productNumber, 'account' => $account))));
  184.             }
  185.             $redirectUrl = $this->generateUrl($redirect->path, (array)$redirect->parameters);
  186.             //logged in
  187.             if ($user = $this->getUser()) {
  188.                 if (!ctype_digit($user->getPassword())) $this->redirect($redirectUrl);
  189.                 $form = $this->createSetPasswordForm($redirectUrl);
  190.                 $form->handleRequest($request);
  191.                 if ($form->isSubmitted() && $form->isValid()) {
  192.                     $em = $this->entityManager;
  193.                     $user = $this->getUser();
  194.                     $data = $form->getData();
  195.                     $password = $data['new_password'];
  196.                     $newPasswordEncoded = $this->getUserPasswordEncoder()->encodePassword($user, $password);
  197.                     $user->setPassword($newPasswordEncoded);
  198.                     $em->getRepository(User::class)->add($user);
  199.                     return $this->redirect($redirectUrl);
  200.                 }
  201.                 //logged in, set password
  202.                 return $this->render('@StartPlatzAlphaBundle/Default/new.membership.login.html.twig', array(
  203.                     'setPasswordForm' => $form->createView(),
  204.                     'setPassword' => true,
  205.                     'redirect' => base64_encode(json_encode($redirect)),
  206.                 ));
  207.             }
  208.             //Not logged in, ask for email to send link.
  209.             return $this->render('@StartPlatzAlphaBundle/Default/new.membership.login.html.twig', array(
  210.                 'setPassword' => false,
  211.                 'targetPath' => $this->generateUrl($redirect->path, (array)$redirect->parameters),
  212.             ));
  213.         }
  214.     */
  215.     #[Route('/login/'name'login')]
  216.     public function loginAction(Request $request)
  217.     {
  218.         if ($targetPath $request->query->get('targetPath')) {
  219.             $parts parse_url((string) $targetPath);
  220.             $redirect $parts['path'];
  221.         } elseif ($targetPath $request->getSession()->get('_security.secured_area.target_path')) {
  222.             $parts parse_url((string) $targetPath);
  223.             $redirect $parts['path'];
  224.         } else {
  225.             $redirect $this->generateUrl('x_home');
  226.         }
  227.         return $this->showLogin($redirect$request);
  228.     }
  229.     protected function showLogin($redirect$request$registrationFormData = [], $forms = [])
  230.     {
  231.         $redirectRouteName null;
  232.         $session $request->getSession();
  233.         try {
  234.             $routingParameter $this->router->match($redirect);
  235.             $redirectRouteName $routingParameter['_route'];
  236.         } catch (Exception) {
  237.         }
  238.         if ($this->getUser()) {
  239.             return $this->redirect($redirect ?: $this->generateUrl('x_home'));
  240.         }
  241.         if ($request->attributes->has(SymfonySecurity::AUTHENTICATION_ERROR)) {
  242.             $error $request->attributes->get(SymfonySecurity::AUTHENTICATION_ERROR);
  243.         } else {
  244.             $error $session->get(SymfonySecurity::AUTHENTICATION_ERROR);
  245.             $session->remove(SymfonySecurity::AUTHENTICATION_ERROR);
  246.         }
  247.         if ($error) {
  248.             $session->getFlashBag()->add('notice'$this->renderView('@StartPlatzUserBundle/Authentication/loginErrorFlash.html.twig'));
  249.         }
  250.         if (!array_key_exists('registrationForm'$forms)) {
  251.             $forms['registrationForm'] = $this->createRegistrationForm($redirect$registrationFormData);
  252.         }
  253.         if (!array_key_exists('pwlostForm'$forms)) {
  254.             $forms['pwlostForm'] = $this->createPwlostForm($redirect$registrationFormData);
  255.         }
  256.         $template "@StartPlatzUserBundle/Authentication/login.html.twig";
  257.         return $this->render($template, ['redirectRouteName' => $redirectRouteName'redirect' => $redirect'loginForm' => $this->createLoginForm($redirect)->createView(), 'registrationForm' => $forms['registrationForm']->createView(), 'pwlostForm' => $forms['pwlostForm']->createView()]);
  258.     }
  259.     protected function createRegistrationForm($redirect null$data = [])
  260.     {
  261.         if ($redirect) {
  262.             $data['redirect'] = $redirect;
  263.         }
  264.         return $this->createForm(
  265.             RegistrationFormType::class,
  266.             $data
  267.         );
  268.     }
  269.     protected function createPwlostForm($redirect null$data = [])
  270.     {
  271.         if ($redirect) {
  272.             $data['redirect'] = $redirect;
  273.         }
  274.         return $this->createForm(
  275.             LostPasswordFormType::class,
  276.             $data
  277.         );
  278.     }
  279.     protected function createLoginForm($redirect)
  280.     {
  281.         return $this->createForm(
  282.             LoginFormType::class,
  283.             ['target_path' => $redirect]
  284.         );
  285.     }
  286.     protected function createSetPasswordForm($redirect)
  287.     {
  288.         return $this->createForm(
  289.             SetPasswordFormType::class,
  290.             ['target_path' => $redirect]
  291.         );
  292.     }
  293.     #[Route('/login/password/check'name'login_password_check')]
  294.     public function loginPasswordCheckAction(): Response
  295.     {
  296.         // This route should be intercepted by the FormLoginAuthenticator
  297.         return $this->redirectToRoute('login');
  298.     }
  299.     /** @return UserRepository */
  300.     protected function getUserRepository()
  301.     {
  302.         return $this->entityManager->getRepository(User::class);
  303.     }
  304.     /**
  305.      * @return UserPasswordEncoderInterface
  306.      */
  307.     protected function getUserPasswordEncoder()
  308.     {
  309.         return $this->encoder;
  310.     }
  311.     protected function createEmailHash($salt$email)
  312.     {
  313.         return Token::createHash($salt$email);
  314.     }
  315.     #[Route('/login/link/create/{redirect}'name'login_link_create')]
  316.     /**
  317.      * @IsGranted("ROLE_USER")
  318.      */
  319.     public function loginLinkCreateAction(Request $request$redirect)
  320.     {
  321.         $url $this->generateUrl($redirect);
  322.         $this->generateLoginLink($url);
  323.         $request->getSession()->getFlashBag()->add('notice''loginlink zu ' $url ' erstellt');
  324.         return $this->redirect($this->generateUrl('community_home'));
  325.     }
  326.     protected function generateLoginLink($redirect '/x')
  327.     {
  328.         /** @var User $user */
  329.         $user $this->getUser();
  330.         $rendered $this->renderView(
  331.             '@StartPlatzUserBundle/Mails/sendLoginLink.txt.twig',
  332.             ['email' => $user->getEmail(), 'name' => $user->getName(), 'hash' => Token::createHash($user->getEmail(), $redirect), 'redirect' => $redirect]
  333.         );
  334.         $this->mailService->send('login-link for startplatz.de''info@startplatz.de''Startplatz - Webseite'$user->getEmail(), null$renderedfalse);
  335.         return true;
  336.     }
  337.     #[Route('/login/link/lg/create/{redirect}'name'login_link_create_lg')]
  338.     public function createLoginLinkLg(Request $request$redirect)
  339.     {
  340.         $url $this->generateUrl($redirect);
  341.         $this->generateLoginLinkLg($url);
  342.         $request->getSession()->getFlashBag()->add('notice''loginlink zu ' $url ' erstellt');
  343.         return $this->redirect($this->generateUrl('community_home'));
  344.     }
  345.     protected function generateLoginLinkLg($redirect '/crm/contacts')
  346.     {
  347.         $email 'lorenz.graef@startplatz.de';
  348.         $rendered $this->renderView(
  349.             '@StartPlatzUserBundle/Mails/sendLoginLink.txt.twig',
  350.             ['email' => $email'name' => 'lorenz''hash' => Token::createHash($email$redirect), 'redirect' => $redirect]
  351.         );
  352.         $this->mailService->send('login-link for startplatz.de''info@startplatz.de''Startplatz - Webseite'$emailnull$renderedfalse);
  353.         return true;
  354.     }
  355.     #[Route('/lost-pw/'name'lost_pw'methods: ['POST'])]
  356.     /**
  357.      * @Template("@StartPlatzUserBundle/Authentication/login.html.twig")
  358.      */
  359.     public function lostPwAction(Request $request)
  360.     {
  361.         $form $this->createPwlostForm();
  362.         $form->handleRequest($request);
  363.         $data $form->getData();
  364.         if ($form->isSubmitted() && $form->isValid()) {
  365.             $reset $this->resetPassword($data);
  366.             if ($reset) {
  367.                 $request->getSession()->getFlashBag()->add('notice'$this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailFlash.html.twig'$data));
  368.                 return $this->redirect($this->generateUrl('login'));
  369.             } else {
  370.                 $request->getSession()->getFlashBag()->add('notice'$this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailErrorFlash.html.twig'$data));
  371.                 return $this->showLogin($data['redirect'], $request$data, ['pwlostForm' => $form]);
  372.             }
  373.         } else {
  374.             return $this->showLogin($data['redirect'], $request$data, ['pwlostForm' => $form]);
  375.         }
  376.     }
  377.     protected function resetPassword($data)
  378.     {
  379.         $password substr(base_convert(sha1(uniqid((string)random_int(0mt_getrandmax()), true)), 1636), 08);
  380.          /** @var User $user */
  381.         if (!($user $this->getUserRepository()->loadUser($data))) {
  382.             return false;
  383.         }
  384.         $user->setPassword($this->getUserPasswordEncoder()->encodePassword($user$password));
  385.         $this->getUserRepository()->add($user);
  386.         //$password= $user->getPassword();
  387.         $rendered $this->renderView(
  388.             '@StartPlatzUserBundle/Mails/login-password.txt.twig',
  389.             ['email' => $user->getEmail(), 'name' => $user->getName(), 'hash' => Token::createHash($data['email'], $data['redirect']), 'redirect' => $data['redirect'], 'password' => $password]
  390.         );
  391.         $this->mailService->send('Dein Passwort für startplatz.de!''info@startplatz.de''Startplatz - Webseite'$data['email'], null$renderedfalse);
  392.         return true;
  393.     }
  394.     #[Route('/profile/send-new-password/'name'user_profile_send_new_password')]
  395.     /**
  396.      * @IsGranted("ROLE_USER")
  397.      */
  398.     public function sendNewPasswordAction(Request $request)
  399.     {
  400.         $user $this->getUser();
  401.         $data['email'] = $user->getEmail();
  402.         $data['redirect'] = '/profile/set-password/';
  403.         $reset $this->resetPassword($data);
  404.         if ($reset) {
  405.             $request->getSession()->getFlashBag()->add('notice'$this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailFlash.html.twig'$data));
  406.         } else {
  407.             $request->getSession()->getFlashBag()->add('notice'$this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailErrorFlash.html.twig'$data));
  408.         }
  409.         return $this->redirect($this->generateUrl('user_profil_change_password'));
  410.     }
  411.     #[Route('/login/link/check/{email}/{hash}/to{redirect}'name'login_email_check'requirements: ['redirect' => '.+'])]
  412.     public function loginLinkCheckAction(Request $request$redirect '/'$hash "")
  413.     {
  414.         if ($user $this->getUser()) {
  415.             $em $this->entityManager;
  416.             $em->getRepository(User::class)->writeActivity($user);
  417.             if ($batches $em->getRepository(Batch::class)->findByExtended(['settings' => 'validateEmail'])) {
  418.                 foreach ($batches as $batch) {
  419.                     $batchId $batch->getId();
  420.                     if ($application $em->getRepository(Application::class)->findOneBy(['batchId' => $batchId'memberId'=>$user->getMemberId()])) {
  421.                         $application->setHasEmailValidated(true);
  422.                         $em->persist($application);
  423.                         $em->flush();
  424.                     }
  425.                 }
  426.             }
  427.             if ($request->get('action') == 'setPassword') {
  428.                 return $this->redirect($this->generateUrl('x_home', ['hash' => $hash]));
  429.             }
  430.             return $this->redirect($redirect);
  431.         } else {
  432.             $this->session->getFlashBag()->add('notice''ERROR: no user found');
  433.             return $this->redirect($this->generateUrl('x_home'));
  434.         }
  435.     }
  436.     /**
  437.      * Token-based login link (new system - one-time use)
  438.      *
  439.      * GET: Shows confirmation page (protects against email security scanners)
  440.      * POST: Authenticator has validated token, complete login and redirect
  441.      * GET with ?app=1: Mobile app SSO bypass, authenticator handles directly
  442.      */
  443.     #[Route('/login/token/{token}'name'login_token_check'methods: ['GET''POST'])]
  444.     public function loginTokenCheckAction(Request $requeststring $token): Response
  445.     {
  446.         // POST or GET with ?app=1: Authenticator has already validated and consumed the token
  447.         if ($request->isMethod('POST') || $request->query->get('app') === '1') {
  448.             return $this->handleTokenLoginSuccess($request$token);
  449.         }
  450.         // GET without ?app=1: Show confirmation page (scanner protection)
  451.         return $this->showTokenConfirmationPage($token);
  452.     }
  453.     /**
  454.      * Show the login confirmation page for token-based magic links
  455.      *
  456.      * This page protects against email security scanners (Microsoft SafeLinks, etc.)
  457.      * that automatically click links in emails. The scanner will see this page but
  458.      * won't click the "Log in" button, preserving the token for the actual user.
  459.      */
  460.     private function showTokenConfirmationPage(string $token): Response
  461.     {
  462.         // Check if token exists and is valid (without consuming it)
  463.         $loginToken $this->loginTokenRepository->findValidToken($token);
  464.         if (!$loginToken) {
  465.             $this->session->getFlashBag()->add('notice''ERROR: Login-Link ist ungültig oder abgelaufen.');
  466.             return $this->redirect($this->generateUrl('login'));
  467.         }
  468.         return $this->render('@StartPlatzUserBundle/Authentication/loginTokenConfirm.html.twig', [
  469.             'token' => $token,
  470.         ]);
  471.     }
  472.     /**
  473.      * Handle successful token-based login (after authenticator validation)
  474.      */
  475.     private function handleTokenLoginSuccess(Request $requeststring $token): Response
  476.     {
  477.         if ($user $this->getUser()) {
  478.             $em $this->entityManager;
  479.             $em->getRepository(User::class)->writeActivity($user);
  480.             // Get redirect from token (stored in database)
  481.             $loginToken $this->loginTokenRepository->findOneBy(['token' => $token]);
  482.             $redirect $loginToken?->getRedirect() ?? '/x/home';
  483.             // Log the magic link login
  484.             if ($memberId $user->getMemberId()) {
  485.                 $member $em->getRepository(Member::class)->find($memberId);
  486.                 if ($member) {
  487.                     $this->memberlogRepository->setMemberLog(
  488.                         'magic_link_login',
  489.                         $member,
  490.                         null// team
  491.                         'system',
  492.                         sprintf(
  493.                             'Magic Link Login (one-time token) - Redirect: %s, Token-ID: %d, IP: %s',
  494.                             $redirect,
  495.                             $loginToken?->getId() ?? 0,
  496.                             $request->getClientIp() ?? 'unknown'
  497.                         )
  498.                     );
  499.                 }
  500.             }
  501.             // Validate email for batches (same logic as legacy route)
  502.             if ($batches $em->getRepository(Batch::class)->findByExtended(['settings' => 'validateEmail'])) {
  503.                 foreach ($batches as $batch) {
  504.                     $batchId $batch->getId();
  505.                     if ($application $em->getRepository(Application::class)->findOneBy(['batchId' => $batchId'memberId' => $user->getMemberId()])) {
  506.                         $application->setHasEmailValidated(true);
  507.                         $em->persist($application);
  508.                         $em->flush();
  509.                     }
  510.                 }
  511.             }
  512.             return $this->redirect($redirect);
  513.         }
  514.         $this->session->getFlashBag()->add('notice''ERROR: Login-Link ist ungültig oder abgelaufen.');
  515.         return $this->redirect($this->generateUrl('login'));
  516.     }
  517.     #[Route('/login/confirm/{email}/{hash}/to{redirect}'name'login_confirm_email'requirements: ['redirect' => '.+'])]
  518.     public function confirmLinkCheckAction(Request $request$email$hash$redirect '/login')
  519.     {
  520.         $em $this->entityManager;
  521.         $action $request->get('action');
  522.         if (!$user $this->getUserRepository()->findUserByConfirm($hash)) {
  523.             if ($this->getUserRepository()->findOneBy(['email' => $email])) {
  524.                 $this->session->getFlashBag()->add('notice''ERROR email already confirmed. Please login.');
  525.             } else {
  526.                 $this->session->getFlashBag()->add('notice''ERROR not matching any user');
  527.             }
  528.             return $this->redirect('/logout');
  529.         }
  530.         if (!$user->getIsEmailConfirmed()) {
  531.             $user->setIsEmailConfirmed(true);
  532.             $user->setEmail($user->getToConfirmEmail());
  533.             $user->setConfirmEmail(null);
  534.             $user->setToConfirmEmail(null);
  535.             $this->getUserRepository()->add($user);
  536.             if ($memberId $user->getMemberId()) {
  537.                 $em->getRepository(Member::class)->changeEmailByMemberId($memberId$user->getEmail(), $user->getEmail());
  538.             }
  539.         }
  540.         return $this->redirect($this->getLoginLink($user->getEmail(), $redirect$action));
  541.     }
  542.     private function getLoginLink($email$redirect '/x'$action null)
  543.     {
  544.         $hash Token::createHash($email$redirect);
  545.         if ($action) {
  546.             $loginLink $this->generateUrl('login_email_check', ['email' => $email'hash' => $hash'redirect' => $redirect'action' => $action]);
  547.         } else {
  548.             $loginLink $this->generateUrl('login_email_check', ['email' => $email'hash' => $hash'redirect' => $redirect]);
  549.         }
  550.         return $loginLink;
  551.     }
  552.     #[Route('/logout/'name'logout')]
  553.     public function logoutAction(): void
  554.     {
  555.     }
  556.     /**
  557.      * @Template
  558.      */
  559.     public function loginStatusAction()
  560.     {
  561.         return [];
  562.     }
  563.     /**
  564.      * Cleanup expired login tokens (for FastCron)
  565.      *
  566.      * Should be called daily to clean up expired tokens from the database.
  567.      * Tokens that are expired or already used are deleted.
  568.      */
  569.     #[Route('/api/cron/cleanup-login-tokens'name'cron_cleanup_login_tokens')]
  570.     public function cleanupLoginTokensAction(): Response
  571.     {
  572.         $log $this->cronjobLogger->start('cleanup:login-tokens''Delete expired/used login tokens''fastcron');
  573.         try {
  574.             $deleted $this->loginTokenRepository->cleanupExpiredTokens();
  575.             $this->cronjobLogger->success($log"Deleted {$deleted} expired/used login tokens"$deleted);
  576.             return new Response(json_encode([
  577.                 'success' => true,
  578.                 'deleted' => $deleted,
  579.                 'timestamp' => date('Y-m-d H:i:s'),
  580.             ]), 200, ['Content-Type' => 'application/json']);
  581.         } catch (\Exception $e) {
  582.             $this->cronjobLogger->fail($log$e->getMessage());
  583.             return new Response(json_encode([
  584.                 'success' => false,
  585.                 'error' => $e->getMessage(),
  586.             ]), 500, ['Content-Type' => 'application/json']);
  587.         }
  588.     }
  589.     // =========================================================================
  590.     // PASSWORD RESET FLOW (Clean Implementation)
  591.     // =========================================================================
  592.     /**
  593.      * Request a password reset magic link (AJAX endpoint)
  594.      *
  595.      * Creates a magic link that redirects to the password-set page after login.
  596.      * Can be called by logged-in users (from profile) or by admins for other users.
  597.      * Sends email via n8n webhook (Hetzner server) for better deliverability.
  598.      */
  599.     #[Route('/password-reset/request'name'password_reset_request'methods: ['GET''POST'])]
  600.     /**
  601.      * @IsGranted("ROLE_USER")
  602.      */
  603.     public function passwordResetRequestAction(Request $request): Response
  604.     {
  605.         $em $this->entityManager;
  606.         $email strtolower((string) $request->get('email'));
  607.         /** @var Member $member */
  608.         if (!$member $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
  609.             return new Response('ERROR User not found'400);
  610.         }
  611.         $user $this->getUserRepository()->findOneByEmail($email);
  612.         if (!$user) {
  613.             return new Response('ERROR User not found'400);
  614.         }
  615.         // Redirect to standalone password-set page after login
  616.         $redirect '/password-reset/set';
  617.         // Create token-based magic link (one-time use, 4 hours validity)
  618.         $loginToken $this->loginTokenRepository->createToken($user240$redirect);
  619.         $loginLink $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
  620.         $loginLink $request->getSchemeAndHttpHost() . $loginLink;
  621.         // Render email body from HTML template (full control over content and formatting)
  622.         $mailText $this->renderView(
  623.             '@StartPlatzUserBundle/Mails/passwordReset.html.twig',
  624.             [
  625.                 'firstName' => $member->getFirstName(),
  626.                 'magicLink' => $loginLink,
  627.             ]
  628.         );
  629.         // Send via n8n webhook (Hetzner server - better deliverability than Mailchimp)
  630.         $payload = [
  631.             'memberEmail' => $member->getEmail(),
  632.             'mailText' => $mailText,
  633.             'mailSubject' => 'Passwort zurücksetzen / Reset your password',
  634.             'fromEmail' => 'info@startplatz.de',
  635.             'fromName' => 'STARTPLATZ',
  636.             'bodyType' => 'html',
  637.         ];
  638.         $this->webhookDispatcher->dispatch(
  639.             'email_notification',
  640.             $payload,
  641.             'AuthenticationController::passwordResetRequestAction',
  642.             $member->getId()
  643.         );
  644.         return new Response('SUCCESS Password reset link sent to ' $email);
  645.     }
  646.     /**
  647.      * Standalone password reset page (after magic link login)
  648.      *
  649.      * Shows a simple form to set a new password. No old password required.
  650.      */
  651.     #[Route('/password-reset/set'name'password_reset_set')]
  652.     /**
  653.      * @IsGranted("ROLE_USER")
  654.      */
  655.     public function passwordResetSetAction(Request $request): Response
  656.     {
  657.         /** @var User $user */
  658.         $user $this->getUser();
  659.         $em $this->entityManager;
  660.         $member $em->getRepository(Member::class)->findOneBy(['email' => $user->getEmail()]);
  661.         if (!$member) {
  662.             $this->session->getFlashBag()->add('notice''ERROR Member not found');
  663.             return $this->redirect($this->generateUrl('login'));
  664.         }
  665.         return $this->render('@StartPlatzUserBundle/PasswordReset/set-password.html.twig', [
  666.             'user' => $user,
  667.             'member' => $member,
  668.         ]);
  669.     }
  670.     /**
  671.      * Process password reset form submission
  672.      */
  673.     #[Route('/password-reset/set/submit'name'password_reset_submit'methods: ['POST'])]
  674.     /**
  675.      * @IsGranted("ROLE_USER")
  676.      */
  677.     public function passwordResetSubmitAction(Request $request): Response
  678.     {
  679.         /** @var User $user */
  680.         $user $this->getUser();
  681.         $em $this->entityManager;
  682.         $newPassword $request->request->get('newPassword');
  683.         $newPasswordConfirm $request->request->get('newPasswordConfirm');
  684.         // Validation
  685.         if (empty($newPassword)) {
  686.             $this->session->getFlashBag()->add('notice''ERROR Please enter a new password.');
  687.             return $this->redirect($this->generateUrl('password_reset_set'));
  688.         }
  689.         if (strlen($newPassword) < 6) {
  690.             $this->session->getFlashBag()->add('notice''ERROR Password must be at least 6 characters.');
  691.             return $this->redirect($this->generateUrl('password_reset_set'));
  692.         }
  693.         if ($newPassword !== $newPasswordConfirm) {
  694.             $this->session->getFlashBag()->add('notice''ERROR Passwords do not match.');
  695.             return $this->redirect($this->generateUrl('password_reset_set'));
  696.         }
  697.         // Set new password
  698.         $newPasswordEncoded $this->encoder->encodePassword($user$newPassword);
  699.         $user->setPassword($newPasswordEncoded);
  700.         $em->getRepository(User::class)->add($user);
  701.         // Log the password change
  702.         $member $em->getRepository(Member::class)->findOneBy(['email' => $user->getEmail()]);
  703.         if ($member) {
  704.             $this->memberlogRepository->setMemberLog(
  705.                 'password_reset',
  706.                 $member,
  707.                 null,
  708.                 'user',
  709.                 sprintf('Password reset completed via magic link, IP: %s'$request->getClientIp() ?? 'unknown')
  710.             );
  711.         }
  712.         $this->session->getFlashBag()->add('notice''SUCCESS Your password has been changed successfully.');
  713.         return $this->redirect($this->generateUrl('x_home'));
  714.     }
  715.     /**
  716.      * Legacy magic link endpoint (migrated from GuestBundle).
  717.      */
  718.     #[Route('/guest/magic-link'name'guest_magic-link')]
  719.     public function legacyMagicLinkAction(Request $request)
  720.     {
  721.         $em $this->entityManager;
  722.         $data $request->request->all();
  723.         $redirect Utility::getRedirectTarget($request);
  724.         $targetPath $data['targetPath'];
  725.         if (!$this->validateLegacyMagicLinkEmail($data)) {
  726.             return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
  727.         }
  728.         if (!$member $em->getRepository(Member::class)->findOneBy(['email' => $data['email']])) {
  729.             $this->session->getFlashBag()->add('notice''ERROR ' "ERROR - we could not find you in our database");
  730.             return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
  731.         }
  732.         $loginLink $this->loginService->getLoginLinkByTargetPath($member->getEmail(), $targetPath);
  733.         $mailSubject "STARTPLATZ Magic Link";
  734.         $mailText $this->renderView(
  735.             '@StartPlatzUserBundle/Mails/send.magic-link.txt.twig',
  736.             [
  737.                 'member' => $member,
  738.                 'loginLink' => $loginLink,
  739.             ]
  740.         );
  741.         $this->callbackService->sendAlertMailPerZapier("{$member->getEmail()}"$mailText$mailSubject"support@startplatz.de");
  742.         $this->session->getFlashBag()->add('notice''SUCCESS ' "We have sent a magic link. Please check your inbox!");
  743.         return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
  744.     }
  745.     private function validateLegacyMagicLinkEmail($data): bool
  746.     {
  747.         if (!($data['email'])) {
  748.             $this->session->getFlashBag()->add('notice''ERROR Please fill in a valid email');
  749.             return false;
  750.         }
  751.         $validator Validation::createValidator();
  752.         $input['email'] = $data['email'];
  753.         $constraint = new Assert\Collection([
  754.             'email' => new Assert\Email(),
  755.         ]);
  756.         if ($violations $validator->validate($input$constraint)) {
  757.             $error false;
  758.             foreach ($violations as $violation) {
  759.                 $this->session->getFlashBag()->add('notice''ERROR ' $violation->getMessage());
  760.                 $error true;
  761.             }
  762.             if ($error) {
  763.                 return false;
  764.             }
  765.         }
  766.         return true;
  767.     }
  768. }