Enregistrement de clés d'accès côté serveur

Présentation

Voici un aperçu des principales étapes à suivre pour enregistrer une clé d'accès :

Flux d'enregistrement d'une clé d'accès

  • Définissez les options permettant de créer une clé d'accès. Envoyez-les au client afin de pouvoir les transmettre à votre appel de création de clé d'accès : l'appel de l'API WebAuthn navigator.credentials.create sur le Web et credentialManager.createCredential sur Android. Une fois que l'utilisateur a confirmé la création de la clé d'accès, l'appel de création de la clé d'accès est résolu et renvoie un identifiant PublicKeyCredential.
  • Validez l'identifiant et stockez-le sur le serveur.

Les sections suivantes détaillent chaque étape.

Créer des options de création d'identifiants

La première étape à effectuer sur le serveur consiste à créer un objet PublicKeyCredentialCreationOptions.

Pour ce faire, appuyez-vous sur votre bibliothèque côté serveur FIDO. Il proposera généralement une fonction utilitaire permettant de créer ces options pour vous. SimpleWebAuthn propose, par exemple, generateRegistrationOptions.

PublicKeyCredentialCreationOptions doit inclure tout ce qui est nécessaire à la création d'une clé d'accès : des informations sur l'utilisateur et sur la partie de confiance, ainsi qu'une configuration pour les propriétés de l'identifiant que vous créez. Une fois que vous avez défini tous ces éléments, transmettez-les si nécessaire à la fonction de votre bibliothèque côté serveur FIDO chargée de créer l'objet PublicKeyCredentialCreationOptions.

Certains champs de PublicKeyCredentialCreationOptions peuvent être des constantes. Les autres doivent être définis de manière dynamique sur le serveur :

  • rpId : pour renseigner l'ID de partie de confiance sur le serveur, utilisez des fonctions ou des variables côté serveur qui vous donnent le nom d'hôte de votre application Web, comme example.com.
  • user.name et user.displayName : pour renseigner ces champs, utilisez les informations de session de l'utilisateur connecté (ou les informations du nouveau compte utilisateur, si l'utilisateur crée une clé d'accès lors de l'inscription). user.name est généralement une adresse e-mail unique pour le RP. user.displayName est un nom convivial. Notez que toutes les plates-formes n'utiliseront pas displayName.
  • user.id : chaîne unique et aléatoire générée lors de la création du compte. Il doit être permanent, contrairement à un nom d'utilisateur qui peut être modifié. L'ID utilisateur identifie un compte, mais ne doit pas contenir d'informations permettant d'identifier personnellement l'utilisateur. Vous disposez probablement déjà d'un ID utilisateur dans votre système. Toutefois, si nécessaire, créez-en un spécifiquement pour les clés d'accès afin d'éviter toute information permettant d'identifier personnellement l'utilisateur.
  • excludeCredentials : liste des ID d'identifiants existants pour éviter de dupliquer une clé d'accès du fournisseur de clés d'accès. Pour remplir ce champ, recherchez dans votre base de données les identifiants existants pour cet utilisateur. Pour en savoir plus, consultez Empêcher la création d'une clé d'accès s'il en existe déjà une.
  • challenge : pour l'enregistrement des identifiants, le challenge n'est pas pertinent, sauf si vous utilisez l'attestation, une technique plus avancée pour valider l'identité d'un fournisseur de clés d'accès et les données qu'il émet. Toutefois, même si vous n'utilisez pas l'attestation, le champ "Défi" reste obligatoire. Pour savoir comment créer un défi sécurisé pour l'authentification, consultez Authentification par clé d'accès côté serveur.

Encodage et décodage

Options de création PublicKeyCredential envoyées par le serveur
PublicKeyCredentialCreationOptions envoyé par le serveur. challenge, user.id et excludeCredentials.credentials doivent être encodés côté serveur en base64URL, afin que PublicKeyCredentialCreationOptions puisse être diffusé via HTTPS.

PublicKeyCredentialCreationOptions inclut des champs qui sont des ArrayBuffer, ils ne sont donc pas acceptés par JSON.stringify(). Cela signifie que, pour le moment, afin de diffuser PublicKeyCredentialCreationOptions via HTTPS, certains champs doivent être encodés manuellement sur le serveur à l'aide de base64URL, puis décodés sur le client.

  • Sur le serveur, l'encodage et le décodage sont généralement gérés par votre bibliothèque côté serveur FIDO.
  • Sur le client, l'encodage et le décodage doivent actuellement être effectués manuellement. Cela deviendra plus facile à l'avenir : une méthode permettant de convertir les options au format JSON en PublicKeyCredentialCreationOptions sera disponible. Vérifiez l'état de l'implémentation dans Chrome.

Exemple de code : créer des options de création d'identifiants

