Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
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
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
8.2
---

* Add the `re_authentication_entry_point` firewall option, which starts a fresh authentication when `IS_AUTHENTICATED_RECENTLY` is denied
* Add the `recent_authentication_lifetime` option, the number of seconds an interactive authentication keeps granting `IS_AUTHENTICATED_RECENTLY`
* Add the `security.expression_language_provider` service to evaluate the security functions outside of authorization expressions
* Add the `debug:roles` command to inspect the role hierarchy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ public function process(ContainerBuilder $container): void
}
}

// an explicit option only, never inferred: re-authentication is not something
// to start by accident on a firewall that happens to have a single entry point
if ($container->hasDefinition($exceptionListenerId = 'security.exception_listener.'.$firewallName)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be good to allow true and null as a value so it uses the default entry point?

&& \array_key_exists(9, ($exceptionListener = $container->getDefinition($exceptionListenerId))->getArguments())
&& null !== $configuredReAuthEntryPoint = $exceptionListener->getArgument(9)
) {
$exceptionListener->replaceArgument(9, new Reference($entryPoints[$configuredReAuthEntryPoint] ?? $fallbackEntryPoints[$configuredReAuthEntryPoint] ?? $configuredReAuthEntryPoint));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it ok if the entry point is not part of the available authenticators on this firewall ? It seems to silence that we misconfigured it.

}

if (!$entryPoints && !$fallbackEntryPoints) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto
->scalarNode('entry_point')
->info(\sprintf('An enabled authenticator name or a service id that implements "%s".', AuthenticationEntryPointInterface::class))
->end()
->scalarNode('re_authentication_entry_point')
->info(\sprintf('Starts a fresh authentication when IS_AUTHENTICATED_RECENTLY is denied. An enabled authenticator name or a service id that implements "%s".', AuthenticationEntryPointInterface::class))
->end()
->scalarNode('provider')->end()
->booleanNode('stateless')->defaultFalse()->end()
->booleanNode('lazy')->defaultFalse()->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,13 @@ private function createExceptionListener(ContainerBuilder $container, array $con
$listener->replaceArgument(3, $id);
$listener->replaceArgument(4, null === $defaultEntryPoint ? null : new Reference($defaultEntryPoint));
$listener->replaceArgument(8, $stateless);
// left as the configured string: RegisterEntryPointPass turns an authenticator
// key into its service id, the same way it does for the main entry point
$listener->replaceArgument(9, $config['re_authentication_entry_point'] ?? null);

if ($stateless && isset($config['re_authentication_entry_point'])) {
throw new InvalidConfigurationException(\sprintf('The "re_authentication_entry_point" option cannot be used on the stateless firewall "%s": it has no session to record when the user authenticated, so IS_AUTHENTICATED_RECENTLY is always denied and re-authentication would loop.', $id));
}

// access denied handler setup
if (isset($config['access_denied_handler'])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
service('security.access.denied_handler')->nullOnInvalid(),
service('logger')->nullOnInvalid(),
false, // Stateless
null, // Re-authentication entry point, resolved by RegisterEntryPointPass
])
->tag('monolog.logger', ['channel' => 'security'])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ public function testLdapUsersOnlyIsRejectedWithoutAnLdapUserProvider()
$container->compile();
}

public function testReAuthenticationEntryPointIsRefusedOnAStatelessFirewall()
{
$container = $this->getRawContainer();
$container->loadFromExtension('security', [
'providers' => [
'default' => ['memory' => ['users' => ['bob' => ['password' => 'x']]]],
],
'firewalls' => [
'api' => [
'stateless' => true,
'http_basic' => true,
're_authentication_entry_point' => 'http_basic',
],
],
]);

$this->expectException(InvalidConfigurationException::class);
$this->expectExceptionMessage('The "re_authentication_entry_point" option cannot be used on the stateless firewall "api"');

$container->compile();
}

