通过即时界面模式快速登录

当未经身份验证的用户点击“登录”链接、开始结账、将商品保存到收藏夹或遇到文章付费墙时,如果将他们引导至专门的登录页面,会导致上下文丢失并增加用户流失率。即时界面模式(navigator.credentials.get() 中的 uiMode: 'immediate')可在设备上存在本地通行密钥 (publicKey) 或已保存的密码 (password: true) 时,立即显示统一的浏览器账号选择器,从而实现上下文快速登录。

与始终显示浏览器界面并回退到跨设备二维码或外部安全密钥的标准模态 WebAuthn 提示不同,如果设备上没有本地可用的符合条件的凭证,即时界面模式会快速失败并返回 NotAllowedError。这样,您的应用就可以让回访用户在当前页面一键完成身份验证,同时让没有本地凭据的用户顺畅过渡到标准登录页面、访客结账页面或内嵌登录表单。

在用户意图明确的时刻,即即将离开当前页面之前,在用户发起的事件处理脚本(需要瞬时用户激活)中触发即时界面模式。如果即时身份验证成功,请立即完成用户的意图(无论是就地更新当前页面,还是直接前往其经过身份验证的目标页面)。如果失败并显示 NotAllowedError,请显示登录或注册流程,其中包含在完成后实现用户意图的路径。

可能的使用场景:

  • 拦截页眉登录链接或按钮:拦截对全局“登录”链接 (event.preventDefault()) 的点击,以便立即对回访用户进行身份验证,或继续前往 NotAllowedError 上的专用登录页面。
  • 结账前动态登录:当回头客点击“前往结账”时,只需点按一下即可完成身份验证,直接进入配送和付款步骤,或在无法完成身份验证时切换到标准登录或访客结账界面。
  • 针对微操作(收藏、点赞、添加书签、保存)的内嵌登录:当已退出账号的用户点击微操作按钮时,在当前页面进行身份验证,并立即完成操作(例如,切换 aria-pressed="true"),或打开后备登录模态窗口或带有返回网址参数的登录页面。
  • 文章内嵌软付费墙:当读者点击付费墙横幅上的“使用您的账号继续阅读”时,系统会就地验证读者身份,或者在后备情况下展开内嵌登录或注册表单。

应避免的反模式:

  • 请勿在专用登录页面上使用即时界面模式:切勿在最终目标登录页面上的主要“登录”或“使用通行密钥登录”按钮中附加 uiMode: 'immediate'。用户只有在当前设备上没有本地凭据,或者打算使用跨设备通行密钥(使用手机扫描二维码)或外部硬件安全密钥时,才会进入专用登录页面。由于 uiMode: 'immediate' 会抑制跨设备二维码和安全密钥选项,并在不存在本地凭据时立即抛出 NotAllowedError,因此在专用登录页面上使用它会使用户陷入死胡同,点击按钮没有任何效果。在专用登录页面上,对表单输入使用条件式界面(mediation: 'conditional' 与 autocomplete="username webauthn"),并搭配标准模态 navigator.credentials.get({ publicKey }) 按钮(请参阅通行密钥身份验证)。
  • 请勿在升级重新身份验证中使用即时界面模式:切勿在执行敏感操作(例如更改密码、更新送货地址、修改已保存的支付方式或添加新通行密钥)之前,使用 uiMode: 'immediate' 对已登录的用户进行重新身份验证。重新身份验证必须通过非空 allowCredentials 列表将提示限制为已登录用户的凭据,即时界面模式会拒绝该列表并显示 NotAllowedError,而省略 allowCredentials 会让用户不小心选择与当前登录账号不同的账号。如需进行升级重新身份验证,请参阅通行密钥重新身份验证。

隐私和安全限制

请求 uiMode: 'immediate' 时,浏览器会强制执行严格的隐私权和安全规则:

  • 需要瞬时用户激活:您必须在用户发起的事件处理脚本(例如 <button> 或 <a> 元素上的 click 监听器)中调用 navigator.credentials.get({ uiMode: 'immediate' }),以防止静默凭据探测。切勿在网页加载 (DOMContentLoaded) 时自动调用该 API。调用该 API 不会消耗临时激活。
  • 请勿传递非空 allowCredentials 列表:指定 uiMode: 'immediate' 时,请勿填充 publicKey.allowCredentials。浏览器会立即拒绝任何带有非空 allowCredentials 列表的即时界面模式请求,并抛出 NotAllowedError,因此依赖方无法探测特定凭证 ID 来推断之前的访问或跨会话跟踪用户。
  • 请勿传递 AbortSignal (signal):指定 uiMode: 'immediate' 时,请勿在 navigator.credentials.get() 上设置 signal 属性,因为浏览器不允许以编程方式关闭即时登录对话框。如果您的网页还运行后台条件界面 (mediation: 'conditional') 请求,请在调用即时界面模式之前对该条件界面 AbortController 调用 abort(),并从即时请求本身中省略 signal。
  • 无痕模式和无痕浏览会话:在无痕模式或无痕浏览窗口中发出的请求始终会被拒绝并返回 NotAllowedError,因此无痕会话与没有本地凭据的会话无法区分。

服务器端设置

选项生成端点

公开一个端点,为未经身份验证的访问者生成可检测到的 WebAuthn 请求选项:

  1. 生成与会话绑定的验证元:创建一个加密随机验证元缓冲区,将其存储在服务器会话中,并将其编码为 Base64URL 格式。
  2. 持久保存用户验证偏好设置:设置 userVerification: 'preferred' 并在会话中存储 expectedUserVerification: 'preferred'。
  3. 省略 allowCredentials:请勿在即时界面模式的选项中包含非空 allowCredentials 列表,因为浏览器会拒绝包含 NotAllowedError 的非空许可清单。
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
  const options = {
    challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
    rpId: 'example.com',
    userVerification: 'preferred',
  };

  req.session.challenge = options.challenge;
  req.session.expectedUserVerification = 'preferred';
  return res.json(options);
});

双重凭证验证端点

由于 navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) 可以使用通行密钥 (PublicKeyCredential) 或已保存的密码 (PasswordCredential) 进行解析,因此请为这两种凭据类型提供验证处理程序:

  1. WebAuthn 断言验证 (/api/login/verify):根据会话质询和存储的公钥验证断言。如果在数据库中找不到凭证 ID,则返回 HTTP 状态 404,以便客户端可以通过 PublicKeyCredential.signalUnknownCredential() 通知密码管理工具。
  2. 密码验证 (/api/login/password):根据用户数据库验证 username (cred.id) 和 password (cred.password),并建立经过身份验证的会话。

客户端实现

统一凭据请求

  1. 同时请求通行密钥和密码:使用 PublicKeyCredential.parseRequestOptionsFromJSON() 解码服务器选项,并在用户发起的点击处理程序中调用 navigator.credentials.get(),同时传入 password: true、publicKey 和 uiMode: 'immediate'(不传入 signal)。
  2. cred.type 的分支验证:
    • 如果 cred.type === 'public-key',请使用 cred.toJSON() 序列化断言,并将其 POST 到 WebAuthn 验证端点。当服务器返回 HTTP 404 时,调用 PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id })。
    • 如果为 cred.type === 'password',则向您的密码验证端点发送 POST { username: cred.id, password: cred.password }。
  3. 在不存在本地凭据时处理 NotAllowedError:捕获 NotAllowedError(当设备上不存在本地通行密钥或已保存的密码时,或者当用户关闭选择器时,系统会立即抛出该异常),并转换到标准登录流程(例如打开内嵌登录对话框或使用返回网址导航到 /signin)。
let isAuthenticating = false;

async function authenticateWithImmediateUi() {
  const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
  if (!optionsResponse.ok) {
    throw new Error('Failed to fetch authentication options');
  }
  const optionsJSON = await optionsResponse.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);

  // MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
  const cred = await navigator.credentials.get({
    password: true,
    publicKey,
    uiMode: 'immediate',
  });

  if (!cred) {
    throw new DOMException('No credential returned', 'NotAllowedError');
  }

  if (cred.type === 'public-key') {
    const encoded = cred.toJSON();
    const verifyResponse = await fetch('/api/login/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(encoded),
    });

    if (verifyResponse.status === 404) {
      await PublicKeyCredential.signalUnknownCredential({
        rpId: publicKey.rpId || window.location.hostname,
        credentialId: encoded.id, // Base64URL-encoded credential ID string
      });
      throw new Error('Passkey credential is no longer recognized by the server');
    }

    if (!verifyResponse.ok) {
      throw new Error('Passkey verification failed');
    }
    return await verifyResponse.json();
  }

  if (cred.type === 'password') {
    const verifyResponse = await fetch('/api/login/password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: cred.id,
        password: cred.password,
      }),
    });

    if (!verifyResponse.ok) {
      throw new Error('Password verification failed');
    }
    return await verifyResponse.json();
  }

  throw new Error(`Unsupported credential type: ${cred.type}`);
}

// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');

favoriteButton.addEventListener('click', async (event) => {
  event.preventDefault();
  if (isAuthenticating) return;

  // If already signed in, toggle the micro-action state immediately
  if (document.body.dataset.signedIn === 'true') {
    const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
    favoriteButton.setAttribute('aria-pressed', String(nextPressed));
    return;
  }

  isAuthenticating = true;
  try {
    const user = await authenticateWithImmediateUi();
    // Complete the user's intended action immediately
    document.body.dataset.signedIn = 'true';
    favoriteButton.setAttribute('aria-pressed', 'true');
    updateHeaderUserState(user);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // No local credentials available or user dismissed dialog:
      // transition smoothly to fallback sign-in UI or redirect with return URL
      showFallbackSignInUi({ returnAction: 'favorite' });
      return;
    }
    console.error('Authentication error:', err);
  } finally {
    isAuthenticating = false;
  }
});

后备策略

立即回退到界面模式

  • 渐进增强体验:将 uiMode: 'immediate' 视为渐进增强。请勿根据浏览器的名称或版本推断其是否支持;唯一可靠的支持信号是通过 PublicKeyCredential.getClientCapabilities() 进行的 capabilities.immediateGet 运行时检查。强制性要求:在调用 navigator.credentials.get({ uiMode: 'immediate' }) 之前,请务必检查 capabilities.immediateGet。无法识别 uiMode: 'immediate' 的浏览器会忽略未知的字典键,并打开一个模态对话框,其中包含通行密钥和二维码,会阻止用户进行其他操作。当 capabilities.immediateGet 为 false 或 navigator.credentials.get() 拒绝 NotAllowedError 时,回退到应用的标准登录体验(例如打开内嵌登录模态窗口或使用返回网址参数导航到专用登录页面),以便不支持的浏览器中的用户看到现有的登录流程保持不变:
async function isImmediateUiSupported() {
  if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
    return false;
  }
  try {
    const capabilities = await PublicKeyCredential.getClientCapabilities();
    return Boolean(capabilities?.immediateGet);
  } catch {
    return false;
  }
}

async function authenticateWithImmediateUi() {
  // MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
  const supported = await isImmediateUiSupported();
  if (!supported) {
    // Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
    throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
  }
  // Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}

客户端功能检测回退

请务必安装 'webauthn-polyfills' 并在客户端模块中导入它,以便在支持 PublicKeyCredential 的情况下随时可以使用 PublicKeyCredential.getClientCapabilities():

import 'webauthn-polyfills';

密码凭证回退

在 navigator.credentials.get() 中同时传递 password: true 和 publicKey,可让支持的浏览器在统一的即时账号选择器中包含来自浏览器密码管理工具的已保存密码。

  • 后备体验:不支持 PasswordCredential 的浏览器会忽略 password: true 选项,或通过 capabilities.immediateGet 功能检查回退到标准登录表单,其中 <input autocomplete="username webauthn"> 和 <input type="password" autocomplete="current-password"> 提供原生密码自动填充功能。

轻松实现 JSON 序列化后备

请始终安装 'webauthn-polyfills' 并在客户端软件包中导入它,以便 PublicKeyCredential.parseRequestOptionsFromJSON() 和 PublicKeyCredential.prototype.toJSON() 在支持 PublicKeyCredential 的浏览器中保持一致:

import 'webauthn-polyfills';

Signal API 同步回退

WebAuthn Signal API (webauthn-signals) 是一项渐进增强功能,当服务器返回 HTTP 404 响应时,该 API 会从用户的密码管理工具中移除过时的通行密钥。

  • 备用体验:在 if (PublicKeyCredential.signalUnknownCredential) 之后限制通话。如果不支持,请妥善跳过信号调用,并显示一条清晰的消息,引导用户使用其他方法登录。