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; ...@@ -5,6 +5,7 @@ use UnicaenAuth\Controller\AuthControllerFactory;
use UnicaenAuth\Service\ShibService; use UnicaenAuth\Service\ShibService;
use UnicaenAuth\Service\ShibServiceFactory; use UnicaenAuth\Service\ShibServiceFactory;
use UnicaenAuth\Service\UserContextFactory; use UnicaenAuth\Service\UserContextFactory;
use UnicaenAuth\Service\UserMapperFactory;
use UnicaenAuth\View\Helper\LdapConnectViewHelperFactory; use UnicaenAuth\View\Helper\LdapConnectViewHelperFactory;
use UnicaenAuth\View\Helper\LocalConnectViewHelperFactory; use UnicaenAuth\View\Helper\LocalConnectViewHelperFactory;
use UnicaenAuth\View\Helper\ShibConnectViewHelperFactory; use UnicaenAuth\View\Helper\ShibConnectViewHelperFactory;
...@@ -154,6 +155,8 @@ return [ ...@@ -154,6 +155,8 @@ return [
['controller' => 'UnicaenAuth\Controller\Utilisateur', 'action' => 'selectionner-profil', 'roles' => []], ['controller' => 'UnicaenAuth\Controller\Utilisateur', 'action' => 'selectionner-profil', 'roles' => []],
['controller' => 'UnicaenAuth\Controller\Auth', 'action' => 'shibboleth', '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 [ ...@@ -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' => [ 'zfcuser' => [
...@@ -428,6 +449,7 @@ return [ ...@@ -428,6 +449,7 @@ return [
'zfcuser_redirect_callback' => 'UnicaenAuth\Authentication\RedirectCallbackFactory', // substituion 'zfcuser_redirect_callback' => 'UnicaenAuth\Authentication\RedirectCallbackFactory', // substituion
ShibService::class => ShibServiceFactory::class, ShibService::class => ShibServiceFactory::class,
'UnicaenAuth\Service\UserContext' => UserContextFactory::class, 'UnicaenAuth\Service\UserContext' => UserContextFactory::class,
'zfcuser_user_mapper' => UserMapperFactory::class,
'MouchardCompleterAuth' => 'UnicaenAuth\Mouchard\MouchardCompleterAuthFactory', 'MouchardCompleterAuth' => 'UnicaenAuth\Mouchard\MouchardCompleterAuthFactory',
], ],
'shared' => [ 'shared' => [
......
...@@ -9,6 +9,10 @@ CREATE TABLE user ( ...@@ -9,6 +9,10 @@ CREATE TABLE user (
UNIQUE INDEX `unique_username` (`username` ASC) UNIQUE INDEX `unique_username` (`username` ASC)
) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8 COLLATE = utf8_unicode_ci; ) 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` ( CREATE TABLE IF NOT EXISTS `user_role` (
`id` INT(11) NOT NULL AUTO_INCREMENT, `id` INT(11) NOT NULL AUTO_INCREMENT,
......
...@@ -10,6 +10,10 @@ CREATE TABLE "USER" ...@@ -10,6 +10,10 @@ CREATE TABLE "USER"
); );
CREATE SEQUENCE "USER_ID_SEQ" ; 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 CREATE TABLE USER_ROLE
( "ID" NUMBER(*,0) NOT NULL ENABLE, ( "ID" NUMBER(*,0) NOT NULL ENABLE,
......
...@@ -8,6 +8,10 @@ CREATE TABLE "user" ( ...@@ -8,6 +8,10 @@ CREATE TABLE "user" (
) ; ) ;
CREATE UNIQUE INDEX user_username_unique ON "user" (username); 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 ( CREATE TABLE user_role (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
role_id VARCHAR(64) NOT NULL, role_id VARCHAR(64) NOT NULL,
......
...@@ -2,19 +2,27 @@ ...@@ -2,19 +2,27 @@
namespace UnicaenAuth\Controller; namespace UnicaenAuth\Controller;
use Doctrine\ORM\NoResultException;
use UnicaenApp\Controller\Plugin\AppInfos;
use UnicaenApp\Controller\Plugin\Mail;
use UnicaenApp\Exception\RuntimeException; use UnicaenApp\Exception\RuntimeException;
use UnicaenAuth\Service\Traits\ShibServiceAwareTrait; use UnicaenAuth\Service\Traits\ShibServiceAwareTrait;
use UnicaenAuth\Service\Traits\UserServiceAwareTrait; use UnicaenAuth\Service\Traits\UserServiceAwareTrait;
use Zend\Authentication\AuthenticationService; use Zend\Authentication\AuthenticationService;
use Zend\Authentication\Exception\ExceptionInterface; use Zend\Authentication\Exception\ExceptionInterface;
use Zend\Http\Request;
use Zend\Http\Response; use Zend\Http\Response;
use Zend\Mvc\Controller\AbstractActionController; use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use ZfcUser\Controller\Plugin\ZfcUserAuthentication; use ZfcUser\Controller\Plugin\ZfcUserAuthentication;
/** /**
* Classe ajoutée lors de l'implémentation de l'auth Shibboleth. * Classe ajoutée lors de l'implémentation de l'auth Shibboleth.
* *
* @method ZfcUserAuthentication zfcUserAuthentication() * @method ZfcUserAuthentication zfcUserAuthentication()
* @method AppInfos appInfos()
* @method Mail mail()
*
* @author Bertrand GAUTHIER <bertrand.gauthier at unicaen.fr> * @author Bertrand GAUTHIER <bertrand.gauthier at unicaen.fr>
*/ */
class AuthController extends AbstractActionController class AuthController extends AbstractActionController
...@@ -108,25 +116,113 @@ 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() 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 ...@@ -52,6 +52,12 @@ abstract class AbstractUser implements UserInterface, ProviderInterface
*/ */
protected $state; protected $state;
/**
* @var string
* @ORM\Column(type="string", length=256)
*/
protected $passwordResetToken;
/** /**
* @var Collection * @var Collection
* @ORM\ManyToMany(targetEntity="UnicaenAuth\Entity\Db\Role") * @ORM\ManyToMany(targetEntity="UnicaenAuth\Entity\Db\Role")
...@@ -202,6 +208,22 @@ abstract class AbstractUser implements UserInterface, ProviderInterface ...@@ -202,6 +208,22 @@ abstract class AbstractUser implements UserInterface, ProviderInterface
$this->state = $state; $this->state = $state;
} }
/**
* @return string
*/
public function getPasswordResetToken()
{
return $this->passwordResetToken;
}
/**
* @param string $passwordResetToken
*/
public function setPasswordResetToken($passwordResetToken = null)
{
$this->passwordResetToken = $passwordResetToken;
}
/** /**
* Get role. * Get role.
* *
......
...@@ -2,24 +2,34 @@ ...@@ -2,24 +2,34 @@
namespace UnicaenAuth\Service; namespace UnicaenAuth\Service;
use UnicaenAuth\Event\UserAuthenticatedEvent; use DateTime;
use PDOException; use Doctrine\ORM\NoResultException;
use Ramsey\Uuid\Uuid;
use UnicaenApp\Entity\Ldap\People; use UnicaenApp\Entity\Ldap\People;
use UnicaenApp\Exception\RuntimeException; use UnicaenApp\Exception\RuntimeException;
use UnicaenApp\Mapper\Ldap\People as LdapPeopleMapper; use UnicaenApp\Mapper\Ldap\People as LdapPeopleMapper;
use UnicaenAuth\Entity\Shibboleth\ShibUser; use UnicaenAuth\Entity\Shibboleth\ShibUser;
use UnicaenAuth\Event\UserAuthenticatedEvent;
use UnicaenAuth\Options\ModuleOptions; use UnicaenAuth\Options\ModuleOptions;
use Zend\Crypt\Password\Bcrypt;
use Zend\EventManager\EventManagerAwareInterface; use Zend\EventManager\EventManagerAwareInterface;
use Zend\EventManager\EventManagerInterface; 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\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait; use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\Validator\Identical;
use ZfcUser\Entity\UserInterface; use ZfcUser\Entity\UserInterface;
use ZfcUser\Options\AuthenticationOptionsInterface; use ZfcUser\Options\AuthenticationOptionsInterface;
use ZfcUser\Options\ModuleOptions as ZfcUserModuleOptions; use ZfcUser\Options\ModuleOptions as ZfcUserModuleOptions;
use UnicaenAuth\Entity\Db\AbstractUser;
/** /**
* Service d'enregistrement dans la table des utilisateurs de l'application * Service traitant des utilisateurs locaux de l'application.
* de l'utilisateur authentifié avec succès.
* *
* @see \UnicaenAuth\Authentication\Adapter\AbstractFactory * @see \UnicaenAuth\Authentication\Adapter\AbstractFactory
* @author Unicaen * @author Unicaen
...@@ -45,15 +55,20 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface ...@@ -45,15 +55,20 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
*/ */
protected $zfcUserOptions; protected $zfcUserOptions;
/**
* @var UserMapper
*/
protected $userMapper;
/** /**
* @var LdapPeopleMapper * @var LdapPeopleMapper
*/ */
protected $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 * @return bool
*/ */
public function userAuthenticated($userData) public function userAuthenticated($userData)
...@@ -68,7 +83,6 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface ...@@ -68,7 +83,6 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
$email = $userData->getMail(); $email = $userData->getMail();
$password = 'ldap'; $password = 'ldap';
$state = in_array('deactivated', ldap_explode_dn($userData->getDn(), 1)) ? 0 : 1; $state = in_array('deactivated', ldap_explode_dn($userData->getDn(), 1)) ? 0 : 1;
break; break;
case $userData instanceof ShibUser: case $userData instanceof ShibUser:
$username = $userData->getUsername(); $username = $userData->getUsername();
...@@ -94,46 +108,42 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface ...@@ -94,46 +108,42 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
throw new RuntimeException("Identité rencontrée inattendue."); throw new RuntimeException("Identité rencontrée inattendue.");
} }
// update/insert de l'utilisateur dans la table de l'appli $mapper = $this->getUserMapper();
$mapper = $this->getServiceLocator()->get('zfcuser_user_mapper'); /* @var $mapper \ZfcUserDoctrineORM\Mapper\User */
try { /** @var UserInterface $entity */
/** @var UserInterface $entity */ $entity = $mapper->findByUsername($username);
$entity = $mapper->findByUsername($username); if (!$entity) {
if (!$entity) { $entityClass = $this->getZfcUserOptions()->getUserEntityClass();
$entityClass = $this->getZfcUserOptions()->getUserEntityClass(); $entity = new $entityClass;
$entity = new $entityClass; $entity->setUsername($username);
$entity->setUsername($username); $method = 'insert';
$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);
} }
catch (PDOException $pdoe) { else {
throw new RuntimeException("Impossible d'enregistrer l'utilisateur authentifié dans la base de données.", null, $pdoe); $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; return true;
} }
/** /**
* @param UserInterface $entity * @param UserAuthenticatedEvent $event
* @param People|ShibUser $userData * @param UserInterface $entity
* @param People|ShibUser $userData
*/ */
private function triggerEvent(UserAuthenticatedEvent $event, $entity, $userData) private function triggerEvent(UserAuthenticatedEvent $event, $entity, $userData)
{ {
...@@ -148,6 +158,18 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface ...@@ -148,6 +158,18 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
$this->getEventManager()->trigger($event); $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 * Retrieve the event manager
* *
...@@ -222,4 +244,137 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface ...@@ -222,4 +244,137 @@ class User implements ServiceLocatorAwareInterface, EventManagerAwareInterface
return $this->zfcUserOptions; 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;
}
/**