In Symfony, IS_AUTHENTICATED_FULLY is the strongest check about how users logged in. However, it tells you how the session started, not when. A user who logged in with their password eight hours ago and left the laptop unlocked is still fully authenticated, so anyone can make a payment or delete their account. Services like GitHub solve this with a sudo mode which asks for your credentials again before sensitive actions. Symfony 8.2 adds this feature natively.

Requiring Recent Authentication

Nicolas Grekas
Contributed by Nicolas Grekas in #66014 and #66066

Symfony 8.2 adds two security attributes that go beyond IS_AUTHENTICATED_FULLY:

  • IS_AUTHENTICATED_RECENTLY: the user entered their credentials within the last two hours;
  • IS_AUTHENTICATED_VERY_RECENTLY: the user entered their credentials within the last five minutes.

For example, you could require a recent login to change an email address, and a very recent one to delete an account:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Controller/AccountController.php
use Symfony\Component\Security\Http\Attribute\IsGranted;
// ...

#[IsGranted('IS_AUTHENTICATED_RECENTLY')]
public function changeEmail(): Response
{
    // ...
}

#[IsGranted('IS_AUTHENTICATED_VERY_RECENTLY')]
public function deleteAccount(): Response
{
    // ...
}

You can use these attributes wherever you use the other IS_AUTHENTICATED_* attributes, including access_control and Twig templates. Security expressions also get two new functions: is_recently_authenticated() and is_very_recently_authenticated().

If users are logged in only because of a remember-me cookie, they never pass these checks. They must log in again first, because the cookie could be used by anyone who has access to their browser.

You can change both time limits in your security configuration. The values are in seconds:

1
2
3
4
# config/packages/security.yaml
security:
    recent_authentication_lifetime: 3_600      # default: 7_200
    very_recent_authentication_lifetime: 60    # default: 300

Asking Users to Authenticate Again

Nicolas Grekas
Contributed by Nicolas Grekas in #66016 , #66055 and #66015

When these attributes are denied, users get a 403 error by default. A better option is to ask users for their credentials again and then take them back to the page they were visiting. To do that, create a service that implements the new ReAuthenticationEntryPointInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/Security/ConfirmPasswordEntryPoint.php
namespace App\Security;

use Symfony\Component\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface;
// ...

final class ConfirmPasswordEntryPoint implements ReAuthenticationEntryPointInterface
{
    public function __construct(
        private UrlGeneratorInterface $urlGenerator,
    ) {
    }

    public function startReAuthentication(Request $request, TokenInterface $token): Response
    {
        // this page displays a password form that submits to the form_login check_path
        return new RedirectResponse($this->urlGenerator->generate('app_confirm_password'));
    }
}

Then configure your firewall to use it with the new re_authentication_entry_point option:

1
2
3
4
5
6
7
# config/packages/security.yaml
security:
    firewalls:
        main:
            form_login:
                # ...
            re_authentication_entry_point: App\Security\ConfirmPasswordEntryPoint

If your firewall's entry point already implements this interface, you can skip that option. The new OpenID Connect login does this for you: it sends users back to their provider with prompt=login to ask for their credentials again.

Symfony also uses the ID token's auth_time claim to check when the user actually authenticated. That way, silently signing in through an old session at the provider doesn't count as a fresh login.

Customizing the Sudo Mode Check

Nicolas Grekas
Contributed by Nicolas Grekas in #66035 , #66065 and #66069

Sometimes, checking how recently someone logged in isn't enough. A banking application, for example, might also require them to have used a hardware key during the session.

The trust resolver decides whether these checks pass. To add your own rules, extend it and override isAuthenticatedRecently() or isAuthenticatedVeryRecently(). This example requires a hardware key to have been used during the session, plus an authentication within the last five minutes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/Security/BankTrustResolver.php
namespace App\Security;

use Symfony\Component\Security\Core\Authentication\AuthenticationMethod;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

final class BankTrustResolver extends AuthenticationTrustResolver
{
    public function isAuthenticatedVeryRecently(?TokenInterface $token = null): bool
    {
        if (!$this->isFullFledged($token)) {
            return false;
        }

        // e.g. ['pwd' => 1789140200, 'hwk' => 1789133290]
        $proofs = $token->getAuthenticationProofs();

        return isset($proofs[AuthenticationMethod::HARDWARE_KEY])
            && time() - max($proofs) <= 300;
    }
}

The token now tracks which authentication methods were used during the session and when each was last used. The method names come from RFC 8176.

The OIDC authenticator gets these methods from the ID token's amr claim. If you're writing your own authenticator, you can declare them with the new AuthenticationMethodBadge.

See the recent authentication docs for how to register your own trust resolver.

Published in #Living on the edge