]> git.immae.eu Git - github/wallabag/wallabag.git/commitdiff
Handle forgot password
authorJeremy <jeremy.benoist@gmail.com>
Sat, 7 Mar 2015 22:25:36 +0000 (23:25 +0100)
committerJeremy <jeremy.benoist@gmail.com>
Sun, 8 Mar 2015 06:35:24 +0000 (07:35 +0100)
15 files changed:
app/config/config_dev.yml
app/config/config_test.yml
app/config/parameters.yml.dist
app/config/security.yml
src/Wallabag/CoreBundle/Controller/SecurityController.php
src/Wallabag/CoreBundle/Entity/User.php
src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Resources/config/services.yml
src/Wallabag/CoreBundle/Resources/views/Mail/forgotPassword.txt.twig [new file with mode: 0644]
src/Wallabag/CoreBundle/Resources/views/Security/checkEmail.html.twig [new file with mode: 0644]
src/Wallabag/CoreBundle/Resources/views/Security/forgotPassword.html.twig [new file with mode: 0644]
src/Wallabag/CoreBundle/Resources/views/Security/login.html.twig
src/Wallabag/CoreBundle/Resources/views/Security/reset.html.twig [new file with mode: 0644]
src/Wallabag/CoreBundle/Tests/Controller/SecurityControllerTest.php

index efaf396e937cf8badaa0edb28aabacdf4a5507e5..205e0f66baee3c678882d8b8d6ca7a8a73455095 100644 (file)
@@ -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
+
index a6ead1e82386f172efc7ca77abbfb2ae17d008b7..00a6bc578cdb33cfd87065d9e2566fc4abe55993 100644 (file)
@@ -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:
index 449697505e1c13f5734c033515d25718c75397b0..8f9670113c7b780547bfb9b11c9967287f57363e 100644 (file)
@@ -41,3 +41,4 @@ parameters:
     items_on_page: 12
     theme: baggy
     language: en_US
+    from_email: no-reply@wallabag.org
index e06c89672aa7712552ece39759c35ec2034aa5ad..90903f310b311b428d245784811a956abdb2a04e 100644 (file)
@@ -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 }
index c2901da2eb2ccfc9b9cbdd4515a05c1c4ebeadef..5007307afcafa26dbab50ec6082e79036a5314ca 100644 (file)
@@ -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;
+    }
 }
index f05c8760e03e2780f5cbbf85e302f93fe2327f26..6a7619ac2ab3addb7892966070cd1d7bd6b0745f 100644 (file)
@@ -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 (file)
index 0000000..c278b84
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+namespace Wallabag\CoreBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Validator\Constraints;
+use Symfony\Component\Validator\ExecutionContextInterface;
+use Doctrine\Bundle\DoctrineBundle\Registry;
+
+class ForgotPasswordType extends AbstractType
+{
+    private $doctrine = null;
+
+    public function __construct(Registry $doctrine)
+    {
+        $this->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 (file)
index 0000000..50ae800
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+namespace Wallabag\CoreBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Validator\Constraints;
+
+class ResetPasswordType extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder
+            ->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';
+    }
+}
index c734a3a57784ed817acc2d624e07b4b014ce567e..062e1651fd7d2448a133048fc9ffc2cda385491c 100644 (file)
@@ -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 (file)
index 0000000..631bcb8
--- /dev/null
@@ -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 (file)
index 0000000..056d65b
--- /dev/null
@@ -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 %}
+    <form>
+        <fieldset class="w500p center">
+            <h2 class="mbs txtcenter">{% trans %}Forgot password{% endtrans %}</h2>
+
+            <p>{{ 'An email has been sent to %email%. It contains a link you must click to reset your password.'|trans({'%email%': email}) }}</p>
+        </fieldset>
+    </form>
+{% 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 (file)
index 0000000..4476ea7
--- /dev/null
@@ -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 %}
+        <form action="{{ path('forgot_password') }}" method="post" name="forgotPasswordform">
+            <fieldset class="w500p center">
+                <h2 class="mbs txtcenter">{% trans %}Forgot password{% endtrans %}</h2>
+
+                {{ form_errors(form) }}
+
+                <p>Enter your email address below and we'll send you password reset instructions.</p>
+
+                <div class="row">
+                    {{ form_label(form.email) }}
+                    {{ form_errors(form.email) }}
+                    {{ form_widget(form.email) }}
+                </div>
+
+                <div class="row mts txtcenter">
+                    <button type="submit">Send me reset instructions</button>
+                </div>
+            </fieldset>
+
+            {{ form_rest(form) }}
+        </form>
+{% endblock %}
index eb8f08c825cbf51857c2e13283b6d31a63d24b71..f669574e8602cbbca83a2cb35f602a7d1048c737 100644 (file)
@@ -5,15 +5,19 @@
 {% block body_class %}login{% endblock %}
 
 {% block menu %}{% endblock %}
+{% block messages %}{% endblock %}
 
 {% block content %}
-    {% if error %}
-        <div>{{ error.message }}</div>
-    {% endif %}
-
         <form action="{{ path('login_check') }}" method="post" name="loginform">
             <fieldset class="w500p center">
                 <h2 class="mbs txtcenter">{% trans %}Login to wallabag{% endtrans %}</h2>
+                {% if error %}
+                    <div>{{ error.message }}</div>
+                {% endif %}
+
+                {% for flashMessage in app.session.flashbag.get('notice') %}
+                    <p>{{ flashMessage }}</p>
+                {% endfor %}
 
                 <div class="row">
                     <label class="col w150p" for="username">{% trans %}Username{% endtrans %}</label>
@@ -26,7 +30,8 @@
                 </div>
 
                 <div class="row mts txtcenter">
-                    <button type="submit">login</button>
+                    <button type="submit">Login</button>
+                    <a href="{{ path('forgot_password') }}" class="small">Forgot your password?</a>
                 </div>
             </fieldset>
         </form>
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 (file)
index 0000000..fda88af
--- /dev/null
@@ -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 %}
+        <form action="{{ path('forgot_password_reset', {'token': token}) }}" method="post" name="loginform">
+            <fieldset class="w500p center">
+                <h2 class="mbs txtcenter">{% trans %}Change password{% endtrans %}</h2>
+
+                {{ form_errors(form) }}
+
+                <div class="row">
+                    {{ form_label(form.new_password.first) }}
+                    {{ form_errors(form.new_password.first) }}
+                    {{ form_widget(form.new_password.first) }}
+                </div>
+
+                <div class="row">
+                    {{ form_label(form.new_password.second) }}
+                    {{ form_errors(form.new_password.second) }}
+                    {{ form_widget(form.new_password.second) }}
+                </div>
+
+                <div class="row mts txtcenter">
+                    <button type="submit">Change password</button>
+                </div>
+            </fieldset>
+
+            {{ form_rest(form) }}
+        </form>
+{% endblock %}
index 54cf5073e5c946175272cd2cab5241b1fcc99f55..e02c4d0533fe04614b6c014c414f724d7a9aa048 100644 (file)
@@ -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()
+            );
+        }
+    }
 }