服务器端通行密钥注册

概览

以下是通行密钥注册所涉及的关键步骤的简要概览:

通行密钥注册流程

  • 定义用于创建通行密钥的选项。将这些参数发送到客户端,以便将其传递给通行密钥创建调用:Web 上的 WebAuthn API 调用 navigator.credentials.create,以及 Android 上的 credentialManager.createCredential。用户确认创建通行密钥后,通行密钥创建调用会解析并返回凭据 PublicKeyCredential。
  • 验证凭证并将其存储在服务器上。

以下各部分将深入探讨每个步骤的具体细节。

创建凭据创建选项

您需要在服务器上执行的第一步是创建 PublicKeyCredentialCreationOptions 对象。

为此,请依赖 FIDO 服务器端库。它通常会提供一个实用函数,可为您创建这些选项。例如,SimpleWebAuthn 提供了 generateRegistrationOptions。

PublicKeyCredentialCreationOptions 应包含创建通行密钥所需的所有信息:用户信息、RP 信息以及要创建的凭证的属性配置。定义完所有这些内容后,根据需要将它们传递给 FIDO 服务器端库中负责创建 PublicKeyCredentialCreationOptions 对象的函数。

PublicKeyCredentialCreationOptions 的某些字段可以是常量。其他参数应在服务器上动态定义:

  • rpId:如需在服务器上填充 RP ID,请使用可提供 Web 应用主机名的服务器端函数或变量,例如 example.com。
  • user.name 和 user.displayName:如需填充这些字段,请使用已登录用户的会话信息(如果用户在注册时创建通行密钥,则使用新用户账号信息)。user.name 通常是电子邮件地址,并且对于 RP 而言是唯一的。user.displayName 是一个简单易记的名称。请注意,并非所有平台都会使用 displayName。
  • user.id:创建账号时生成的随机唯一字符串。它应该是永久性的,而不像用户名那样可以修改。用户 ID 用于标识账号,但不应包含任何个人身份信息 (PII)。您的系统中可能已经有用户 ID,但如果需要,请专门为通行密钥创建一个用户 ID,以确保其中不包含任何 PII。
  • excludeCredentials:现有凭据 ID 的列表,用于防止通行密钥提供程序重复创建通行密钥。如需填充此字段,请在数据库中查找此用户的现有凭据。如需了解详情,请参阅防止在已存在通行密钥时创建新的通行密钥。
  • challenge:对于凭证注册,除非您使用认证(一种更高级的技术,用于验证通行密钥提供商的身份及其发出的数据),否则该质询无关紧要。不过,即使您不使用认证,质询仍然是必填字段。如需了解如何创建安全的身份验证质询,请参阅服务器端通行密钥身份验证。

编码和解码

服务器发送的 PublicKeyCredentialCreationOptions
PublicKeyCredentialCreationOptions 由服务器发送。challenge 和 user.id 等二进制字段会在服务器端编码为 base64URL,以便通过 HTTPS 以 JSON 格式传递选项。

标准 WebAuthn PublicKeyCredentialCreationOptions 对象包含 ArrayBuffer 字段,这些字段无法直接使用 JSON.stringify() 进行序列化。如需通过 HTTPS 发送这些选项,必须将二进制字段编码为 base64URL 字符串:

  • 在服务器上,您的 FIDO 服务器端库(例如 SimpleWebAuthn)会自动生成一个 JSON 可序列化选项对象(例如 PublicKeyCredentialCreationOptionsJSON),其中包含 Base64URL 编码字段。
  • 在客户端,现代浏览器支持使用 PublicKeyCredential.parseCreationOptionsFromJSON() 将 JSON 选项直接转换为 PublicKeyCredentialCreationOptions。使用 @simplewebauthn/browser 等客户端库时,startRegistration({ optionsJSON: options }) 等函数会自动处理此转换。

示例代码:创建凭据创建选项

我们在示例中使用的是 SimpleWebAuthn 库。在这里,我们将公钥凭证选项的创建工作交给其 generateRegistrationOptions 函数。

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } 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: 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: isoUint8Array.fromUTF8String(user.id),
      userName: user.username,
      userDisplayName: user.displayName || '',
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        residentKey: 'required',
        userVerification: 'preferred',
      },
    });

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

存储公钥

服务器发送的 PublicKeyCredentialCreationOptions
navigator.credentials.create 会返回一个 PublicKeyCredential 对象。

当 navigator.credentials.create 在客户端上成功解析时,即表示已成功创建通行密钥。系统会返回一个 PublicKeyCredential 对象。

PublicKeyCredential 对象包含一个 AuthenticatorAttestationResponse 对象,该对象表示通行密钥提供程序对客户端创建通行密钥指令的响应。其中包含您作为 RP 稍后对用户进行身份验证所需的新凭据的相关信息。如需详细了解 AuthenticatorAttestationResponse,请参阅附录:AuthenticatorAttestationResponse。

将 PublicKeyCredential 对象发送给服务器。收到后,请进行验证。

将此验证步骤交给 FIDO 服务器端库。它通常会为此提供一个实用函数。例如,SimpleWebAuthn 提供了 verifyRegistrationResponse。如需了解其背后的运作机制,请参阅附录:验证注册响应。

验证成功后,请将凭证信息存储在数据库中,以便用户日后可以使用与该凭证关联的通行密钥进行身份验证。

