diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml new file mode 100644 index 0000000000..796ff0cbac --- /dev/null +++ b/config/packages/reset_password.yaml @@ -0,0 +1,2 @@ +symfonycasts_reset_password: + request_password_repository: App\Repository\ResetPasswordRequestRepository diff --git a/src/Controller/ResetPassword.php b/src/Controller/ResetPassword.php new file mode 100644 index 0000000000..573bb9a013 --- /dev/null +++ b/src/Controller/ResetPassword.php @@ -0,0 +1,104 @@ + _m('Email'), 'constraints' => [ new NotBlank(['message' => _m('Please enter an email') ]) ]]], + ['password_reset_request', SubmitType::class, ['label' => _m('Submit request')]], + ]); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + return EmailVerifier::processSendingPasswordResetEmail($form->get('email')->getData(), $this); + } + + return [ + '_template' => 'reset_password/request.html.twig', + 'password_reset_form' => $from->createView(), + ]; + } + + /** + * Confirmation page after a user has requested a password reset. + */ + public function checkEmail() + { + // We prevent users from directly accessing this page + if (null === ($resetToken = $this->getTokenObjectFromSession())) { + throw new RedirectException('request_reset_password'); + } + + return [ + '_template' => 'reset_password/check_email.html.twig', + 'resetToken' => $resetToken, + ]; + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + public function reset(Request $request, string $token = null) + { + 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); + throw new RedirectException('reset_password'); + } + + $token = $this->getTokenFromSession(); + if (null === $token) { + throw new ClientException(_m('No reset password token found in the URL or in the session')); + } + + try { + $user = EmailVerifier::validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('reset_password_error', _m('There was a problem validating your reset request - {reason}', ['reason' => $e->getReason()])); + throw new RedirectException('request_reset_password'); + } + + // The token is valid; allow the user to change their password. + $form = From::create([ + FormFields::password(), + ['password_reset', SubmitType::class, ['label' => _m('Change password')]], + ]); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + EmailVerifier::removeResetRequest($token); + + $user->setPassword(LocalUser::hashPassword($form->get('password')->getData())); + DB::flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + throw new RedirectException('main_all'); + } + + return [ + '_template' => 'reset_password/reset.html.twig', + 'resetForm' => $form->createView(), + ]; + } +} diff --git a/src/Core/GNUsocial.php b/src/Core/GNUsocial.php index 71d1802530..febd71aa30 100644 --- a/src/Core/GNUsocial.php +++ b/src/Core/GNUsocial.php @@ -46,6 +46,7 @@ use App\Core\DB\DB; use App\Core\I18n\I18n; use App\Core\Queue\Queue; use App\Core\Router\Router; +use App\Security\EmailVerifier; use App\Util\Common; use App\Util\Exception\ConfigurationException; use App\Util\Formatting; @@ -62,6 +63,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; @@ -69,6 +71,7 @@ use Symfony\Component\Security\Core\Security as SSecurity; use Symfony\Component\Security\Http\Util\TargetPathTrait; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use Twig\Environment; @@ -96,7 +99,9 @@ class GNUsocial implements EventSubscriberInterface protected ContainerBagInterface $config; protected Environment $twig; protected ?Request $request; + protected MailerInterface $mailer_helper; protected VerifyEmailHelperInterface $email_verify_helper; + protected ResetPasswordHelperInterface $reset_password_helper; /** * Symfony dependency injection gives us access to these services @@ -117,25 +122,29 @@ class GNUsocial implements EventSubscriberInterface ContainerBagInterface $conf, Environment $twig, RequestStack $request_stack, - VerifyEmailHelperInterface $email_helper) + MailerInterface $mailer, + VerifyEmailHelperInterface $email_verify_helper, + ResetPasswordHelperInterface $reset_helper) { - $this->logger = $logger; - $this->translator = $trans; - $this->entity_manager = $em; - $this->router = $router; - $this->url_generator = $url_gen; - $this->form_factory = $ff; - $this->message_bus = $mb; - $this->event_dispatcher = $ed; - $this->session = $sess; - $this->security = $sec; - $this->module_manager = $mm; - $this->client = $cl; - $this->sanitizer = $san; - $this->config = $conf; - $this->twig = $twig; - $this->request = $request_stack->getCurrentRequest(); - $this->email_verify_helper = $email_helper; + $this->logger = $logger; + $this->translator = $trans; + $this->entity_manager = $em; + $this->router = $router; + $this->url_generator = $url_gen; + $this->form_factory = $ff; + $this->message_bus = $mb; + $this->event_dispatcher = $ed; + $this->session = $sess; + $this->security = $sec; + $this->module_manager = $mm; + $this->client = $cl; + $this->sanitizer = $san; + $this->config = $conf; + $this->twig = $twig; + $this->request = $request_stack->getCurrentRequest(); + $this->mailer_helper = $mailer; + $this->email_verify_helper = $email_verify_helper; + $this->reset_password_helper = $reset_helper; $this->initialize(); } @@ -163,7 +172,7 @@ class GNUsocial implements EventSubscriberInterface HTTPClient::setClient($this->client); Formatting::setTwig($this->twig); Cache::setupCache(); - EmailVerifier::setVerifyEmailHelper($this->email_verify_helper); + EmailVerifier::setEmailHelpers($this->mailer_helper, $this->email_verify_helper, $this->reset_password_helper); DB::initTableMap(); diff --git a/src/Entity/ResetPasswordRequest.php b/src/Entity/ResetPasswordRequest.php new file mode 100644 index 0000000000..9fecc7daa5 --- /dev/null +++ b/src/Entity/ResetPasswordRequest.php @@ -0,0 +1,89 @@ +nickname = $nickname; + return $this; + } + + public function getNickname(): string + { + return $this->nickname; + } + + public function setCreated(DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getCreated(): DateTimeInterface + { + return $this->created; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public function __construct(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken) + { + $this->user_id = $user->getId(); + $this->expires = $expiresAt; + $this->selector = $selector; + $this->token = $hashedToken; + } + + public function getUser(): object + { + return LocalUser::getWithPK($this->user_id); + } + + public function getRequestedAt(): \DateTimeInterface + { + return $this->created; + } + + public function isExpired(): bool + { + return $this->expires->getTimestamp() <= time(); + } + + public function getExpiresAt(): \DateTimeInterface + { + return $this->expires; + } + + public function getHashedToken(): string + { + return $this->token; + } + + public static function schemaDef(): array + { + return [ + 'name' => 'reset_password_request', + 'description' => 'Represents a request made by a user to change their passowrd', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true], + 'user_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'LocalUser.id', 'multiplicity' => 'many to many', 'not null' => true, 'description' => 'foreign key to local_user table'], + 'selector' => ['type' => 'char', 'length' => 20], + 'token' => ['type' => 'char', 'length' => 100], + 'expires' => ['type' => 'datetime', 'not null' => true], + 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], + ], + 'primary key' => ['id'], + ]; + } +} diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/Routes/Main.php b/src/Routes/Main.php index 1118ee7496..29c9a71a5d 100644 --- a/src/Routes/Main.php +++ b/src/Routes/Main.php @@ -45,6 +45,9 @@ abstract class Main $r->connect('login', '/login', [C\Security::class, 'login']); $r->connect('logout', '/logout', [C\Security::class, 'logout']); $r->connect('register', '/register', [C\Security::class, 'register']); + $r->connect('check_email', '/check-email', [C\ResetPassword::class, 'checkEmail']); + $r->connect('request_reset_password', '/request-reset-password', [C\ResetPassword::class, 'requestPasswordReset']); + $r->connect('reset_password', '/reset/{token?}', [C\ResetPassword::class, 'reset']); $r->connect('root', '/', RedirectController::class, ['defaults' => ['route' => 'main_all']]); $r->connect('main_public', '/main/public', [C\Network::class, 'public']); diff --git a/src/Security/EmailVerifier.php b/src/Security/EmailVerifier.php index c1245f4c59..be9dd741bf 100644 --- a/src/Security/EmailVerifier.php +++ b/src/Security/EmailVerifier.php @@ -2,21 +2,40 @@ namespace App\Security; +use App\Core\Controller; use App\Core\DB\DB; -use App\Core\Mailer; +use App\Util\Exception\NotFoundException; +use App\Util\Exception\RedirectException; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Security\Core\User\UserInterface; +use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; abstract class EmailVerifier { + private static ?MailerInterface $mailer_helper; private static ?VerifyEmailHelperInterface $verify_email_helper; - public function setVerifyEmailHelper(VerifyEmailHelperInterface $helper) + private static ?ResetPasswordHelperInterface $reset_password_helper; + + public static function setEmailHelpers(MailerInterface $mailer, VerifyEmailHelperInterface $email_helper, ResetPasswordHelperInterface $reset_helper) { - self::$verifyEmailHelper = $helper; + self::$mailer_helper = $mailer; + self::$verify_email_helper = $email_helper; + self::$reset_password_helper = $reset_helper; + } + + public static function validateTokenAndFetchUser(string $token) + { + return self::$reset_password_helper->validateTokenAndFetchUser($token); + } + + public static function removeResetRequest(string $token) + { + return self::$reset_password_helper->removeResetRequest($token); } public static function sendEmailConfirmation(UserInterface $user): void @@ -39,7 +58,12 @@ abstract class EmailVerifier $email->context($context); - Mailer::send($email); + self::send($email); + } + + public function send($email) + { + return self::$mailer_helper->send($email); } /** @@ -47,11 +71,37 @@ abstract class EmailVerifier */ public function handleEmailConfirmation(Request $request, UserInterface $user): void { - $this->verify_email_helper->validateEmailConfirmation($request->getUri(), $user->getId(), $user->getOutgoingEmail()); - + self::$verify_email_helper->validateEmailConfirmation($request->getUri(), $user->getId(), $user->getOutgoingEmail()); $user->setIsEmailVerified(true); - DB::persist($user); DB::flush(); } + + public function processSendingPasswordResetEmail(string $emailFormData, Controller $controller) + { + try { + $user = DB::findOneBy('local_user', ['outgoing_email' => $emailFormData]); + $reset_token = self::$reset_password_helper->generateResetToken($user); + // Found a user + } catch (NotFoundException | ResetPasswordExceptionInterface) { + // Not found, do not reveal whether a user account was found or not. + throw new RedirectException('check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('foo@email.com', 'FOO NAME')) + ->to($user->getOutgoingEmail()) + ->subject('Your password reset request') + ->htmlTemplate('reset_password/email.html.twig') + ->context([ + 'resetToken' => $reset_token, + ]); + + self::send($email); + + // Store the token object in session for retrieval in check-email route. + $controller->setTokenObjectInSession($reset_token); + + throw new RedirectException('check_email'); + } } diff --git a/templates/reset_password/check_email.html.twig b/templates/reset_password/check_email.html.twig new file mode 100644 index 0000000000..00701d0e74 --- /dev/null +++ b/templates/reset_password/check_email.html.twig @@ -0,0 +1,11 @@ +{% extends 'base.html.twig' %} + +{% block title %}Password Reset Email Sent{% endblock %} + +{% block body %} +

+ An email has been sent that contains a link that you can click to reset your password. + This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}. +

+

If you don't receive an email please check your spam folder or try again.

+{% endblock %} diff --git a/templates/reset_password/email.html.twig b/templates/reset_password/email.html.twig new file mode 100644 index 0000000000..824a218619 --- /dev/null +++ b/templates/reset_password/email.html.twig @@ -0,0 +1,9 @@ +

Hi!

+ +

To reset your password, please visit the following link

+ +{{ url('app_reset_password', {token: resetToken.token}) }} + +

This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.

+ +

Cheers!

diff --git a/templates/reset_password/request.html.twig b/templates/reset_password/request.html.twig new file mode 100644 index 0000000000..beeb5bdcf8 --- /dev/null +++ b/templates/reset_password/request.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} + {% for flashError in app.flashes('reset_password_error') %} + + {% endfor %} +

Reset your password

+ + {{ form_start(requestForm) }} + {{ form_row(requestForm.outgoing_email) }} +
+ + Enter your email address and we we will send you a + link to reset your password. + +
+ + + {{ form_end(requestForm) }} +{% endblock %} \ No newline at end of file diff --git a/templates/reset_password/reset.html.twig b/templates/reset_password/reset.html.twig new file mode 100644 index 0000000000..799aa10f54 --- /dev/null +++ b/templates/reset_password/reset.html.twig @@ -0,0 +1,12 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} +

Reset your password

+ + {{ form_start(resetForm) }} + {{ form_row(resetForm.plainPassword) }} + + {{ form_end(resetForm) }} +{% endblock %}