-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Security] Idle sessions expiration. #12807
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5f095a3
d1cc4c4
6fb88a5
8c01e6b
d263eee
d7a03f0
43f58a1
8579c41
29ef542
f62e061
2f32566
98e1c09
e70e491
ddfa80c
d573605
ffd3415
2cfe9b1
598d0e8
d5dc414
eae647e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -277,6 +277,13 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto | |
->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() | ||
->end() | ||
->end() | ||
->arrayNode('session_expiration') | ||
->canBeUnset() | ||
->children() | ||
->integerNode('max_idle_time')->defaultValue(ini_get('session.gc_maxlifetime'))->min(1)->end() | ||
->scalarNode('expiration_url')->defaultNull()->end() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about |
||
->end() | ||
->end() | ||
; | ||
|
||
$abstractFactoryKeys = array(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -225,6 +225,15 @@ | |
<argument type="service" id="event_dispatcher" on-invalid="null"/> | ||
</service> | ||
|
||
<service id="security.authentication.session_expiration_listener" class="Symfony\Component\Security\Http\Firewall\SessionExpirationListener" public="false" abstract="true"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You now also need to update the required version of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shoud I add a requirement for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modifying the existing requirement looks okay to me. |
||
<tag name="monolog.logger" channel="security" /> | ||
<argument type="service" id="security.token_storage" /> | ||
<argument type="service" id="security.http_utils" /> | ||
<argument /> <!-- Max idle time --> | ||
<argument /> <!-- Target-URL --> | ||
<argument type="service" id="logger" on-invalid="null" /> | ||
</service> | ||
|
||
<service id="security.access_listener" class="Symfony\Component\Security\Http\Firewall\AccessListener" public="false"> | ||
<tag name="monolog.logger" channel="security" /> | ||
<argument type="service" id="security.token_storage" /> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{% extends "::base.html.twig" %} | ||
|
||
{% block body %} | ||
Hello {{ user.username }}!<br /><br /> | ||
You're browsing to path "{{ app.request.pathInfo }}". | ||
|
||
{% endblock %} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SimpleLoginBundle; | ||
|
||
use Symfony\Component\HttpKernel\Bundle\Bundle; | ||
|
||
class SimpleLoginBundle extends Bundle | ||
{ | ||
public function getParent() | ||
{ | ||
return 'FormLoginBundle'; | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we just use the |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Bundle\SecurityBundle\Tests\Functional; | ||
|
||
use Symfony\Bridge\PhpUnit\ClockMock; | ||
use Symfony\Component\Security\Http\Firewall\SessionExpirationListener; | ||
|
||
/** | ||
* @group functional | ||
* @group time-sensitive | ||
*/ | ||
class SessionExpirationTest extends WebTestCase | ||
{ | ||
protected function setUp() | ||
{ | ||
parent::setUp(); | ||
|
||
ClockMock::register(SessionExpirationListener::class); | ||
} | ||
|
||
public function testExpiredExceptionRedirectsToTargetUrl() | ||
{ | ||
$client = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'config.yml')); | ||
$form = $client->request('GET', '/login')->selectButton('login')->form(); | ||
$form['_username'] = 'antonio'; | ||
$form['_password'] = 'secret'; | ||
$client->submit($form); | ||
$this->assertRedirect($client->getResponse(), '/profile'); | ||
|
||
$client->request('GET', '/protected_resource'); | ||
$this->assertEquals(200, $client->getResponse()->getStatusCode()); | ||
|
||
sleep(5); //Wait for session to expire | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
$client->request('GET', '/protected_resource'); | ||
$this->assertRedirect($client->getResponse(), '/expired'); | ||
} | ||
|
||
protected function tearDown() | ||
{ | ||
$this->deleteTmpDir('SessionExpiration'); | ||
|
||
parent::tearDown(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<?php | ||
|
||
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\FormLoginBundle\FormLoginBundle; | ||
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SimpleLoginBundle\SimpleLoginBundle; | ||
use Symfony\Bundle\TwigBundle\TwigBundle; | ||
use Symfony\Bundle\SecurityBundle\SecurityBundle; | ||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; | ||
|
||
return array( | ||
new FrameworkBundle(), | ||
new SecurityBundle(), | ||
new TwigBundle(), | ||
new FormLoginBundle(), | ||
new SimpleLoginBundle(), | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
imports: | ||
- { resource: ./../config/default.yml } | ||
|
||
security: | ||
encoders: | ||
Symfony\Component\Security\Core\User\User: plaintext | ||
|
||
providers: | ||
in_memory: | ||
memory: | ||
users: | ||
antonio: { password: secret, roles: [ROLE_USER] } | ||
|
||
firewalls: | ||
# This firewall doesn't make sense in combination with the rest of the | ||
# configuration file, but it's here for testing purposes (do not use | ||
# this file in a real world scenario though) | ||
login_form: | ||
pattern: ^/login$ | ||
security: false | ||
|
||
default: | ||
form_login: | ||
check_path: /login_check | ||
default_target_path: /profile | ||
anonymous: ~ | ||
session_expiration: | ||
max_idle_time: 1 | ||
expiration_url: /expired | ||
|
||
access_control: | ||
- { path: .*, roles: IS_AUTHENTICATED_FULLY } |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
_form_login_bundle: | ||
resource: '@FormLoginBundle/Resources/config/routing.yml' |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,7 +17,7 @@ | |
], | ||
"require": { | ||
"php": ">=5.5.9", | ||
"symfony/security": "~3.1,>=3.1.2", | ||
"symfony/security": "~3.2", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can be removed - on the |
||
"symfony/http-kernel": "~3.1", | ||
"symfony/polyfill-php70": "~1.0" | ||
}, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Core\Exception; | ||
|
||
/** | ||
* SessionExpiredException is thrown when session has been idle for a long time. | ||
* | ||
* @author Antonio J. García Lagar <aj@garcialagar.es> | ||
*/ | ||
class SessionExpiredException extends AuthenticationException | ||
{ | ||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getMessageKey() | ||
{ | ||
return 'Session has expired.'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this key needs to be added in the translation files of the Security component (at least the English file which is the reference, and then any other language for which you are able to translate it yourself) |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Http\Firewall; | ||
|
||
use Psr\Log\LoggerInterface; | ||
use Symfony\Component\HttpKernel\Event\GetResponseEvent; | ||
use Symfony\Component\Security\Core\Exception\SessionExpiredException; | ||
use Symfony\Component\Security\Http\HttpUtils; | ||
use Symfony\Component\HttpFoundation\Session\SessionInterface; | ||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | ||
|
||
/** | ||
* SessionExpirationListener controls idle sessions. | ||
* | ||
* @author Antonio J. García Lagar <aj@garcialagar.es> | ||
*/ | ||
class SessionExpirationListener implements ListenerInterface | ||
{ | ||
private $tokenStorage; | ||
private $httpUtils; | ||
private $maxIdleTime; | ||
private $targetUrl; | ||
private $logger; | ||
|
||
public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, $maxIdleTime, $targetUrl = null, LoggerInterface $logger = null) | ||
{ | ||
$this->tokenStorage = $tokenStorage; | ||
$this->httpUtils = $httpUtils; | ||
$this->setMaxIdleTime($maxIdleTime); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can inline that check. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is cleaner with the extra method. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @xabbuh in this case |
||
$this->targetUrl = $targetUrl; | ||
$this->logger = $logger; | ||
} | ||
|
||
/** | ||
* Handles expired sessions. | ||
* | ||
* @param GetResponseEvent $event A GetResponseEvent instance | ||
* | ||
* @throws SessionExpiredException If the session has expired | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please separate the |
||
*/ | ||
public function handle(GetResponseEvent $event) | ||
{ | ||
$request = $event->getRequest(); | ||
$session = $request->getSession(); | ||
|
||
if (null === $session || null === $token = $this->tokenStorage->getToken()) { | ||
return; | ||
} | ||
|
||
if (!$this->hasSessionExpired($session)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need for the extra method imo There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think is cleaner with the extra method. |
||
return; | ||
} | ||
|
||
if (null !== $this->logger) { | ||
$this->logger->info(sprintf("Expired session detected for user named '%s'", $token->getUsername())); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit pick, but use |
||
} | ||
|
||
$this->tokenStorage->setToken(null); | ||
$session->invalidate(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, so this is the trickiest part of this PR. You're effectively imitating what The disadvantage to this is that you couldn't do something different based on a normal user logout versus a session expiration logout. But, is that a problem? And if it is, could we dispatch a special event (e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After 3 years, my vision is slightly different. I'll remove the For me, the logout is an interactive user action and different from the session expiration case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm... so then the purpose of this listener would be to only invalidate the session after a certain amount of idle time. If that's the case... then this is kind of unrelated to security, right? This could just be a general framework feature of the session: a framework-level way to invalidate sessions that are idle. Wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @weaverryan It took me some time to review this, as the history behind this PR is too old. It is the result of an split of #12009, which was a reimplementation of #786, into #12807(this PR) and #12810, so I decided to write a recap We have to implement 2 different things here: first, a feature to detect idle sessions, and last, to provide a default behavior once the expired session is detected. Detecting idle sessions It is now implemented as a firewall listener, so it can be enabled or disabled for different firewalls in the same app, but if the session is invalidated inside one firewall, it will logout from any firewall that uses the If we decide that this feature is not related to security, but to the framework, we can implement it as a Anyway, once the expired session is detected, I think that a new event should be dispatched (currently not implemented). Your proposed name Default behavior on expired sessions For me, the best option here is to invalidate the session (should we allow not to override this?), and redirect the user to a given path that will return an accurate response depending on the given request (html, json, etc...) or to throw a In a previous comment you proposed to force an user logout calling the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @ajgarlag! Sorry for the slow reply again :p. After reading your reply, it seems to me that this PR indeed has nothing to do with security. We're simply implementing a framework-level "session timeout" feature. This is necessary because there's no reliable way to do this with php.ini configuration. If there were, then we would just have the user do that. With that in mind, this 100% seems like "session" configuration. And so, it would be configured under the IF we want to allow the user to hook into this further, then dispatching the event we discussed may make sense. I WOULD do that, then we can see what others think. To summarize (and tell me if you disagree):
|
||
|
||
if (null === $this->targetUrl) { | ||
throw new SessionExpiredException(); | ||
} | ||
|
||
$response = $this->httpUtils->createRedirectResponse($request, $this->targetUrl); | ||
$event->setResponse($response); | ||
} | ||
|
||
/** | ||
* @param int $maxIdleTime | ||
*/ | ||
private function setMaxIdleTime($maxIdleTime) | ||
{ | ||
if ($maxIdleTime > ini_get('session.gc_maxlifetime') && null !== $this->logger) { | ||
$this->logger->warning("Max idle time should not be greater than 'session.gc_maxlifetime'"); | ||
} | ||
$this->maxIdleTime = (int) $maxIdleTime; | ||
} | ||
|
||
/** | ||
* Checks if the given session has expired. | ||
* | ||
* @param SessionInterface $session | ||
* | ||
* @return bool | ||
*/ | ||
private function hasSessionExpired(SessionInterface $session) | ||
{ | ||
return time() - $session->getMetadataBag()->getLastUsed() >= $this->maxIdleTime; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if i have multiple webservers in different countries and a global session storage, i'll have timezone related session expires? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. of course, you're right. thanks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this even work when the value for
session.gc_maxlifetime
differs between the CLI PHP executable and php.ini value for the web server?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know. I thought that this was the best default value, but didn't thought about different runtime configurations. Any suggestion for a better default value is welcome.
Anyway I've proposed to trigger an
E_USER_WARNING
when the SessionExpirationListener is built, so the user can detect the problem.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess using
null
here and determine the ini setting value at runtime then would be safer.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about it. If this ini option has different values for web and cli environments, it will be problematic:
if we set it at runtime, this value can vary between http requests and console commands; but if we set it at config time, the value will be read only once when the container is compiled, and the maybe the value is not the preferred one.
Anyway, ICYMI the
E_USER_WARNING
was removed and now the behavior is to log the problem withWARN
level.Maybe we should remove any default value and require the user to provide it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@xabbuh I'm waiting for your final decision here:
I'll implement your preferred option and the PR will be ready for merge.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ping @xabbuh
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should make the option required (if the
session_expiration
key is present)