Skip to content

[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

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

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 with WARN level.

Maybe we should remove any default value and require the user to provide it.

Copy link
Contributor Author

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:

  • Set the option at config time
  • Set the value at runtime setting the option to null at config time.
  • Make the option required

I'll implement your preferred option and the PR will be ready for merge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ping @xabbuh

Copy link
Member

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)

->scalarNode('expiration_url')->defaultNull()->end()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about expiration_path? This seems more consistent with the keys under form_login, for example. And this could also be a URL, correct? It looks like you set it up that way :)

->end()
->end()
;

$abstractFactoryKeys = array();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,11 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a
$listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider));
}

// Session expiration listener
if (isset($firewall['session_expiration'])) {
$listeners[] = new Reference($this->createSessionExpirationListener($container, $id, $firewall));
}

// Access listener
$listeners[] = new Reference('security.access_listener');

Expand Down Expand Up @@ -594,6 +599,17 @@ private function createSwitchUserListener($container, $id, $config, $defaultProv
return $switchUserListenerId;
}

private function createSessionExpirationListener($container, $id, $config)
{
$expiredSessionListenerId = 'security.authentication.session_expiration_listener.'.$id;
$listener = $container->setDefinition($expiredSessionListenerId, new DefinitionDecorator('security.authentication.session_expiration_listener'));

$listener->replaceArgument(2, $config['session_expiration']['max_idle_time']);
$listener->replaceArgument(3, $config['session_expiration']['expiration_url']);

return $expiredSessionListenerId;
}

private function createExpression($container, $expression)
{
if (isset($this->expressions[$id = 'security.expression.'.sha1($expression)])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You now also need to update the required version of the symfony/security-http component in the SecurityBundle.

Copy link
Contributor Author

@ajgarlag ajgarlag Sep 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shoud I add a requirement for symfony/security-http:~3.2 or should I modify the required version for symfony/security to ~3.2?

Copy link
Member

Choose a reason for hiding this comment

The 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" />
Expand Down
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';
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use the FormLoginBundle directly in the tests? Then we could remove this file and the new after_login.html.twig. That bundle already gives us a functional /profile URL, which is all we really need I think.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Wait (add a space)

$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'
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
],
"require": {
"php": ">=5.5.9",
"symfony/security": "~3.1,>=3.1.2",
"symfony/security": "~3.2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be removed - on the 3.4 branch, this is already updated to "symfony/security": "~3.4|~4.0",

"symfony/http-kernel": "~3.1",
"symfony/polyfill-php70": "~1.0"
},
Expand Down
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.';
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Up @@ -66,6 +66,10 @@
<source>Account is locked.</source>
<target>Account is locked.</target>
</trans-unit>
<trans-unit id="17">
<source>Session has expired.</source>
<target>Session has expired.</target>
</trans-unit>
</body>
</file>
</xliff>
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
<source>Account is locked.</source>
<target>La cuenta está bloqueada.</target>
</trans-unit>
<trans-unit id="17">
<source>Session has expired.</source>
<target>La sesión ha expirado.</target>
</trans-unit>
</body>
</file>
</xliff>
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can inline that check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is cleaner with the extra method.

Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please separate the @param and @throws with an empty phpdoc line

*/
public function handle(GetResponseEvent $event)
{
$request = $event->getRequest();
$session = $request->getSession();

if (null === $session || null === $token = $this->tokenStorage->getToken()) {
return;
}

if (!$this->hasSessionExpired($session)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for the extra method imo

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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()));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit pick, but use ' around the string and then "%s" inside. We almost always wrap strings in single quotes (sometimes this isn't followed, but usually when we need "\n")

}

$this->tokenStorage->setToken(null);
$session->invalidate();
Copy link
Member

Choose a reason for hiding this comment

The 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 LogoutListener does + some of its logout handlers (e.g. SessionLogoutHandler). So then (to play devil's advocate), why not actually inject the logout handlers and use them here? We are logging the user out... so shouldn't those handlers be called? And to go further, couldn't we use the normal DefaultLogoutSuccessHandler to figure out where to take the user next?

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. security.session_expired) in this class, before calling the logout handlers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After 3 years, my vision is slightly different. I'll remove the $tokenStorage->setToken(null) so this listener will only detect the session expiration and optionally (enabled by default) will invalidate the session.

For me, the logout is an interactive user action and different from the session expiration case.
If you need to force the logout you could disable the session invalidation and redirect the user to the logout path. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 ContextListener to restore the token.

If we decide that this feature is not related to security, but to the framework, we can implement it as a kernel.request event listener that, IMO, should be executed with higher priority than the firewall one, so the user is not authenticated from an expired session. I'm not sure which component (HttpKernel?) should provide this listener.

Anyway, once the expired session is detected, I think that a new event should be dispatched (currently not implemented). Your proposed name security.session_expired it's only an option if we decide that this feature is a security concern.

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 SessionExpiredException in no path is defined. If we implement this feature as a kernel event listener, this behavior should be hooked to the new session_expired event.

In a previous comment you proposed to force an user logout calling the LogoutSuccessHandler and all defined LogoutHandlers, but I see two problems here:

  1. In the app there could be more than one firewall, so; which logout should be executed when an expired session is detected if we decide that this option should not be implemented as a firewall listener, but as a kernel listener?
  2. If we decide to execute the logout from the current firewall, (in this case the expired session listener should be a firewall listener, or has to be executed after the firewall listener), what should be done if there is no logout listener defined for the current path?

Copy link
Member

Choose a reason for hiding this comment

The 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 framework.session configuration. And I don't think ANY other configuration is necessary - no need to have expiration_url. Nope, we would simply invalidate the user's session and that's it (e.g. allow the request to continue).

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):

  1. This is not related to security at all - implement it under the framework.session configuration. The listener would be configured in FrameworkBundle, but the listener/subscriber class itself would live in the HttpKernel component.

  2. When a session "expires", simply invalidate the session and do nothing else. Well, let's also dispatch a new event, and we can see if people think it's a good idea.


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;

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The time function returns a UNIX timestamp, which is timezone independent and the lastUsed property of the MetadataBag is computed using the time function too, so the session expires are not related to timezones.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

of course, you're right. thanks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLastUsed will return null if session is not started

}
}
Loading