src/Controller/SecurityController.php line 109

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