[CORE] Add passowrd reset and forgot password functionality

This commit is contained in:
Hugo Sales 2021-07-29 17:26:14 +00:00
parent c3d2f04841
commit ccd5ebf8e4
Signed by untrusted user: someonewithpc
GPG Key ID: 7D0C7EAFC9D835A0
11 changed files with 337 additions and 26 deletions

View File

@ -0,0 +1,2 @@
symfonycasts_reset_password:
request_password_repository: App\Repository\ResetPasswordRequestRepository

View File

@ -0,0 +1,104 @@
<?php
namespace App\Controller;
use App\Core\Controller;
use App\Entity\LocalUser;
use App\Util\Exception\RedirectException;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\NotBlank;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
class ResetPassword extends Controller
{
use ResetPasswordControllerTrait;
/**
* Display & process form to request a password reset.
*/
public function requestPasswordReset(Request $request)
{
$from = Form::create([
['email', EmailType::class, ['label' => _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(),
];
}
}

View File

@ -46,6 +46,7 @@ use App\Core\DB\DB;
use App\Core\I18n\I18n; use App\Core\I18n\I18n;
use App\Core\Queue\Queue; use App\Core\Queue\Queue;
use App\Core\Router\Router; use App\Core\Router\Router;
use App\Security\EmailVerifier;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ConfigurationException; use App\Util\Exception\ConfigurationException;
use App\Util\Formatting; use App\Util\Formatting;
@ -62,6 +63,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface; 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\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Twig\Environment; use Twig\Environment;
@ -96,7 +99,9 @@ class GNUsocial implements EventSubscriberInterface
protected ContainerBagInterface $config; protected ContainerBagInterface $config;
protected Environment $twig; protected Environment $twig;
protected ?Request $request; protected ?Request $request;
protected MailerInterface $mailer_helper;
protected VerifyEmailHelperInterface $email_verify_helper; protected VerifyEmailHelperInterface $email_verify_helper;
protected ResetPasswordHelperInterface $reset_password_helper;
/** /**
* Symfony dependency injection gives us access to these services * Symfony dependency injection gives us access to these services
@ -117,7 +122,9 @@ class GNUsocial implements EventSubscriberInterface
ContainerBagInterface $conf, ContainerBagInterface $conf,
Environment $twig, Environment $twig,
RequestStack $request_stack, RequestStack $request_stack,
VerifyEmailHelperInterface $email_helper) MailerInterface $mailer,
VerifyEmailHelperInterface $email_verify_helper,
ResetPasswordHelperInterface $reset_helper)
{ {
$this->logger = $logger; $this->logger = $logger;
$this->translator = $trans; $this->translator = $trans;
@ -135,7 +142,9 @@ class GNUsocial implements EventSubscriberInterface
$this->config = $conf; $this->config = $conf;
$this->twig = $twig; $this->twig = $twig;
$this->request = $request_stack->getCurrentRequest(); $this->request = $request_stack->getCurrentRequest();
$this->email_verify_helper = $email_helper; $this->mailer_helper = $mailer;
$this->email_verify_helper = $email_verify_helper;
$this->reset_password_helper = $reset_helper;
$this->initialize(); $this->initialize();
} }
@ -163,7 +172,7 @@ class GNUsocial implements EventSubscriberInterface
HTTPClient::setClient($this->client); HTTPClient::setClient($this->client);
Formatting::setTwig($this->twig); Formatting::setTwig($this->twig);
Cache::setupCache(); Cache::setupCache();
EmailVerifier::setVerifyEmailHelper($this->email_verify_helper); EmailVerifier::setEmailHelpers($this->mailer_helper, $this->email_verify_helper, $this->reset_password_helper);
DB::initTableMap(); DB::initTableMap();

View File

@ -0,0 +1,89 @@
<?php
namespace App\Entity;
use App\Core\Entity;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
class ResetPasswordRequest extends Entity implements ResetPasswordRequestInterface
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private string $nickname;
private \DateTimeInterface $created;
public function setNickname(string $nickname): self
{
$this->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'],
];
}
}

View File

View File

@ -45,6 +45,9 @@ abstract class Main
$r->connect('login', '/login', [C\Security::class, 'login']); $r->connect('login', '/login', [C\Security::class, 'login']);
$r->connect('logout', '/logout', [C\Security::class, 'logout']); $r->connect('logout', '/logout', [C\Security::class, 'logout']);
$r->connect('register', '/register', [C\Security::class, 'register']); $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('root', '/', RedirectController::class, ['defaults' => ['route' => 'main_all']]);
$r->connect('main_public', '/main/public', [C\Network::class, 'public']); $r->connect('main_public', '/main/public', [C\Network::class, 'public']);

View File

@ -2,21 +2,40 @@
namespace App\Security; namespace App\Security;
use App\Core\Controller;
use App\Core\DB\DB; 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\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
abstract class EmailVerifier abstract class EmailVerifier
{ {
private static ?MailerInterface $mailer_helper;
private static ?VerifyEmailHelperInterface $verify_email_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 public static function sendEmailConfirmation(UserInterface $user): void
@ -39,7 +58,12 @@ abstract class EmailVerifier
$email->context($context); $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 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); $user->setIsEmailVerified(true);
DB::persist($user); DB::persist($user);
DB::flush(); 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');
}
} }

View File

@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}Password Reset Email Sent{% endblock %}
{% block body %}
<p>
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') }}.
</p>
<p>If you don't receive an email please check your spam folder or <a href="{{ path('app_forgot_password_request') }}">try again</a>.</p>
{% endblock %}

View File

@ -0,0 +1,9 @@
<h1>Hi!</h1>
<p>To reset your password, please visit the following link</p>
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}">{{ url('app_reset_password', {token: resetToken.token}) }}</a>
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
<p>Cheers!</p>

View File

@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}Reset your password{% endblock %}
{% block body %}
{% for flashError in app.flashes('reset_password_error') %}
<div class="alert alert-danger" role="alert">{{ flashError }}</div>
{% endfor %}
<h1>Reset your password</h1>
{{ form_start(requestForm) }}
{{ form_row(requestForm.outgoing_email) }}
<div>
<small>
Enter your email address and we we will send you a
link to reset your password.
</small>
</div>
<button class="btn btn-primary">Send password reset email</button>
{{ form_end(requestForm) }}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html.twig' %}
{% block title %}Reset your password{% endblock %}
{% block body %}
<h1>Reset your password</h1>
{{ form_start(resetForm) }}
{{ form_row(resetForm.plainPassword) }}
<button class="btn btn-primary">Reset password</button>
{{ form_end(resetForm) }}
{% endblock %}