Skip to content
Snippets Groups Projects
Commit 37801973 authored by Camille Simiand's avatar Camille Simiand
Browse files

Add symfonycasts reset recipe and improve templates style

parent f9307b37
Branches
Tags
1 merge request!23Tuleap 47 reset my password
Showing
with 540 additions and 3 deletions
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ef3d93e38257fb419a3956465e6b1666",
"content-hash": "5cd7753539e87c732ac326302ed3425f",
"packages": [
{
"name": "composer/package-versions-deprecated",
......@@ -7627,6 +7627,56 @@
],
"time": "2021-11-20T16:42:42+00:00"
},
{
"name": "symfonycasts/reset-password-bundle",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/SymfonyCasts/reset-password-bundle.git",
"reference": "09fcd0d893bbd58d4477787c6cb5a4ffc4fc9455"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/09fcd0d893bbd58d4477787c6cb5a4ffc4fc9455",
"reference": "09fcd0d893bbd58d4477787c6cb5a4ffc4fc9455",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/config": "^4.4 | ^5.0 | ^6.0",
"symfony/dependency-injection": "^4.4 | ^5.0 | ^6.0",
"symfony/deprecation-contracts": "^2.2 | ^3.0",
"symfony/http-kernel": "^4.4 | ^5.0 | ^6.0"
},
"conflict": {
"doctrine/orm": "<2.7",
"symfony/framework-bundle": "<4.4",
"symfony/http-foundation": "<4.4"
},
"require-dev": {
"doctrine/doctrine-bundle": "^2.0.3",
"doctrine/orm": "^2.7",
"symfony/framework-bundle": "^4.4 | ^5.0 | ^6.0",
"symfony/phpunit-bridge": "^5.0 | ^6.0",
"vimeo/psalm": "^4.3"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"SymfonyCasts\\Bundle\\ResetPassword\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Symfony bundle that adds password reset functionality.",
"support": {
"issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues",
"source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.11.0"
},
"time": "2021-11-30T17:32:21+00:00"
},
{
"name": "symfonycasts/verify-email-bundle",
"version": "v1.7.0",
......
......@@ -17,4 +17,5 @@ return [
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
];
symfonycasts_reset_password:
request_password_repository: App\Repository\ResetPasswordRequestRepository
......@@ -34,4 +34,4 @@ security:
# switch_user: true
access_control:
- { path: ^/(?!register|login|generate-captcha|verify/email), roles: ROLE_USER }
- { path: ^/(?!register|login|generate-captcha|verify/email|reset-password), roles: ROLE_USER }
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20211213084054 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$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 utf8 COLLATE `utf8_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 down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE reset_password_request');
}
}
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20211213085507 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$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 utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, email_canonical VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, firstname VARCHAR(255) NOT NULL, username VARCHAR(255) NOT NULL, username_canonical VARCHAR(255) NOT NULL, $credential_expired TINYINT(1) NOT NULL, roles LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, enabled TINYINT(1) NOT NULL, salt VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649A0D96FBF (email_canonical), UNIQUE INDEX UNIQ_8D93D64992FC23A8 (username_canonical), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_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 down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
$this->addSql('DROP TABLE reset_password_request');
$this->addSql('DROP TABLE `user`');
}
}
<?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 SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
/**
* @Route("/reset-password")
*/
class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;
private $resetPasswordHelper;
private $entityManager;
public function __construct(
ResetPasswordHelperInterface $resetPasswordHelper,
EntityManagerInterface $entityManager
) {
$this->resetPasswordHelper = $resetPasswordHelper;
$this->entityManager = $entityManager;
}
/**
* Display & process form to request a password reset.
*
* @Route("", name="app_forgot_password_request")
*/
public function request(Request $request, MailerInterface $mailer): Response
{
$form = $this->createForm(ResetPasswordRequestFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
return $this->processSendingPasswordResetEmail(
$form->get('email')->getData(),
$mailer
);
}
return $this->render('reset_password/request.html.twig', [
'requestForm' => $form->createView(),
]);
}
/**
* Confirmation page after a user has requested a password reset.
*
* @Route("/check-email", name="app_check_email")
*/
public function checkEmail(): Response
{
// Generate a fake token if the user does not exist or someone hit this page directly.
// This prevents exposing whether or not a user was found with the given email address or not
if (null === ($resetToken = $this->getTokenObjectFromSession())) {
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
}
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}", name="app_reset_password")
*/
public function reset(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
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('app_reset_password');
}
$token = $this->getTokenFromSession();
if (null === $token) {
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
}
try {
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
} catch (ResetPasswordExceptionInterface $e) {
$this->addFlash('reset_password_error', sprintf(
'There was a problem validating your reset request - %s',
$e->getReason()
));
return $this->redirectToRoute('app_forgot_password_request');
}
// The token is valid; allow the user to change their password.
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// A password reset token should be used only once, remove it.
$this->resetPasswordHelper->removeResetRequest($token);
// Encode(hash) the plain password, and set it.
$encodedPassword = $userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
);
$user->setPassword($encodedPassword);
$this->entityManager->flush();
// The session is cleaned up after the password has been changed.
$this->cleanSessionAfterReset();
return $this->redirectToRoute('app_login');
}
return $this->render('reset_password/reset.html.twig', [
'resetForm' => $form->createView(),
]);
}
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): RedirectResponse
{
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData,
]);
// Do not reveal whether a user account was found or not.
if (!$user) {
return $this->redirectToRoute('app_check_email');
}
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface $e) {
// If you want to tell the user why a reset email was not sent, uncomment
// the lines below and change the redirect to 'app_forgot_password_request'.
// Caution: This may reveal if a user is registered or not.
//
// $this->addFlash('reset_password_error', sprintf(
// 'There was a problem handling your password reset request - %s',
// $e->getReason()
// ));
return $this->redirectToRoute('app_check_email');
}
$email = (new TemplatedEmail())
->from(new Address('contact@localhost.com', 'MemoRekall'))
->to($user->getEmail())
->subject('Your password reset request')
->htmlTemplate('reset_password/email.html.twig')
->context([
'resetToken' => $resetToken,
])
;
$mailer->send($email);
// Store the token object in session for retrieval in check-email route.
$this->setTokenObjectInSession($resetToken);
return $this->redirectToRoute('app_check_email');
}
}
<?php
namespace App\Entity;
use App\Repository\ResetPasswordRequestRepository;
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;
}
}
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class ChangePasswordFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
'label' => 'New password',
],
'second_options' => [
'attr' => ['autocomplete' => 'new-password'],
'label' => 'Repeat Password',
],
'invalid_message' => 'The password fields must match.',
// Instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordRequestFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'attr' => ['autocomplete' => 'email'],
'constraints' => [
new NotBlank([
'message' => 'Please enter your email',
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
<?php
namespace App\Repository;
use App\Entity\ResetPasswordRequest;
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);
}
}
......@@ -731,6 +731,18 @@
"symfony/yaml": {
"version": "v5.3.6"
},
"symfonycasts/reset-password-bundle": {
"version": "1.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "1.0",
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
},
"files": [
"config/packages/reset_password.yaml"
]
},
"symfonycasts/verify-email-bundle": {
"version": "v1.6.0"
},
......
{% extends 'layout.html.twig' %}
{% block title %}Password Reset Email Sent{% endblock %}
{% block body %}
<p>
If an account matching your email exists, then an email was just sent that contains a link that you can use 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 %}
<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>
{% extends 'layout.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 class="m-auto mb-4 mt-4 col-6">Reset your password</h1>
{{ form_start(requestForm, {'attr': {'class': 'd-flex flex-column justify-content-center'}}) }}
{{ form_row(requestForm.email, {'row_attr': {'class' : 'm-auto mb-2 mt-4 col-6'}}) }}
<div class="m-auto mb-4 mt-4 col-6">
<small>
Enter your email address and we will send you a
link to reset your password.
</small>
</div>
<button class="btn btn-primary m-auto mb-4 mt-4 col-2" type="submit">Reset password</button>
{{ form_end(requestForm) }}
{% endblock %}
{% extends 'layout.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 %}
......@@ -41,8 +41,11 @@
</div>
#}
<button class="btn btn-primary col-2 m-auto" type="submit">
<a href="/reset-password" class="m-auto">I forgot my password</a>
<button class="btn btn-primary col-2 m-auto mt-4" type="submit">
Log in
</button>
</form>
{% endblock %}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment