From 6894d48e03c397096bb64420373afa60c397fe97 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 7 Mar 2015 23:25:36 +0100 Subject: [PATCH] Handle forgot password --- app/config/config_dev.yml | 10 +- app/config/config_test.yml | 4 +- app/config/parameters.yml.dist | 1 + app/config/security.yml | 1 + .../Controller/SecurityController.php | 122 ++++++++++++++++++ src/Wallabag/CoreBundle/Entity/User.php | 56 ++++++++ .../Form/Type/ForgotPasswordType.php | 52 ++++++++ .../Form/Type/ResetPasswordType.php | 34 +++++ .../CoreBundle/Resources/config/services.yml | 8 ++ .../views/Mail/forgotPassword.txt.twig | 6 + .../views/Security/checkEmail.html.twig | 17 +++ .../views/Security/forgotPassword.html.twig | 31 +++++ .../Resources/views/Security/login.html.twig | 15 ++- .../Resources/views/Security/reset.html.twig | 35 +++++ .../Controller/SecurityControllerTest.php | 97 ++++++++++++++ 15 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php create mode 100644 src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php create mode 100644 src/Wallabag/CoreBundle/Resources/views/Mail/forgotPassword.txt.twig create mode 100644 src/Wallabag/CoreBundle/Resources/views/Security/checkEmail.html.twig create mode 100644 src/Wallabag/CoreBundle/Resources/views/Security/forgotPassword.html.twig create mode 100644 src/Wallabag/CoreBundle/Resources/views/Security/reset.html.twig diff --git a/app/config/config_dev.yml b/app/config/config_dev.yml index efaf396e..205e0f66 100644 --- a/app/config/config_dev.yml +++ b/app/config/config_dev.yml @@ -44,5 +44,11 @@ monolog: assetic: use_controller: true -#swiftmailer: -# delivery_address: me@example.com +swiftmailer: + # see http://mailcatcher.me/ + transport: smtp + host: 'localhost' + port: 1025 + username: null + password: null + diff --git a/app/config/config_test.yml b/app/config/config_test.yml index a6ead1e8..00a6bc57 100644 --- a/app/config/config_test.yml +++ b/app/config/config_test.yml @@ -13,7 +13,9 @@ web_profiler: intercept_redirects: false swiftmailer: - disable_delivery: true + # to be able to read emails sent + spool: + type: file doctrine: dbal: diff --git a/app/config/parameters.yml.dist b/app/config/parameters.yml.dist index 44969750..8f967011 100644 --- a/app/config/parameters.yml.dist +++ b/app/config/parameters.yml.dist @@ -41,3 +41,4 @@ parameters: items_on_page: 12 theme: baggy language: en_US + from_email: no-reply@wallabag.org diff --git a/app/config/security.yml b/app/config/security.yml index e06c8967..90903f31 100644 --- a/app/config/security.yml +++ b/app/config/security.yml @@ -59,4 +59,5 @@ security: - { path: ^/api/salt, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/forgot-password, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/, roles: ROLE_USER } diff --git a/src/Wallabag/CoreBundle/Controller/SecurityController.php b/src/Wallabag/CoreBundle/Controller/SecurityController.php index c2901da2..5007307a 100644 --- a/src/Wallabag/CoreBundle/Controller/SecurityController.php +++ b/src/Wallabag/CoreBundle/Controller/SecurityController.php @@ -2,9 +2,12 @@ namespace Wallabag\CoreBundle\Controller; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\SecurityContext; +use Wallabag\CoreBundle\Form\Type\ResetPasswordType; class SecurityController extends Controller { @@ -25,4 +28,123 @@ class SecurityController extends Controller 'error' => $error, )); } + + /** + * Request forgot password: show form + * + * @Route("/forgot-password", name="forgot_password") + * @Method({"GET", "POST"}) + */ + public function forgotPasswordAction(Request $request) + { + $form = $this->createForm('forgot_password'); + $form->handleRequest($request); + + if ($form->isValid()) { + $user = $this->getDoctrine()->getRepository('WallabagCoreBundle:User')->findOneByEmail($form->get('email')->getData()); + + // generate "hard" token + $user->setConfirmationToken(rtrim(strtr(base64_encode(hash('sha256', uniqid(mt_rand(), true), true)), '+/', '-_'), '=')); + $user->setPasswordRequestedAt(new \DateTime()); + + $em = $this->getDoctrine()->getManager(); + $em->persist($user); + $em->flush(); + + $message = \Swift_Message::newInstance() + ->setSubject('Reset Password') + ->setFrom($this->container->getParameter('from_email')) + ->setTo($user->getEmail()) + ->setBody($this->renderView('WallabagCoreBundle:Mail:forgotPassword.txt.twig', array( + 'username' => $user->getUsername(), + 'confirmationUrl' => $this->generateUrl('forgot_password_reset', array('token' => $user->getConfirmationToken()), true), + ))) + ; + $this->get('mailer')->send($message); + + return $this->redirect($this->generateUrl('forgot_password_check_email', + array('email' => $this->getObfuscatedEmail($user->getEmail())) + )); + } + + return $this->render('WallabagCoreBundle:Security:forgotPassword.html.twig', array( + 'form' => $form->createView(), + )); + } + + /** + * Tell the user to check his email provider + * + * @Route("/forgot-password/check-email", name="forgot_password_check_email") + * @Method({"GET"}) + */ + public function checkEmailAction(Request $request) + { + $email = $request->query->get('email'); + + if (empty($email)) { + // the user does not come from the forgotPassword action + return $this->redirect($this->generateUrl('forgot_password')); + } + + return $this->render('WallabagCoreBundle:Security:checkEmail.html.twig', array( + 'email' => $email, + )); + } + + /** + * Reset user password + * + * @Route("/forgot-password/{token}", name="forgot_password_reset") + * @Method({"GET", "POST"}) + */ + public function resetAction(Request $request, $token) + { + $user = $this->getDoctrine()->getRepository('WallabagCoreBundle:User')->findOneByConfirmationToken($token); + + if (null === $user) { + $this->createNotFoundException(sprintf('No user found with token "%s"', $token)); + } + + $form = $this->createForm(new ResetPasswordType()); + $form->handleRequest($request); + + if ($form->isValid()) { + $user->setPassword($form->get('new_password')->getData()); + + $em = $this->getDoctrine()->getManager(); + $em->persist($user); + $em->flush(); + + $this->get('session')->getFlashBag()->add( + 'notice', + 'The password has been reset successfully' + ); + + return $this->redirect($this->generateUrl('login')); + } + + return $this->render('WallabagCoreBundle:Security:reset.html.twig', array( + 'token' => $token, + 'form' => $form->createView(), + )); + } + + /** + * Get the truncated email displayed when requesting the resetting. + * + * Keeping only the part following @ in the address. + * + * @param string $email + * + * @return string + */ + protected function getObfuscatedEmail($email) + { + if (false !== $pos = strpos($email, '@')) { + $email = '...'.substr($email, $pos); + } + + return $email; + } } diff --git a/src/Wallabag/CoreBundle/Entity/User.php b/src/Wallabag/CoreBundle/Entity/User.php index f05c8760..6a7619ac 100644 --- a/src/Wallabag/CoreBundle/Entity/User.php +++ b/src/Wallabag/CoreBundle/Entity/User.php @@ -77,6 +77,16 @@ class User implements AdvancedUserInterface, \Serializable */ private $isActive = true; + /** + * @ORM\Column(name="confirmation_token", type="string", nullable=true) + */ + private $confirmationToken; + + /** + * @ORM\Column(name="password_requested_at", type="datetime", nullable=true) + */ + private $passwordRequestedAt; + /** * @var date * @@ -377,4 +387,50 @@ class User implements AdvancedUserInterface, \Serializable { return $this->config; } + + /** + * Set confirmationToken + * + * @param string $confirmationToken + * @return User + */ + public function setConfirmationToken($confirmationToken) + { + $this->confirmationToken = $confirmationToken; + + return $this; + } + + /** + * Get confirmationToken + * + * @return string + */ + public function getConfirmationToken() + { + return $this->confirmationToken; + } + + /** + * Set passwordRequestedAt + * + * @param \DateTime $passwordRequestedAt + * @return User + */ + public function setPasswordRequestedAt($passwordRequestedAt) + { + $this->passwordRequestedAt = $passwordRequestedAt; + + return $this; + } + + /** + * Get passwordRequestedAt + * + * @return \DateTime + */ + public function getPasswordRequestedAt() + { + return $this->passwordRequestedAt; + } } diff --git a/src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php b/src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php new file mode 100644 index 00000000..c278b84f --- /dev/null +++ b/src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php @@ -0,0 +1,52 @@ +doctrine = $doctrine; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('email', 'email', array( + 'constraints' => array( + new Constraints\Email(), + new Constraints\NotBlank(), + new Constraints\Callback(array(array($this, 'validateEmail'))), + ), + )) + ; + } + + public function getName() + { + return 'forgot_password'; + } + + public function validateEmail($email, ExecutionContextInterface $context) + { + $user = $this->doctrine + ->getRepository('WallabagCoreBundle:User') + ->findOneByEmail($email); + + if (!$user) { + $context->addViolationAt( + 'email', + 'No user found with this email', + array(), + $email + ); + } + } +} diff --git a/src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php b/src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php new file mode 100644 index 00000000..50ae800b --- /dev/null +++ b/src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php @@ -0,0 +1,34 @@ +add('new_password', 'repeated', array( + 'type' => 'password', + 'invalid_message' => 'The password fields must match.', + 'required' => true, + 'first_options' => array('label' => 'New password'), + 'second_options' => array('label' => 'Repeat new password'), + 'constraints' => array( + new Constraints\Length(array( + 'min' => 8, + 'minMessage' => 'Password should by at least 8 chars long', + )), + new Constraints\NotBlank(), + ), + )) + ; + } + + public function getName() + { + return 'change_passwd'; + } +} diff --git a/src/Wallabag/CoreBundle/Resources/config/services.yml b/src/Wallabag/CoreBundle/Resources/config/services.yml index c734a3a5..062e1651 100644 --- a/src/Wallabag/CoreBundle/Resources/config/services.yml +++ b/src/Wallabag/CoreBundle/Resources/config/services.yml @@ -22,9 +22,17 @@ services: - @security.context - %theme% # default theme from parameters.yml + # custom form type wallabag_core.form.type.config: class: Wallabag\CoreBundle\Form\Type\ConfigType arguments: - %liip_theme.themes% tags: - { name: form.type, alias: config } + + wallabag_core.form.type.forgot_password: + class: Wallabag\CoreBundle\Form\Type\ForgotPasswordType + arguments: + - @doctrine + tags: + - { name: form.type, alias: forgot_password } diff --git a/src/Wallabag/CoreBundle/Resources/views/Mail/forgotPassword.txt.twig b/src/Wallabag/CoreBundle/Resources/views/Mail/forgotPassword.txt.twig new file mode 100644 index 00000000..631bcb88 --- /dev/null +++ b/src/Wallabag/CoreBundle/Resources/views/Mail/forgotPassword.txt.twig @@ -0,0 +1,6 @@ +Hello {{username}}! + +To reset your password - please visit {{confirmationUrl}} + +Regards, +Wallabag bot diff --git a/src/Wallabag/CoreBundle/Resources/views/Security/checkEmail.html.twig b/src/Wallabag/CoreBundle/Resources/views/Security/checkEmail.html.twig new file mode 100644 index 00000000..056d65b5 --- /dev/null +++ b/src/Wallabag/CoreBundle/Resources/views/Security/checkEmail.html.twig @@ -0,0 +1,17 @@ +{% extends "WallabagCoreBundle::layout.html.twig" %} + +{% block title %}{% trans %}Forgot password{% endtrans %}{% endblock %} + +{% block body_class %}login{% endblock %} + +{% block menu %}{% endblock %} + +{% block content %} +
+
+

{% trans %}Forgot password{% endtrans %}

+ +

{{ 'An email has been sent to %email%. It contains a link you must click to reset your password.'|trans({'%email%': email}) }}

+
+
+{% endblock %} diff --git a/src/Wallabag/CoreBundle/Resources/views/Security/forgotPassword.html.twig b/src/Wallabag/CoreBundle/Resources/views/Security/forgotPassword.html.twig new file mode 100644 index 00000000..4476ea7b --- /dev/null +++ b/src/Wallabag/CoreBundle/Resources/views/Security/forgotPassword.html.twig @@ -0,0 +1,31 @@ +{% extends "WallabagCoreBundle::layout.html.twig" %} + +{% block title %}{% trans %}Forgot password{% endtrans %}{% endblock %} + +{% block body_class %}login{% endblock %} + +{% block menu %}{% endblock %} + +{% block content %} +
+
+

{% trans %}Forgot password{% endtrans %}

+ + {{ form_errors(form) }} + +

Enter your email address below and we'll send you password reset instructions.

+ +
+ {{ form_label(form.email) }} + {{ form_errors(form.email) }} + {{ form_widget(form.email) }} +
+ +
+ +
+
+ + {{ form_rest(form) }} +
+{% endblock %} diff --git a/src/Wallabag/CoreBundle/Resources/views/Security/login.html.twig b/src/Wallabag/CoreBundle/Resources/views/Security/login.html.twig index eb8f08c8..f669574e 100644 --- a/src/Wallabag/CoreBundle/Resources/views/Security/login.html.twig +++ b/src/Wallabag/CoreBundle/Resources/views/Security/login.html.twig @@ -5,15 +5,19 @@ {% block body_class %}login{% endblock %} {% block menu %}{% endblock %} +{% block messages %}{% endblock %} {% block content %} - {% if error %} -
{{ error.message }}
- {% endif %} -