Nous utilisons la bibliothèque SimpleWebAuthn dans nos exemples. Ici, nous confions la création des options d'identifiants à clé publique à sa fonction generateRegistrationOptions.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
  const { user } = res.locals;
  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // `excludeCredentials` prevents users from re-registering existing
    // credentials for a given passkey provider
    const excludeCredentials = [];
    const credentials = Credentials.findByUserId(user.id);
    if (credentials.length > 0) {
      for (const cred of credentials) {
        excludeCredentials.push({
          id: isoBase64URL.toBuffer(cred.id),
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

    // Generate registration options for WebAuthn create
    const options = await generateRegistrationOptions({
      rpName: process.env.RP_NAME,
      rpID: process.env.HOSTNAME,
      userID: user.id,
      userName: user.username,
      userDisplayName: user.displayName || '',
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        requireResidentKey: true
      },
    });

    // Keep the challenge in the session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

Stocker la clé publique

Options de création PublicKeyCredential envoyées par le serveur
navigator.credentials.create renvoie un objet PublicKeyCredential.

Lorsque navigator.credentials.create est résolu avec succès sur le client, cela signifie qu'une clé d'accès a été créée. Un objet PublicKeyCredential est renvoyé.

L'objet PublicKeyCredential contient un objet AuthenticatorAttestationResponse, qui représente la réponse du fournisseur de clés d'accès à l'instruction du client de créer une clé d'accès. Il contient des informations sur les nouveaux identifiants dont vous avez besoin en tant que RP pour authentifier l'utilisateur ultérieurement. En savoir plus sur AuthenticatorAttestationResponse dans l'annexe : AuthenticatorAttestationResponse

Envoyez l'objet PublicKeyCredential au serveur. Une fois que vous l'avez reçu, validez-le.

Transférez cette étape de validation à votre bibliothèque côté serveur FIDO. Il proposera généralement une fonction utilitaire à cet effet. SimpleWebAuthn propose, par exemple, verifyRegistrationResponse. Pour en savoir plus sur ce qui se passe en coulisses, consultez l'annexe : validation de la réponse à l'enregistrement.

Une fois la validation réussie, stockez les informations d'identification dans votre base de données afin que l'utilisateur puisse s'authentifier ultérieurement avec la clé d'accès associée à ces identifiants.

Utilisez une table dédiée pour les identifiants de clé publique associés aux clés d'accès. Un utilisateur ne peut avoir qu'un seul mot de passe, mais peut avoir plusieurs clés d'accès (par exemple, une clé d'accès synchronisée via le trousseau iCloud d'Apple et une autre via le Gestionnaire de mots de passe de Google).

Voici un exemple de schéma que vous pouvez utiliser pour stocker les informations d'identification :

Schéma de base de données pour les clés d'accès

  • Table Users :
    • user_id : ID utilisateur principal. ID aléatoire, unique et permanent pour l'utilisateur. Utilisez-le comme clé primaire pour votre table Users.
    • username : nom d'utilisateur défini par l'utilisateur, potentiellement modifiable.
    • passkey_user_id : ID utilisateur sans informations permettant d'identifier personnellement l'utilisateur, spécifique aux clés d'accès, représenté par user.id dans vos options d'enregistrement. Lorsque l'utilisateur tente de s'authentifier ultérieurement, l'authentificateur rend cepasskey_user_id disponible dans sa réponse d'authentification dans userHandle. Nous vous recommandons de ne pas définir passkey_user_id comme clé principale. Les clés primaires ont tendance à devenir des informations permettant d'identifier personnellement l'utilisateur dans les systèmes, car elles sont largement utilisées.
  • Tableau Identifiants de clé publique :
    • id : ID des identifiants. Utilisez-le comme clé primaire pour votre table Identifiants de clé publique.
    • public_key : clé publique de l'identifiant.
    • passkey_user_id : utilisez cette colonne comme clé étrangère pour établir un lien avec la table Users.
    • backed_up : une clé d'accès est sauvegardée si elle est synchronisée par le fournisseur de clés d'accès. Le stockage de l'état de sauvegarde est utile si vous souhaitez envisager de supprimer les mots de passe à l'avenir pour les utilisateurs qui détiennent des clés d'accès backed_up. Vous pouvez vérifier si la clé d'accès est sauvegardée en examinant le flag BE dans authenticatorData ou en utilisant une fonctionnalité de bibliothèque côté serveur FIDO qui est généralement disponible pour vous permettre d'accéder facilement à ces informations. Le stockage de l'éligibilité à la sauvegarde peut être utile pour répondre aux éventuelles questions des utilisateurs.
    • name : nom à afficher facultatif pour l'identifiant, afin de permettre aux utilisateurs de lui attribuer un nom personnalisé.
    • transports : tableau de transports. Le stockage des transports est utile pour l'expérience utilisateur d'authentification. Lorsque des transports sont disponibles, le navigateur peut se comporter en conséquence et afficher une UI qui correspond au transport utilisé par le fournisseur de clés d'accès pour communiquer avec les clients, en particulier pour les cas d'utilisation de réauthentification où allowCredentials n'est pas vide.

D'autres informations peuvent être utiles à stocker pour l'expérience utilisateur, y compris des éléments tels que le fournisseur de clés d'accès, l'heure de création des identifiants et l'heure de la dernière utilisation. Pour en savoir plus, consultez Conception de l'interface utilisateur des clés d'accès.

Exemple de code : stocker les identifiants

Nous utilisons la bibliothèque SimpleWebAuthn dans nos exemples. Ici, nous confions la validation de la réponse d'enregistrement à sa fonction verifyRegistrationResponse.

import { isoBase64URL } from '@simplewebauthn/server/helpers';


router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;
  const response = req.body;
  // This sample code is for registering a passkey for an existing,
  // signed-in user

  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // Verify the credential
    const { verified, registrationInfo } = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('Verification failed.');
    }

    const {
      aaguid,
      credentialPublicKey,
      credentialID,
      credentialBackedUp
    } = registrationInfo;

    // Name the credential based on AAGUID
    const name =
      aaguid === undefined ||
      aaguid === '000000-0000-0000-0000-00000000' ?
        req.useragent?.platform : aaguids[aaguid].name;

    const base64CredentialID = isoBase64URL.fromBuffer(credentialID);
    const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);

    // Existing, signed-in user
    const { user } = res.locals;

    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      transports: response.response.transports,
      backed_up: credentialBackedUp,
      registered_at: new Date().getTime()
    });

    // Kill the challenge for this session
    delete req.session.challenge;

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

