Handle forgot password

This commit is contained in:
Jeremy 2015-03-07 23:25:36 +01:00
parent f37d1427a1
commit 6894d48e03
15 changed files with 481 additions and 8 deletions

View file

@ -44,5 +44,11 @@ monolog:
assetic: assetic:
use_controller: true use_controller: true
#swiftmailer: swiftmailer:
# delivery_address: me@example.com # see http://mailcatcher.me/
transport: smtp
host: 'localhost'
port: 1025
username: null
password: null

View file

@ -13,7 +13,9 @@ web_profiler:
intercept_redirects: false intercept_redirects: false
swiftmailer: swiftmailer:
disable_delivery: true # to be able to read emails sent
spool:
type: file
doctrine: doctrine:
dbal: dbal:

View file

@ -41,3 +41,4 @@ parameters:
items_on_page: 12 items_on_page: 12
theme: baggy theme: baggy
language: en_US language: en_US
from_email: no-reply@wallabag.org

View file

@ -59,4 +59,5 @@ security:
- { path: ^/api/salt, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/api/salt, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/forgot-password, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: ROLE_USER } - { path: ^/, roles: ROLE_USER }

View file

@ -2,9 +2,12 @@
namespace Wallabag\CoreBundle\Controller; namespace Wallabag\CoreBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\SecurityContext; use Symfony\Component\Security\Core\SecurityContext;
use Wallabag\CoreBundle\Form\Type\ResetPasswordType;
class SecurityController extends Controller class SecurityController extends Controller
{ {
@ -25,4 +28,123 @@ class SecurityController extends Controller
'error' => $error, '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;
}
} }

View file

@ -77,6 +77,16 @@ class User implements AdvancedUserInterface, \Serializable
*/ */
private $isActive = true; 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 * @var date
* *
@ -377,4 +387,50 @@ class User implements AdvancedUserInterface, \Serializable
{ {
return $this->config; 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;
}
} }

View file

@ -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
);
}
}
}

View file

@ -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';
}
}

View file

@ -22,9 +22,17 @@ services:
- @security.context - @security.context
- %theme% # default theme from parameters.yml - %theme% # default theme from parameters.yml
# custom form type
wallabag_core.form.type.config: wallabag_core.form.type.config:
class: Wallabag\CoreBundle\Form\Type\ConfigType class: Wallabag\CoreBundle\Form\Type\ConfigType
arguments: arguments:
- %liip_theme.themes% - %liip_theme.themes%
tags: tags:
- { name: form.type, alias: config } - { 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 }

View file

@ -0,0 +1,6 @@
Hello {{username}}!
To reset your password - please visit {{confirmationUrl}}
Regards,
Wallabag bot

View file

@ -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 %}

View file

@ -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 %}

View file

@ -5,15 +5,19 @@
{% block body_class %}login{% endblock %} {% block body_class %}login{% endblock %}
{% block menu %}{% endblock %} {% block menu %}{% endblock %}
{% block messages %}{% endblock %}
{% block content %} {% block content %}
<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 %} {% if error %}
<div>{{ error.message }}</div> <div>{{ error.message }}</div>
{% endif %} {% endif %}
<form action="{{ path('login_check') }}" method="post" name="loginform"> {% for flashMessage in app.session.flashbag.get('notice') %}
<fieldset class="w500p center"> <p>{{ flashMessage }}</p>
<h2 class="mbs txtcenter">{% trans %}Login to wallabag{% endtrans %}</h2> {% endfor %}
<div class="row"> <div class="row">
<label class="col w150p" for="username">{% trans %}Username{% endtrans %}</label> <label class="col w150p" for="username">{% trans %}Username{% endtrans %}</label>
@ -26,7 +30,8 @@
</div> </div>
<div class="row mts txtcenter"> <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> </div>
</fieldset> </fieldset>
</form> </form>

View file

@ -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 %}

View file

@ -3,6 +3,8 @@
namespace Wallabag\CoreBundle\Tests\Controller; namespace Wallabag\CoreBundle\Tests\Controller;
use Wallabag\CoreBundle\Tests\WallabagTestCase; use Wallabag\CoreBundle\Tests\WallabagTestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
class SecurityControllerTest extends WallabagTestCase class SecurityControllerTest extends WallabagTestCase
{ {
@ -37,4 +39,99 @@ class SecurityControllerTest extends WallabagTestCase
$this->assertContains('Bad credentials', $client->getResponse()->getContent()); $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()
);
}
}
} }