使用专用表来存储与通行密钥关联的公钥凭据。用户只能有一个密码,但可以有多个通行密钥,例如,一个通过 Apple iCloud 钥匙串同步的通行密钥,以及一个通过 Google 密码管理工具同步的通行密钥。

以下是可用于存储凭证信息的架构示例:

通行密钥的数据库架构

  • Users 表:
    • user_id:主要用户 ID。用户的随机、唯一、永久 ID。将此作为 Users 表的主键。
    • username。用户定义的用户名,可能可以修改。
    • passkey_user_id:不含个人身份信息的通行密钥专用用户 ID,在注册选项中由 user.id 表示。当用户稍后尝试进行身份验证时,身份验证器将在其 userHandle 中的身份验证响应中提供此 passkey_user_id。我们建议您不要将 passkey_user_id 设置为主键。主键往往会成为系统中的事实上的个人身份信息,因为它们被广泛使用。
  • “公钥凭证”表:
    • id:凭证 ID。请将此 ID 用作公钥凭证表的主键。
    • public_key:凭据的公钥。
    • passkey_user_id:将此列用作外键,与 Users 表建立关联。
    • backed_up:如果通行密钥由通行密钥提供方同步,则表示该通行密钥已备份。如果您想考虑在未来为持有 backed_up 通行密钥的用户弃用密码,则存储备份状态会很有用。您可以通过检查 authenticatorData 中的 BE 标志来确认通行密钥是否已备份,也可以使用 FIDO 服务器端库功能(通常可让您轻松访问此信息)。存储备份资格有助于解决潜在的用户咨询。
    • name:凭证的可选显示名称,以便用户为凭证指定自定义名称。
    • transports:传输数组。存储传输方式有助于提升身份验证用户体验。当传输方式可用时,浏览器可以做出相应行为,并显示与通行密钥提供方用于与客户端通信的传输方式相匹配的界面,尤其是在 allowCredentials 不为空的重新身份验证用例中。

出于用户体验方面的考虑,存储其他信息(包括通行密钥提供方、凭证创建时间和上次使用时间等内容)可能会很有帮助。如需了解详情,请参阅通行密钥界面设计。

示例代码:存储凭证

我们在示例中使用的是 SimpleWebAuthn 库。 在这里,我们将注册响应验证交给其 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,
      credential,
      credentialBackedUp
    } = registrationInfo;

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

    const base64PublicKey = isoBase64URL.fromBuffer(credential.publicKey);

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

    // Save the credential
    await Credentials.update({
      id: credential.id,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      transports: credential.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 });
  }
});

附录:AuthenticatorAttestationResponse

AuthenticatorAttestationResponse 包含两个重要对象:

  • response.clientDataJSON 是客户端数据的 JSON 版本,在网络上,这些数据是浏览器看到的数据。它包含 RP 来源、质询以及 androidPackageName(如果客户端是 Android 应用)。作为 RP,读取 clientDataJSON 可让您访问浏览器在发出 create 请求时看到的信息。
  • response.attestationObject包含两部分信息:
    • attestationStatement,除非您使用证明,否则该字符串不相关。
    • authenticatorData 是通行密钥提供方看到的数据。作为 RP,读取 authenticatorData 可让您访问通行密钥提供程序看到的数据,并在发出 create 请求时返回这些数据。

authenticatorData包含与新创建的通行密钥关联的公钥凭证的相关基本信息:

  • 公钥凭据本身及其唯一凭据 ID。
  • 与凭证关联的 RP ID。
  • 用于描述创建通行密钥时的用户状态的标志:用户是否实际存在,以及用户是否已成功通过验证(请参阅userVerification 深入探究)。
  • AAGUID 是通行密钥提供商(例如 Google 密码管理工具)的标识符。您可以根据 AAGUID 识别通行密钥提供商,并在通行密钥管理页面中显示其名称。(请参阅使用 AAGUID 确定通行密钥提供方)

尽管 authenticatorData 嵌套在 attestationObject 中,但无论您是否使用认证,通行密钥实现都需要它包含的信息。authenticatorData 经过编码,并且包含以二进制格式编码的字段。您的服务器端库通常会处理解析和解码。如果您不使用服务器端库,请考虑利用 getAuthenticatorData() 客户端,以节省服务器端的一些解析和解码工作。

附录:验证注册响应

在后台,验证注册响应包括以下检查:

  • 确保 RP ID 与您的网站匹配。
  • 确保请求的来源是您网站的预期来源(主网站网址、Android 应用)。
  • 如果您要求进行用户验证,请确保用户验证标志 authenticatorData.uv 为 true。
  • 用户在线状态标志 authenticatorData.up 通常应为 true,但如果凭证是有条件创建的,则应为 false。
  • 检查客户端是否能够提供您给它的质询。如果您不使用认证,则此检查并不重要。不过,实现此检查是一种最佳实践:它可确保您的代码在您决定将来使用证明时做好准备。
  • 确保凭证 ID 尚未注册给任何用户。
  • 验证通行密钥提供程序用于创建凭证的算法是否为您列出的算法(在 publicKeyCredentialCreationOptions.pubKeyCredParams 的每个 alg 字段中,通常在服务器端库中定义,您看不到)。这样可以确保用户只能使用您选择允许的算法进行注册。

如需了解详情,请查看 SimpleWebAuthn 的 verifyRegistrationResponse 源代码,或深入了解规范中的完整验证列表。

后续步骤

服务器端通行密钥身份验证