Configurer l’authentification par passkey avec Symfony

Ajouter l’inscription et la connexion WebAuthn à Symfony 7.1 avec les bundles open source de web-auth.
Les passkeys sont des identifiants fondés sur WebAuthn. Elles remplacent le secret partagé d’un mot de passe par une paire de clés. L’utilisateur confirme la connexion avec un téléphone, une clé de sécurité ou le mécanisme biométrique de son appareil. La clé privée ne quitte pas l’authentificateur, ce qui réduit notamment le risque d’hameçonnage.
Je vais configurer l’inscription et la connexion par passkey avec Symfony 7.1, PHP 8.3 et des composants open source, sans service SaaS tiers.
Grâce au travail de Florent Morselli, je dispose d’une bibliothèque prête pour la production, répartie en trois composants principaux :
-
webauthn-lib implémente le protocole WebAuthn.
-
webauthn-stimulus-bundle gère les échanges entre l’authentificateur et le serveur.
-
webauthn-symfony-bundle relie la bibliothèque à Symfony.
Configurer un projet Symfony
Le Maker Bundle génère l’inscription et la connexion par mot de passe. Je pars de cette base avant d’ajouter les passkeys aux deux parcours.
docker compose up -d && symfony serve --no-tlsEntité User
La classe utilisateur générée par défaut suffit pour cette démonstration. Elle contient déjà email, password et roles.
<?php
namespace App\Entity;
// importations...
#[ORM\Table(name: '`user`')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
#[UniqueEntity(fields: ['email'], message: 'something went wrong !')]
class User implements
UserInterface,
PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column(nullable: true)]
private ?string $password = null;
// methods...
}Puisque j’utilise PostgreSQL avec Docker, générons les fichiers de migration et appliquons-les à la base de données PostgreSQL.
symfony console make:migration
symfony console doctrine:migrations:migratePour simplifier la persistance de l’entité, ajoutons deux méthodes utilitaires à la classe UserRepository.
<?php
namespace App\Repository;
// importations...
class UserRepository extends ServiceEntityRepository implements
PasswordUpgraderInterface
{
// constructor...
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
public function remove(User $user): void
{
$this->getEntityManager()->remove($user);
$this->getEntityManager()->flush();
}
// interface implementation...
}Zone protégée
Créons MainController, une zone accessible uniquement aux utilisateurs authentifiés.
symfony console make:controller MainControllerSymfony propose deux façons d’imposer un contrôle d’accès à certaines routes :
-
Utiliser l’attribut
IS_GRANTEDdirectement dans le contrôleur. -
Définir des règles de contrôle d’accès dans
config/packages/security.yaml.
<?php
namespace App\Controller;
// importations...
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MainController extends AbstractController
{
#[Route('/main', name: 'app_main')]
public function index(): Response
{
return $this->render('main/index.html.twig', [
'controller_name' => 'MainController',
]);
}
}Configurer l’authentification par passkey
La base par mot de passe fonctionne. J’ajoute maintenant les passkeys avec les bibliothèques mentionnées plus haut.
composer require web-auth/webauthn-lib
composer require web-auth/webauthn-symfony-bundle
composer require web-auth/webauthn-stimulusLa partie utilisatrice, ou Relying Party
Dans WebAuthn, la partie utilisatrice, ou Relying Party, abrégée en RP, est l’application qui demande l’authentification. L’authentificateur conserve les passkeys et répond à cette demande. Il peut s’agir d’un smartphone, d’une clé matérielle ou du système biométrique d’un appareil.
-
RP Name. Il s’agit du nom de l’application affiché pendant l’authentification, par exemple
My Application. -
Le RP ID correspond généralement au nom de domaine, par exemple
localhostoumyapp.com. Il relie la demande d’authentification au domaine. Un identifiant enregistré pour une RP ne peut donc pas servir à une autre.
Utilisez des variables d’environnement pour configurer la RP selon l’environnement de déploiement.
# .env
###> web-auth/webauthn-symfony-bundle ###
RELYING_PARTY_ID=localhost
RELYING_PARTY_NAME="My Application"
###< web-auth/webauthn-symfony-bundle ###Source d’identifiants
Après l’enregistrement d’un authentificateur, l’application reçoit un Public Key Credential Source. Cet objet conserve les informations nécessaires aux connexions suivantes.
La source décrit aussi l’authentificateur. L’application peut ainsi distinguer plusieurs passkeys appartenant au même utilisateur.
<?php
namespace App\Entity;
use Symfony\Component\Uid\Uuid;
use App\Repository\WebauthnCredentialSourceRepository;
use Doctrine\ORM\Mapping as ORM;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\TrustPath;
#[ORM\Table(name: 'webauthn_credentials')]
#[ORM\Entity(repositoryClass: WebauthnCredentialSourceRepository::class)]
class WebauthnCredentialSource extends PublicKeyCredentialSource
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
public function __construct(
string $publicKeyCredentialId,
string $type,
array $transports,
string $attestationType,
TrustPath $trustPath,
Uuid $aaguid,
string $credentialPublicKey,
string $userHandle,
int $counter
) {
parent::__construct(
$publicKeyCredentialId, $type, $transports,
$attestationType,$trustPath,
$aaguid, $credentialPublicKey,
$userHandle, $counter
);
}
}<?php
namespace App\Repository;
use App\Entity\User;
use App\Entity\WebauthnCredentialSource;
use Doctrine\Persistence\ManagerRegistry;
use Webauthn\{
Bundle\Repository\DoctrineCredentialSourceRepository,
PublicKeyCredentialSource
};
final class WebauthnCredentialSourceRepository extends DoctrineCredentialSourceRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WebauthnCredentialSource::class);
}
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
{
if (!$publicKeyCredentialSource instanceof WebauthnCredentialSource) {
$publicKeyCredentialSource = new WebauthnCredentialSource(
$publicKeyCredentialSource->publicKeyCredentialId,
$publicKeyCredentialSource->type,
$publicKeyCredentialSource->transports,
$publicKeyCredentialSource->attestationType,
$publicKeyCredentialSource->trustPath,
$publicKeyCredentialSource->aaguid,
$publicKeyCredentialSource->credentialPublicKey,
$publicKeyCredentialSource->userHandle,
$publicKeyCredentialSource->counter
);
}
parent::saveCredentialSource($publicKeyCredentialSource);
}
}Utilisateur associé aux identifiants
Cet objet représente l’utilisateur connu de WebAuthn et regroupe les données exigées par la spécification.
Pour éviter les conflits et garantir l’identification unique de chaque utilisateur :
-
Chaque utilisateur doit disposer d’un identifiant unique.
-
Le nom d’utilisateur doit aussi être unique. J’utilise ici l’adresse e-mail.
<?php
namespace App\Repository;
use App\Entity\User;
use LogicException;
use Random\RandomException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Connection;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\{
Exception\InvalidDataException,
Bundle\Repository\CanGenerateUserEntity,
Bundle\Repository\CanRegisterUserEntity,
PublicKeyCredentialUserEntity,
Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface
};
final readonly class WebauthnCredentialUserRepository implements
PublicKeyCredentialUserEntityRepositoryInterface,
CanRegisterUserEntity,
CanGenerateUserEntity
{
public function __construct(
private UserRepository $userRepository,
private Connection $connection
) {
}
/**
* @see https://dba.stackexchange.com/q/253090
* @see https://dba.stackexchange.com/a/253098
* @todo using UUIDs would be a better idea as they are decoupled from the database
*/
public function generateNextUserEntityId(): string
{
return (string) $this->connection
->executeQuery('SELECT last_value + 1 FROM user_id_seq;')
->fetchOne();
}
public function saveUserEntity(PublicKeyCredentialUserEntity $userEntity): void
{
/** @var User|null $user */
$user = $this->userRepository->findOneBy(['id' => $userEntity->id]);
if ($user === null) {
$user = (new User())
->setEmail($userEntity->name)
->setRoles(['ROLE_USER']);
}
$this->userRepository->save($user);
}
public function findOneByUsername(string $username): ?PublicKeyCredentialUserEntity
{
$user = $this->userRepository->findOneBy(['email' => $username]);
return $this->getUserEntity($user);
}
public function findOneByUserHandle(string $userHandle): ?PublicKeyCredentialUserEntity
{
$user = $this->userRepository->findOneBy(['id' => $userHandle]);
return $this->getUserEntity($user);
}
public function generateUserEntity(?string $username, ?string $displayName): PublicKeyCredentialUserEntity
{
$randomUserData = Base64UrlSafe::encodeUnpadded(random_bytes(32));
return PublicKeyCredentialUserEntity::create(
$username ?? $randomUserData,
$this->generateNextUserEntityId(),
$displayName ?? $username ?? $randomUserData,
null
);
}
private function getUserEntity(null|User $user): ?PublicKeyCredentialUserEntity
{
if ($user === null) {
return null;
}
return new PublicKeyCredentialUserEntity(
$user->getUserIdentifier(),
(string) $user->getId(),
$user->getDisplayName(),
null
);
}
}Configurez ensuite le bundle webauthn avec les dépôts des identifiants et des utilisateurs, puis les profils de création et de requête.
# config/packages/webauthn.yaml
webauthn:
credential_repository: 'App\Repository\WebauthnCredentialSourceRepository'
user_repository: 'App\Repository\WebauthnCredentialUserRepository'
creation_profiles:
default:
rp:
name: '%env(RELYING_PARTY_NAME)%'
id: '%env(RELYING_PARTY_ID)%'
request_profiles:
default:
rp_id: '%env(RELYING_PARTY_ID)%'Pour activer l’authentification des utilisateurs, il suffit de déclarer l’authentificateur webauthn dans le pare-feu concerné, ici main.
# config/packages/security.yaml
security:
firewalls:
main:
# ...
webauthn:
registration:
enabled: true
profile: default
routes:
options_path: '/passkeys/attestation/options'
result_path: '/passkeys/attestation/result'
authentication:
enabled: true
profile: default
routes:
options_path: '/passkeys/assertion/options'
result_path: '/passkeys/assertion/result'Gérer localhost
WebAuthn exige un contexte sécurisé. En développement, vous pouvez déclarer certains contextes locaux comme sûrs même lorsque HTTPS n’est pas activé.
En développement, déclarez les Relying Party IDs locaux considérés comme sûrs. Cette exception permet de tester les passkeys sans connexion HTTPS.
parameters:
# Do not use this in production - for testing purposes only
webauthn.secured_rp_ids: ['localhost']Inscription et connexion avec des passkeys
Le contrôleur Stimulus fourni relie maintenant le formulaire classique aux cérémonies WebAuthn.
Inscription avec creation_profiles
-
Les utilisateurs peuvent enregistrer leurs authentificateurs pendant la configuration initiale. Lorsqu’ils remplissent le formulaire d’inscription et choisissent les passkeys, le contrôleur Stimulus prend en charge le processus d’enregistrement WebAuthn.
-
S’ils préfèrent ne pas utiliser de passkey immédiatement, ils peuvent toujours créer un compte avec une authentification classique par mot de passe. Vous pouvez aussi leur permettre d’ajouter des passkeys plus tard depuis les paramètres de leur compte.
{{ form_start(registrationForm, {
attr: {
...stimulus_controller('@web-auth/webauthn-stimulus', {
usernameField: registrationForm.email.vars.full_name,
creationSuccessRedirectUri: path('app_main'),
creationResultUrl: path('webauthn.controller.security.main.creation.result'),
creationOptionsUrl: path('webauthn.controller.security.main.creation.options'),
}).toArray
}
}) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.plainPassword, {label: 'Password'}) }}
<button type="submit">Register</button>
<button {{ stimulus_action('@web-auth/webauthn-stimulus', 'signup') }}>
Register with passkey
</button>
{{ form_end(registrationForm) }}Connexion avec request_profiles
-
Une fois inscrits, les utilisateurs peuvent se connecter à l’aide de leurs passkeys. Les challenges appropriés sont envoyés à leur authentificateur.
-
Une fois l’authentification réussie, les utilisateurs accèdent à l’application.
<form method="post" {{ stimulus_controller('@web-auth/webauthn-stimulus',
{
useBrowserAutofill: true,
usernameField: '_username',
requestSuccessRedirectUri: path('app_main'),
requestResultUrl: path('webauthn.controller.security.main.request.result'),
requestOptionsUrl: path('webauthn.controller.security.main.request.options')
}
) }}>
// input[name=_username]
// input[name=_password]
// input[name=_csrf_token, type=hidden]
<button type="submit">Connect</button>
<button {{ stimulus_action('@web-auth/webauthn-stimulus', 'signin') }}>
Connect with passkey
</button>
</div>
</form>Démonstration

Préparer le passage en production
Cette configuration ajoute les passkeys à l’inscription et à la connexion sans supprimer immédiatement le mot de passe. C’est utile pour migrer progressivement une application existante. En production, vérifiez aussi les origines autorisées, imposez HTTPS et testez les parcours de récupération sur les appareils réellement pris en charge.