Registro da chave de acesso do lado do servidor

Visão geral

Confira uma visão geral de alto nível das principais etapas envolvidas no registro da chave de acesso:

Fluxo de registro da chave de acesso

  • Defina as opções para criar uma chave de acesso. Envie-os ao cliente para que você possa transmiti-los à chamada de criação da chave de acesso: a chamada de API WebAuthn navigator.credentials.create na Web e credentialManager.createCredential no Android. Depois que o usuário confirma a criação da chave de acesso, a chamada é resolvida e retorna uma credencial PublicKeyCredential.
  • Verifique a credencial e armazene-a no servidor.

As seções a seguir mergulham nos detalhes de cada etapa.

Criar opções de criação de credenciais

A primeira etapa no servidor é criar um objeto PublicKeyCredentialCreationOptions.

Para isso, use sua biblioteca FIDO do lado do servidor. Normalmente, ele oferece uma função utilitária que pode criar essas opções para você. O SimpleWebAuthn oferece, por exemplo, generateRegistrationOptions.

O PublicKeyCredentialCreationOptions precisa incluir tudo o que é necessário para a criação da chave de acesso: informações sobre o usuário, sobre a RP e uma configuração para as propriedades da credencial que você está criando. Depois de definir tudo isso, transmita-os conforme necessário para a função na biblioteca FIDO do lado do servidor responsável por criar o objeto PublicKeyCredentialCreationOptions.

Alguns dos campos da PublicKeyCredentialCreationOptions podem ser constantes. Outras precisam ser definidas dinamicamente no servidor:

  • rpId: para preencher o ID da RP no servidor, use funções ou variáveis do lado do servidor ou variáveis que forneçam o nome do host do aplicativo da Web, como example.com.
  • user.name e user.displayName:para preencher esses campos, use as informações da sessão do usuário que fez login ou as informações da nova conta de usuário, se ele estiver criando uma chave de acesso na inscrição. user.name costuma ser um endereço de e-mail exclusivo da RP. user.displayName é um nome fácil de usar. Nem todas as plataformas usam displayName.
  • user.id: uma string aleatória e exclusiva gerada na criação da conta. Deve ser permanente, ao contrário de um nome de usuário editável. O ID do usuário identifica uma conta, mas não pode conter informações de identificação pessoal (PII). Você provavelmente já tem um ID do usuário no seu sistema, mas, se necessário, crie um especificamente para as chaves de acesso e evite PIIs nele.
  • excludeCredentials: uma lista dos IDs das credenciais para evitar a duplicação de uma chave de acesso do provedor da chave de acesso. Para preencher este campo, procure no seu banco de dados as credenciais existentes para esse usuário. Confira os detalhes em Impedir a criação de uma nova chave de acesso se já houver uma.
  • challenge: para o registro de credenciais, o desafio não é relevante, a menos que você use atestado, uma técnica mais avançada para verificar a identidade de um provedor de chaves de acesso e os dados que ele emite. No entanto, mesmo que você não use atestados, o desafio ainda é um campo obrigatório. Nesse caso, você pode definir esse desafio como um único 0 para simplificar. As instruções para criar um desafio seguro para a autenticação estão disponíveis em Autenticação da chave de acesso do lado do servidor.

Codificação e decodificação

PublicKeyCredentialCreationOptions enviadas pelo servidor
PublicKeyCredentialCreationOptions enviado pelo servidor. challenge, user.id e excludeCredentials.credentials precisam ser codificados no lado do servidor em base64URL para que PublicKeyCredentialCreationOptions possa ser entregue por HTTPS.

PublicKeyCredentialCreationOptions incluem campos que são ArrayBuffers, por isso não são aceitos por JSON.stringify(). Isso significa que, no momento, para entregar PublicKeyCredentialCreationOptions por HTTPS, alguns campos precisam ser codificados manualmente no servidor usando base64URL e decodificados no cliente.

  • No servidor, a codificação e a decodificação são responsabilidade da biblioteca FIDO do lado do servidor.
  • No cliente, a codificação e a decodificação precisam ser feitas manualmente no momento. Vai ser mais fácil no futuro: um método para converter opções como JSON em PublicKeyCredentialCreationOptions será disponibilizado. Confira o status da implementação no Chrome.