Annexe : AuthenticatorAttestationResponse

AuthenticatorAttestationResponse contient deux objets importants :

  • response.clientDataJSON est une version JSON des données client, qui sur le Web sont les données telles qu'elles sont vues par le navigateur. Il contient l'origine de la RP, le challenge et androidPackageName si le client est une application Android. En tant que RP, la lecture de clientDataJSON vous donne accès aux informations que le navigateur a vues au moment de la requête create.
  • response.attestationObjectcontient deux informations :
    • attestationStatement, qui n'est pas pertinent, sauf si vous utilisez l'attestation.
    • authenticatorData correspond aux données telles qu'elles sont vues par le fournisseur de clés d'accès. En tant que RP, la lecture de authenticatorData vous donne accès aux données vues par le fournisseur de clés d'accès et renvoyées au moment de la requête create.

authenticatorDatacontient des informations essentielles sur les identifiants de clé publique associés à la clé d'accès nouvellement créée :

  • L'identifiant de clé publique lui-même et un identifiant unique pour celui-ci.
  • ID de RP associé aux identifiants.
  • Indicateurs décrivant l'état de l'utilisateur lors de la création de la clé d'accès : si l'utilisateur était réellement présent et s'il a été validé (voir Analyse approfondie de userVerification).
  • L'AAGUID est un identifiant du fournisseur de clés d'accès, tel que le Gestionnaire de mots de passe de Google. En fonction de l'AAGUID, vous pouvez identifier le fournisseur de clés d'accès et afficher son nom sur une page de gestion des clés d'accès. (voir Déterminer le fournisseur de clés d'accès avec AAGUID)

Même si authenticatorData est imbriqué dans attestationObject, les informations qu'il contient sont nécessaires à l'implémentation de votre clé d'accès, que vous utilisiez ou non l'attestation. authenticatorData est encodé et contient des champs encodés au format binaire. Votre bibliothèque côté serveur gère généralement l'analyse et le décodage. Si vous n'utilisez pas de bibliothèque côté serveur, envisagez d'utiliser getAuthenticatorData() côté client pour vous éviter du travail d'analyse et de décodage côté serveur.

Annexe : validation de la réponse d'enregistrement

En coulisses, la validation de la réponse à l'enregistrement consiste à effectuer les vérifications suivantes :

  • Assurez-vous que l'ID de la partie de confiance correspond à votre site.
  • Assurez-vous que l'origine de la requête est une origine attendue pour votre site (URL du site principal, application Android).
  • Si vous avez besoin de valider l'identité de l'utilisateur, assurez-vous que l'indicateur de validation de l'utilisateur authenticatorData.uv est défini sur true.
  • L'indicateur de présence de l'utilisateur authenticatorData.up est généralement défini sur true, mais si l'identifiant est créé de manière conditionnelle, il doit être défini sur false.
  • Vérifiez que le client a pu fournir le code secret que vous lui avez donné. Si vous n'utilisez pas l'attestation, cette vérification n'est pas importante. Toutefois, il est recommandé d'implémenter cette vérification, car elle garantit que votre code est prêt si vous décidez d'utiliser l'attestation à l'avenir.
  • Assurez-vous que l'ID d'identifiant n'est pas encore enregistré pour un utilisateur.
  • Vérifiez que l'algorithme utilisé par le fournisseur de clé d'accès pour créer l'identifiant est un algorithme que vous avez listé (dans chaque champ alg de publicKeyCredentialCreationOptions.pubKeyCredParams, qui est généralement défini dans votre bibliothèque côté serveur et n'est pas visible pour vous). Ainsi, les utilisateurs ne peuvent s'inscrire qu'avec les algorithmes que vous avez choisis d'autoriser.

Pour en savoir plus, consultez le code source de SimpleWebAuthn pour verifyRegistrationResponse ou la liste complète des validations dans la spécification.

Étape suivante

Authentification par clé d'accès côté serveur