{% trans %}Login to wallabag{% endtrans %}

+ {% if error %} +
{{ error.message }}
+ {% endif %} + + {% for flashMessage in app.session.flashbag.get('notice') %} +

{{ flashMessage }}

+ {% endfor %}
@@ -26,7 +30,8 @@
- + + Forgot your password?
diff --git a/src/Wallabag/CoreBundle/Resources/views/Security/reset.html.twig b/src/Wallabag/CoreBundle/Resources/views/Security/reset.html.twig new file mode 100644 index 00000000..fda88af2 --- /dev/null +++ b/src/Wallabag/CoreBundle/Resources/views/Security/reset.html.twig @@ -0,0 +1,35 @@ +{% extends "WallabagCoreBundle::layout.html.twig" %} + +{% block title %}{% trans %}Change password{% endtrans %}{% endblock %} + +{% block body_class %}login{% endblock %} + +{% block menu %}{% endblock %} + +{% block content %} +
+
+

{% trans %}Change password{% endtrans %}

+ + {{ form_errors(form) }} + +
+ {{ form_label(form.new_password.first) }} + {{ form_errors(form.new_password.first) }} + {{ form_widget(form.new_password.first) }} +
+ +
+ {{ form_label(form.new_password.second) }} + {{ form_errors(form.new_password.second) }} + {{ form_widget(form.new_password.second) }} +
+ +
+ +
+
+ + {{ form_rest(form) }} +
+{% endblock %} diff --git a/src/Wallabag/CoreBundle/Tests/Controller/SecurityControllerTest.php b/src/Wallabag/CoreBundle/Tests/Controller/SecurityControllerTest.php index 54cf5073..e02c4d05 100644 --- a/src/Wallabag/CoreBundle/Tests/Controller/SecurityControllerTest.php +++ b/src/Wallabag/CoreBundle/Tests/Controller/SecurityControllerTest.php @@ -3,6 +3,8 @@ namespace Wallabag\CoreBundle\Tests\Controller; use Wallabag\CoreBundle\Tests\WallabagTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; class SecurityControllerTest extends WallabagTestCase { @@ -37,4 +39,99 @@ class SecurityControllerTest extends WallabagTestCase $this->assertContains('Bad credentials', $client->getResponse()->getContent()); } + + public function testForgotPassword() + { + $client = $this->getClient(); + + $crawler = $client->request('GET', '/forgot-password'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + $this->assertContains('Forgot password', $client->getResponse()->getContent()); + + $form = $crawler->filter('button[type=submit]'); + + $this->assertCount(1, $form); + + return array( + 'form' => $form->form(), + 'client' => $client, + ); + } + + /** + * @depends testForgotPassword + */ + public function testSubmitForgotPasswordFail($parameters) + { + $form = $parameters['form']; + $client = $parameters['client']; + + $data = array( + 'forgot_password[email]' => 'baggy', + ); + + $client->submit($form, $data); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + $this->assertContains('No user found with this email', $client->getResponse()->getContent()); + } + + /** + * @depends testForgotPassword + * + * Instead of using collector which slow down the test suite + * http://symfony.com/doc/current/cookbook/email/testing.html + * + * Use a different way where Swift store email as file + */ + public function testSubmitForgotPassword($parameters) + { + $form = $parameters['form']; + $client = $parameters['client']; + + $spoolDir = $client->getKernel()->getContainer()->getParameter('swiftmailer.spool.default.file.path'); + + // cleanup pool dir + $filesystem = new Filesystem(); + $filesystem->remove($spoolDir); + + // to use `getCollector` since `collect: false` in config_test.yml + $client->enableProfiler(); + + $data = array( + 'forgot_password[email]' => 'bobby@wallabag.org', + ); + + $client->submit($form, $data); + + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + + $crawler = $client->followRedirect(); + + $this->assertContains('An email has been sent to', $client->getResponse()->getContent()); + + // find every files (ie: emails) inside the spool dir except hidden files + $finder = new Finder(); + $finder + ->in($spoolDir) + ->ignoreDotFiles(true) + ->files(); + + $this->assertCount(1, $finder, 'Only one email has been sent'); + + foreach ($finder as $file) { + $message = unserialize(file_get_contents($file)); + + $this->assertInstanceOf('Swift_Message', $message); + $this->assertEquals('Reset Password', $message->getSubject()); + $this->assertEquals('no-reply@wallabag.org', key($message->getFrom())); + $this->assertEquals('bobby@wallabag.org', key($message->getTo())); + $this->assertContains( + 'To reset your password - please visit', + $message->getBody() + ); + } + } } -- 2.41.0