public function testLdapUsersOnlyIsAcceptedWithAnLdapLegInAChainProvider()
{
if (!property_exists(CheckLdapCredentialsListener::class, 'ldapUsersOnly')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;

/**
* Thrown when access was denied because the user did not authenticate recently enough.
*
* The user is authenticated, so this is not a failure to identify them: it asks them to
* prove possession of their credentials again before a sensitive action. An entry point
* receiving this should start a fresh authentication rather than an ordinary login, which
* an existing session would otherwise satisfy without the user typing anything.
*
* @see \Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter::IS_AUTHENTICATED_RECENTLY
*/
class ReAuthenticationRequiredException extends InsufficientAuthenticationException
{
public function getMessageKey(): string
{
return 'Re-authentication is required to access this resource.';
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
8.2
---

* Add `ReAuthenticationRequiredException` and a re-authentication entry point to `ExceptionListener`, started when `IS_AUTHENTICATED_RECENTLY` is denied
* Stamp the `auth_time` token attribute from the OIDC ID token claim of the same name, so `max_age` and `IS_AUTHENTICATED_RECENTLY` agree
* Add `AuthenticationTimeListener`, which records the time of the last interactive authentication as the `auth_time` token attribute
* Add `allowed_time_drift` option to `OidcTokenHandler` to configure time tolerance for token validation (`iat`, `nbf`, `exp` claims)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
use Symfony\Component\Security\Core\Exception\LazyResponseException;
use Symfony\Component\Security\Core\Exception\LogoutException;
use Symfony\Component\Security\Core\Exception\ReAuthenticationRequiredException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException;
Expand Down Expand Up @@ -58,6 +60,7 @@ public function __construct(
private ?AccessDeniedHandlerInterface $accessDeniedHandler = null,
private ?LoggerInterface $logger = null,
private bool $stateless = false,
private ?AuthenticationEntryPointInterface $reAuthenticationEntryPoint = null,
) {
}

Expand Down Expand Up @@ -144,6 +147,31 @@ private function handleAccessDeniedException(ExceptionEvent $event, AccessDenied
return;
}

// Matching the whole attribute list rather than searching it is deliberate: an
// access_control rule is decided on all of its roles at once, so a denial of
// [ROLE_ADMIN, IS_AUTHENTICATED_RECENTLY] does not say which one failed, and
// re-authenticating would not help a user who simply lacks the role.
if (null !== $this->reAuthenticationEntryPoint
&& [AuthenticatedVoter::IS_AUTHENTICATED_RECENTLY] === $exception->getAttributes()
) {
$this->logger?->debug('The authentication is not recent enough, starting re-authentication.', ['entry_point' => $this->reAuthenticationEntryPoint]);

if (!$this->stateless && !$this->reAuthenticationEntryPoint instanceof FallbackAuthenticationEntryPointInterface) {
$this->setTargetPath($event->getRequest());
}

try {
$event->setResponse($this->reAuthenticationEntryPoint->start($event->getRequest(), new ReAuthenticationRequiredException('Re-authentication is required to access this resource.', 0, $exception)));

return;
} catch (NotAnEntryPointException) {
// an entry point that cannot start re-authentication leaves the denial
// standing: 403 describes a stale authentication better than the 401
// an unusable entry point would otherwise produce
$this->logger?->debug('The re-authentication entry point declined to start, falling back to access denied.');
}
}

$this->logger?->debug('Access denied, the user is neither anonymous, nor remember-me.', ['exception' => $exception]);

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
use Symfony\Component\Security\Http\EntryPoint\FallbackAuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\Firewall\ExceptionListener;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException;
use Symfony\Component\Security\Core\Exception\ReAuthenticationRequiredException;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class ExceptionListenerTest extends TestCase
{
Expand Down Expand Up @@ -222,7 +226,7 @@ private function createEvent(\Exception $exception, $kernel = null, ?Session $se
return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception);
}

private function createExceptionListener(?TokenStorageInterface $tokenStorage = null, ?AuthenticationTrustResolverInterface $trustResolver = null, ?HttpUtils $httpUtils = null, ?AuthenticationEntryPointInterface $authenticationEntryPoint = null, $errorPage = null, ?AccessDeniedHandlerInterface $accessDeniedHandler = null)
private function createExceptionListener(?TokenStorageInterface $tokenStorage = null, ?AuthenticationTrustResolverInterface $trustResolver = null, ?HttpUtils $httpUtils = null, ?AuthenticationEntryPointInterface $authenticationEntryPoint = null, $errorPage = null, ?AccessDeniedHandlerInterface $accessDeniedHandler = null, ?AuthenticationEntryPointInterface $reAuthenticationEntryPoint = null)
{
return new ExceptionListener(
$tokenStorage ?? new TokenStorage(),
Expand All @@ -231,7 +235,85 @@ private function createExceptionListener(?TokenStorageInterface $tokenStorage =
'key',
$authenticationEntryPoint,
$errorPage,
$accessDeniedHandler
$accessDeniedHandler,
null,
false,
$reAuthenticationEntryPoint
);
}

private function createFullFledgedTrustResolver(): AuthenticationTrustResolverInterface
{
$trustResolver = $this->createMock(AuthenticationTrustResolverInterface::class);
$trustResolver->expects($this->once())->method('isFullFledged')->willReturn(true);

return $trustResolver;
}

public function testReAuthenticationEntryPointStartsOnAStaleAuthentication()
{
$exception = new AccessDeniedException();
$exception->setAttributes([AuthenticatedVoter::IS_AUTHENTICATED_RECENTLY]);
$event = $this->createEvent($exception);

$entryPoint = $this->createMock(AuthenticationEntryPointInterface::class);
$entryPoint->expects($this->once())
->method('start')
->with($this->anything(), $this->isInstanceOf(ReAuthenticationRequiredException::class))
->willReturn(new Response('Confirm your password', 200));

$listener = $this->createExceptionListener(null, $this->createFullFledgedTrustResolver(), null, null, null, null, $entryPoint);
$listener->onKernelException($event);

$this->assertSame('Confirm your password', $event->getResponse()->getContent());
}

public function testReAuthenticationEntryPointIsNotStartedForAnUnrelatedDenial()
{
$exception = new AccessDeniedException();
$exception->setAttributes(['ROLE_ADMIN']);
$event = $this->createEvent($exception);

$entryPoint = $this->createMock(AuthenticationEntryPointInterface::class);
$entryPoint->expects($this->never())->method('start');

$listener = $this->createExceptionListener(null, $this->createFullFledgedTrustResolver(), null, null, null, null, $entryPoint);
$listener->onKernelException($event);

$this->assertInstanceOf(AccessDeniedHttpException::class, $event->getThrowable());
}

public function testReAuthenticationEntryPointIsNotStartedWhenAnotherAttributeMayHaveFailed()
{
// an access_control rule is decided on all of its roles at once, so this denial
// does not say which attribute failed and re-authenticating may not help
$exception = new AccessDeniedException();
$exception->setAttributes(['ROLE_ADMIN', AuthenticatedVoter::IS_AUTHENTICATED_RECENTLY]);
$event = $this->createEvent($exception);

$entryPoint = $this->createMock(AuthenticationEntryPointInterface::class);
$entryPoint->expects($this->never())->method('start');

$listener = $this->createExceptionListener(null, $this->createFullFledgedTrustResolver(), null, null, null, null, $entryPoint);
$listener->onKernelException($event);

$this->assertInstanceOf(AccessDeniedHttpException::class, $event->getThrowable());
}

public function testAnUnusableReAuthenticationEntryPointFallsBackToAccessDenied()
{
$exception = new AccessDeniedException();
$exception->setAttributes([AuthenticatedVoter::IS_AUTHENTICATED_RECENTLY]);
$event = $this->createEvent($exception);

$entryPoint = $this->createMock(AuthenticationEntryPointInterface::class);
$entryPoint->expects($this->once())->method('start')->willThrowException(new NotAnEntryPointException());

$listener = $this->createExceptionListener(null, $this->createFullFledgedTrustResolver(), null, null, null, null, $entryPoint);
$listener->onKernelException($event);

// a 403 describes a stale authentication better than the 401 the entry point
// machinery would otherwise produce
$this->assertInstanceOf(AccessDeniedHttpException::class, $event->getThrowable());
}
}
Loading