src/Controller/SecurityController.php line 102

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Book;
  4. use App\Entity\Subscription;
  5. use App\Entity\MagicToken;
  6. use App\Entity\Token;
  7. use App\Entity\User;
  8. use App\Entity\UserAttribute;
  9. use App\Entity\UserAttributeValue;
  10. use App\Entity\Country;
  11. use App\Entity\UserGroup;
  12. use App\Form\Model\Security\RegisterModel;
  13. use App\Form\Model\Security\ResetModel;
  14. use App\Form\Type\Security\RegisterType;
  15. use App\Form\Type\Security\ResetType;
  16. use App\Service\GeoIP2;
  17. use App\Service\Raynet;
  18. use App\Service\TeacherProfileService;
  19. use App\Utils\TokenUtil;
  20. use DateTime;
  21. use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
  22. use Doctrine\ORM\EntityManagerInterface;
  23. use Exception;
  24. use Swift_Mailer;
  25. use Swift_Message;
  26. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  27. use Symfony\Component\HttpFoundation\JsonResponse;
  28. use Symfony\Component\HttpFoundation\Request;
  29. use Symfony\Component\HttpFoundation\Response;
  30. use Symfony\Component\Routing\Annotation\Route;
  31. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  32. use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
  33. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  34. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  35. use Symfony\Contracts\Translation\TranslatorInterface;
  36. /**
  37.  * @Route("/{_locale}", name="security_")
  38.  */
  39. class SecurityController extends AbstractController
  40. {
  41.     /**
  42.      * @var EntityManagerInterface
  43.      */
  44.     private $em;
  45.     /**
  46.      * @var TranslatorInterface
  47.      */
  48.     private $t;
  49.     /**
  50.      * @var UserPasswordEncoderInterface
  51.      */
  52.     private $encoder;
  53.     /**
  54.      * SecurityController constructor.
  55.      * @param EntityManagerInterface $em
  56.      * @param TranslatorInterface $t
  57.      * @param UserPasswordEncoderInterface $encoder
  58.      */
  59.     public function __construct(EntityManagerInterface $emTranslatorInterface $tUserPasswordEncoderInterface $encoder)
  60.     {
  61.         $this->em $em;
  62.         $this->$t;
  63.         $this->encoder $encoder;
  64.     }
  65.     /**
  66.      * @Route("/login/", name="login")
  67.      *
  68.      * @param AuthenticationUtils $authenticationUtils
  69.      * @return Response
  70.      */
  71.     public function loginAction(Request $requestAuthenticationUtils $authenticationUtils): Response
  72.     {
  73.         if ($this->get('security.authorization_checker')->isGranted('ROLE_USER')) {
  74.             return $this->redirect($this->generateUrl('app_library_index'$request->query->all()));
  75.         }
  76.         return $this->redirect($this->generateUrl('security_home'$request->query->all()));
  77.     }
  78.     /**
  79.      * @Route("/", name="home")
  80.      *
  81.      * @param Request $request
  82.      * @param AuthenticationUtils $authenticationUtils
  83.      * @return Response
  84.      */
  85.     public function appLoginAction(Request $requestAuthenticationUtils $authenticationUtils): Response
  86.     {
  87.         // TODO: redirect, when user is logged in?
  88.         $error $authenticationUtils->getLastAuthenticationError();
  89.         $lastUsername $authenticationUtils->getLastUsername();
  90.         $session $this->get('session');
  91.         $redirect $session->get('redirect_url') ?? $this->generateUrl('app_library_index'$request->query->all());
  92.         return $this->render('app/home.html.twig', [
  93.             'last_username' => $lastUsername,
  94.             'error' => $error,
  95.             'redirect' => $redirect,
  96.         ]);
  97.     }
  98.     /**
  99.      * Clean login page (login form + SSO + register link only), used as the OAuth2 /authorize landing page.
  100.      *
  101.      * @Route("/sign-in/", name="sign_in")
  102.      */
  103.     public function signInAction(Request $requestAuthenticationUtils $authenticationUtils): Response
  104.     {
  105.         $error $authenticationUtils->getLastAuthenticationError();
  106.         $lastUsername $authenticationUtils->getLastUsername();
  107.         $session $this->get('session');
  108.         $redirect $session->get('redirect_url') ?? $this->generateUrl('app_library_index'$request->query->all());
  109.         return $this->render('app/login.html.twig', [
  110.             'last_username' => $lastUsername,
  111.             'error' => $error,
  112.             'redirect' => $redirect,
  113.         ]);
  114.     }
  115.     /**
  116.      * Consumes a single-use magic token (from POST /api/account/session-bridge), logs the user
  117.      * into the web session and drops them on the locked-down standalone settings screen.
  118.      *
  119.      * @Route("/account/session-bridge/{token}", name="account_session_bridge", methods={"GET"})
  120.      */
  121.     public function sessionBridgeAction(Request $requeststring $token): Response
  122.     {
  123.         $magic $this->em->getRepository(MagicToken::class)->findOneBy(['code' => $token]);
  124.         if ($magic instanceof MagicToken) {
  125.             $expired = (new DateTime())->getTimestamp() > $magic->getExpiration()->getTimestamp();
  126.             $user $magic->getUser();
  127.             // Single use: drop it whether valid or expired.
  128.             $this->em->remove($magic);
  129.             $this->em->flush();
  130.             if (!$expired) {
  131.                 $authToken = new UsernamePasswordToken($usernull'main'$user->getRoles());
  132.                 $this->get('security.token_storage')->setToken($authToken);
  133.                 $session $request->getSession();
  134.                 $session->set('_security_main'serialize($authToken));
  135.                 $session->set('account_bridge'true);
  136.                 return $this->redirectToRoute('app_settings_index');
  137.             }
  138.         }
  139.         // Render the error on the standalone page instead of redirecting to the homepage:
  140.         // the app must not be able to fall back to a normal login that escapes the locked flow.
  141.         return $this->render('app/account_bridge_error.html.twig');
  142.     }
  143.     /**
  144.      * @Route("/new-password/", name="new_password")
  145.      *
  146.      * @return Response
  147.      */
  148.     public function newPasswordAction(): Response
  149.     {
  150.         return $this->render('security/password.new.html.twig');
  151.     }
  152.     /**
  153.      * @Route("/new-password/handle/", name="new_password_handle")
  154.      *
  155.      * @param Request $request
  156.      * @param Swift_Mailer $mailer
  157.      * @return Response
  158.      */
  159.     public function newPasswordHandleAction(Request $requestSwift_Mailer $mailer): Response
  160.     {
  161.         $email $request->request->get('address') ?? $request->query->get('address');
  162.         $user $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
  163.         if (!$user instanceof User) {
  164.             $this->addFlash('danger'$this->t->trans('password.reset.mail.invalid'));
  165.             return $this->redirectToRoute('security_new_password');
  166.         }
  167.         try {
  168.             $tokenValue TokenUtil::generate(64);
  169.             $token $this->em->getRepository(Token::class)->findOneBy(['user' => $user]);
  170.             if (!$token instanceof Token) {
  171.                 $token = new Token();
  172.             }
  173.             $expirationDate = (new DateTime())->modify('+1 hour');
  174.             $token
  175.                 ->setCode($tokenValue)
  176.                 ->setUser($user)
  177.                 ->setExpiration($expirationDate);
  178.             $this->em->persist($token);
  179.             $this->em->flush();
  180.             $message = (new Swift_Message("Forgotten Password"))
  181.                 ->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
  182.                 ->setTo($user->getEmail())
  183.                 ->setBody(
  184.                     $this->renderView('mail/password.reset.html.twig', [
  185.                         'magic_link' => $this->generateUrl('security_create_password', ['code' => $token->getCode()],
  186.                             UrlGeneratorInterface::ABSOLUTE_URL),
  187.                     ]),
  188.                     'text/html'
  189.                 );
  190.             if ($klerkId $this->getParameter('klerk_forget')) {
  191.                 $message->getHeaders()->addTextHeader('X-CampaignID'$klerkId);
  192.             }
  193.             $mailer->send($message);
  194.             $this->addFlash('success'$this->t->trans('password.reset.mail.success'));
  195.             return $this->redirectToRoute('security_home');
  196.         } catch (Exception $e) {
  197.             $this->addFlash('danger'$this->t->trans('password.reset.error.internal'));
  198.             return $this->redirectToRoute('security_new_password');
  199.         }
  200.     }
  201.     /**
  202.      * @Route("/reset-password/{code}/", name="create_password")
  203.      *
  204.      * @param Request $request
  205.      * @param string $code
  206.      * @return Response
  207.      * @throws Exception
  208.      */
  209.     public function resetPasswordAction(Request $requeststring $code): Response
  210.     {
  211.         $token $this->em->getRepository(Token::class)->findOneBy(['code' => $code]);
  212.         if (!$token instanceof Token) {
  213.             $this->addFlash('danger'$this->t->trans('password.reset.notrequested'));
  214.             return $this->redirectToRoute('security_home');
  215.         }
  216.         if ((new DateTime())->getTimestamp() > $token->getExpiration()->getTimestamp()) {
  217.             $this->addFlash('danger'$this->t->trans('password.reset.expired'));
  218.             return $this->redirectToRoute('security_home');
  219.         }
  220.         $form $this->createForm(ResetType::class);
  221.         $form->handleRequest($request);
  222.         if ($form->isSubmitted() && $form->isValid()) {
  223.             /** @var ResetModel $model */
  224.             $model $form->getData();
  225.             try {
  226.                 $user $token->getUser();
  227.                 $password $this->encoder->encodePassword($user$model->password);
  228.                 $user->setPassword($password);
  229.                 $user->setActive(true);
  230.                 $this->em->persist($user);
  231.                 $this->em->flush();
  232.                 $this->addFlash('success'$this->t->trans('password.reset.success'));
  233.                 return $this->redirectToRoute('security_home');
  234.             } catch (Exception $e) {
  235. //                $this->addFlash('danger', $this->t->trans('password.reset.error.internal'));
  236.                 throw $e;
  237.             }
  238.         }
  239.         return $this->render('security/password.reset.html.twig', [
  240.             'form' => $form->createView(),
  241.         ]);
  242.     }
  243.     /**
  244.      * Confirms a newly registered user's email address (reuses the password-reset Token).
  245.      *
  246.      * @Route("/verify-email/{code}/", name="verify_email")
  247.      *
  248.      * @param string $code
  249.      * @param Swift_Mailer $mailer
  250.      * @return Response
  251.      * @throws Exception
  252.      */
  253.     public function verifyEmailAction(string $codeSwift_Mailer $mailer): Response
  254.     {
  255.         $token $this->em->getRepository(Token::class)->findOneBy(['code' => $code]);
  256.         if (!$token instanceof Token || (new DateTime())->getTimestamp() > $token->getExpiration()->getTimestamp()) {
  257.             $this->addFlash('danger'$this->t->trans('register.verify.invalid'));
  258.             return $this->redirectToRoute('security_home');
  259.         }
  260.         $user $token->getUser();
  261.         $user->setActive(true);
  262.         $this->em->remove($token);
  263.         $this->em->flush();
  264.         //8.6.2026 na přání klienta vypnuto posílání uvítacího e-mailu na učitele
  265.         if (!in_array(UserGroup::ROLE_TEACHER$user->getRoles(), true)) {
  266.             $message = (new Swift_Message($this->t->trans('registration.title', [], 'mailing')))
  267.                 ->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
  268.                 ->setTo($user->getEmail())
  269.                 ->setBody(
  270.                     $this->renderView('mail/registration.html.twig'),
  271.                     'text/html'
  272.                 );
  273.             if ($klerkId $this->getParameter('klerk_welcome')) {
  274.                 $message->getHeaders()->addTextHeader('X-CampaignID'$klerkId);
  275.             }
  276.             $mailer->send($message);
  277.         }
  278.         $this->addFlash('success'$this->t->trans('register.verify.success'));
  279.         return $this->redirectToRoute('security_home');
  280.     }
  281.     /**
  282.      * Autocomplete endpoint for the school select on the registration page.
  283.      * Reads the slim JSON produced by `app:import-schools` and returns
  284.      * matching rows in the shape Select2 expects.
  285.      *
  286.      * @Route("/register/schools-search/", name="register_schools_search", methods={"GET"})
  287.      */
  288.     public function registerSchoolsSearchAction(Request $request): JsonResponse
  289.     {
  290.         $query trim((string) $request->query->get('q'''));
  291.         $kindFilter trim((string) $request->query->get('kind''')); // '', 'zs', 'ms', 'zs_ms'
  292.         $limit 30;
  293.         static $cache null;
  294.         if ($cache === null) {
  295.             $path $this->getParameter('kernel.project_dir') . '/var/data/skoly.json';
  296.             $cache is_file($path)
  297.                 ? (json_decode((string) file_get_contents($path), true) ?: [])
  298.                 : [];
  299.         }
  300.         // Map UI kind onto accepted kinds in the data (OR within the array):
  301.         //   'zs'       → entry must have 'zs'
  302.         //   'ms'       → entry must have 'ms'
  303.         //   'zs_ms'    → entry must have 'zs' OR 'ms' (any of them)
  304.         //   ''/'other' → no filter
  305.         $anyOf = [];
  306.         if ($kindFilter === 'zs')    { $anyOf = ['zs']; }
  307.         if ($kindFilter === 'ms')    { $anyOf = ['ms']; }
  308.         if ($kindFilter === 'zs_ms') { $anyOf = ['zs''ms']; }
  309.         $normalize = static function (string $s): string {
  310.             $s mb_strtolower($s'UTF-8');
  311.             if (function_exists('iconv')) {
  312.                 $ascii = @iconv('UTF-8''ASCII//TRANSLIT//IGNORE'$s);
  313.                 if ($ascii !== false) {
  314.                     $s $ascii;
  315.                 }
  316.             }
  317.             return $s;
  318.         };
  319.         $needle $normalize($query);
  320.         $matches = [];
  321.         foreach ($cache as $row) {
  322.             if ($anyOf) {
  323.                 $kinds $row['kinds'] ?? '';
  324.                 $hit false;
  325.                 foreach ($anyOf as $k) {
  326.                     if (strpos($kinds$k) !== false) {
  327.                         $hit true;
  328.                         break;
  329.                     }
  330.                 }
  331.                 if (!$hit) {
  332.                     continue;
  333.                 }
  334.             }
  335.             if ($needle !== '') {
  336.                 $hay $normalize($row['name'] . ' ' . ($row['address'] ?? ''));
  337.                 if (strpos($hay$needle) === false) {
  338.                     continue;
  339.                 }
  340.             }
  341.             $matches[] = [
  342.                 'id'      => $row['id'],
  343.                 'text'    => $row['name'] . ($row['address'] ? ' — ' $row['address'] : ''),
  344.                 'name'    => $row['name'],
  345.                 'address' => $row['address'] ?? '',
  346.             ];
  347.             if (count($matches) >= $limit) {
  348.                 break;
  349.             }
  350.         }
  351.         return new JsonResponse(['results' => $matches]);
  352.     }
  353.     /**
  354.      * @Route("/register/teacher-info/", name="register_teacher_info")
  355.      *
  356.      * @param Request $request
  357.      * @param TeacherProfileService $teacherProfile
  358.      * @return Response
  359.      */
  360.     public function registerTeacherInfoAction(Request $requestTeacherProfileService $teacherProfileGeoIP2 $geoIP2): Response
  361.     {
  362.         /** @var User|null $user */
  363.         $user $this->getUser();
  364.         if (!$user instanceof User || !in_array(UserGroup::ROLE_TEACHER$user->getRoles(), true)) {
  365.             return $this->redirectToRoute('security_home');
  366.         }
  367.         if ($request->isMethod('POST')) {
  368.             if (!$this->isCsrfTokenValid('teacher_info'$request->request->get('_token'))) {
  369.                 $this->addFlash('danger'$this->t->trans('register.error.internal'));
  370.                 return $this->redirectToRoute('security_register_teacher_info');
  371.             }
  372.             $teacherProfile->apply($user$request);
  373.             $countryCode trim((string) $request->request->get('teacher_country'''));
  374.             if ($countryCode !== '') {
  375.                 $country $this->em->getRepository(Country::class)->findOneBy(['code' => $countryCode]);
  376.                 if ($country instanceof Country) {
  377.                     $user->setCountry($country);
  378.                 }
  379.             }
  380.             $this->em->flush();
  381.             if (!$teacherProfile->isComplete($user)) {
  382.                 $this->addFlash('danger'$this->t->trans('teacher.info.incomplete'));
  383.                 return $this->redirectToRoute('security_register_teacher_info');
  384.             }
  385.             $this->addFlash('success'$this->t->trans('settings.success'));
  386.             return $this->redirectToRoute('app_library_index');
  387.         }
  388.         $country $user->getCountry();
  389.         return $this->render('app/register_teacher_info.html.twig', [
  390.             'prefill' => $teacherProfile->getPrefill($user),
  391.             'cz' => $country !== null && $country->getCode() === 'CZ',
  392.             'countries' => $this->em->getRepository(Country::class)->findBy([], ['name' => 'ASC']),
  393.             'current_country' => $country?->getCode() ?? $geoIP2->getCountry()?->getCode(),
  394.         ]);
  395.     }
  396.     /**
  397.      * @Route("/register/", name="register")
  398.      *
  399.      * @param Request $request
  400.      * @param Swift_Mailer $mailer
  401.      * @param GeoIP2 $geoIP2
  402.      * @return Response
  403.      */
  404.     public function registerAction(Request $requestSwift_Mailer $mailerGeoIP2 $geoIP2Raynet $raynetTeacherProfileService $teacherProfile): Response
  405.     {
  406.         $form $this->createForm(RegisterType::class, new RegisterModel($geoIP2->getCountry()));
  407.         $form->handleRequest($request);
  408.         if ($form->isSubmitted() && $form->isValid()) {
  409.             /** @var RegisterModel $model */
  410.             $model $form->getData();
  411.             if ($model->emailConfirm !== null) {
  412.                 return $this->redirectToRoute('security_register');
  413.             }
  414.             if (!$request->request->get('cf-turnstile-response') || !$this->validateTurnstile($request->request->get('cf-turnstile-response'))) {
  415.                 $this->addFlash('danger'$this->t->trans('register.error.internal'));
  416.                 return $this->redirectToRoute('security_register');
  417.             }
  418.             try {
  419.                 $group $this->em->getRepository(UserGroup::class)->findOneBy(['role' => $model->role]);
  420.                 $user = (new User())
  421.                     ->setUsername($model->email)
  422.                     ->setEmail($model->email)
  423.                     ->setGroup($group)
  424.                     ->setCountry($model->country)
  425.                     ->setActive(false);
  426.                 $password $this->encoder->encodePassword($user$model->password);
  427.                 $user->setPassword($password);
  428.                 $firstnameAttr $this->em->getRepository(UserAttribute::class)->findOneBy(['name' => 'firstname']);
  429.                 $lastnameAttr $this->em->getRepository(UserAttribute::class)->findOneBy(['name' => 'lastname']);
  430.                 $firstnameAttrValue = (new UserAttributeValue())
  431.                     ->setUser($user)
  432.                     ->setAttribute($firstnameAttr)
  433.                     ->setValue($model->firstname);
  434.                 $lastnameAttrValue = (new UserAttributeValue())
  435.                     ->setUser($user)
  436.                     ->setAttribute($lastnameAttr)
  437.                     ->setValue($model->lastname);
  438.                 $this->em->persist($user);
  439.                 $this->em->persist($firstnameAttrValue);
  440.                 $this->em->persist($lastnameAttrValue);
  441.                 if ($model->role === UserGroup::ROLE_TEACHER) {
  442.                     $teacherProfile->apply($user$request);
  443.                 }
  444.                 //temporarily add 1 month subscription of all books here
  445.                 $books $this->em->getRepository(Book::class)->findAll();
  446.                 foreach ($books as $book) {
  447.                     $subscription = new Subscription($user$book, new DateTime('+1 month'), nulltruefalsefalse);
  448.                     $this->em->persist($subscription);
  449.                 }
  450.                 try {
  451.                     $this->em->flush();
  452.                 } catch (UniqueConstraintViolationException $e) {
  453.                     $this->addFlash('danger'$this->t->trans('register.error.existing'));
  454.                     return $this->redirectToRoute('security_home');
  455.                 }
  456.                 $this->addFlash('success'$this->t->trans('register.verify.sent'));
  457.                 $verificationToken = (new Token())
  458.                     ->setCode(TokenUtil::generate(64))
  459.                     ->setUser($user)
  460.                     ->setExpiration((new DateTime())->modify('+1 week'));
  461.                 $this->em->persist($verificationToken);
  462.                 $this->em->flush();
  463.                 $message = (new Swift_Message($this->t->trans('register.verify.title', [], 'mailing')))
  464.                     ->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
  465.                     ->setTo($user->getEmail())
  466.                     ->setBody(
  467.                         $this->renderView('mail/password.reset.html.twig', [
  468.                             'magic_link' => $this->generateUrl('security_verify_email', ['code' => $verificationToken->getCode()],
  469.                                 UrlGeneratorInterface::ABSOLUTE_URL),
  470.                             'content_key' => 'register.verify.mailcontent',
  471.                         ]),
  472.                         'text/html'
  473.                     );
  474.                 $mailer->send($message);
  475.                 //send to raynet
  476.                 if (($model->role === UserGroup::ROLE_TEACHER) && ($_SERVER['APP_ENV'] === 'prod')) {
  477.                     $raynet->insertLead(
  478.                         'Contact from MyWow!',
  479.                         $model->firstname,
  480.                         $model->lastname,
  481.                         $model->email,
  482.                         '',
  483.                         $model->country->getCode()
  484.                     );
  485.                 }
  486.                 return $this->redirectToRoute('security_home');
  487.             } catch (Exception $e) {
  488.                 $this->addFlash('danger'$this->t->trans('register.error.internal'));
  489.                 return $this->redirectToRoute('security_home');
  490.             }
  491.         }
  492.         return $this->render('app/register.html.twig', [
  493.             'form' => $form->createView(),
  494.         ]);
  495.     }
  496.     private function validateTurnstile($token) {
  497.         $url 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
  498.         $data = [
  499.             'secret' => $this->getParameter('cf_turnstile_secret'),
  500.             'response' => $token,
  501.             'remoteip' => $_SERVER['REMOTE_ADDR']
  502.         ];
  503.         $options = [
  504.             'http' => [
  505.                 'header' => "Content-type: application/x-www-form-urlencoded\r\n",
  506.                 'method' => 'POST',
  507.                 'content' => http_build_query($data)
  508.             ]
  509.         ];
  510.         $context stream_context_create($options);
  511.         $response file_get_contents($urlfalse$context);
  512.         if ($response === FALSE) {
  513.             return false;
  514.         }
  515.         return json_decode($responsetrue)['success'] ?? false;
  516.     }
  517. }