mirror of
https://github.com/wallabag/wallabag.git
synced 2024-11-23 01:21:03 +00:00
Handle forgot password
This commit is contained in:
parent
f37d1427a1
commit
6894d48e03
15 changed files with 481 additions and 8 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -41,3 +41,4 @@ parameters:
|
|||
items_on_page: 12
|
||||
theme: baggy
|
||||
language: en_US
|
||||
from_email: no-reply@wallabag.org
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
52
src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php
Normal file
52
src/Wallabag/CoreBundle/Form/Type/ForgotPasswordType.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
34
src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php
Normal file
34
src/Wallabag/CoreBundle/Form/Type/ResetPasswordType.php
Normal 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';
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
Hello {{username}}!
|
||||
|
||||
To reset your password - please visit {{confirmationUrl}}
|
||||
|
||||
Regards,
|
||||
Wallabag bot
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue