Skip to content

Commit

Permalink
Add password reset
Browse files Browse the repository at this point in the history
  • Loading branch information
samwilson committed Mar 14, 2021
1 parent d57cd4d commit af1ec72
Show file tree
Hide file tree
Showing 16 changed files with 567 additions and 225 deletions.
385 changes: 191 additions & 194 deletions composer.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion config/packages/framework.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
csrf_protection: true
#http_method_override: true

# Enables session support. Note that the session will ONLY be started if you read or write from it.
Expand Down
5 changes: 1 addition & 4 deletions config/packages/reset_password.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
symfonycasts_reset_password:
# Replace symfonycasts.reset_password.fake_request_repository with the full
# namespace of the password reset request repository after it has been created.
# i.e. App\Repository\ResetPasswordRequestRepository
request_password_repository: symfonycasts.reset_password.fake_request_repository
request_password_repository: App\Repository\ResetPasswordRequestRepository
1 change: 1 addition & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ services:
App\Settings:
arguments:
$projectDir: '%kernel.project_dir%'
$mailFrom: '%env(APP_MAIL_SENDER)%'

App\WebRequestProcessor:
tags: { name: monolog.processor }
Expand Down
42 changes: 42 additions & 0 deletions migrations/Version20210308102817.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20210308102817 extends AbstractMigration
{

public function getDescription(): string
{
return 'Add password reset requests.';
}

public function up(Schema $schema): void
{
$this->addSql(
'CREATE TABLE reset_password_request (
id INT AUTO_INCREMENT NOT NULL,
user_id INT NOT NULL,
selector VARCHAR(20) NOT NULL,
hashed_token VARCHAR(100) NOT NULL,
requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
INDEX IDX_7CE748AA76ED395 (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'
);
$this->addSql(
'ALTER TABLE reset_password_request
ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'
);
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE reset_password_request');
}
}
148 changes: 148 additions & 0 deletions src/Controller/ResetPasswordController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

namespace App\Controller;

use App\Entity\User;
use App\Repository\UserRepository;
use App\Settings;
use Exception;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;

private $resetPasswordHelper;

public function __construct(ResetPasswordHelperInterface $resetPasswordHelper)
{
$this->resetPasswordHelper = $resetPasswordHelper;
}

/**
* @Route("/reset", name="reset")
*/
public function request(
Request $request,
MailerInterface $mailer,
UserRepository $userRepository,
Settings $settings
): Response {
// For GET requests, display the 'request' button.
if (!$request->isMethod('POST')) {
return $this->render('reset_password/request.html.twig', []);
}
// Otherwise, send the request confirmation email.
// Check the CSRF token.
if (!$this->isCsrfTokenValid('reset_request', $request->get('csrf_token'))) {
throw new Exception('Invalid CSRF token');
}
// Find the user.
$user = $userRepository->findOneBy(['username' => $request->get('username')]);
if (!$user) {
// Pretend, if there's no user with this username.
return $this->redirectToRoute('reset_check');
}
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface $e) {
// Pretend, if anything went wrong.
return $this->redirectToRoute('reset_check');
}
// Send the email.
$email = (new TemplatedEmail())
->from(new Address($settings->getMailFrom()))
->to($user->getEmail())
->subject('[' . $settings->siteName() . '] Password reset request')
->htmlTemplate('reset_password/email.html.twig')
->context(['resetToken' => $resetToken]);
$mailer->send($email);
// Store the token for later checking.
$this->setTokenObjectInSession($resetToken);
// Show a 'check email' confirmation message.
return $this->redirectToRoute('reset_check');
}

/**
* @Route("/reset-check", name="reset_check")
*/
public function checkEmail(Request $request): Response
{
$resetToken = $this->getTokenObjectFromSession();
if (!$resetToken) {
return $this->redirectToRoute('reset');
}
return $this->render('reset_password/check_email.html.twig', [
'resetToken' => $resetToken,
]);
}

/**
* Validates and process the reset URL that the user clicked in their email.
*
* @Route("/reset-token/{token}", name="reset_token")
*/
public function reset(
Request $request,
UserPasswordEncoderInterface $passwordEncoder,
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);
return $this->redirectToRoute('reset_token');
}

$token = $this->getTokenFromSession();
if (null === $token) {
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
}

try {
/** @var User $user */
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
} catch (ResetPasswordExceptionInterface $e) {
$this->addFlash('error', $e->getReason());
return $this->redirectToRoute('reset');
}

