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
+
intercept_redirects: false
swiftmailer:
- disable_delivery: true
+ # to be able to read emails sent
+ spool:
+ type: file
doctrine:
dbal:
items_on_page: 12
theme: baggy
language: en_US
+ from_email: no-reply@wallabag.org
- { 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 }
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
{
'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;
+ }
}
*/
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
*
{
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;
+ }
}
--- /dev/null
+<?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
+ );
+ }
+ }
+}
--- /dev/null
+<?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';
+ }
+}
- @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 }
--- /dev/null
+Hello {{username}}!
+
+To reset your password - please visit {{confirmationUrl}}
+
+Regards,
+Wallabag bot
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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 %}
{% 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>
</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>
--- /dev/null
+{% 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 %}
namespace Wallabag\CoreBundle\Tests\Controller;
use Wallabag\CoreBundle\Tests\WallabagTestCase;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Finder;
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()
+ );
+ }
+ }
}