通行密钥注册

本指南详细介绍了如何让用户为其账号注册通行密钥,从而提供一种高度安全、防钓鱼式攻击的无密码登录替代方案。

数据库要求

为了支持通行密钥注册,您的数据库凭证表必须存储以下字段:

export interface StoredPasskeyCredential {
  id: string; // Base64URL-encoded credential ID (unique lookup key)
  passkeyUserId: string; // Associated application user ID
  credentialPublicKey: string; // Base64URL-encoded public key used to verify assertion signatures
  credentialType: "public-key";
  credentialDeviceType: "singleDevice" | "multiDevice"; // Helps distinguish device-bound vs cloud-synced passkeys
  credentialBackedUp: boolean; // Boolean backup state reported by the authenticator
  aaguid: string; // Authenticator Attestation GUID
  providerIcon?: string; // Provider icon derived from the AAGUID registry (dark or light theme URLs)
  name: string; // Provider name derived from AAGUID registry
  transports: string[]; // Array of transport names (e.g. 'internal', 'hybrid') necessary for exclusion options
  lastUsedAt?: number; // Optional epoch timestamp of last sign-in
  registeredAt: number; // Registration epoch timestamp
  counter: number; // Authenticator sign-in signature counter used to prevent replay attacks
}

服务器端

选项生成

创建一个用于生成 WebAuthn 创建参数的端点。根据类别标准依赖经过审核的库,而不是手动加密。

  1. 使用预定义的 RP ID:使用预定义的正确 RP ID 作为常量字符串。
  2. 创建安全的验证元:在服务器上生成一个高熵、加密安全的随机缓冲区,将其安全地存储在用户的会话中,并将其编码为 Base64URL 格式,以便在选项中传递。
  3. 避免重复的通行密钥:将用户现有的预注册凭据 ID 映射到 excludeCredentials 选项数组。这样可以防止身份验证器在同一通行密钥提供商账号中注册重复的凭据。
  4. 强制执行可检测到的凭证:在 authenticatorSelection 选项中设置 requireResidentKey: true 和 residentKey: "required",以请求可检测到的凭证,这是可检测到的登录所必需的。
  5. 配置用户验证:指定 userVerification: "preferred" 或 userVerification: "required"。许多合规性用例(例如金融、医疗保健)都要求 'required' 在创建时强制执行用户验证。
  6. 确定连接范围:
    • 推广流程:在标准密码登录或注册后推广活动之后立即提议创建通行密钥时,请设置 authenticatorAttachment: "platform" 以强制执行平台身份验证器并绕过外部安全密钥提示。
    • 管理流程:如果从专用设置或安全面板(除了平台身份验证器之外,还支持外部安全密钥)调用,请完全省略 authenticatorAttachment 属性。
    • 提示:接受 promotion: boolean 请求标志,以便通过单个端点有条件地处理这两个流程。
// Options generation example
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // Cryptographically random challenge
  rp: { id: "example.com", name: "Secure Application" },
  user: {
    id: userBase64UrlId, // Unique base64url string identifying the account
    name: "user@example.com",
    displayName: "Jane Doe",
  },
  pubKeyCredParams: [
    {
      type: "public-key",
      alg: -7,
    },
    {
      type: "public-key",
      alg: -257,
    },
  ],
  excludeCredentials: userExistingCredentials.map((cred) => ({
    type: "public-key",
    id: cred.id,
    transports: cred.transports,
  })),
  authenticatorSelection: {
    residentKey: "required",
    requireResidentKey: true,
    userVerification: "preferred",
    ...(isPromotionFlow && { authenticatorAttachment: "platform" }),
  },
};

验证

  1. 验证质询:安全地验证质询是否与预期的会话绑定质询一致。
  2. 验证用户是否在场:
    • 确保解析后的身份验证器数据中返回的用户存在 (UP) 标志为 true,以确认创建时用户确实在场。
  3. 放宽对“首选”的验证:
    • 如果创建选项指定了 userVerification: "preferred",则必须使用 requireUserVerification: false 配置服务器端验证调用。否则,在未进行用户验证的情况下注册的身份验证器(例如,停用了屏幕锁定)将触发虚假的服务器验证失败。

