Configurer l’authentification par passkey avec Symfony

Illustration de 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 :

  1. webauthn-lib implémente le protocole WebAuthn.

  2. webauthn-stimulus-bundle gère les échanges entre l’authentificateur et le serveur.

  3. 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.

Bash
symfony new passkey-auth --webapp
php bin/console make:user
php bin/console make:security:form-login
php bin/console make:registration-form
Bash
docker compose up -d && symfony serve --no-tls

Entité 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
<?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.

Bash
symfony console make:migration
symfony console doctrine:migrations:migrate

Pour simplifier la persistance de l’entité, ajoutons deux méthodes utilitaires à la classe UserRepository.

PHP
<?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.

Bash
symfony console make:controller MainController

Symfony propose deux façons d’imposer un contrôle d’accès à certaines routes :

  1. Utiliser l’attribut IS_GRANTED directement dans le contrôleur.

  2. Définir des règles de contrôle d’accès dans config/packages/security.yaml.

PHP
<?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.

Bash
composer require web-auth/webauthn-lib
composer require web-auth/webauthn-symfony-bundle
composer require web-auth/webauthn-stimulus

La 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.

  1. RP Name. Il s’agit du nom de l’application affiché pendant l’authentification, par exemple My Application.

  2. Le RP ID correspond généralement au nom de domaine, par exemple localhost ou myapp.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.

Bash
# .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
<?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
<?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 :

  1. Chaque utilisateur doit disposer d’un identifiant unique.

  2. Le nom d’utilisateur doit aussi être unique. J’utilise ici l’adresse e-mail.

PHP
<?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.

YAML
# 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.

YAML
# 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.

YAML
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.

XML
{{ 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.

XML
<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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1726976979571/d2b79f46-29c6-4067-9b89-85e2c7390b9a.jpeg align="center")

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.

Références

Articles liés