Exemplo de código: criar opções de criação de credenciais

Vamos usar a biblioteca SimpleWebAuthn (link em inglês) nos exemplos. Aqui, passamos a criação de opções de credenciais de chave pública para a função 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 = 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 });
  }
});

Armazenar a chave pública

PublicKeyCredentialCreationOptions enviadas pelo servidor
navigator.credentials.create retorna um objeto PublicKeyCredential.

Quando navigator.credentials.create é resolvido com sucesso no cliente, isso significa que uma chave de acesso foi criada. Um objeto PublicKeyCredential é retornado.

O objeto PublicKeyCredential contém um objeto AuthenticatorAttestationResponse, que representa a resposta do provedor de chaves de acesso à instrução do cliente para criar uma. Ele contém informações sobre a nova credencial que você precisa como RP para autenticar o usuário mais tarde. Saiba mais sobre AuthenticatorAttestationResponse no Apêndice: AuthenticatorAttestationResponse.

Envie o objeto PublicKeyCredential ao servidor. Faça a verificação assim que você receber o e-mail.

Entregue essa etapa de verificação à biblioteca do lado do servidor da FIDO. Ele geralmente oferece uma função utilitária para essa finalidade. O SimpleWebAuthn oferece, por exemplo, verifyRegistrationResponse. Saiba o que acontece nos bastidores em Apêndice: verificação da resposta de registro.

Quando a verificação for concluída, armazene as informações da credencial no seu banco de dados para que o usuário possa fazer a autenticação mais tarde com a chave de acesso associada a essa credencial.

Use uma tabela dedicada para credenciais de chave pública associadas a chaves de acesso. Um usuário só pode ter uma única senha, mas pode ter várias chaves de acesso. Por exemplo, uma chave de acesso sincronizada pelas Chaves do iCloud da Apple e outra pelo Gerenciador de senhas do Google.

Confira um exemplo de esquema que pode ser usado para armazenar informações de credenciais:

Esquema de banco de dados para chaves de acesso

  • Tabela Users:
    • user_id: o ID do usuário principal. Um ID aleatório, exclusivo e permanente para o usuário. Use-a como uma chave primária para sua tabela Usuários.
    • username. Um nome de usuário definido pelo usuário, possivelmente editável.
    • passkey_user_id: o ID do usuário sem PII específico da chave de acesso, representado por user.id nas opções de registro. Quando o usuário tentar se autenticar, o autenticador vai disponibilizar esse passkey_user_id na resposta de autenticação em userHandle. Recomendamos não definir passkey_user_id como a chave primária. As chaves primárias tendem a se tornar PII de fato nos sistemas, porque são amplamente usadas.
  • Tabela de credenciais da chave pública:
    • id: ID da credencial. Use-a como uma chave primária para sua tabela de credenciais da chave pública.
    • public_key: chave pública da credencial.
    • passkey_user_id: use como uma chave estrangeira para estabelecer um link com a tabela Usuários.
    • backed_up: o backup de uma chave de acesso será feito se ela for sincronizada pelo provedor da chave de acesso. Armazenar o estado de backup é útil se você quiser descartar senhas de usuários com chaves de acesso backed_up no futuro. É possível verificar se a chave de acesso está armazenada em backup examinando as flags em authenticatorData ou usando um recurso de biblioteca FIDO do lado do servidor, que normalmente está disponível para oferecer acesso fácil a essas informações. Armazenar a qualificação para o backup pode ser útil para resolver possíveis dúvidas dos usuários.
    • name: opcionalmente, um nome de exibição para a credencial, permitindo que os usuários deem nomes personalizados às credenciais.
    • transports: uma matriz de transportes. O armazenamento de transportes é útil para a experiência do usuário de autenticação. Quando os transportes estão disponíveis, o navegador pode se comportar corretamente e mostrar uma interface que corresponde ao transporte que o provedor de chaves de acesso usa para se comunicar com os clientes, principalmente nos casos de uso de reautenticação em que allowCredentials não está vazio.

