diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/SessionManagerSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/SessionManagerSubscriber.php new file mode 100644 index 0000000000..049314c33f --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/SessionManagerSubscriber.php @@ -0,0 +1,126 @@ +app = $app; + } + + public static function getSubscribedEvents() + { + return array( + KernelEvents::REQUEST => array('onKernelRequest', -112), + ); + } + + public function onKernelRequest(GetResponseEvent $event) + { + $modulesIds = array( + "prod" => 1, + "client" => 2, + "admin" => 3, + "thesaurus" => 5, + "report" => 10, + "lightbox" => 6, + ); + + $pathInfo = array_filter(explode('/', $event->getRequest()->getPathInfo())); + + if(count($pathInfo) < 1) { + return; + } + + $moduleName = strtolower($pathInfo[1]); + if (!array_key_exists($moduleName, $modulesIds) ) { + return; + } + + // this route is polled by js in admin/databox to refresh infos (progress bar...) + if (preg_match("#^/admin/databox/[0-9]+/informations/documents/#", $event->getRequest()->getPathInfo()) == 1) { + return; + } + + // this route is polled by js in admin/tasks to refresh tasks status + if ($event->getRequest()->getPathInfo() == "/admin/task-manager/tasks/" && $event->getRequest()->getContentType() == 'json') { + return; + } + + // if we are already disconnected (ex. from another window), quit immediatly + if (!($this->app['authentication']->isAuthenticated())) { + if ($event->getRequest()->isXmlHttpRequest()) { + $response = new Response("End-Session", 403); + } else { + $response = new RedirectResponse($this->app["url_generator"]->generate("homepage", array("redirect"=>'..' . $event->getRequest()->getPathInfo()))); + } + $response->headers->set('X-Phraseanet-End-Session', '1'); + + $event->setResponse($response); + + return; + } + + $session = $this->app['EM']->find('Entities\Session', $this->app['session']->get('session_id')); + + $idle = 0; + if (isset($this->app["phraseanet.configuration"]["session"]["idle"])) { + $idle = (int)($this->app["phraseanet.configuration"]["session"]["idle"]); + } + $now = new \DateTime(); + $dt = $now->getTimestamp() - $session->getUpdated()->getTimestamp(); + if ($idle > 0 && $dt > $idle) { + // we must disconnet due to idletime + $this->app['authentication']->closeAccount(); + if ($event->getRequest()->isXmlHttpRequest()) { + $response = new Response("End-Session", 403); + } else { + $response = new RedirectResponse($this->app["url_generator"]->generate("homepage", array("redirect"=>'..' . $event->getRequest()->getPathInfo()))); + } + $response->headers->set('X-Phraseanet-End-Session', '1'); + + $event->setResponse($response); + + return; + } + + $moduleId = $modulesIds[$moduleName]; + + $session->setUpdated(new \DateTime()); + + if (!$session->hasModuleId($moduleId)) { + $module = new SessionModule(); + $module->setModuleId($moduleId); + $module->setSession($session); + $session->addModule($module); + + $this->app['EM']->persist($module); + } else { + $this->app['EM']->persist($session->getModuleById($moduleId)->setUpdated(new \DateTime())); + } + + $this->app['EM']->persist($session); + $this->app['EM']->flush(); + } +} diff --git a/lib/Alchemy/Phrasea/Form/Login/PhraseaAuthenticationForm.php b/lib/Alchemy/Phrasea/Form/Login/PhraseaAuthenticationForm.php index 25778b8f21..32581ec17f 100644 --- a/lib/Alchemy/Phrasea/Form/Login/PhraseaAuthenticationForm.php +++ b/lib/Alchemy/Phrasea/Form/Login/PhraseaAuthenticationForm.php @@ -45,14 +45,22 @@ class PhraseaAuthenticationForm extends AbstractType ), )); - $builder->add('remember-me', $this->app['phraseanet.configuration']['session']['idle'] < 1 ? 'checkbox' : 'hidden', array( - 'label' => $this->app['phraseanet.configuration']['session']['idle'] < 1 ? _('Remember me') : "", - 'mapped' => false, - 'required' => false, - 'attr' => array( - 'value' => '1', - ) - )); + if ($this->app['phraseanet.configuration']['session']['idle'] < 1) { + $builder->add('remember-me', 'checkbox' , array( + 'label' => _('Remember me'), + 'mapped' => false, + 'required' => false, + 'attr' => array( + 'value' => '1', + ) + )); + } else { + $builder->add('remember-me', 'hidden' , array( + 'label' => '', + 'mapped' => false, + 'required' => false + )); + } $builder->add('redirect', 'hidden', array( 'required' => false, diff --git a/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php b/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php index 0a0edb3d90..333a3a5698 100644 --- a/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php +++ b/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php @@ -1738,6 +1738,34 @@ class LoginTest extends \PhraseanetWebTestCaseAuthenticatedAbstract $this->assertSame(200, self::$DI['client']->getResponse()->getStatusCode()); } + public function testLoginPageWithIdleSessionTime() + { + $this->logout(self::$DI['app']); + self::$DI['app']['phraseanet.configuration']['session'] = array( + 'idle' =>10, + 'lifetime' => 60475, + ); + + $crawler = self::$DI['client']->request('GET', '/login/'); + + $this->assertSame(200, self::$DI['client']->getResponse()->getStatusCode()); + $this->assertEquals('hidden', $crawler->filter('input[name="remember-me"]')->attr('type')); + } + + public function testLoginPageWithNoIdleSessionTime() + { + $this->logout(self::$DI['app']); + self::$DI['app']['phraseanet.configuration']['session'] = array( + 'idle' => 0, + 'lifetime' => 60475, + ); + + $crawler = self::$DI['client']->request('GET', '/login/'); + + $this->assertSame(200, self::$DI['client']->getResponse()->getStatusCode()); + $this->assertEquals('checkbox', $crawler->filter('input[name="remember-me"]')->attr('type')); + } + private function addUsrAuthDoctrineEntitySupport($id, $out, $participants = false) { $repo = $this->getMockBuilder('Doctrine\ORM\EntityRepository\UsrAuthProviderRepository') diff --git a/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/SessionManagerSubscriberTest.php b/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/SessionManagerSubscriberTest.php new file mode 100644 index 0000000000..d3615aeacc --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/SessionManagerSubscriberTest.php @@ -0,0 +1,227 @@ +addSubscriber(new SessionManagerSubscriber($app)); + $app['phraseanet.configuration']['session'] = array( + 'idle' => 0, + 'lifetime' => 60475, + ); + + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/prod', function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', '/prod'); + + $this->assertTrue($client->getResponse()->isRedirect()); + $this->assertNotNUll($client->getResponse()->headers->get('x-phraseanet-end-session')); + $this->assertNotNUll($client->getResponse()->headers->get('location')); + $this->assertEquals('/login?redirect=..%2Fprod', $client->getResponse()->headers->get('location')); + } + + public function testEndSessionXmlXhttpRequest() + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + $app['phraseanet.configuration']['session'] = array( + 'idle' => 0, + 'lifetime' => 60475, + ); + + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/prod', function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', '/prod', array(), array(), array( + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X-Requested-With' => 'XMLHttpRequest', + + )); + + $this->assertTrue($client->getResponse()->isClientError()); + $this->assertNotNUll($client->getResponse()->headers->get('x-phraseanet-end-session')); + } + + public function testEndSessionAuthenticated() + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + $app['authentication'] = $this->getMockBuilder('Alchemy\Phrasea\Authentication\Authenticator')->disableOriginalConstructor()->getMock(); + $app['authentication']->expects($this->any())->method('isAuthenticated')->will($this->returnValue(true)); + + $session = new Session(); + $session->setUpdated(new \DateTime()); + + $app['EM'] = $this->getMockBuilder('Doctrine\ORM\EntityManager')->disableOriginalConstructor()->getMock(); + $app['EM']->expects($this->once())->method('find')->with($this->equalTo('Entities\Session'))->will($this->returnValue($session)); + $app['EM']->expects($this->exactly(2))->method('persist')->will($this->returnValue(null)); + $app['EM']->expects($this->once())->method('flush')->will($this->returnValue(null)); + + $app['phraseanet.configuration']['session'] = array( + 'idle' => 0, + 'lifetime' => 60475, + ); + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/prod', function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', '/prod'); + + $this->assertTrue($client->getResponse()->isOK()); + } + + public function testEndSessionAuthenticatedWithOutdatedIdle() + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + $app['authentication'] = $this->getMockBuilder('Alchemy\Phrasea\Authentication\Authenticator')->disableOriginalConstructor()->getMock(); + $app['authentication']->expects($this->any())->method('isAuthenticated')->will($this->returnValue(true)); + $app['authentication']->expects($this->once())->method('closeAccount')->will($this->returnValue(null)); + + $session = new Session(); + $session->setUpdated(new \DateTime('-1 hour')); + + $app['EM'] = $this->getMockBuilder('Doctrine\ORM\EntityManager')->disableOriginalConstructor()->getMock(); + $app['EM']->expects($this->once())->method('find')->with($this->equalTo('Entities\Session'))->will($this->returnValue($session)); + + $app['phraseanet.configuration']['session'] = array( + 'idle' => 10, + 'lifetime' => 60475, + ); + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/prod', function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', '/prod'); + + $this->assertTrue($client->getResponse()->isRedirect()); + $this->assertNotNUll($client->getResponse()->headers->get('x-phraseanet-end-session')); + $this->assertNotNUll($client->getResponse()->headers->get('location')); + $this->assertEquals('/login?redirect=..%2Fprod', $client->getResponse()->headers->get('location')); + } + + public function testEndSessionAuthenticatedWithOutdatedIdleXmlHttpRequest() + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + $app['authentication'] = $this->getMockBuilder('Alchemy\Phrasea\Authentication\Authenticator')->disableOriginalConstructor()->getMock(); + $app['authentication']->expects($this->any())->method('isAuthenticated')->will($this->returnValue(true)); + $app['authentication']->expects($this->once())->method('closeAccount')->will($this->returnValue(null)); + + $session = new Session(); + $session->setUpdated(new \DateTime('-1 hour')); + + $app['EM'] = $this->getMockBuilder('Doctrine\ORM\EntityManager')->disableOriginalConstructor()->getMock(); + $app['EM']->expects($this->once())->method('find')->with($this->equalTo('Entities\Session'))->will($this->returnValue($session)); + + $app['phraseanet.configuration']['session'] = array( + 'idle' => 10, + 'lifetime' => 60475, + ); + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/prod', function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', '/prod', array(), array(), array( + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X-Requested-With' => 'XMLHttpRequest', + )); + + $this->assertTrue($client->getResponse()->isClientError()); + $this->assertNotNUll($client->getResponse()->headers->get('x-phraseanet-end-session')); + } + + public function testUndefinedModule() + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get('/undefined-module', function () { + return 'undefined-module'; + }); + + $client = new Client($app); + $client->request('GET', '/undefined-module'); + + $this->assertTrue($client->getResponse()->isOk()); + $this->assertEquals('undefined-module', $client->getResponse()->getContent()); + } + + /** + * @dataProvider forbiddenRouteProvider + */ + public function testForbiddenRoutes($route) + { + $app = new Application('test'); + $app['dispatcher']->addSubscriber(new SessionManagerSubscriber($app)); + $app['authentication'] = $this->getMockBuilder('Alchemy\Phrasea\Authentication\Authenticator')->disableOriginalConstructor()->getMock(); + $app['authentication']->expects($this->never())->method('isAuthenticated'); + + $app['EM'] = $this->getMockBuilder('Doctrine\ORM\EntityManager')->disableOriginalConstructor()->getMock(); + $app['EM']->expects($this->never())->method('flush'); + + + $app->get('/login', function () { + return ''; + })->bind("homepage"); + + $app->get($route, function () { + return ''; + }); + + $client = new Client($app); + $client->request('GET', $route, array(), array(), array( + 'HTTP_CONTENT-TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X-Requested-With' => 'XMLHttpRequest', + )); + } + + public function forbiddenRouteProvider() + { + return array( + array('/admin/databox/17/informations/documents/'), + array('/admin/task-manager/tasks/'), + ); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Form/Login/PhraseaAuthenticationFormTest.php b/tests/Alchemy/Tests/Phrasea/Form/Login/PhraseaAuthenticationFormTest.php index bc5f77ec6c..92b73ae307 100644 --- a/tests/Alchemy/Tests/Phrasea/Form/Login/PhraseaAuthenticationFormTest.php +++ b/tests/Alchemy/Tests/Phrasea/Form/Login/PhraseaAuthenticationFormTest.php @@ -9,6 +9,6 @@ class PhraseaAuthenticationFormTest extends FormTestCase { protected function getForm() { - return new PhraseaAuthenticationForm(); + return new PhraseaAuthenticationForm(self::$DI['app']); } }