通行密钥身份验证

本指南详细介绍了如何使用可检测到的凭据实现回访用户身份验证,包括通过显式按钮触发和无缝浏览器自动填充建议(条件式界面)。

服务器端

选项生成

创建一个端点,该端点使用经过审核的库按照标准生成 WebAuthn 请求参数。

  1. 使用预定义的 RP ID:使用预定义的正确 RP ID 作为常量字符串。
  2. 生成 challenge:生成一个高熵、采用加密保护的随机缓冲区,将其安全地存储在用户的会话中,并将其编码为 Base64URL 格式。
  3. 可检测到的凭据映射:为 allowCredentials 指定一个空数组 []。此操作会请求可检测到的凭据,这意味着用户无需先输入用户名;通行密钥提供程序会显示可用账号。
  4. 用户验证级别:设置为 userVerification: "preferred"(如果企业合规政策明确要求,则设置为 "required")。
    • 请求的 userVerification 限制级别必须保留在选项端点的服务器会话记录中,而不是通过查询字符串从客户端传回。这样,验证端点就可以安全地强制执行严格的匹配限制,而不会面临客户端操纵的风险。
// Options generation example (discoverable flow)
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
  rpId: "example.com",
  allowCredentials: [], // Request discoverable passkeys
  userVerification: "preferred",
};

// Persist expected UV level to user session
req.session.expectedUserVerification = "preferred";

验证端点

安全地验证客户端返回的断言,以对用户进行身份验证:

  1. 验证会话质询:在客户端响应与存储在会话中的预期质询之间强制执行严格的质询匹配。
  2. 强制执行 UV 偏好设置:
    • 如果会话的 expectedUserVerification 请求了 "preferred",则允许使用无需用户验证的身份验证器(例如,停用身份验证器屏幕锁定),方法是将 requireUserVerification: false 传递给服务器端验证库。如果请求了 "required",则严格强制执行生物识别/PIN 码输入。
  3. 清理服务器错误 404:如果在数据库中找不到客户端返回的凭证 ID,请返回明确的 HTTP 404 错误,以便客户端可以触发 Signal API。

客户端逻辑

HTML 表单注释

为用户名和密码输入框添加注解,以便原生利用条件式界面。自动补全令牌会组合 WebAuthn 规范参数,而 autofocus 会在输入框获得焦点时立即触发浏览器自动填充弹出式窗口。

<!-- Autocomplete tokens must contain webauthn space-separated -->
<form id="signin-form">
  <input
    type="text"
    name="username"
    autocomplete="username webauthn"
    autofocus
    data-testid="username-field"
  />
  <input type="password" name="password" autocomplete="current-password" />
  <button type="submit">Sign in</button>
</form>

显式按钮流程

当用户点击“使用通行密钥登录”按钮时,触发通行密钥身份验证。在调用通行密钥提示之前,中止任何正在进行的表单自动填充(条件式获取)调用。

条件式中介流程(表单自动填充)

在网页加载时启用表单自动填充建议,以便在用户聚焦于登录字段时原生提供通行密钥身份验证:

  1. 功能检测:在网页加载时调用 PublicKeyCredential.getClientCapabilities(),如果 conditionalGet 不可用,则跳过使用通行密钥登录。
  2. 解码选项:使用 PublicKeyCredential.parseRequestOptionsFromJSON() 解码提取的凭证 JSON 对象。
  3. 调用条件式获取:使用 mediation: "conditional" 调用 navigator.credentials.get(),并传递 AbortController 信号。这会以静默方式注册自动填充,而不会呈现通行密钥对话框。
  4. Try/Catch 异常隔离:将 navigator.credentials.get 调用封装在 try/catch 代码块中:
    • NotAllowedError:用户取消了通行密钥登录提示,或提示超时。
    • AbortError:身份验证请求已通过编程方式取消。
  5. Call Signal API:将服务器验证 fetch() 调用封装在 try/catch 块中:
    • 显示出错提示,以便用户了解哪里出了问题。
    • 仅当服务器明确返回 HTTP 状态 404(未找到凭据)且用户未通过身份验证时,才调用 signalUnknownCredential()。
    • 传递给 signalUnknownCredential() 的 credentialId 参数必须严格采用 Base64URL 编码的凭据 ID 字符串(例如 encoded.id),而不是原始 ArrayBuffer 对象 credential.rawId。
  6. 对响应进行编码:先使用 .toJSON() 对凭据 AuthenticatorAssertionResponse 进行编码,然后再将其发送到服务器进行验证。
// optionsFetch and loginVerifyFetch are app-defined HTTP methods
import { optionsFetch, loginVerifyFetch } from "./api.js";

let autofillAbortController = new AbortController();

async function initializeConditionalAutofill() {
  // Feature detect Conditional Get autofill support
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (capabilities.conditionalGet === true) {
    const loginOptionsJSON = await optionsFetch();
    const publicKey =
      PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

    try {
      // Initiate Conditional UI form autofill suggestions
      const credential = await navigator.credentials.get({
        publicKey,
        signal: autofillAbortController.signal,
        mediation: "conditional",
      });

      // Segregated verification fetch
      const encoded = credential.toJSON();
      const response = await loginVerifyFetch(encoded);
      if (!response.ok && response.status === 404) {
        // Note: this code path runs pre-authentication, satisfying the unauth precondition
        if (PublicKeyCredential.signalUnknownCredential) {
          await PublicKeyCredential.signalUnknownCredential({
            rpId, // RP ID must match the one defined on the server
            credentialId: encoded.id,
          });
        }
      }
    } catch (err) {
      // Silently swallow expected client WebAuthn exceptions
      if (["NotAllowedError", "AbortError"].includes(err.name)) {
        return;
      }
      console.error("Unexpected conditional get error:", err);
    }
  }
}

async function triggerButtonAuthentication() {
  // Abort any pending Conditional Get call to prevent passkey prompt collisions
  autofillAbortController.abort();
  autofillAbortController = new AbortController(); // Reset controller for next triggers

  const loginOptionsJSON = await optionsFetch();
  const publicKey =
    PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

  let credential;
  try {
    // Passkey explicit prompt trigger
    credential = await navigator.credentials.get({
      publicKey,
      signal: autofillAbortController.signal,
    });
  } catch (err) {
    if (err.name === "NotAllowedError") {
      console.log("User cancelled passkey login.");
    } else if (err.name === "AbortError") {
      console.log("The authentication operation was aborted.");
    }
    // Re-arm Conditional autofill Suggestions after cancelled explicit button prompts
    initializeConditionalAutofill();
    return; // Safe exit
  }

  // Segregated verification try/catch (HTTP 404 trigger)
  const encoded = credential.toJSON();
  try {
    const response = await loginVerifyFetch(encoded);
    if (!response.ok && response.status === 404) {
      // Note: this code path runs pre-authentication, satisfying the unauth precondition
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encoded.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Verification request error:", serverErr);
  }
}

// Trigger Conditional Get on load
window.addEventListener("DOMContentLoaded", initializeConditionalAutofill);

后备策略

通行密钥功能检测回退

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

import 'webauthn-polyfills';

Signal API 同步回退

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

  • 备用体验:通过 if (PublicKeyCredential.signalUnknownCredential) 限制访问。如果不支持,系统会顺利绕过后台验证同步,而不会抛出浏览器异常。

Easy JSON Serialization Fallback

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

import 'webauthn-polyfills';