Outras informações podem ser úteis para armazenar para fins de experiência do usuário, incluindo itens como o provedor da chave de acesso, o horário de criação da credencial e o horário do último uso. Leia mais em Design da interface do usuário de chaves de acesso.

Exemplo de código: armazenar a credencial

Vamos usar a biblioteca SimpleWebAuthn (link em inglês) nos exemplos. Aqui, entregamos a verificação de resposta de registro à função 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 { credentialPublicKey, credentialID } = registrationInfo;

    // Existing, signed-in user
    const { user } = res.locals;
    
    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      publicKey: base64PublicKey,
      // Optional: set the platform as a default name for the credential
      // (example: "Pixel 7")
      name: req.useragent.platform, 
      transports: response.response.transports,
      passkey_user_id: user.passkey_user_id,
      backed_up: registrationInfo.credentialBackedUp
    });

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

Apêndice: AuthenticatorAttestationResponse

AuthenticatorAttestationResponse contém dois objetos importantes:

  • response.clientDataJSON é uma versão JSON dos dados do cliente, que, na Web, são dados como são vistos pelo navegador. Ele contém a origem da RP, o desafio e androidPackageName, se o cliente for um app Android. Como RP, a leitura de clientDataJSON dá acesso às informações que o navegador encontrou no momento da solicitação create.
  • response.attestationObjectcontém duas informações:
    • attestationStatement, que não é relevante, a menos que você use atestado.
    • authenticatorData são dados conforme vistos pelo provedor da chave de acesso. Como RP, a leitura de authenticatorData dá acesso aos dados vistos pelo provedor da chave de acesso e retornados no momento da solicitação create.

authenticatorDatacontém informações essenciais sobre a credencial da chave pública associada à chave de acesso recém-criada:

  • A própria credencial de chave pública e um ID de credencial exclusivo para ela.
  • O ID da RP associado à credencial.
  • Flags que descrevem o status do usuário quando a chave de acesso foi criada: se um usuário estava realmente presente e se ele foi verificado (consulte userVerification).
  • AAGUID, que identifica o provedor da chave de acesso. Mostrar o provedor da chave de acesso pode ser útil para seus usuários, especialmente se eles tiverem uma chave registrada para seu serviço em vários provedores.

Mesmo que o authenticatorData esteja aninhado em attestationObject, as informações que ele contém são necessárias para a implementação da chave de acesso, mesmo que você não use o atestado. O authenticatorData é codificado e contém campos codificados em formato binário. Sua biblioteca do lado do servidor geralmente lida com a análise e a decodificação. Se você não estiver usando uma biblioteca do lado do servidor, aproveite o getAuthenticatorData() do lado do cliente para economizar tempo na análise e decodificação do lado do servidor.

Apêndice: verificação da resposta de registro

Internamente, a verificação da resposta de registro consiste nas seguintes verificações:

  • Verifique se o ID da parte restrita corresponde ao seu site.
  • Verifique se a origem da solicitação é esperada para seu site (URL do site principal, app Android).
  • Se você exigir a verificação do usuário, verifique se o sinalizador de verificação do usuário authenticatorData.uv é true. Verifique se a flag de presença do usuário authenticatorData.up é true, já que ela é sempre obrigatória para chaves de acesso.
  • Verifique se o cliente foi capaz de apresentar o desafio que você apresentou. Se você não usa um atestado, essa verificação não é importante. No entanto, implementar essa verificação é uma prática recomendada: ela garante que seu código esteja pronto se você decidir usar atestados no futuro.
  • Verifique se o ID da credencial ainda não está registrado para nenhum usuário.
  • Verifique se o algoritmo usado pelo provedor da chave de acesso para criar a credencial é um algoritmo listado em cada campo alg de publicKeyCredentialCreationOptions.pubKeyCredParams, que normalmente é definido na biblioteca do lado do servidor e não aparece para você. Isso garante que os usuários só possam se registrar com algoritmos que você optou por permitir.

Para saber mais, consulte o código-fonte de verifyRegistrationResponse do SimpleWebAuthn ou veja a lista completa de verificações na especificação (link em inglês).

A seguir

Autenticação da chave de acesso do lado do servidor