if (!$request->isMethod('post')) {
return $this->render('reset_password/reset.html.twig', []);
}

// Check the CSRF token.
if (!$this->isCsrfTokenValid('reset_password', $request->get('csrf_token'))) {
throw new Exception('Invalid token');
}

$pass = $request->get('password');
$pass2 = $request->get('password_verification');
if (!$pass || !$pass2 || $pass !== $pass2) {
$this->addFlash('error', 'Passwords do not match.');
return $this->redirectToRoute('reset_token');
}

// A password reset token should be used only once, remove it.
$this->resetPasswordHelper->removeResetRequest($token);

// Encode the plain password, and set it.
$user->setPassword($passwordEncoder->encodePassword($user, $pass));
$em = $this->getDoctrine()->getManager();
$em->persist($user);
$em->flush();
$this->cleanSessionAfterReset();
$this->addFlash('success', 'Password changed. You can now log in with your new password.');
return $this->redirectToRoute('login');
}
}
7 changes: 0 additions & 7 deletions src/Controller/SecurityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,6 @@ public function register(
}
}

/**
* @Route("/reminder", name="reminder")
*/
public function reminder(AuthenticationUtils $authenticationUtils): Response
{
}

/**
* @Route("/login", name="login")
*/
Expand Down
46 changes: 46 additions & 0 deletions src/Entity/ResetPasswordRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace App\Entity;

use App\Repository\ResetPasswordRequestRepository;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;

/**
* @ORM\Entity(repositoryClass=ResetPasswordRequestRepository::class)
*/
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
use ResetPasswordRequestTrait;

/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
*/
private $user;

public function __construct(object $user, DateTimeInterface $expiresAt, string $selector, string $hashedToken)
{
$this->user = $user;
$this->initialize($expiresAt, $selector, $hashedToken);
}

public function getId(): ?int
{
return $this->id;
}

public function getUser(): object
{
return $this->user;
}
}
36 changes: 36 additions & 0 deletions src/Repository/ResetPasswordRequestRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Repository;

use App\Entity\ResetPasswordRequest;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;

/**
* @method ResetPasswordRequest|null find($id, $lockMode = null, $lockVersion = null)
* @method ResetPasswordRequest|null findOneBy(array $criteria, array $orderBy = null)
* @method ResetPasswordRequest[] findAll()
* @method ResetPasswordRequest[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
{
use ResetPasswordRequestRepositoryTrait;

public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResetPasswordRequest::class);
}

public function createResetPasswordRequest(
object $user,
DateTimeInterface $expiresAt,
string $selector,
string $hashedToken
): ResetPasswordRequestInterface {
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
}
}
15 changes: 14 additions & 1 deletion src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@ class Settings
/** @var string */
private $projectDir;

/** @var string */
private $mailFrom;

/** @var mixed[] Keys are settings' names, values are their values. */
private $data;

public function __construct(
SettingRepository $settingRepository,
EntityManagerInterface $entityManager,
string $projectDir
string $projectDir,
string $mailFrom
) {
$this->settingRepository = $settingRepository;
$this->entityManager = $entityManager;
$this->projectDir = $projectDir;
$this->mailFrom = $mailFrom;
}

private function getData(): array
Expand Down Expand Up @@ -62,6 +67,14 @@ public function siteName(): string
return $this->getData()['site_name'] ?? 'A Twyne Site';
}

/**
* Get the email address to send mail from.
*/
public function getMailFrom()
{
return $this->mailFrom;
}

public function dataStore(): string
{
return $this->getData()['data_store'] ?? 'local';
Expand Down
22 changes: 22 additions & 0 deletions templates/reset_password/check_email.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}

{% block title %}Reminder{% endblock %}

{% block body %}

<h1>Check your email</h1>

<p>
If your details are correct,
you will receive an email
containing a URL at which to reset your password.
</p>

<p>
If you don't receive an email please
check your spam folder,
<a href="{{ path('reset') }}">try again</a>,
or contact a site administrator.
</p>

{% endblock %}
7 changes: 7 additions & 0 deletions templates/reset_password/email.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p>A request was received to reset your password on <em>{{ settings.siteName }}</em>.</p>

<p>If this was not you, please disregard this email; there is no need to take any action.</p>

<p>To reset your password, please visit the following link:</p>

<p><a href="{{ url('reset_token', {token: resetToken.token}) }}">{{ url('reset_token', {token: resetToken.token}) }}</a></p>
Loading

0 comments on commit af1ec72

Please sign in to comment.