diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6d998f7..b7ad3e4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -24,11 +24,8 @@ security: provider: app_user_provider custom_authenticator: App\Security\LoginFormAuthenticator - ##form_login: - # login_path: app_login - # check_path: app_login logout: - path: app_logout + path: security_logout switch_user: true remember_me: diff --git a/src/Controller/FrontendController.php b/src/Controller/FrontendController.php index 72605ec..50f07f8 100644 --- a/src/Controller/FrontendController.php +++ b/src/Controller/FrontendController.php @@ -6,7 +6,6 @@ declare(strict_types=1); namespace App\Controller; use App\Repository\QuotesRepository; -use Exception; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -27,13 +26,4 @@ class FrontendController extends AbstractController 'quote' => json_encode(value: $quote->getQuote()) ]); } - - /** - * @throws Exception - */ - #[Route(path: '/logout', name: 'app_logout')] - public function logout(): never - { - throw new Exception(message: 'Logout should never be reached.'); - } } diff --git a/src/Controller/ResetPasswordController.php b/src/Controller/ResetPasswordController.php deleted file mode 100644 index ad27e80..0000000 --- a/src/Controller/ResetPasswordController.php +++ /dev/null @@ -1,179 +0,0 @@ -createForm(type: ResetPasswordRequestFormType::class); - $form->handleRequest(request: $request); - - if ($form->isSubmitted() && $form->isValid()) { - return $this->processSendingPasswordResetEmail( - formData: $form->get(name: 'account')->getData(), - mailer: $mailer - ); - } - - return $this->render(view: '@default/security/forgot_password.html.twig', parameters: [ - 'requestForm' => $form->createView(), - ]); - } - - /** - * Confirmation page after a user has requested a password reset. - */ - #[Route(path: '/security/recovery/mail/sent', name: 'security_recovery_mail_sent')] - public function checkEmail(): Response - { - // Generate a fake token if the user does not exist or someone hit this page directly. - // This prevents exposing whether a user was found with the given email address or username or not - if (null === ($resetToken = $this->getTokenObjectFromSession())) { - $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); - } - - return $this->render(view: '@default/security/recovery_mail_sent.html.twig', parameters: [ - 'resetToken' => $resetToken, - ]); - } - - /** - * Validates and process the reset URL that the user clicked in their email. - */ - #[Route(path: '/security/recovery/reset/{token}', name: 'security_recovery_reset')] - public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, string $token = null): Response - { - if ($token) { - // We store the token in session and remove it from the URL, to avoid the URL being - // loaded in a browser and potentially leaking the token to 3rd party JavaScript. - $this->storeTokenInSession(token: $token); - - return $this->redirectToRoute(route: 'security_recovery_reset'); - } - - $token = $this->getTokenFromSession(); - if (null === $token) { - throw $this->createNotFoundException(message: 'No reset password token found in the URL or in the session.'); - } - - try { - $user = $this->resetPasswordHelper->validateTokenAndFetchUser(fullToken: $token); - } catch (ResetPasswordExceptionInterface $e) { - $this->addFlash(type: 'reset_password_error', message: sprintf( - '%s - %s', - $translator->trans(id: ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, parameters: [], domain: 'ResetPasswordBundle'), - $translator->trans(id: $e->getReason(), parameters: [], domain: 'ResetPasswordBundle') - )); - - return $this->redirectToRoute(route: 'app_forgot_password_request'); - } - - // The token is valid; allow the user to change their password. - $form = $this->createForm(type: ChangePasswordFormType::class); - $form->handleRequest(request: $request); - - if ($form->isSubmitted() && $form->isValid()) { - // A password reset token should be used only once, remove it. - $this->resetPasswordHelper->removeResetRequest(fullToken: $token); - - // Encode(hash) the plain password, and set it. - $encodedPassword = $passwordHasher->hashPassword( - user: $user, - plainPassword: $form->get(name: 'plainPassword')->getData() - ); - - $user->setPassword($encodedPassword); - $this->entityManager->flush(); - - // The session is cleaned up after the password has been changed. - $this->cleanSessionAfterReset(); - - $this->addFlash(type: 'success', message: 'Your password has been changed.'); - - return $this->redirectToRoute(route: 'app_main'); - } - - return $this->render(view: '@default/security/reset_password.html.twig', parameters: [ - 'resetForm' => $form->createView(), - ]); - } - - private function processSendingPasswordResetEmail(string $formData, MailerInterface $mailer): RedirectResponse - { - $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [ - 'email' => $formData, - ]); - - if (!$user) { - $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [ - 'username' => $formData, - ]); - } - - // Do not reveal whether a user account was found or not. -// if (!$user) { -// return $this->redirectToRoute(route: 'app_check_email'); -// } - - try { - $resetToken = $this->resetPasswordHelper->generateResetToken(user: $user); - } catch (ResetPasswordExceptionInterface $e) { - $this->addFlash(type: 'reset_password_error', message: sprintf( - '%s - %s', - ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, - $e->getReason() - )); - - return $this->redirectToRoute(route: 'security_forgot_password'); - } - - $email = (new TemplatedEmail()) - ->from(new Address(address: 'tracer@24unix.net', name: '24unix.net')) - ->to($user->getEmail()) - ->subject(subject: 'Your password reset request') - ->htmlTemplate(template: '@default/security/mail/recovery.html.twig') - ->context(context: [ - 'resetToken' => $resetToken, - ]); - - $mailer->send(message: $email); - - // Store the token object in session for retrieval in check-email route. - $this->setTokenObjectInSession(token: $resetToken); - - return $this->redirectToRoute(route: 'security_recovery_mail_sent'); - } -} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 3391c39..22137cb 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,31 +2,44 @@ namespace App\Controller; -use App\Form\LoginFormType; +use App\Entity\User; +use App\Form\ChangePasswordFormType; use App\Form\RegistrationFormType; +use App\Form\ResetPasswordRequestFormType; use App\Repository\UserRepository; use App\Security\EmailVerifier; use Doctrine\ORM\EntityManagerInterface; +use Exception; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use Symfony\Contracts\Translation\TranslatorInterface; +use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait; +use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface; +use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; class SecurityController extends AbstractController { + use ResetPasswordControllerTrait; - public function __construct(private readonly EmailVerifier $emailVerifier) + public function __construct(private readonly EmailVerifier $emailVerifier, + private readonly ResetPasswordHelperInterface $resetPasswordHelper, + private readonly EntityManagerInterface $entityManager + ) { // empty body } - #[Route(path: '/login', name: 'app_login')] + #[Route(path: '/security/login', name: 'security_login')] public function index(AuthenticationUtils $authenticationUtils): Response { // get the login error if there is one @@ -43,8 +56,16 @@ class SecurityController extends AbstractController ]); } + /** + * @throws Exception + */ + #[Route(path: '/security/logout', name: 'security_logout')] + public function logout(): never + { + throw new Exception(message: 'Logout should never be reached.'); + } - #[Route(path: '/register', name: 'app_register')] + #[Route(path: '/security/register', name: 'security_register')] public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response { $form = $this->createForm(type: RegistrationFormType::class); @@ -67,15 +88,15 @@ class SecurityController extends AbstractController $entityManager->flush(); // generate a signed url and email it to the user - $this->emailVerifier->sendEmailConfirmation(verifyEmailRouteName: 'app_verify_email', user: $user, + $this->emailVerifier->sendEmailConfirmation(verifyEmailRouteName: 'security_verify_email', user: $user, email: (new TemplatedEmail()) ->from(new Address(address: 'info@24unix.net', name: '24unix.net')) ->to($user->getEmail()) ->subject(subject: 'Please Confirm your Email') ->htmlTemplate(template: '@default/security/mail/registration.html.twig') - ->context(context: [ - 'username' => $user->getUsername() - ]) + ->context(context: [ + 'username' => $user->getUsername() + ]) ); return $this->render(view: '@default/security/registration_finished.html.twig'); @@ -86,7 +107,7 @@ class SecurityController extends AbstractController ]); } - #[Route(path: '/verify/email', name: 'app_verify_email')] + #[Route(path: '/security/verify/email', name: 'security_verify_email')] public function verifyUserEmail(Request $request, TranslatorInterface $translator, UserRepository $userRepository): Response { $id = $request->get(key: 'id'); @@ -115,4 +136,152 @@ class SecurityController extends AbstractController return $this->redirectToRoute(route: 'app_main'); } + /** + * Display & process form to request a password reset. + */ + #[Route(path: '/security/forgot/password', name: 'security_forgot_password')] + public function request(Request $request, MailerInterface $mailer): Response + { + $form = $this->createForm(type: ResetPasswordRequestFormType::class); + $form->handleRequest(request: $request); + + if ($form->isSubmitted() && $form->isValid()) { + return $this->processSendingPasswordResetEmail( + formData: $form->get(name: 'account')->getData(), + mailer: $mailer + ); + } + + return $this->render(view: '@default/security/forgot_password.html.twig', parameters: [ + 'requestForm' => $form->createView(), + ]); + } + + /** + * Confirmation page after a user has requested a password reset. + */ + #[Route(path: '/security/recovery/mail/sent', name: 'security_recovery_mail_sent')] + public function checkEmail(): Response + { + // Generate a fake token if the user does not exist or someone hit this page directly. + // This prevents exposing whether a user was found with the given email address or username or not + if (null === ($resetToken = $this->getTokenObjectFromSession())) { + $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); + } + + return $this->render(view: '@default/security/recovery_mail_sent.html.twig', parameters: [ + 'resetToken' => $resetToken, + ]); + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + #[Route(path: '/security/recovery/reset/{token}', name: 'security_recovery_reset')] + public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, string $token = null): Response + { + if ($token) { + // We store the token in session and remove it from the URL, to avoid the URL being + // loaded in a browser and potentially leaking the token to 3rd party JavaScript. + $this->storeTokenInSession(token: $token); + + return $this->redirectToRoute(route: 'security_recovery_reset'); + } + + $token = $this->getTokenFromSession(); + if (null === $token) { + throw $this->createNotFoundException(message: 'No reset password token found in the URL or in the session.'); + } + + try { + $user = $this->resetPasswordHelper->validateTokenAndFetchUser(fullToken: $token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash(type: 'reset_password_error', message: sprintf( + '%s - %s', + $translator->trans(id: ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, parameters: [], domain: 'ResetPasswordBundle'), + $translator->trans(id: $e->getReason(), parameters: [], domain: 'ResetPasswordBundle') + )); + + return $this->redirectToRoute(route: 'app_forgot_password_request'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(type: ChangePasswordFormType::class); + $form->handleRequest(request: $request); + + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + $this->resetPasswordHelper->removeResetRequest(fullToken: $token); + + // Encode(hash) the plain password, and set it. + $encodedPassword = $passwordHasher->hashPassword( + user: $user, + plainPassword: $form->get(name: 'plainPassword')->getData() + ); + + $user->setPassword($encodedPassword); + $this->entityManager->flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + $this->addFlash(type: 'success', message: 'Your password has been changed.'); + + return $this->redirectToRoute(route: 'app_main'); + } + + return $this->render(view: '@default/security/reset_password.html.twig', parameters: [ + 'resetForm' => $form->createView(), + ]); + } + + private function processSendingPasswordResetEmail(string $formData, MailerInterface $mailer): RedirectResponse + { + $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [ + 'email' => $formData, + ]); + + if (!$user) { + $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [ + 'username' => $formData, + ]); + } + + // Do not reveal whether a user account was found or not. +// if (!$user) { +// return $this->redirectToRoute(route: 'app_check_email'); +// } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken(user: $user); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash(type: 'reset_password_error', message: sprintf( + '%s - %s', + ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, + $e->getReason() + )); + + return $this->redirectToRoute(route: 'security_forgot_password'); + } + + $email = (new TemplatedEmail()) + ->from(new Address(address: 'tracer@24unix.net', name: '24unix.net')) + ->to($user->getEmail()) + ->subject(subject: 'Your password reset request') + ->htmlTemplate(template: '@default/security/mail/recovery.html.twig') + ->context(context: [ + 'resetToken' => $resetToken, + ]); + + try { + $mailer->send(message: $email); + } catch (TransportExceptionInterface $e) { + $this->addFlash(type: 'error', message: $e->getMessage()); + } + + // Store the token object in session for retrieval in check-email route. + $this->setTokenObjectInSession(token: $resetToken); + + return $this->redirectToRoute(route: 'security_recovery_mail_sent'); + } } diff --git a/src/Exception/UserNotVerifiedException.php b/src/Exception/UserNotVerifiedException.php new file mode 100644 index 0000000..afc4460 --- /dev/null +++ b/src/Exception/UserNotVerifiedException.php @@ -0,0 +1,63 @@ +identifier; + } + + /** + * Set the user identifier (e.g. username or email address). + */ + public function setUserIdentifier(string $identifier): void + { + $this->identifier = $identifier; + } + + /** + * {@inheritdoc} + */ + #[ArrayShape(shape: ['{{ username }}' => "null|string", '{{ user_identifier }}' => "null|string"])] + public function getMessageData(): array + { + return ['{{ username }}' => $this->identifier, '{{ user_identifier }}' => $this->identifier]; + } + + /** + * {@inheritdoc} + */ + public function __serialize(): array + { + return [$this->identifier, parent::__serialize()]; + } + + /** + * {@inheritdoc} + */ + public function __unserialize(array $data): void + { + [$this->identifier, $parentData] = $data; + $parentData = is_array(value: $parentData) ? $parentData : unserialize(data: $parentData); + parent::__unserialize(data: $parentData); + } +} diff --git a/src/Repository/QuotesRepository.php b/src/Repository/QuotesRepository.php index bd843ec..c84ca94 100644 --- a/src/Repository/QuotesRepository.php +++ b/src/Repository/QuotesRepository.php @@ -4,7 +4,10 @@ namespace App\Repository; use App\Entity\Quotes; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\ORM\NonUniqueResultException; use Doctrine\Persistence\ManagerRegistry; +use Exception; /** * @method Quotes|null find($id, $lockMode = null, $lockVersion = null) @@ -21,7 +24,7 @@ class QuotesRepository extends ServiceEntityRepository public function add(Quotes $entity, bool $flush = true): void { - $this->_em->persist($entity); + $this->_em->persist(entity: $entity); if ($flush) { $this->_em->flush(); } @@ -29,31 +32,40 @@ class QuotesRepository extends ServiceEntityRepository public function remove(Quotes $entity, bool $flush = true): void { - $this->_em->remove($entity); + $this->_em->remove(entity: $entity); if ($flush) { $this->_em->flush(); } } - /** - * @return float|int|mixed|string|null - * - * @throws \Doctrine\ORM\NonUniqueResultException - */ - public function findOneRandom(): mixed + public function findOneRandom(): ?QueryBuilder { - $idLimits = $this->createQueryBuilder(alias: 'q') - ->select('MIN(q.id)', 'MAX(q.id)') - ->getQuery() - ->getOneOrNullResult(); - $randomPossibleId = random_int(min: $idLimits[1], max: $idLimits[2]); + try { + $idLimits = $this->createQueryBuilder(alias: 'q') + ->select('MIN(q.id)', 'MAX(q.id)') + ->getQuery() + ->getOneOrNullResult(); + } catch (NonUniqueResultException) { + $idLimits = 0; + } - return $this->createQueryBuilder(alias: 'q') - ->where(predicates: 'q.id >= :random_id') - ->setParameter(key: 'random_id', value: $randomPossibleId) - ->setMaxResults(maxResults: 1) - ->getQuery() - ->getOneOrNullResult(); + try { + $randomPossibleId = random_int(min: $idLimits[1], max: $idLimits[2]); + } catch(Exception) { + $randomPossibleId = 0; // return first, if any + } + + try { + return $this->createQueryBuilder(alias: 'q') + ->where(predicates: 'q.id >= :random_id') + ->setParameter(key: 'random_id', value: $randomPossibleId) + ->setMaxResults(maxResults: 1) + ->getQuery() + ->getOneOrNullResult(); + } catch (NonUniqueResultException) { + // max results is 1 + return null; + } } // /** diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index 1f99595..6f72500 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -2,28 +2,21 @@ namespace App\Security; -use App\Entity\User; -use App\Form\LoginFormType; +use App\Exception\UserNotVerifiedException; use App\Repository\UserRepository; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormBuilder; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\Util\TargetPathTrait; /** @@ -41,7 +34,7 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator public function authenticate(Request $request): Passport { $username = $request->request->get(key: 'username'); - $password = $request->request->get(key: 'password'); + $password = $request->request->get(key: 'password'); $csrfToken = $request->request->get(key: '_csrf_token'); $request->getSession()->set(name: Security::LAST_USERNAME, value: $username); @@ -58,6 +51,10 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator throw new UserNotFoundException(); } + if (!$user->isVerified()) { + throw new UserNotVerifiedException(); + } + return $user; }), @@ -82,7 +79,7 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator protected function getLoginUrl(Request $request): string { - return $this->router->generate(name: 'app_login'); + return $this->router->generate(name: 'security_login'); } } diff --git a/templates/themes/default/_header.html.twig b/templates/themes/default/_header.html.twig index 523479c..8e916d3 100644 --- a/templates/themes/default/_header.html.twig +++ b/templates/themes/default/_header.html.twig @@ -42,7 +42,7 @@
{% endif %} - + Logout @@ -50,7 +50,7 @@ {% else %}