forked from GNUsocial/gnu-social
[CORE] Add passowrd reset and forgot password functionality
This commit is contained in:
parent
6d2f8daeae
commit
56481c8289
2
config/packages/reset_password.yaml
Normal file
2
config/packages/reset_password.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
symfonycasts_reset_password:
|
||||
request_password_repository: App\Repository\ResetPasswordRequestRepository
|
104
src/Controller/ResetPassword.php
Normal file
104
src/Controller/ResetPassword.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
@ -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,7 +122,9 @@ 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;
|
||||
@ -135,7 +142,9 @@ class GNUsocial implements EventSubscriberInterface
|
||||
$this->config = $conf;
|
||||
$this->twig = $twig;
|
||||
$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();
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
89
src/Entity/ResetPasswordRequest.php
Normal file
89
src/Entity/ResetPasswordRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
0
src/Repository/.gitignore
vendored
0
src/Repository/.gitignore
vendored
@ -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']);
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
11
templates/reset_password/check_email.html.twig
Normal file
11
templates/reset_password/check_email.html.twig
Normal 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 %}
|
9
templates/reset_password/email.html.twig
Normal file
9
templates/reset_password/email.html.twig
Normal 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>
|
22
templates/reset_password/request.html.twig
Normal file
22
templates/reset_password/request.html.twig
Normal 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 %}
|
12
templates/reset_password/reset.html.twig
Normal file
12
templates/reset_password/reset.html.twig
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user