diff --git a/Controller/PasswordController.php b/Controller/PasswordController.php index f6ea24bea..71382262e 100644 --- a/Controller/PasswordController.php +++ b/Controller/PasswordController.php @@ -10,6 +10,12 @@ use Chill\MainBundle\Entity\User; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Translation\TranslatorInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Chill\MainBundle\Security\PasswordRecover\RecoverPasswordHelper; +use Symfony\Component\HttpFoundation\Response; +use Chill\MainBundle\Security\PasswordRecover\TokenManager; class PasswordController extends Controller { @@ -31,14 +37,30 @@ class PasswordController extends Controller */ protected $chillLogger; + /** + * + * @var RecoverPasswordHelper + */ + protected $recoverPasswordHelper; + + /** + * + * @var TokenManager + */ + protected $tokenManager; + public function __construct( LoggerInterface $chillLogger, UserPasswordEncoderInterface $passwordEncoder, + RecoverPasswordHelper $recoverPasswordHelper, + TokenManager $tokenManager, TranslatorInterface $translator ) { $this->chillLogger = $chillLogger; $this->passwordEncoder = $passwordEncoder; $this->translator = $translator; + $this->tokenManager = $tokenManager; + $this->recoverPasswordHelper = $recoverPasswordHelper; } /** @@ -97,14 +119,148 @@ class PasswordController extends Controller */ private function passwordForm(User $user) { - return $this->createForm( + return $this + ->createForm( UserPasswordType::class, [], - [ 'user' => $this->getUser() ] + [ 'user' => $user ] ) ->add('submit', SubmitType::class, array('label' => 'Change password')) ; } + public function recoverAction(Request $request) + { + $query = $request->query; + $username = $query->get(TokenManager::USERNAME_CANONICAL); + $hash = $query->getAlnum(TokenManager::HASH); + $token = $query->getAlnum(TokenManager::TOKEN); + $timestamp = $query->getInt(TokenManager::TIMESTAMP); + $user = $this->getDoctrine()->getRepository(User::class) + ->findOneByUsernameCanonical($username); + + if (NULL === $user) { + throw $this->createNotFoundException(sprintf('User %s not found', $username)); + } + + if (TRUE !== $this->tokenManager->verify($hash, $token, $user, $timestamp)) { + return new Response("Invalid token", Response::HTTP_FORBIDDEN); + } + + $form = $this->passwordForm($user); + $form->remove('actual_password'); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $password = $form->get('new_password')->getData(); + $user->setPassword($this->passwordEncoder->encodePassword($user, $password)); + // logging for prod + $this + ->chillLogger + ->notice( + 'setting new password for user', + array( + 'user' => $user->getUsername() + ) + ); + + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('password_request_recover_changed'); + } + + return $this->render('@ChillMain/Password/recover_password_form.html.twig', [ + 'form' => $form->createView() + ]); + + } + + public function changeConfirmedAction() + { + return $this->render('@ChillMain/Password/recover_password_changed.html.twig'); + } + + public function requestRecoverAction(Request $request) + { + $form = $this->requestRecoverForm(); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* @var $qb \Doctrine\ORM\QueryBuilder */ + $qb = $this->getDoctrine()->getManager() + ->createQueryBuilder(); + $qb->select('u') + ->from(User::class, 'u') + ->where($qb->expr()->eq('u.usernameCanonical', 'UNACCENT(LOWER(:pattern))')) + ->orWhere($qb->expr()->eq('u.emailCanonical', 'UNACCENT(LOWER(:pattern))' )) + ->setParameter('pattern', $form->get('username_or_email')->getData()) + ; + + $user = $qb->getQuery()->getSingleResult(); + + if (empty($user->getEmail())) { + $this->addFlash('error', $this->translator->trans('This account does not have an email address. ' + . 'Please ask your administrator to renew your password.')); + } else { + $this->recoverPasswordHelper->sendRecoverEmail($user, + (new \DateTimeImmutable('now'))->add(new \DateInterval('PT30M'))); + + // logging for prod + $this + ->chillLogger + ->notice( + 'Sending an email for password recovering', + array( + 'user' => $user->getUsername() + ) + ); + + return $this->redirectToRoute('password_request_recover_confirm'); + } + } + + return $this->render('@ChillMain/Password/request_recover_password.html.twig', [ + 'form' => $form->createView() + ]); + } + + public function requestRecoverConfirmAction() + { + return $this->render('@ChillMain/Password/request_recover_password_confirm.html.twig'); + } + + protected function requestRecoverForm() + { + $builder = $this->createFormBuilder(); + $builder + ->add('username_or_email', TextType::class, [ + 'label' => 'Username or email', + 'constraints' => [ + new Callback([ + 'callback' => function($pattern, ExecutionContextInterface $context, $payload) { + $qb = $this->getDoctrine()->getManager() + ->createQueryBuilder(); + $qb->select('COUNT(u)') + ->from(User::class, 'u') + ->where($qb->expr()->eq('u.usernameCanonical', 'UNACCENT(LOWER(:pattern))')) + ->orWhere($qb->expr()->eq('u.emailCanonical', 'UNACCENT(LOWER(:pattern))' )) + ->setParameter('pattern', $pattern) + ; + + if ((int) $qb->getQuery()->getSingleScalarResult() !== 1) { + $context->addViolation('This username or email does not exists'); + } + } + ]) + ] + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Request recover' + ]); + + return $builder->getForm(); + } } diff --git a/DependencyInjection/ChillMainExtension.php b/DependencyInjection/ChillMainExtension.php index df0686f34..13a335108 100644 --- a/DependencyInjection/ChillMainExtension.php +++ b/DependencyInjection/ChillMainExtension.php @@ -79,6 +79,9 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $container->setParameter('chill_main.pagination.item_per_page', $config['pagination']['item_per_page']); + $container->setParameter('chill_main.notifications', + $config['notifications']); + // add the key 'widget' without the key 'enable' $container->setParameter('chill_main.widgets', isset($config['widgets']['homepage']) ? @@ -100,6 +103,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $loader->load('services/fixtures.yml'); $loader->load('services/menu.yml'); $loader->load('services/security.yml'); + $loader->load('services/notification.yml'); } public function getConfiguration(array $config, ContainerBuilder $container) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 52fce914f..c573a92be 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -67,6 +67,24 @@ class Configuration implements ConfigurationInterface ->end() // end of integer 'item_per_page' ->end() // end of children ->end() // end of pagination + ->arrayNode('notifications') + ->children() + ->scalarNode('from_email') + ->cannotBeEmpty() + ->end() + ->scalarNode('from_name') + ->cannotBeEmpty() + ->end() + ->enumNode('scheme') + ->cannotBeEmpty() + ->values(['http', 'https']) + ->defaultValue('https') + ->end() + ->scalarNode('host') + ->cannotBeEmpty() + ->end() + ->end() + ->end() // end of notifications ->arrayNode('widgets') ->canBeEnabled() ->canBeUnset() diff --git a/Notification/Mailer.php b/Notification/Mailer.php new file mode 100644 index 000000000..2b784b3a1 --- /dev/null +++ b/Notification/Mailer.php @@ -0,0 +1,168 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Notification; + +use Chill\MainBundle\Entity\User; +use Psr\Log\LoggerInterface; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Translation\TranslatorInterface; + +/** + * Classe d'aide pour l'envoi de notification. + * + * Héberge toutes les méthodes pour ré-écrire les URL en fonction de la langue + * de l'utilisateur. + * + */ +class Mailer +{ + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * + * @var \Twig\Environment + */ + protected $twig; + + /** + * + * @var \Swift_Mailer + */ + protected $mailer; + + /** + * + * @var \Swift_Mailer + */ + protected $forcedMailer; + + /** + * + * @var RouterInterface + */ + protected $router; + + /** + * + * @var TranslatorInterface + */ + protected $translator; + + /** + * + * @var array + */ + protected $routeParameters; + + public function __construct( + LoggerInterface $logger, + \Twig\Environment $twig, + \Swift_Mailer $mailer, + // due to bug https://github.com/symfony/swiftmailer-bundle/issues/127 + \Swift_Transport $mailerTransporter, + RouterInterface $router, + TranslatorInterface $translator, + $routeParameters + ) { + $this->logger = $logger; + $this->twig = $twig; + $this->mailer = $mailer; + $this->router = $router; + $this->translator = $translator; + $this->routeParameters = $routeParameters; + $this->forcedMailer = new \Swift_Mailer($mailerTransporter); + } + + + /** + * Envoie une notification à un utilisateur. + * + * @param \User $to + * @param array $subject Subject of the message [ 0 => $message (required), 1 => $parameters (optional), 3 => $domain (optional) ] + * @param array $bodies The bodies. An array where keys are the contentType and values the bodies + * @param \callable $callback a callback to customize the message (add attachment, etc.) + */ + public function sendNotification( + $recipient, + array $subject, + array $bodies, + callable $callback = null, + $force = false + ) + { + $fromEmail = $this->routeParameters['from_email']; + $fromName = $this->routeParameters['from_name']; + $to = $recipient instanceof User ? $recipient->getEmail() : $recipient; + + $subjectI18n = $this->translator->trans( + $subject[0], + $subject[1] ?? [], + $subject[2] ?? null + ); + + $message = (new \Swift_Message($subjectI18n)) + ->setFrom($fromEmail, $fromName) + ->setTo($to) + ; + + foreach ($bodies as $contentType => $content) { + $message->setBody($content, $contentType); + } + + if ($callback !== null) { + \call_user_func($callback, $message); + } + + $this->logger->info("[notification] Sending notification", [ + 'to' => $message->getTo(), + 'subject' => $message->getSubject() + ]); + + $this->sendMessage($message, $force); + } + + public function sendMessage(\Swift_Message $message, $force) + { + if ($force) { + $this->forcedMailer->send($message); + } else { + $this->mailer->send($message); + } + } + + public function renderContentToUser(User $to, $template, array $parameters = array()) + { + $context = $this->router->getContext(); + $previousHost = $context->getHost(); + $previousScheme = $context->getScheme(); + + $context->setHost($this->routeParameters['host']); + $context->setScheme($this->routeParameters['scheme']); + + $content = $this->twig->render($template, $parameters); + + // reset the host + $context->setHost($previousHost); + $context->setScheme($previousScheme); + + return $content; + } +} diff --git a/Resources/config/routing.yml b/Resources/config/routing.yml index 8d1c5fe01..3cb1bd000 100644 --- a/Resources/config/routing.yml +++ b/Resources/config/routing.yml @@ -21,6 +21,14 @@ chill_main_exports: chill_postal_code: resource: "@ChillMainBundle/Resources/config/routing/postal-code.yml" prefix: "{_locale}/postal-code" + +chill_password: + resource: "@ChillMainBundle/Resources/config/routing/password.yml" + prefix: "{_locale}/password" + +chill_password_recover: + resource: "@ChillMainBundle/Resources/config/routing/password_recover.yml" + prefix: "public/{_locale}/password" root: path: / @@ -78,7 +86,3 @@ login_check: logout: path: /logout - -change_my_password: - path: /{_locale}/password/edit - defaults: { _controller: ChillMainBundle:Password:userPassword } \ No newline at end of file diff --git a/Resources/config/routing/password.yml b/Resources/config/routing/password.yml new file mode 100644 index 000000000..ae87e1ee2 --- /dev/null +++ b/Resources/config/routing/password.yml @@ -0,0 +1,4 @@ +change_my_password: + path: /edit + defaults: { _controller: ChillMainBundle:Password:userPassword } + diff --git a/Resources/config/routing/password_recover.yml b/Resources/config/routing/password_recover.yml new file mode 100644 index 000000000..1f5bee846 --- /dev/null +++ b/Resources/config/routing/password_recover.yml @@ -0,0 +1,15 @@ +password_recover: + path: /recover + defaults: { _controller: ChillMainBundle:Password:recover } + +password_request_recover: + path: /request-recover + defaults: { _controller: ChillMainBundle:Password:requestRecover } + +password_request_recover_confirm: + path: /request-confirm + defaults: { _controller: ChillMainBundle:Password:requestRecoverConfirm } + +password_request_recover_changed: + path: /request-changed + defaults: { _controller: ChillMainBundle:Password:changeConfirmed } \ No newline at end of file diff --git a/Resources/config/services/notification.yml b/Resources/config/services/notification.yml new file mode 100644 index 000000000..54d63365c --- /dev/null +++ b/Resources/config/services/notification.yml @@ -0,0 +1,10 @@ +services: + Chill\MainBundle\Notification\Mailer: + arguments: + - "@logger" + - "@twig" + - "@mailer" + - "@swiftmailer.transport" + - "@router" + - "@translator" + - "%chill_main.notifications%" diff --git a/Resources/config/services/security.yml b/Resources/config/services/security.yml index a5c5471f5..a3c80409b 100644 --- a/Resources/config/services/security.yml +++ b/Resources/config/services/security.yml @@ -20,4 +20,15 @@ services: $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' tags: - { name: security.voter } + + Chill\MainBundle\Security\PasswordRecover\TokenManager: + arguments: + $secret: 'secret' + $logger: '@Psr\Log\LoggerInterface' + + Chill\MainBundle\Security\PasswordRecover\RecoverPasswordHelper: + arguments: + $tokenManager: '@Chill\MainBundle\Security\PasswordRecover\TokenManager' + $urlGenerator: '@Symfony\Component\Routing\Generator\UrlGeneratorInterface' + $mailer: '@Chill\MainBundle\Notification\Mailer' \ No newline at end of file diff --git a/Resources/public/modules/login_page/login.scss b/Resources/public/modules/login_page/login.scss index 4c764bdcc..b22592159 100644 --- a/Resources/public/modules/login_page/login.scss +++ b/Resources/public/modules/login_page/login.scss @@ -43,7 +43,7 @@ label { padding-right: 5px; } input { - + color: 'black'; } form { @@ -61,3 +61,12 @@ button { font-weight: 300; } +p.forgot-password-link { + text-align: center; + + a { + font-weight: 300; + color: #fff; + text-decoration: none; + } +} \ No newline at end of file diff --git a/Resources/translations/messages.fr.yml b/Resources/translations/messages.fr.yml index 1d7fc866c..298b34e49 100644 --- a/Resources/translations/messages.fr.yml +++ b/Resources/translations/messages.fr.yml @@ -189,6 +189,22 @@ Choose the format: Choisir le format 'select2.error_loading': Erreur de chargement des résultats 'select2.searching': Recherche en cours... -# page changement mot de passe +# change password Change my password: Modification du mot de passe -Your actual password: Mot de passe actuel \ No newline at end of file +Your actual password: Mot de passe actuel + +# recover password +Forgot your password ?: Mot de passe oublié ? +Recover password: Remplacement du mot de passe +Username or email: Nom d'utilisateur ou email +Request recover: Demande de remplacement +Check your email: Vérifiez votre courriel +An email has been sent to your address. Click on the link inside this email to confirm that you are the owner of this account.: Un courriel a été envoyé à votre adresse. Cliquez sur le lien de cet email pour confirmer que vous êtes bien le propriétaire de ce compte. +You requested to recover your password: Vous avez demandé à renouveler votre mot de passe. +Click on the link below to recover your password: Cliquez sur le lien ci-dessous pour re-générer votre mot de passe +Regards,: Cordialement, +Your administrator: Votre administrateur +Recover your password: Regénération du mot de passe +New password set: Le nouveau mot de passe est enregistré +Your password has been set.: Votre mot de passe a été changé. +Log in with your new password: Connectez-vous avec votre nouveau mot de passe \ No newline at end of file diff --git a/Resources/translations/validators.fr.yml b/Resources/translations/validators.fr.yml index ba260f2c6..d4d2a28aa 100644 --- a/Resources/translations/validators.fr.yml +++ b/Resources/translations/validators.fr.yml @@ -4,6 +4,7 @@ The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle. "The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés." The password fields must match: Les mots de passe doivent correspondre +The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères" A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle. diff --git a/Resources/views/Login/login.html.twig b/Resources/views/Login/login.html.twig index 248ea0e5d..114188f75 100644 --- a/Resources/views/Login/login.html.twig +++ b/Resources/views/Login/login.html.twig @@ -42,9 +42,11 @@
- - + + + + diff --git a/Resources/views/Password/recover_email.txt.twig b/Resources/views/Password/recover_email.txt.twig new file mode 100644 index 000000000..3fb9954be --- /dev/null +++ b/Resources/views/Password/recover_email.txt.twig @@ -0,0 +1,10 @@ +{{ user.username }}, + +{{ 'You requested to recover your password'|trans }} + +{{ 'Click on the link below to recover your password'|trans }} : + +{{ url|raw }} + +{{ 'Regards,'|trans }} +{{ 'Your administrator'|trans }} diff --git a/Resources/views/Password/recover_layout.html.twig b/Resources/views/Password/recover_layout.html.twig new file mode 100644 index 000000000..fa4c58bdb --- /dev/null +++ b/Resources/views/Password/recover_layout.html.twig @@ -0,0 +1,49 @@ +{# + * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, + / + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . +#} + + + + + + + {{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }} + + + + + + + + + +
+ + + {% block content %}{% endblock %} +
+ + diff --git a/Resources/views/Password/recover_password_changed.html.twig b/Resources/views/Password/recover_password_changed.html.twig new file mode 100644 index 000000000..c1d4b54c8 --- /dev/null +++ b/Resources/views/Password/recover_password_changed.html.twig @@ -0,0 +1,14 @@ +{% extends "@ChillMain/Password/recover_layout.html.twig" %} + +{% block title %}{{ "New password set"|trans }}{% endblock %} + +{% block content %} +
+ +

{{ "New password set"|trans }}

+ +

{{ "Your password has been set."|trans }} {{ "Log in with your new password"|trans }}

+ +
+ +{% endblock %} diff --git a/Resources/views/Password/recover_password_form.html.twig b/Resources/views/Password/recover_password_form.html.twig new file mode 100644 index 000000000..83622c25e --- /dev/null +++ b/Resources/views/Password/recover_password_form.html.twig @@ -0,0 +1,25 @@ +{% extends "@ChillMain/Password/recover_layout.html.twig" %} + +{% set title = app.request.get('title', "Recover your password"|trans) %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+ +

{{ title }}

+ + {{ form_start(form) }} + {{ form_row(form.new_password) }} + +
    +
  • + {{ form_widget(form.submit, { 'attr': { 'class': 'sc-button orange' } } ) }} +
  • +
+ + {{ form_end(form) }} + +
+ +{% endblock %} \ No newline at end of file diff --git a/Resources/views/Password/request_recover_password.html.twig b/Resources/views/Password/request_recover_password.html.twig new file mode 100644 index 000000000..f40fbec5a --- /dev/null +++ b/Resources/views/Password/request_recover_password.html.twig @@ -0,0 +1,43 @@ +{# + * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, + / + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . +#} + + +{% extends "@ChillMain/Password/recover_layout.html.twig" %} + + +{% block title %}{{"Recover password"|trans}}{% endblock %} + +{% block content %} +
+ +

{{ 'Recover password'|trans }}

+ + {{ form_start(form) }} + {{ form_row(form.username_or_email) }} + +
    +
  • + {{ form_widget(form.submit, { 'attr': { 'class': 'sc-button orange' } } ) }} +
  • +
+ + {{ form_end(form) }} + +
+ +{% endblock %} diff --git a/Resources/views/Password/request_recover_password_confirm.html.twig b/Resources/views/Password/request_recover_password_confirm.html.twig new file mode 100644 index 000000000..c7431a31d --- /dev/null +++ b/Resources/views/Password/request_recover_password_confirm.html.twig @@ -0,0 +1,15 @@ +{% extends "@ChillMain/Password/recover_layout.html.twig" %} + +{% block title "Check your email"|trans %} + +{% block content %} + +
+ +

{{ 'Check your email'|trans }}

+ +

{{ 'An email has been sent to your address. Click on the link inside this email to confirm that you are the owner of this account.'|trans }}

+ +
+ +{% endblock %} diff --git a/Security/PasswordRecover/RecoverPasswordHelper.php b/Security/PasswordRecover/RecoverPasswordHelper.php new file mode 100644 index 000000000..7df4cf7e5 --- /dev/null +++ b/Security/PasswordRecover/RecoverPasswordHelper.php @@ -0,0 +1,98 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Security\PasswordRecover; + +use Chill\MainBundle\Security\PasswordRecover\TokenManager; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Chill\MainBundle\Notification\Mailer; +use Chill\MainBundle\Entity\User; + +/** + * + * + * @author Julien Fastré + */ +class RecoverPasswordHelper +{ + /** + * + * @var TokenManager + */ + protected $tokenManager; + + /** + * + * @var UrlGeneratorInterface + */ + protected $urlGenerator; + + /** + * + * @var Mailer + */ + protected $mailer; + + const RECOVER_PASSWORD_ROUTE = 'password_recover'; + + public function __construct( + TokenManager $tokenManager, + UrlGeneratorInterface $urlGenerator, + Mailer $mailer + ) { + $this->tokenManager = $tokenManager; + $this->urlGenerator = $urlGenerator; + $this->mailer = $mailer; + } + + + public function generateUrl(User $user, \DateTimeInterface $expiration, $absolute = true) + { + return $this->urlGenerator->generate( + self::RECOVER_PASSWORD_ROUTE, + $this->tokenManager->generate($user, $expiration), + $absolute ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + + public function sendRecoverEmail( + User $user, + \DateTimeInterface $expiration, + $template = '@ChillMain/Password/recover_email.txt.twig', + array $templateParameters = [], + $force = false + ) { + $content = $this->mailer->renderContentToUser( + $user, + $template, + \array_merge([ + 'user' => $user, + 'url' => $this->generateUrl($user, $expiration, true) + ], + $templateParameters + )); + + $this->mailer->sendNotification( + $user, + [ 'Recover your password' ], + [ + 'text/plain' => $content, + ], + null, + $force); + } +} diff --git a/Security/PasswordRecover/TokenManager.php b/Security/PasswordRecover/TokenManager.php new file mode 100644 index 000000000..ea9c3ab7c --- /dev/null +++ b/Security/PasswordRecover/TokenManager.php @@ -0,0 +1,96 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Security\PasswordRecover; + +use Chill\MainBundle\Entity\User; +use Psr\Log\LoggerInterface; + +/** + * + * + * @author Julien Fastré + */ +class TokenManager +{ + /** + * + * @var string + */ + protected $secret; + + /** + * + * @var LoggerInterface + */ + protected $logger; + + const TOKEN = 't'; + const HASH = 'h'; + const TIMESTAMP = 'ts'; + const USERNAME_CANONICAL = 'u'; + + public function __construct($secret, LoggerInterface $logger) + { + $this->secret = $secret; + $this->logger = $logger; + } + + public function generate(User $user, \DateTimeInterface $expiration) + { + $token = \random_bytes(32); + $username = $user->getUsernameCanonical(); + + if (empty($username)) { + throw new \UnexpectedValueException("username should not be empty to generate a token"); + } + + $timestamp = $expiration->getTimestamp(); + $hash = \hash('sha512', $token.$username.$timestamp.$this->secret); + + return [ + self::HASH => $hash, + self::TOKEN => \bin2hex($token), + self::TIMESTAMP => $timestamp, + self::USERNAME_CANONICAL => $username + ]; + } + + public function verify($hash, $token, User $user, $timestamp) + { + $token = \hex2bin($token); + $username = $user->getUsernameCanonical(); + $date = \DateTimeImmutable::createFromFormat('U', $timestamp); + + if ($date < new \DateTime('now')) { + + $this->logger->info('receiving a password recover token with expired ' + . 'validity'); + + return false; + } + + $expected = \hash('sha512', $token.$username.$timestamp.$this->secret); + + if ($expected !== $hash) { + return false; + } + + return true; + } + +} diff --git a/Tests/Security/PasswordRecover/TokenManagerTest.php b/Tests/Security/PasswordRecover/TokenManagerTest.php new file mode 100644 index 000000000..fdfc4f2f0 --- /dev/null +++ b/Tests/Security/PasswordRecover/TokenManagerTest.php @@ -0,0 +1,117 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Tests\PasswordRecover; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Chill\MainBundle\Security\PasswordRecover\TokenManager; +use Chill\MainBundle\Entity\User; + +/** + * + * + * @author Julien Fastré + */ +class TokenManagerTest extends KernelTestCase +{ + protected $tokenManager; + + public static function setUpBefore() + { + + } + + public function setUp() + { + self::bootKernel(); + + $logger = self::$kernel + ->getContainer() + ->get('logger'); + + $this->tokenManager = new TokenManager('secret', $logger); + } + + public function testGenerate() + { + $tokenManager = $this->tokenManager; + $user = (new User())->setUsernameCanonical('test'); + $expiration = new \DateTimeImmutable('tomorrow'); + + $tokens = $tokenManager->generate($user, $expiration); + + $this->assertInternalType('array', $tokens); + $this->assertArrayHasKey('h', $tokens); + $this->assertArrayHasKey('t', $tokens); + $this->assertNotEmpty($tokens['h']); + $this->assertNotEmpty($tokens['t']); + $this->assertEquals($user->getUsernameCanonical(), $tokens['u']); + } + + /** + * @expectedException \UnexpectedValueException + */ + public function testGenerateEmptyUsernameCanonical() + { + $tokenManager = $this->tokenManager; + // set a username, but not a username canonical + $user = (new User())->setUsername('test'); + $expiration = new \DateTimeImmutable('tomorrow'); + + $tokenManager->generate($user, $expiration); + } + + public function testVerify() + { + $tokenManager = $this->tokenManager; + $user = (new User())->setUsernameCanonical('test'); + $expiration = new \DateTimeImmutable('tomorrow'); + + $tokens = $tokenManager->generate($user, $expiration); + + $hash = $tokens[TokenManager::HASH]; + $token = $tokens[TokenManager::TOKEN]; + $timestamp = $tokens[TokenManager::TIMESTAMP]; + + $verification = $tokenManager->verify($hash, $token, $user, $timestamp); + + $this->assertTrue($verification); + + // test with altering token + $this->assertFalse($tokenManager->verify($hash.'5', $token, $user, $timestamp)); + $this->assertFalse($tokenManager->verify($hash, $token.'25', $user, $timestamp)); + $this->assertFalse($tokenManager->verify($hash, $token, $user->setUsernameCanonical('test2'), $timestamp)); + $this->assertFalse($tokenManager->verify($hash, $token, $user, $timestamp+1)); + } + + public function testVerifyExpiredFails() + { + $tokenManager = $this->tokenManager; + $user = (new User())->setUsernameCanonical('test'); + $expiration = (new \DateTimeImmutable('now'))->sub(new \DateInterval('PT1S')); + + $tokens = $tokenManager->generate($user, $expiration); + + $hash = $tokens[TokenManager::HASH]; + $token = $tokens[TokenManager::TOKEN]; + $timestamp = $tokens[TokenManager::TIMESTAMP]; + + $verification = $tokenManager->verify($hash, $token, $user, $timestamp); + + $this->assertFalse($verification); + } +}