Merge pull request #1507 from aztech-dev/recovery

Extract password recovery logic in service
This commit is contained in:
Benoît Burnichon
2015-10-07 17:50:51 +02:00
10 changed files with 307 additions and 44 deletions

View File

@@ -0,0 +1,61 @@
<?php
namespace Alchemy\Phrasea\Account;
use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Core\Configuration\RegistrationManager;
use Alchemy\Phrasea\Model\Entities\User;
class CollectionRequestMapper
{
/**
* @var Application
*/
private $app;
/**
* @var RegistrationManager
*/
private $registrationManager;
public function __construct(Application $app, RegistrationManager $registrationManager)
{
$this->app = $app;
$this->registrationManager = $registrationManager;
}
public function getUserRequests(User $user)
{
$databoxStatuses = $this->registrationManager->getRegistrationSummary($user);
$demands = array();
foreach ($databoxStatuses as $databoxId => $data) {
foreach ($data['registrations']['by-type']['pending'] as $collectionId => $waiting) {
$demands[] = $this->mapCollectionStatus($databoxId, $collectionId, "pending");
}
foreach ($data['registrations']['by-type']['rejected'] as $collectionId => $waiting) {
$demands[] = $this->mapCollectionStatus($databoxId, $collectionId, "rejected");
}
foreach ($data['registrations']['by-type']['accepted'] as $collectionId => $waiting) {
$demands[] = $this->mapCollectionStatus($databoxId, $collectionId, "accepted");
}
}
return $demands;
}
private function mapCollectionStatus($databoxId, $collectionId, $status)
{
$baseId = \phrasea::baseFromColl($databoxId, $collectionId, $this->app);
return array(
"databox_id" => $databoxId,
"base_id" => $baseId,
"collection_id" => $collectionId,
"status" => $status
);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Alchemy\Phrasea\Authentication;
use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Exception\InvalidArgumentException;
use Alchemy\Phrasea\Model\Entities\User;
use Alchemy\Phrasea\Model\Manipulator\TokenManipulator;
use Alchemy\Phrasea\Model\Manipulator\UserManipulator;
use Alchemy\Phrasea\Model\Repositories\TokenRepository;
use Alchemy\Phrasea\Model\Repositories\UserRepository;
use Alchemy\Phrasea\Notification\Deliverer;
use Alchemy\Phrasea\Notification\Mail\MailRequestPasswordUpdate;
use Alchemy\Phrasea\Notification\Receiver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class RecoveryService
{
/**
* @var Application
*/
private $application;
/**
* @var Deliverer
*/
private $mailer;
/**
* @var TokenManipulator
*/
private $tokenManipulator;
/**
* @var TokenRepository
*/
private $tokenRepository;
/**
* @var UserManipulator
*/
private $userManipulator;
/**
* @var UserRepository
*/
private $userRepository;
/**
* @var UrlGeneratorInterface
*/
private $urlGenerator;
/**
* @param Application $application
* @param Deliverer $mailer
* @param TokenManipulator $tokenManipulator
* @param TokenRepository $tokenRepository
* @param UserManipulator $userManipulator
* @param UserRepository $userRepository
* @param UrlGeneratorInterface $urlGenerator
*/
public function __construct(
Application $application,
Deliverer $mailer,
TokenManipulator $tokenManipulator,
TokenRepository $tokenRepository,
UserManipulator $userManipulator,
UserRepository $userRepository,
UrlGeneratorInterface $urlGenerator
) {
$this->application = $application;
$this->mailer = $mailer;
$this->tokenManipulator = $tokenManipulator;
$this->tokenRepository = $tokenRepository;
$this->userManipulator = $userManipulator;
$this->userRepository = $userRepository;
$this->urlGenerator = $urlGenerator;
}
/**
* @param $email
* @param bool $notifyUser
* @return string
* @throws InvalidArgumentException
*/
public function requestPasswordResetToken($email, $notifyUser = true)
{
$user = $this->userRepository->findByEmail($email);
if (! $user) {
throw new InvalidArgumentException('phraseanet::erreur: Le compte n\'a pas ete trouve');
}
return $this->requestPasswordResetTokenByUser($user, $notifyUser);
}
/**
* @param $login
* @param bool $notifyUser
* @return string
* @throws InvalidArgumentException
*/
public function requestPasswordResetTokenByLogin($login, $notifyUser = true)
{
$user = $this->userRepository->findByLogin($login);
if (! $user) {
throw new InvalidArgumentException('phraseanet::erreur: Le compte n\'a pas ete trouve');
}
return $this->requestPasswordResetTokenByUser($user, $notifyUser);
}
/**
* @param User $user
* @param bool $notifyUser
* @return string
*/
private function requestPasswordResetTokenByUser(User $user, $notifyUser = true)
{
$receiver = Receiver::fromUser($user);
$token = $this->tokenManipulator->createResetPasswordToken($user);
if ($notifyUser) {
$url = $this->urlGenerator->generate('login_renew_password', [ 'token' => $token->getValue() ], true);
$mail = MailRequestPasswordUpdate::create($this->application, $receiver);
$mail->setLogin($user->getLogin());
$mail->setButtonUrl($url);
$mail->setExpiration(new \DateTime('+1 day'));
$this->mailer->deliver($mail);
}
return $token;
}
public function resetPassword($resetToken, $newPassword)
{
$token = $this->tokenRepository->findValidToken($resetToken);
if ($token === null || $token->getType() != TokenManipulator::TYPE_PASSWORD) {
$this->application->abort(401, 'A token is required');
}
$this->userManipulator->setPassword($token->getUser(), $newPassword);
$this->tokenManipulator->delete($token);
}
}

View File

@@ -9,6 +9,7 @@
*/ */
namespace Alchemy\Phrasea\Controller\Api; namespace Alchemy\Phrasea\Controller\Api;
use Alchemy\Phrasea\Account\CollectionRequestMapper;
use Alchemy\Phrasea\Application\Helper\DataboxLoggerAware; use Alchemy\Phrasea\Application\Helper\DataboxLoggerAware;
use Alchemy\Phrasea\Application\Helper\DispatcherAware; use Alchemy\Phrasea\Application\Helper\DispatcherAware;
use Alchemy\Phrasea\Authentication\Context; use Alchemy\Phrasea\Authentication\Context;
@@ -782,6 +783,42 @@ class V1Controller extends Controller
return $grants; return $grants;
} }
private function listUserDemands(User $user)
{
return (new CollectionRequestMapper($this->app, $this->app['registration.manager']))->getUserRequests($user);
}
public function resetPassword(Request $request, $email)
{
/** @var \Alchemy\Phrasea\Authentication\RecoveryService $service */
$service = $this->app['authentication.recovery_service'];
try {
$token = $service->requestPasswordResetToken($email, false);
}
catch (\Exception $exception) {
$token = $service->requestPasswordResetTokenByLogin($email, false);
}
return Result::create($request, [ 'reset_token' => $token ]);
}
public function setNewPassword(Request $request, $token)
{
$password = $request->request->get('password', null);
/** @var \Alchemy\Phrasea\Authentication\RecoveryService $service */
$service = $this->app['authentication.recovery_service'];
try {
$service->resetPassword($token, $password);
}
catch (\Exception $exception) {
return Result::create($request, [ 'success' => false ]);
}
return Result::create($request, [ 'success' => true ]);
}
public function addRecordAction(Request $request) public function addRecordAction(Request $request)
{ {
if (count($request->files->get('file')) == 0) { if (count($request->files->get('file')) == 0) {
@@ -1036,7 +1073,6 @@ class V1Controller extends Controller
continue; continue;
} }
if ($record->isStory()) { if ($record->isStory()) {
$ret['results']['stories'][] = $this->listStory($request, $record); $ret['results']['stories'][] = $this->listStory($request, $record);
} else { } else {
@@ -2299,6 +2335,12 @@ class V1Controller extends Controller
"collections" => $this->listUserCollections($this->getAuthenticatedUser()) "collections" => $this->listUserCollections($this->getAuthenticatedUser())
]; ];
if (! constant('API_SKIP_USER_REGISTRATIONS')) {
// I am infinitely sorry... if you feel like it, you can fix the tests database bootstrapping
// to use SQLite in all cases and remove this check. Good luck...
$ret["demands"] = $this->listUserDemands($this->getAuthenticatedUser());
}
return Result::create($request, $ret)->createResponse(); return Result::create($request, $ret)->createResponse();
} }