客户端逻辑

  1. 在网页加载时限制界面:
    • 在网页加载时,调用 PublicKeyCredential.getClientCapabilities(),如果 conditionalGet 或 passkeyPlatformAuthenticator 不可用,则停用“创建通行密钥”按钮。
  2. 调用创建和序列化:使用 PublicKeyCredential.parseCreationOptionsFromJSON() 解码服务器选项,并将生成的配置传递给 navigator.credentials.create()。
    • 在提取验证端点之前,调用 credential.toJSON() 将 AuthenticatorAttestationResponse 编码为有效的 JSON 可序列化对象。
  3. 处理 WebAuthn 异常:
    • InvalidStateError:匹配的通行密钥已存在(通过 excludeCredentials 匹配)。
    • NotAllowedError:用户取消了身份验证通行密钥对话框,或该对话框超时。
    • AbortError:操作已中止。
    • SecurityError:安全来源 (HTTPS) 或 RP ID 不匹配错误(配置问题)。
  4. 针对 Signal API 的 Try/Catch 隔离:
    • 将服务器验证 fetch() 调用封装在 try/catch 块中。当服务器验证提取失败(任何状态 response.ok === false 或网络抛出)时,调用 signalUnknownCredential()。
// optionsFetch and registerVerifyFetch are app-defined HTTP methods
import { optionsFetch, registerVerifyFetch } from "./api.js";

async function registerPasskey(isPromotion = false) {
  // Verify passkey capability and conditional UI are available
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (
    !capabilities.passkeyPlatformAuthenticator ||
    !capabilities.conditionalGet
  ) {
    // Hide "Create passkey" buttons and fall back to password flows instead
    showStandardPasswordFallbackUI();
    return;
  }

  const creationOptionsJSON = await optionsFetch({ promotion: isPromotion });
  const publicKey =
    PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);

  let credential;
  try {
    // passkey prompt execution
    credential = await navigator.credentials.create({ publicKey });
  } catch (err) {
    if (err.name === "InvalidStateError") {
      console.log("A passkey already exists for this account.");
      alert("A passkey already exists for this account.");
    } else if (err.name === "SecurityError") {
      console.error("Configuration RP ID or Secure Context error.");
      alert("Configuration RP ID or Secure Context error.");
    } else if (err.name === "NotAllowedError") {
      console.log("User cancelled the passkey dialog.");
    } else if (err.name === "AbortError") {
      console.log("The creation operation has been aborted.");
    }
    return; // Safe API exit, do not signal unknown for standard WebAuthn cancels
  }

  // Server Verification phase (Segregated Try/Catch)
  let encodedResponse = credential.toJSON();
  try {
    const response = await registerVerifyFetch(encodedResponse);
    if (!response.ok) {
      // Server verification failed to verify/authenticate the credential (orphaned)
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encodedResponse.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Server verification network failure:", serverErr);
    await publickeycredential.signalunknowncredential({
      rpId, // RP ID must match the one defined on the server
      credentialid: encodedresponse.id, // base64url-encoded credential id
    });
  }
}

后备策略

Signal API 同步回退

WebAuthn Signal API (webauthn-signals) 是一种渐进式优化,用于使密码管理工具与服务器凭证状态保持同步。

  • 后备体验:如果浏览器不支持 PublicKeyCredential.signalUnknownCredential,则必须通过特征检测门控 (if (PublicKeyCredential.signalUnknownCredential)) 安全地绕过调用,并且服务器端验证仅记录失败,而不触发管理器更新。

Easy JSON Serialization Fallback

始终安装“webauthn-polyfills”并将其导入到上下文中。 请注意,只要支持 PublicKeyCredential,就同样支持 PublicKeyCredential.parseCreationOptionsFromJSON 和 PublicKeyCredential.prototype.toJSON。

import 'webauthn-polyfills';