Skip to content
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

Session cookies expiration logic #659

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ services:
- '@twig'
- []
- '@logger'

Surfnet\ServiceProviderDashboard\Infrastructure\DashboardBundle\Security\Authentication\Listener\ExplicitSessionTimeoutListener:

Surfnet\ServiceProviderDashboard\Infrastructure\DashboardBundle\Security\Authentication\Listener\AuthenticatedUserListener:

Surfnet\ServiceProviderDashboard\Infrastructure\DashboardBundle\Security\Authentication\Session\SessionLifetimeGuard:
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types = 1);

/**
* Copyright 2024 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Surfnet\ServiceProviderDashboard\Infrastructure\DashboardBundle\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class AuthenticatedUserListener implements EventSubscriberInterface
{
public function __construct(
private readonly TokenStorageInterface $tokenStorage,
private readonly SessionLifetimeGuard $sessionLifetimeGuard,
private readonly AuthenticatedSessionStateHandler $sessionStateHandler,
private readonly LoggerInterface $logger,
) {
}

public static function getSubscribedEvents(): array
{
return [
// The firewall, which makes the token available, listens at P8
// We must jump in after the firewall, forcing us to overwrite the translator locale.
KernelEvents::REQUEST => ['updateLastInteractionMoment', 6],
];
}

public function updateLastInteractionMoment(RequestEvent $event): void
{
$token = $this->tokenStorage->getToken();

if ($token === null || !$this->sessionLifetimeGuard->sessionLifetimeWithinLimits($this->sessionStateHandler)) {
return;
}
$this->logger->notice('Logged in user with a session within time limits detected, updating session state');

// see ExplicitSessionTimeoutHandler for the rationale
if ($event->getRequest()->getMethod() === 'GET') {
$this->sessionStateHandler->setCurrentRequestUri($event->getRequest()->getRequestUri());
}
$this->sessionStateHandler->updateLastInteractionMoment();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types = 1);

/**
* Copyright 2024 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Surfnet\ServiceProviderDashboard\Infrastructure\DashboardBundle\EventListener;

use Psr\Log\LoggerInterface;
use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Authentication\AuthenticatedSessionStateHandler;
use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Authentication\Session\SessionLifetimeGuard;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final readonly class ExplicitSessionTimeoutListener implements EventSubscriberInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
private AuthenticatedSessionStateHandler $authenticatedSession,
#[Autowire(service: SessionLifetimeGuard::class)]
private SessionLifetimeGuard $sessionLifetimeGuard,
private RouterInterface $router,
private LoggerInterface $logger,
private EventDispatcherInterface $eventDispatcher,
) {
}

public static function getSubscribedEvents(): array
{
return [
// The firewall, which makes the token available, listens at P8
// We must jump in after the firewall, forcing us to overwrite the translator locale.
KernelEvents::REQUEST => ['checkSessionTimeout', 5],
];
}

public function checkSessionTimeout(RequestEvent $event): void
{
$token = $this->tokenStorage->getToken();

if ($token === null || $this->sessionLifetimeGuard->sessionLifetimeWithinLimits($this->authenticatedSession)) {
return;
}

$invalidatedBy = [];
if (!$this->sessionLifetimeGuard->sessionLifetimeWithinAbsoluteLimit($this->authenticatedSession)) {
$invalidatedBy[] = 'absolute';
}

if (!$this->sessionLifetimeGuard->sessionLifetimeWithinRelativeLimit($this->authenticatedSession)) {
$invalidatedBy[] = 'relative';
}

$this->logger->notice(sprintf(
'Authenticated user found, but session was determined to be outside of the "%s" time limit. User will '
. 'be logged out and redirected to session-expired page to attempt new login.',
implode(' and ', $invalidatedBy),
));

$request = $event->getRequest();

// if the current request was not a GET request we cannot safely redirect to that page after login as it
// may require a form resubmit for instance. Therefor, we redirect to the last GET request (either current
// or previous).
$afterLoginRedirectTo = $this->authenticatedSession->getCurrentRequestUri();

if ($event->getRequest()->getMethod() === 'GET') {
$afterLoginRedirectTo = $event->getRequest()->getRequestUri();
}

// log the user out using Symfony methodology, see the LogoutListener
$event->setResponse(new RedirectResponse($this->router->generate('selfservice_security_session_expired')));

// something to clear cookies
$this->eventDispatcher->dispatch(new LogoutEvent($request, $token));
$this->tokenStorage->setToken(null);

// the session is restarted after invalidation during the logout, so we can (re)store the last GET request
$this->authenticatedSession->setCurrentRequestUri($afterLoginRedirectTo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

/**
* Copyright 2017 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Authentication;

use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Exception\LogicException;
use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Value\DateTime;

interface AuthenticatedSessionStateHandler
{
/**
* Sets the moment at which the user was authenticated
*
* @return void
* @throws LogicException when an authentication moment was already logged
*/
public function logAuthenticationMoment();

/**
* @return bool
*/
public function isAuthenticationMomentLogged();

/**
* Gets the moment at which the user was authenticated
*
* @return DateTime
* @throws LogicException when no authentication moment was logged
*/
public function getAuthenticationMoment();

/**
* Updates the last interaction moment to the current moment
*
* @return void
*/
public function updateLastInteractionMoment();

/**
* Retrieves the last interaction moment
*
* @return DateTime
*/
public function getLastInteractionMoment();

/**
* @return bool
*/
public function hasSeenInteraction();

/**
* @param string $uri
*/
public function setCurrentRequestUri($uri);

/**
* @return string
*/
public function getCurrentRequestUri();

/**
* Migrates the current session to a new session id while maintaining all
* session attributes.
*/
public function migrate();

/**
* Invalidates the session
*
* Clears all session attributes and flashes and regenerates the
* session and deletes the old session from persistence
*/
public function invalidate();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/**
* Copyright 2017 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Authentication\Session;

use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Security\Authentication\AuthenticatedSessionStateHandler;
use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Value\DateTime;
use Surfnet\ServiceProviderDashboard\Infrastructure\DashboardSamlBundle\Value\TimeFrame;

class SessionLifetimeGuard
{
/**
* @var TimeFrame
*/
private $relativeTimeoutLimit;
/**
* @var TimeFrame
*/
private $absoluteTimeoutLimit;

public function __construct(TimeFrame $absoluteTimeoutLimit, TimeFrame $relativeTimeoutLimit)
{
$this->absoluteTimeoutLimit = $absoluteTimeoutLimit;
$this->relativeTimeoutLimit = $relativeTimeoutLimit;
}

/**
* @param AuthenticatedSessionStateHandler $sessionStateHandler
* @return bool
*/
public function sessionLifetimeWithinLimits(AuthenticatedSessionStateHandler $sessionStateHandler)
{
return $this->sessionLifetimeWithinAbsoluteLimit($sessionStateHandler)
&& $this->sessionLifetimeWithinRelativeLimit($sessionStateHandler);
}

/**
* @param AuthenticatedSessionStateHandler $sessionStateHandler
* @return bool
*/
public function sessionLifetimeWithinAbsoluteLimit(AuthenticatedSessionStateHandler $sessionStateHandler)
{
if (!$sessionStateHandler->isAuthenticationMomentLogged()) {
return true;
}

$authenticationMoment = $sessionStateHandler->getAuthenticationMoment();
$sessionTimeoutMoment = $this->absoluteTimeoutLimit->getEndWhenStartingAt($authenticationMoment);
$now = DateTime::now();

if ($now->comesBeforeOrIsEqual($sessionTimeoutMoment)) {
return true;
}

return false;
}

/**
* @param AuthenticatedSessionStateHandler $sessionStateHandler
* @return bool
*/
public function sessionLifetimeWithinRelativeLimit(AuthenticatedSessionStateHandler $sessionStateHandler)
{
if (!$sessionStateHandler->hasSeenInteraction()) {
return true;
}

$lastInteractionMoment = $sessionStateHandler->getLastInteractionMoment();
$sessionTimeoutMoment = $this->relativeTimeoutLimit->getEndWhenStartingAt($lastInteractionMoment);
$now = DateTime::now();

if ($now->comesBeforeOrIsEqual($sessionTimeoutMoment)) {
return true;
}

return false;
}
}
Loading
Loading