Commit d6a350da authored by Bertrand Gauthier's avatar Bertrand Gauthier
Browse files

Ajout de la fonctionnalité 'mot de passe oublié'. Attention: requiert une màj de la BDD.

parent ecfc8ff5
......@@ -5,6 +5,7 @@ use UnicaenAuth\Controller\AuthControllerFactory;
use UnicaenAuth\Service\ShibService;
use UnicaenAuth\Service\ShibServiceFactory;
use UnicaenAuth\Service\UserContextFactory;
use UnicaenAuth\Service\UserMapperFactory;
use UnicaenAuth\View\Helper\LdapConnectViewHelperFactory;
use UnicaenAuth\View\Helper\LocalConnectViewHelperFactory;
use UnicaenAuth\View\Helper\ShibConnectViewHelperFactory;
......@@ -154,6 +155,8 @@ return [
['controller' => 'UnicaenAuth\Controller\Utilisateur', 'action' => 'selectionner-profil', 'roles' => []],
['controller' => 'UnicaenAuth\Controller\Auth', 'action' => 'shibboleth', 'roles' => []],
['controller' => 'UnicaenAuth\Controller\Auth', 'action' => 'requestPasswordReset', 'roles' => []],
['controller' => 'UnicaenAuth\Controller\Auth', 'action' => 'changePassword', 'roles' => []],
],
],
],
......@@ -221,6 +224,24 @@ return [
],
],
],
'requestPasswordReset' => [
'type' => 'Segment',
'options' => [
'route' => '/request-password-reset',
'defaults' => [
'action' => 'requestPasswordReset',
],
],
],
'changePassword' => [
'type' => 'Segment',
'options' => [
'route' => '/change-password/:token',
'defaults' => [
'action' => 'changePassword',
],
],
],
],
],
'zfcuser' => [
......@@ -428,6 +449,7 @@ return [
'zfcuser_redirect_callback' => 'UnicaenAuth\Authentication\RedirectCallbackFactory', // substituion
ShibService::class => ShibServiceFactory::class,
'UnicaenAuth\Service\UserContext' => UserContextFactory::class,
'zfcuser_user_mapper' => UserMapperFactory::class,
'MouchardCompleterAuth' => 'UnicaenAuth\Mouchard\MouchardCompleterAuthFactory',
],
'shared' => [
......
......@@ -9,6 +9,10 @@ CREATE TABLE user (
UNIQUE INDEX `unique_username` (`username` ASC)
) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8 COLLATE = utf8_unicode_ci;
alter table user add PASSWORD_RESET_TOKEN varchar2(256) default null;
create unique index USER_PASSWORD_RESET_TOKEN_UN on user (PASSWORD_RESET_TOKEN);
CREATE TABLE IF NOT EXISTS `user_role` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
......
......@@ -10,6 +10,10 @@ CREATE TABLE "USER"
);
CREATE SEQUENCE "USER_ID_SEQ" ;
alter table "USER" add PASSWORD_RESET_TOKEN varchar2(256) default null;
create unique index USER_PASSWORD_RESET_TOKEN_UN on "USER" (PASSWORD_RESET_TOKEN);
CREATE TABLE USER_ROLE
( "ID" NUMBER(*,0) NOT NULL ENABLE,
......
......@@ -8,6 +8,10 @@ CREATE TABLE "user" (
) ;
CREATE UNIQUE INDEX user_username_unique ON "user" (username);
alter table "user" add PASSWORD_RESET_TOKEN varchar2(256) default null;
create unique index USER_PASSWORD_RESET_TOKEN_UN on "user" (PASSWORD_RESET_TOKEN);
CREATE TABLE user_role (
id BIGSERIAL PRIMARY KEY,
role_id VARCHAR(64) NOT NULL,
......
......@@ -2,19 +2,27 @@
namespace UnicaenAuth\Controller;
use Doctrine\ORM\NoResultException;
use UnicaenApp\Controller\Plugin\AppInfos;
use UnicaenApp\Controller\Plugin\Mail;
use UnicaenApp\Exception\RuntimeException;
use UnicaenAuth\Service\Traits\ShibServiceAwareTrait;
use UnicaenAuth\Service\Traits\UserServiceAwareTrait;
use Zend\Authentication\AuthenticationService;
use Zend\Authentication\Exception\ExceptionInterface;
use Zend\Http\Request;
use Zend\Http\Response;
use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use ZfcUser\Controller\Plugin\ZfcUserAuthentication;
/**
* Classe ajoutée lors de l'implémentation de l'auth Shibboleth.
*
* @method ZfcUserAuthentication zfcUserAuthentication()
* @method AppInfos appInfos()
* @method Mail mail()
*
* @author Bertrand GAUTHIER <bertrand.gauthier at unicaen.fr>
*/
class AuthController extends AbstractActionController
......@@ -108,25 +116,113 @@ class AuthController extends AbstractActionController
}
}
public function sendPasswordRenewalMailAction()
/**
* @return Response|ViewModel
*/
public function requestPasswordResetAction()
{
// lecture email fourni
$form = $this->userService->createResetPasswordEmailForm();
$view = new ViewModel();
$view->setVariable('form', $form);
$view->setTemplate('unicaen-auth/auth/request-password-reset-form');
/** @var Request $request */
$request = $this->getRequest();
if ($request->isPost()) {
$data = $request->getPost();
$form->setData($data);
if ($form->isValid()) {
$email = $data['email'];
$this->processPasswordResetRequest($email);
$view->setVariable('email', $email);
$view->setTemplate('unicaen-auth/auth/request-password-reset-success');
}
}
// tester email connu dans table utilisateur
return $view;
}
// générer / enregistrer token dans table utilisateur
private function processPasswordResetRequest($email)
{
try {
$token = $this->userService->updateUserPasswordResetToken($email);
} catch (NoResultException $nre) {
// aucun utilisateur trouvé tel que username = $email
return;
}
// envoyer mail avec lien/token
// envoi du mail contenant le lien de changement de mdp
$app = $this->appInfos()->getNom();
$subject = "[$app] Demande de changement de mot de passe";
$changePasswordUrl = $this->url()->fromRoute('auth/changePassword', ['token' => $token], ['force_canonical' => true]);
$body = <<<EOS
<p>Une demande de changement de mot de passe a été faite sur l'application $app.</p>
<p>Si vous n'en êtes pas l'auteur, vous pouvez ignorer ce message.</p>
<p>Cliquez sur le lien suivant pour accéder au formulaire de changement de votre mot de passe :<br><a href='$changePasswordUrl'>$changePasswordUrl</a></p>
EOS;
$message = $this->mail()->createNewMessage($body, $subject);
$message->setTo($email);
$this->mail()->send($message);
}
/**
* @return array|ViewModel
*/
public function changePasswordAction()
{
// lecture token fourni
$token = $this->params()->fromRoute('token');
$view = new ViewModel();
// test token fourni existe dans table utilisateur
// recherche du token spécifié dans table utilisateur
$user = $this->userService->getUserMapper()->findOneByPasswordResetToken($token);
if ($user === null) {
// token inexistant
$view->setVariable('result', 'unknown_token');
$view->setTemplate('unicaen-auth/auth/change-password-result');
return $view;
}
$form = $this->userService->createPasswordChangeForm();
/** @var Request $request */
$request = $this->getRequest();
if ($request->isPost()) {
$data = $request->getPost();
$form->setData($data);
if ($form->isValid()) {
// màj password
$password = $this->params()->fromPost('password');
$this->userService->updateUserPassword($user, $password);
$view->setVariable('result', 'success');
$view->setTemplate('unicaen-auth/auth/change-password-result');
// todo: faut-il déconnecter l'utilisateur (attention au logout shib différent) ?
return $view;
}
}
// test durée de vie du token
$date = $this->userService->extractDateFromResetPasswordToken($token);
if ($date < date_create()) {
// token expiré, on le raz
$this->userService->clearUserPasswordResetToken($user);
$view->setVariable('result', 'dead_token');
$view->setTemplate('unicaen-auth/auth/change-password-result');
return $view;
}
// afficher formulaire de màj
$view->setVariable('form', $form);
$view->setTemplate('unicaen-auth/auth/change-password-form');
// màj password
return $view;
}
}
\ No newline at end of file
......@@ -52,6 +52,12 @@ abstract class AbstractUser implements UserInterface, ProviderInterface
*/
protected $state;
/**
* @var string
* @ORM\Column(type="string", length=256)
*/
protected $passwordResetToken;
/**
* @var Collection
* @ORM\ManyToMany(targetEntity="UnicaenAuth\Entity\Db\Role")
......@@ -202,6 +208,22 @@ abstract class AbstractUser implements UserInterface, ProviderInterface
$this->state = $state;
}
/**
* @return string
*/
public function getPasswordResetToken()
{
return $this->passwordResetToken;
}
/**
* @param string $passwordResetToken
*/
public function setPasswordResetToken($passwordResetToken = null)
{
$this->passwordResetToken = $passwordResetToken;
}
/**
* Get role.
*
......
......@@ -2,24 +2,34 @@
namespace UnicaenAuth\Service;
use UnicaenAuth\Event\UserAuthenticatedEvent;
use PDOException;
use DateTime;
use Doctrine\ORM\NoResultException;
use Ramsey\Uuid\Uuid;
use UnicaenApp\Entity\Ldap\People;
use UnicaenApp\Exception\RuntimeException;
use UnicaenApp\Mapper\Ldap\People as LdapPeopleMapper;
use UnicaenAuth\Entity\Shibboleth\ShibUser;
use UnicaenAuth\Event\UserAuthenticatedEvent;
use UnicaenAuth\Options\ModuleOptions;
use Zend\Crypt\Password\Bcrypt;
use Zend\EventManager\EventManagerAwareInterface;
use Zend\EventManager\EventManagerInterface;
use Zend\Form\Element\Csrf;
use Zend\Form\Element\Password;
use Zend\Form\Element\Submit;
use Zend\Form\Element\Text;
use Zend\Form\Form;
use Zend\InputFilter\Input;
use Zend\ServiceManager\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\Validator\Identical;
use ZfcUser\Entity\UserInterface;
use ZfcUser\Options\AuthenticationOptionsInterface;
use ZfcUser\Options\ModuleOptions as ZfcUserModuleOptions;
use UnicaenAuth\Entity\Db\AbstractUser;
/**
* Service d'enregistrement dans la table des utilisateurs de l'application
* de l'utilisateur authentifié avec succès.
* Service traitant des utilisateurs locaux de l'application.
*
* @see \UnicaenAuth\Authentication\Adapter\AbstractFactory
* @author Unicaen
......@@ -45,15 +55,20 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
*/
protected $zfcUserOptions;
/**
* @var UserMapper
*/
protected $userMapper;
/**
* @var LdapPeopleMapper
*/
protected $ldapPeopleMapper;
/**
* Save authenticated user in database from LDAP data.
* Save authenticated user in database from LDAP or Shibboleth data.
*
* @param UserInterface|People $userData
* @param People|ShibUser $userData
* @return bool
*/
public function userAuthenticated($userData)
......@@ -68,7 +83,6 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
$email = $userData->getMail();
$password = 'ldap';
$state = in_array('deactivated', ldap_explode_dn($userData->getDn(), 1)) ? 0 : 1;
break;
case $userData instanceof ShibUser:
$username = $userData->getUsername();
......@@ -94,46 +108,42 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
throw new RuntimeException("Identité rencontrée inattendue.");
}
// update/insert de l'utilisateur dans la table de l'appli
$mapper = $this->getServiceLocator()->get('zfcuser_user_mapper'); /* @var $mapper \ZfcUserDoctrineORM\Mapper\User */
try {
/** @var UserInterface $entity */
$entity = $mapper->findByUsername($username);
if (!$entity) {
$entityClass = $this->getZfcUserOptions()->getUserEntityClass();
$entity = new $entityClass;
$entity->setUsername($username);
$method = 'insert';
}
else {
$method = 'update';
}
$entity->setEmail($email);
$entity->setDisplayName($userData->getDisplayName());
$entity->setPassword($password);
$entity->setState($state);
// pre-persist
$event = new UserAuthenticatedEvent(UserAuthenticatedEvent::PRE_PERSIST);
$this->triggerEvent($event, $entity, $userData);
// persist
$mapper->$method($entity);
// post-persist
$event = new UserAuthenticatedEvent(UserAuthenticatedEvent::POST_PERSIST);
$this->triggerEvent($event, $entity, $userData);
$mapper = $this->getUserMapper();
/** @var UserInterface $entity */
$entity = $mapper->findByUsername($username);
if (!$entity) {
$entityClass = $this->getZfcUserOptions()->getUserEntityClass();
$entity = new $entityClass;
$entity->setUsername($username);
$method = 'insert';
}
catch (PDOException $pdoe) {
throw new RuntimeException("Impossible d'enregistrer l'utilisateur authentifié dans la base de données.", null, $pdoe);
else {
$method = 'update';
}
$entity->setEmail($email);
$entity->setDisplayName($userData->getDisplayName());
$entity->setPassword($password);
$entity->setState($state);
// pre-persist
$event = new UserAuthenticatedEvent(UserAuthenticatedEvent::PRE_PERSIST);
$this->triggerEvent($event, $entity, $userData);
// persist
$mapper->$method($entity);
// post-persist
$event = new UserAuthenticatedEvent(UserAuthenticatedEvent::POST_PERSIST);
$this->triggerEvent($event, $entity, $userData);
return true;
}
/**
* @param UserInterface $entity
* @param People|ShibUser $userData
* @param UserAuthenticatedEvent $event
* @param UserInterface $entity
* @param People|ShibUser $userData
*/
private function triggerEvent(UserAuthenticatedEvent $event, $entity, $userData)
{
......@@ -148,6 +158,18 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
$this->getEventManager()->trigger($event);
}
/**
* @return UserMapper
*/
public function getUserMapper()
{
if ($this->userMapper === null) {
$this->userMapper = $this->getServiceLocator()->get('zfcuser_user_mapper');
}
return $this->userMapper;
}
/**
* Retrieve the event manager
*
......@@ -222,4 +244,137 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
return $this->zfcUserOptions;
}
const PASSWORD_RESET_TOKEN_SEP = '-';
const PASSWORD_RESET_TOKEN_DATE_FORMAT = 'YmdHis';
/**
* Construit le formulaire de saisie de l'adresse électronique à laquelle envoyer le lien de
* changement de mot de passe.
*
* @return Form
*/
public function createResetPasswordEmailForm()
{
$form = new Form();
$form->add((new Text('email'))->setLabel("Adresse électronique :"));
$form->add((new Csrf('csrf')));
$form->add((new Submit('submit'))->setLabel("Envoyer le lien"));
$form->getInputFilter()->add((new Input('email'))->setRequired(true));
return $form;
}
/**
* Construit le formulaire de saisie d'un nouveau mot de passe.
*
* @return Form
*/
public function createPasswordChangeForm()
{
$form = new Form();
$form->add((new Password('password'))->setLabel("Nouveau mot de passe :"));
$form->add((new Password('passwordbis'))->setLabel("Confirmation du nouveau mot de passe :"));
$form->add((new Csrf('csrf')));
$form->add((new Submit('submit'))->setLabel("Enregistrer"));
$form->getInputFilter()->add((new Input('password'))->setRequired(true));
$passwordbisInput = (new Input('passwordbis'))->setRequired(true);
$passwordbisInput->getValidatorChain()->attach(new Identical('password'));
$form->getInputFilter()->add($passwordbisInput);
return $form;
}
/**
* Si l'utilisateur dont le username égale l'email spécifié est trouvé,
* génère puis enregistre le token permettant d'autoriser cet utilisateur à changer son mot de passe.
*
* @param string $email Email de l'utilisateur qui doit être aussi son username
* @return string|null Token généré
* @throws NoResultException Aucun utilisateur trouvé avec cet email
*/
public function updateUserPasswordResetToken($email)
{
// Si l'email est inconnu, on ne fera rien mais on ne le signale pas sinon le formulaire permettrait
// de tester si des emails potentiellement valides existent dans la base.
$user = $this->getUserMapper()->findByEmail($email); /** @var User $user */
if ($user === null) {
throw new NoResultException();
}
// Génération du token.
$token = $this->generatePasswordResetToken();
// Enregistrement du token dans la table des utilisateurs
$user->setPasswordResetToken($token);
$this->getUserMapper()->update($user);
return $token;
}
/**
* @param AbstractUser $user
*/
public function clearUserPasswordResetToken(AbstractUser $user)
{
$user->setPasswordResetToken(null);
$this->getUserMapper()->update($user);
}
/**
* @param AbstractUser $user
* @param string $password
*/
public function updateUserPassword(AbstractUser $user, $password)
{
$bcrypt = new Bcrypt();
$bcrypt->setCost($this->getZfcUserOptions()->getPasswordCost());
$password = $bcrypt->create($password);
$user->setPasswordResetToken(null);
$user->setPassword($password);
$this->getUserMapper()->update($user);
}
/**
* Génération d'un token pour la demande de renouvellement de mot de passe.
*
* @return string
*/
public function generatePasswordResetToken()
{
// NB: la date de fin de vie du token est concaténée à la fin.
$token = Uuid::uuid4()->toString() . self::PASSWORD_RESET_TOKEN_SEP . date('YmdHis', time() + 3600*24);
// durée de vie = 24h
return $token;
}
/**
* Génération du motif permettant de rechercher un token dans la table des utilisateurs.
*
* Rappel: la date de génération est concaténée à la fin.
*
* @param string $tokenUnderTest Le token recherché
* @return string
*/
public function generatePasswordResetTokenSearchPattern($tokenUnderTest)
{
return $tokenUnderTest . self::PASSWORD_RESET_TOKEN_SEP . '%';
}
/**
* Extrait la date de fin de vie d'un token.
*
* @param string $token
* @return DateTime
*/
public function extractDateFromResetPasswordToken($token)
{
$ts = ltrim(strrchr($token, $sep = self::PASSWORD_RESET_TOKEN_SEP), $sep);
$date = DateTime::createFromFormat(self::PASSWORD_RESET_TOKEN_DATE_FORMAT, $ts);
return $date;
}
}
\ No newline at end of file
<?php
namespace UnicaenAuth\Service;
use UnicaenAuth\Entity\Db\User;
use ZfcUserDoctrineORM\Mapper\User as ZfcUserDoctrineORMUserMapper;
class UserMapper extends ZfcUserDoctrineORMUserMapper
{
/**
* @param string $token
* @return User
*/
public function findOneByPasswordResetToken($token)
{
/** @var User $user */
$user = $this->em->getRepository($this->options->getUserEntityClass())->findOneBy(['passwordResetToken' => $token]);
return $user;
}
}
\ No newline at end of file
<?php
namespace UnicaenAuth\Service;
use Doctrine\ORM\EntityManagerInterface;