View File

@@ -16,10 +16,11 @@ use Alchemy\Phrasea\Authentication\AccountCreator;
use Alchemy\Phrasea\Authentication\Exception\NotAuthenticatedException; use Alchemy\Phrasea\Authentication\Exception\NotAuthenticatedException;
use Alchemy\Phrasea\Authentication\Exception\AuthenticationException; use Alchemy\Phrasea\Authentication\Exception\AuthenticationException;
use Alchemy\Phrasea\Authentication\Context; use Alchemy\Phrasea\Authentication\Context;
use Alchemy\Phrasea\Authentication\Phrasea\NativeAuthentication; use Alchemy\Phrasea\Authentication\Phrasea\PasswordAuthenticationInterface;
use Alchemy\Phrasea\Authentication\Phrasea\PasswordEncoder; use Alchemy\Phrasea\Authentication\Phrasea\PasswordEncoder;
use Alchemy\Phrasea\Authentication\Provider\ProviderInterface; use Alchemy\Phrasea\Authentication\Provider\ProviderInterface;
use Alchemy\Phrasea\Authentication\ProvidersCollection; use Alchemy\Phrasea\Authentication\ProvidersCollection;
use Alchemy\Phrasea\Authentication\RecoveryService;
use Alchemy\Phrasea\Authentication\SuggestionFinder; use Alchemy\Phrasea\Authentication\SuggestionFinder;
use Alchemy\Phrasea\Controller\Controller; use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\Core\Configuration\ConfigurationInterface; use Alchemy\Phrasea\Core\Configuration\ConfigurationInterface;
@@ -470,23 +471,17 @@ class LoginController extends Controller
public function renewPassword(Request $request) public function renewPassword(Request $request)
{ {
if (null === $tokenValue = $request->get('token')) { $service = $this->getRecoveryService();
$this->app->abort(401, 'A token is required');
}
if (null === $token = $this->getTokenRepository()->findValidToken($tokenValue)) {
$this->app->abort(401, 'A token is required');
}
$form = $this->app->form(new PhraseaRecoverPasswordForm($this->getTokenRepository())); $form = $this->app->form(new PhraseaRecoverPasswordForm($this->getTokenRepository()));
$form->setData(['token' => $token->getValue()]); $form->setData(['token' => $request->get('token') ]);
if ('POST' === $request->getMethod()) { if ('POST' === $request->getMethod()) {
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isValid()) { if ($form->isValid()) {
$data = $form->getData(); $data = $form->getData();
$this->getUserManipulator()->setPassword($token->getUser(), $data['password']);
$this->getTokenManipulator()->delete($token); $service->resetPassword($data['token'], $data['password']);
$this->app->addFlash('success', $this->app->trans('login::notification: Mise a jour du mot de passe avec succes')); $this->app->addFlash('success', $this->app->trans('login::notification: Mise a jour du mot de passe avec succes'));
return $this->app->redirectPath('homepage'); return $this->app->redirectPath('homepage');
@@ -495,7 +490,7 @@ class LoginController extends Controller
return $this->render('login/renew-password.html.twig', array_merge( return $this->render('login/renew-password.html.twig', array_merge(
$this->getDefaultTemplateVariables($request), $this->getDefaultTemplateVariables($request),
['form' => $form->createView()] ['form' => $form->createView() ]
)); ));
} }
@@ -508,6 +503,7 @@ class LoginController extends Controller
public function forgotPassword(Request $request) public function forgotPassword(Request $request)
{ {
$form = $this->app->form(new PhraseaForgotPasswordForm()); $form = $this->app->form(new PhraseaForgotPasswordForm());
$service = $this->getRecoveryService();
try { try {
if ('POST' === $request->getMethod()) { if ('POST' === $request->getMethod()) {
@@ -516,27 +512,13 @@ class LoginController extends Controller
if ($form->isValid()) { if ($form->isValid()) {
$data = $form->getData(); $data = $form->getData();
if (null === $user = $this->getUserRepository()->findByEmail($data['email'])) {
throw new FormProcessingException(_('phraseanet::erreur: Le compte n\'a pas ete trouve'));
}
try { try {
$receiver = Receiver::fromUser($user); $service->requestPasswordResetToken($data['email'], true);
} catch (InvalidArgumentException $e) { }
throw new FormProcessingException($this->app->trans('Invalid email address')); catch (InvalidArgumentException $ex) {
throw new FormProcessingException($this->app->trans($ex->getMessage()));
} }
$token = $this->getTokenManipulator()->createResetPasswordToken($user);
$url = $this->app->url('login_renew_password', ['token' => $token->getValue()], true);
$expirationDate = new \DateTime('+1 day');
$mail = MailRequestPasswordUpdate::create($this->app, $receiver);
$mail->setLogin($user->getLogin());
$mail->setButtonUrl($url);
$mail->setExpiration($expirationDate);
$this->deliver($mail);
$this->app->addFlash('info', $this->app->trans('phraseanet:: Un email vient de vous etre envoye')); $this->app->addFlash('info', $this->app->trans('phraseanet:: Un email vient de vous etre envoye'));
return $this->app->redirectPath('login_forgot_password'); return $this->app->redirectPath('login_forgot_password');
@@ -548,9 +530,8 @@ class LoginController extends Controller
return $this->render('login/forgot-password.html.twig', array_merge( return $this->render('login/forgot-password.html.twig', array_merge(
$this->getDefaultTemplateVariables($request), $this->getDefaultTemplateVariables($request),
[ [ 'form' => $form->createView() ]
'form' => $form->createView(), ));
]));
} }
/** /**
@@ -698,8 +679,8 @@ class LoginController extends Controller
public function postAuthProcess(Request $request, User $user) public function postAuthProcess(Request $request, User $user)
{ {
$date = new \DateTime('+' . (int) $this->getConf()->get(['registry', 'actions', 'validation-reminder-days']) . ' days'); $date = new \DateTime('+' . (int) $this->getConf()->get(['registry', 'actions', 'validation-reminder-days']) . ' days');
$manager = $this->getEntityManager(); $manager = $this->getEntityManager();
foreach ($this->getValidationParticipantRepository()->findNotConfirmedAndNotRemindedParticipantsByExpireDate($date) as $participant) { foreach ($this->getValidationParticipantRepository()->findNotConfirmedAndNotRemindedParticipantsByExpireDate($date) as $participant) {
$validationSession = $participant->getSession(); $validationSession = $participant->getSession();
$basket = $validationSession->getBasket(); $basket = $validationSession->getBasket();
@@ -862,8 +843,11 @@ class LoginController extends Controller
} }
try { try {
$usr_id = $this->getNativeAuthentication() $usr_id = $this->getPasswordAuthentication()->getUsrId(
->getUsrId($request->request->get('login'), $request->request->get('password'), $request); $request->request->get('login'),
$request->request->get('password'),
$request
);
} catch (RequireCaptchaException $e) { } catch (RequireCaptchaException $e) {
$this->app->requireCaptcha(); $this->app->requireCaptcha();
$this->app->addFlash('warning', $this->app->trans('Please fill the captcha')); $this->app->addFlash('warning', $this->app->trans('Please fill the captcha'));
@@ -883,7 +867,6 @@ class LoginController extends Controller
} }
$user = $this->getUserRepository()->find($usr_id); $user = $this->getUserRepository()->find($usr_id);
$session = $this->postAuthProcess($request, $user); $session = $this->postAuthProcess($request, $user);
$response = $this->generateAuthResponse($this->getBrowser(), $request->request->get('redirect')); $response = $this->generateAuthResponse($this->getBrowser(), $request->request->get('redirect'));
@@ -1064,9 +1047,9 @@ class LoginController extends Controller
} }
/** /**
* @return NativeAuthentication * @return PasswordAuthenticationInterface
*/ */
private function getNativeAuthentication() private function getPasswordAuthentication()
{ {
return $this->app['auth.native']; return $this->app['auth.native'];
} }
@@ -1086,4 +1069,12 @@ class LoginController extends Controller
{ {
return $this->app['configuration.store']; return $this->app['configuration.store'];
} }
/**
* @return RecoveryService
*/
private function getRecoveryService()
{
return $this->app['authentication.recovery_service'];
}
} }

View File

@@ -249,6 +249,12 @@ class V1 implements ControllerProviderInterface, ServiceProviderInterface
$controllers->get('/me/', 'controller.api.v1:getCurrentUserAction'); $controllers->get('/me/', 'controller.api.v1:getCurrentUserAction');
$controllers->post('/accounts/reset-password/{login}/', 'controller.api.v1:resetPassword')
->before('controller.api.v1:ensureAdmin');
$controllers->post('/accounts/update-password/{token}/', 'controller.api.v1:setNewPassword')
->before('controller.api.v1:ensureAdmin');
return $controllers; return $controllers;
} }
} }

View File

@@ -22,6 +22,7 @@ use Alchemy\Phrasea\Authentication\Phrasea\FailureHandledNativeAuthentication;
use Alchemy\Phrasea\Authentication\Phrasea\NativeAuthentication; use Alchemy\Phrasea\Authentication\Phrasea\NativeAuthentication;
use Alchemy\Phrasea\Authentication\Phrasea\OldPasswordEncoder; use Alchemy\Phrasea\Authentication\Phrasea\OldPasswordEncoder;
use Alchemy\Phrasea\Authentication\Phrasea\PasswordEncoder; use Alchemy\Phrasea\Authentication\Phrasea\PasswordEncoder;
use Alchemy\Phrasea\Authentication\RecoveryService;
use Alchemy\Phrasea\Authentication\SuggestionFinder; use Alchemy\Phrasea\Authentication\SuggestionFinder;
use Silex\Application; use Silex\Application;
use Silex\ServiceProviderInterface; use Silex\ServiceProviderInterface;
@@ -87,6 +88,18 @@ class AuthenticationManagerServiceProvider implements ServiceProviderInterface
return new Manager($app['authentication'], $app['authentication.providers']); return new Manager($app['authentication'], $app['authentication.providers']);
}); });
$app['authentication.recovery_service'] = $app->share(function (Application $app) {
return new RecoveryService(
$app,
$app['notification.deliverer'],
$app['manipulator.token'],
$app['repo.tokens'],
$app['manipulator.user'],
$app['repo.users'],
$app['url_generator']
);
});
$app['auth.password-encoder'] = $app->share(function (Application $app) { $app['auth.password-encoder'] = $app->share(function (Application $app) {
return new PasswordEncoder($app['conf']->get(['main','key'])); return new PasswordEncoder($app['conf']->get(['main','key']));
}); });

