The new sudo mode of Symfony 8.2 checks when users last entered their credentials. Some applications also need to know how they did it: a password is fine to read your invoices, but sending money may require a hardware security key. This post explains how Symfony records that information and how you can use it to ask users for stronger authentication.

Recording How Users Authenticated

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

In Symfony 8.2, security tokens store the authentication methods proven during the session and when each one was last used. The names of these methods come from RFC 8176 and are available as constants of the AuthenticationMethod class. The form_login, json_login and http_basic authenticators use AuthenticationMethod::PASSWORD and the OpenID Connect authenticator uses the amr claim of the ID token.

In your own authenticators, add the new AuthenticationMethodBadge to the passport with the methods that you verified:

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

use Symfony\Component\Security\Core\Authentication\AuthenticationMethod;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\AuthenticationMethodBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
// ...

final class SecurityKeyAuthenticator extends AbstractAuthenticator
{
    // ...

    public function authenticate(Request $request): Passport
    {
        $assertion = $request->getPayload()->getString('assertion');

        return new Passport(
            new UserBadge($this->securityKeys->findUserIdentifier($assertion)),
            new CustomCredentials($this->securityKeys->verify(...), $assertion),
            [new AuthenticationMethodBadge(AuthenticationMethod::HARDWARE_KEY)],
        );
    }
}

When the same user authenticates again, Symfony keeps the previous methods. If someone logs in with a password and later uses their security key, the token holds both. The getAuthenticationProofs() method returns the method names as keys and the Unix timestamps of their last use as values:

1
2
$token->getAuthenticationProofs();
// ['pwd' => 1789140200, 'hwk' => 1789140260]

Re-Authentication for Your Own Security Attributes

Nicolas Grekas
Contributed by Nicolas Grekas in #66242

In sudo mode, denying IS_AUTHENTICATED_RECENTLY or IS_AUTHENTICATED_VERY_RECENTLY asks users to authenticate again instead of returning a 403 error. In Symfony 8.2, your voters can do the same by calling the new requestReAuthentication() method of the Vote object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/Security/Voter/SecurityKeyVoter.php
namespace App\Security\Voter;

use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\AuthenticationMethod;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class SecurityKeyVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return 'IS_SECURITY_KEY_USED' === $attribute;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        if (isset($token->getAuthenticationProofs()[AuthenticationMethod::HARDWARE_KEY])) {
            return true;
        }

        // users without a security key get a 403 error, because logging in again won't help them
        $user = $token->getUser();
        if ($user instanceof User && $user->hasSecurityKey()) {
            $vote?->requestReAuthentication($attribute);
        }

        return false;
    }
}

Now, an action protected with #[IsGranted('IS_SECURITY_KEY_USED')] starts the re-authentication entry point of your firewall when the user has a security key but hasn't used it in the current session. This only works when a single attribute is denied, so a check that combines several roles still returns a 403 error.

Your entry point can read the SecurityRequestAttributes::RE_AUTHENTICATION_ATTRIBUTE request attribute to know which attribute was denied (and, for example, show the security key prompt instead of the password form).

Requesting Stronger Authentication from OpenID Connect Providers

Nicolas Grekas
Contributed by Nicolas Grekas in #66184

If your users log in with OpenID Connect, the provider performs the re-authentication. You can tell it which kind of authentication you need with the acr_values parameter. Use the request attribute shown in the previous section to add it only when your voter requested the re-authentication:

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

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\OidcAuthorizationRequestEvent;
use Symfony\Component\Security\Http\SecurityRequestAttributes;

#[AsEventListener]
final class RequestSecurityKeyListener
{
    public function __invoke(OidcAuthorizationRequestEvent $event): void
    {
        $deniedAttribute = $event->getRequest()->attributes->get(SecurityRequestAttributes::RE_AUTHENTICATION_ATTRIBUTE);

        if ('IS_SECURITY_KEY_USED' === $deniedAttribute) {
            // 'phrh' = phishing-resistant authentication with a hardware-protected key
            $event->setParam('acr_values', 'phrh');
        }
    }
}

The acr_values parameter is a request, so the provider might authenticate the user in a different way. That's why Symfony 8.2 also stores the acr claim of the ID token in the oidc_acr token attribute. Check it in your voter to know what the provider actually did:

1
2
3
if ($token->hasAttribute('oidc_acr') && 'phrh' === $token->getAttribute('oidc_acr')) {
    return true;
}

The phr and phrh values are defined in the OpenID Connect EAP ACR Values specification, but you can use any value supported by your provider.

Published in #Living on the edge