Guide de l’authentification par passkey avec Symfony

Illustration de Guide de l’authentification par passkey avec Symfony

Une configuration de l’authentification par passkey avec Symfony, fondée sur WebAuthn et des bundles Symfony open source.

Introduction

Les passkeys, également appelées identifiants WebAuthn, constituent une approche moderne de l’authentification sans mot de passe. Cette évolution de la sécurité web vise à remplacer les connexions classiques par mot de passe grâce à l’association de la cryptographie à clé publique et d’une authentification matérielle. Avec les passkeys, les utilisateurs s’authentifient à l’aide d’un authentificateur — comme leur téléphone ou une clé de sécurité — plutôt qu’avec un mot de passe, souvent exposé aux fuites de données, aux attaques par hameçonnage et à d’autres risques de sécurité.

Dans cet article, je vais mettre en place l’inscription et la connexion par passkey avec Symfony 7.1, PHP 8.3 et un bundle open source, sans dépendre d’un fournisseur 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 : la bibliothèque principale, chargée de la logique du protocole WebAuthn.

  2. webauthn-stimulus-bundle : les interactions entre les appareils des utilisateurs et le serveur.

  3. webauthn-symfony-bundle : l’intégration au framework Symfony.

Passons maintenant au code pour voir comment configurer l’ensemble dans Symfony.

Configurer un projet Symfony

Le Maker Bundle de Symfony permet de générer facilement les fonctionnalités essentielles, comme l’inscription et la connexion des utilisateurs. Une fois cette base en place, j’enrichirai le projet en intégrant les passkeys à la connexion et à l’inscription.

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

Pour cette démonstration, la classe utilisateur générée par défaut suffit. Elle contient déjà les champs essentiels email, password et roles, ce qui constitue un bon point de départ.

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 un MainController qui servira de zone protégée, 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

Il est temps d’implémenter l’authentification par passkey. Je le ferai nativement à l’aide des bibliothèques fournies par Florent, 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 (Relying Party)

Dans le contexte de l’authentification par passkey et WebAuthn, la partie utilisatrice (Relying Party), souvent abrégée en « RP », désigne l’application ou le service qui interagit avec l’utilisateur et son authentificateur. L’authentificateur est un appareil ou un système qui stocke les passkeys de manière sécurisée et répond aux demandes d’authentification, par exemple un smartphone, une clé matérielle ou un appareil biométrique.

  1. RP Name : il s’agit du nom lisible de l’application, que l’utilisateur reconnaîtra pendant le processus d’authentification. Par exemple : « My Application ».

  2. RP ID : il s’agit généralement du nom de domaine de l’application, par exemple localhost ou myapp.com. Le RP ID relie la demande d’authentification au domaine et garantit ainsi que les identifiants enregistrés pour une RP ne peuvent pas être utilisés par une autre.

Vous pouvez utiliser des variables d’environnement pour configurer dynamiquement les valeurs de la partie utilisatrice (RP).

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 par un utilisateur, votre application reçoit un objet Public Key Credential Source. Cet objet conserve toutes les informations d’identification nécessaires pour authentifier l’utilisateur lors de ses prochaines connexions.

La Public Key Credential Source contient non seulement les données nécessaires à l’authentification, mais aussi des informations détaillées sur l’authentificateur lui-même. Votre application peut ainsi gérer plus efficacement les identifiants de l’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 un utilisateur qui interagit avec votre application et ses authentificateurs. Il regroupe les données essentielles de l’utilisateur tout en respectant les contraintes propres à la spécification WebAuthn.

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 — l’adresse e-mail dans mon cas — doit également être unique.

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
        );
    }
}

Une fois cette étape terminée, vous devez configurer correctement le bundle webauthn. Cela consiste à indiquer les dépôts personnalisés pour les sources d’identifiants et les entités utilisateur, puis à définir les profils de création et de requête du processus d’authentification.

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 »

Dans un environnement de développement, HTTPS n’est pas toujours activé, ce qui peut compliquer l’implémentation de WebAuthn. Bien que HTTPS soit essentiel à la sécurité des communications, vous pouvez configurer votre application pour qu’elle considère certains contextes comme sécurisés, même en son absence.

Vous pouvez contourner la vérification du schéma en définissant une liste de Relying Party IDs que votre application considère comme sûrs. Cette approche permet de tester et de développer les fonctionnalités d’authentification par passkey sans connexion sécurisée.

YAML
parameters:
    # Do not use this in production - for testing purposes only
    webauthn.secured_rp_ids: ['localhost']

Inscription et connexion avec des passkeys

Maintenant que tout est configuré, vous pouvez utiliser le contrôleur Stimulus fourni pour transformer votre formulaire de connexion classique en un formulaire entièrement compatible avec WebAuthn.

Inscription (creation_profiles, cérémonie d’attestation)

  • 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 (request_profiles, cérémonie d’assertion)

  • 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")

Conclusion

L’intégration de l’authentification par passkey avec WebAuthn dans une application Symfony améliore la sécurité des utilisateurs tout en facilitant leur expérience. Merci pour votre lecture et bon développement !

Bon développement !

Références

Articles liés