View File

@@ -177,7 +177,7 @@ class Token
/** /**
* Set expiration * Set expiration
* *
* @param \DateTime $updated * @param \DateTime $expiration
* @return Token * @return Token
*/ */
public function setExpiration(\DateTime $expiration = null) public function setExpiration(\DateTime $expiration = null)

View File

@@ -14,6 +14,7 @@
<php> <php>
<ini name="display_errors" value="on"/> <ini name="display_errors" value="on"/>
<ini name="memory_limit" value="2G"/> <ini name="memory_limit" value="2G"/>
<const name="API_SKIP_USER_REGISTRATIONS" value="true" />
</php> </php>
<testsuites> <testsuites>

View File

@@ -332,7 +332,7 @@ class LoginTest extends \PhraseanetAuthenticatedWebTestCase
]); ]);
$response = self::$DI['client']->getResponse(); $response = self::$DI['client']->getResponse();
$this->assertEquals(401, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
} }
public function testRenewPasswordBadTokenWheneverItsAuthenticated() public function testRenewPasswordBadTokenWheneverItsAuthenticated()
@@ -359,7 +359,7 @@ class LoginTest extends \PhraseanetAuthenticatedWebTestCase
$response = self::$DI['client']->getResponse(); $response = self::$DI['client']->getResponse();
$this->assertEquals(401, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
} }
public function testRenewPasswordNoTokenWheneverItsAuthenticated() public function testRenewPasswordNoTokenWheneverItsAuthenticated()

View File

@@ -30,7 +30,6 @@ abstract class PhraseanetTestCase extends WebTestCase
const USER_AGENT_IE6 = 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; SV1; .NET CLR 1.1.4322)'; const USER_AGENT_IE6 = 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; SV1; .NET CLR 1.1.4322)';
const USER_AGENT_IPHONE = 'Mozilla/5.0 (iPod; U; CPU iPhone OS 2_1 like Mac OS X; fr-fr) AppleWebKit/525.18.1 (KHTML, like Gecko) Version/3.1.1 Mobile/5F137 Safari/525.20'; const USER_AGENT_IPHONE = 'Mozilla/5.0 (iPod; U; CPU iPhone OS 2_1 like Mac OS X; fr-fr) AppleWebKit/525.18.1 (KHTML, like Gecko) Version/3.1.1 Mobile/5F137 Safari/525.20';
protected static $DI; protected static $DI;
private static $recordsInitialized = false; private static $recordsInitialized = false;