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
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
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
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.
These are fantastic innovations, and one thing leads to another. Symfony thrives because of people like you, Nicolas Grekas. I find myself thinking that over and over again. I’m glad I no longer have to build my own 2FA for sensitive admin actions, but can simply require stronger authentication from the customer’s identity provider. For me, these blog posts are like Christmas presents spread out throughout the entire year—every year. Thank you all!