Merge pull request 'merge_ResetPassword_to_Security' (#1) from merge_ResetPassword_to_Security into master

Reviewed-on: tracer/24unix.net.web#1
This commit is contained in:
tracer 2022-11-01 16:19:33 +01:00
commit 6e9a07b62c
10 changed files with 291 additions and 238 deletions

View File

@ -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:

View File

@ -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.');
}
}

View File

@ -1,179 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\ChangePasswordFormType;
use App\Form\ResetPasswordRequestFormType;
use Doctrine\ORM\EntityManagerInterface;
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\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;
public function __construct(
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
private readonly EntityManagerInterface $entityManager
)
{
// empty body
}
/**
* 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,
]);
$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');
}
}

View File

@ -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,7 +88,7 @@ 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())
@ -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');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Exception;
use JetBrains\PhpStorm\ArrayShape;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use function is_array;
class UserNotVerifiedException extends AuthenticationException
{
private ?string $identifier = null;
/**
* {@inheritdoc}
*/
public function getMessageKey(): string
{
return 'User is not verified.';
}
/**
* Get the user identifier (e.g. username or email address).
*/
public function getUserIdentifier(): ?string
{
return $this->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);
}
}

View File

@ -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
{
try {
$idLimits = $this->createQueryBuilder(alias: 'q')
->select('MIN(q.id)', 'MAX(q.id)')
->getQuery()
->getOneOrNullResult();
$randomPossibleId = random_int(min: $idLimits[1], max: $idLimits[2]);
} catch (NonUniqueResultException) {
$idLimits = 0;
}
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;
}
}
// /**

View File

@ -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;
/**
@ -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');
}
}

View File

@ -42,7 +42,7 @@
<div class="dropdown-divider"></div>
{% endif %}
<a class="dropdown-item" href="{{ path('app_logout') }}">
<a class="dropdown-item" href="{{ path('security_logout') }}">
<span class="fa fa-lg fa-fw fa-sign-out" aria-hidden="true"></span>&nbsp;
Logout
</a>
@ -50,7 +50,7 @@
</li>
{% else %}
<li class="nav-item">
<a class="btn btn-primary button-login" href="{{ path('app_login') }}" role="button"
<a class="btn btn-primary button-login" href="{{ path('security_login') }}" role="button"
id="buttonLogin">
<span class="fa fa-sign-in fa-lg fa-fw" aria-hidden="true"></span>Log In
</a>

View File

@ -27,14 +27,18 @@
<label for="inputPassword">Password</label>
<input type="password" name="password" id="inputPassword" class="form-control" required>
</div>
<div class="form-check mb-3">
<div class="form-check mx-3">
<label>
<input type="checkbox" name="_remember_me" class="form-check-input">Remember me
</label>
</div>
<button class="btn btn-lg btn-primary float-end" type="submit">
<div>
<a href="{{ path('security_forgot_password') }}">Forgot password?</a>
<button class="btn btn-primary float-end" type="submit">
Sign in
</button>
</div>
<a href="{{ path('security_register') }}">Need an account? Register now.</a>
</form>
</div>
</div>

View File

@ -7,6 +7,6 @@
{{ form_start(resetForm) }}
{{ form_row(resetForm.plainPassword) }}
<button class="btn btn-primary">Reset password</button>
<button class="btn btn-primary">Update password</button>
{{ form_end(resetForm) }}
{% endblock %}