指南/第 05 章

CHAPTER 05 / 12

认证详解:从签名到可信登录会话

认证使用 navigator.credentials.get() 请求已有凭据产生断言。真正决定登录成功的是服务器:它必须找到已登记公钥,验证本次响应和预期上下文,再签发或更新登录会话。

英文原文 §5.5 认证请求选项 · §5.2.2 认证响应 · §7.2 验证认证断言

先选登录路径#

路径 A:先确定用户名#

用户先输入用户名或邮箱。服务器查出对应账户已登记的 credential ID,在 allowCredentials 中发送给浏览器。浏览器请求认证器使用这些凭据之一。

它适合兼容不可发现凭据,也适合需要先定位租户或账户的系统。服务器必须验证返回的凭据确实属于预期账户,且符合本次下发的允许列表,不能只按“签名有效”就接受任意账户的凭据。

查询接口还要考虑账户枚举:未知用户名与已知用户名在响应、错误消息、耗时和限速策略上的差异,可能暴露账户是否存在。不要在公开错误里直接显示“此邮箱存在,但尚未注册通行密钥”。英文原文 §14.6.2 Username Enumeration

路径 B:先选择通行密钥#

不提前输入用户名,服务器省略 allowCredentials 或发送空列表。客户端依靠可发现凭据,为该 RP 提供账户选择。此路径要求用户具有可发现凭据;普通不可发现凭据不会因列表为空而自动变得可发现。

服务器收到响应后,利用已登记的 credential ID 定位凭据与账户;还要验证 userHandle 与该凭据绑定的账户一致。此类未预先识别用户的流程应要求非空的有效 user handle,并将其与数据库中的稳定用户句柄比对,不能信任客户端额外附带的显示名称或账户 ID。英文原文 §7.2 第 6 步的账户识别规则

问题 先输入用户名 先选择通行密钥
allowCredentials 当前账户的已登记凭据列表 省略或为空
是否支持不可发现凭据 支持 通常不适用
账户何时确定 发出断言请求前 验证断言时通过登记记录确定
核心额外检查 凭据属于预期账户和列表 credential ID 与 user handle 一致地映射到已登记账户
主要体验取舍 多一步输入,定位明确 输入更少,依赖可发现凭据与账户选择界面

两种路径都要验证 challenge、origin、RP ID、UP/UV 和签名;不输入用户名只是移除了一个账户定位步骤,不会移除安全检查。

请求选项#

字段 例子 注意事项
challenge 服务器新生成的随机字节 与本次认证会话绑定、短时有效且只能使用一次
rpId example.com 必须与凭据创建时 RP ID 一致;注意字段名与注册的 rp.id 不同
allowCredentials {type,id,transports} 列表 ID 在调用前从 Base64URL 解码;transport 是交互提示
userVerification "required" 服务端也必须检查 UV;preferred 无法提供必然 UV 的保证
timeout 60000 客户端提示,不能代替服务器过期时间
extensions 可选扩展输入 只请求理解且实际需要的扩展

allowCredentials[].transports 可来自注册时的 getTransports(),有助于客户端呈现合适选择。它不是服务器证明私钥在何处的可信证据,也不应被误用为授权条件。英文原文 §5.8.3 凭据描述

原生 API 登录示例#

以下代码复用注册章中的 fromBase64url()、toBase64url() 和 postJSON()。假设 /api/authentication/options 直接返回 JSON 请求选项,服务器通过会话或等效机制保存预期挑战;/api/authentication/verify 只有在完整验证通过后才建立会话。

js
async function signInWithPasskey() {
  if (!window.isSecureContext || !window.PublicKeyCredential) {
    throw new Error("当前环境不支持 WebAuthn 安全调用");
  }

  // 此处为可发现凭据登录;服务端不预先指定用户名。
  const options = await postJSON("/api/authentication/options");
  const publicKey = {
    ...options,
    challenge: fromBase64url(options.challenge),
  };
  if (options.allowCredentials !== undefined) {
    publicKey.allowCredentials = options.allowCredentials.map(item => ({
      ...item,
      id: fromBase64url(item.id),
    }));
  }

  const credential = await navigator.credentials.get({ publicKey });
  if (!(credential instanceof PublicKeyCredential)) {
    throw new Error("未获得公钥凭据响应");
  }

  const response = credential.response;
  const result = await postJSON("/api/authentication/verify", {
    id: credential.id,
    rawId: toBase64url(credential.rawId),
    type: credential.type,
    clientExtensionResults: credential.getClientExtensionResults(),
    response: {
      clientDataJSON: toBase64url(response.clientDataJSON),
      authenticatorData: toBase64url(response.authenticatorData),
      signature: toBase64url(response.signature),
      userHandle: response.userHandle === null
        ? null
        : toBase64url(response.userHandle),
    },
  });

  // 只有服务器确认通过后,才显示登录成功或跳转。
  return result;
}

userHandle 允许为 null,尤其在已预先识别账户的某些流程中。序列化时要保留 null,不要把它编码成字符串 "null"、空字符串或全零字节。服务端根据本次登录路径,按规范检查其是否必须存在以及是否匹配。

上面的接口名称与完整示例一致。认证之后的会话 Cookie 应由服务器设置,JavaScript 返回的 {verified:true} 本身不是受保护资源的访问凭证。

服务器验证清单:每项都有关联目的#

以下是对 §7.2 的学习性归纳,生产代码应使用成熟验证器完成数据解析和密码学校验,并在其外层实现正确的业务状态管理:

  1. 检查响应结构、类型、大小与编码,确认对应一个本服务器发起且尚未过期的认证流程。
  2. 若曾下发非空 allowCredentials,确认返回 ID 在列表中;从数据库找到凭据、公钥及其账户关系。
  3. 验证账户识别规则,包括预期账户归属和需要时的 userHandle 一致性。
  4. 解析 clientDataJSON,要求类型是 webauthn.get;challenge 与服务器保存值一致;origin 属于精确允许列表;根据部署策略处理跨来源上下文。
  5. 验证 authenticatorData.rpIdHash 等于预期 RP ID 的 SHA-256;检查 UP,以及策略要求时的 UV。
  6. 处理请求的扩展结果,使用该凭据登记的公钥和算法,验证 authenticatorData || SHA-256(clientDataJSON) 上的签名。
  7. 按计数器与现代凭据的备份能力策略处理风险及状态更新;原子消费挑战,完成必要记录,然后建立会话。

“原子消费”是为了防止两个并发请求使用同一挑战都通过。短有效期和服务端签名的 token 也不能自动保证一次性;若采用无状态挑战,需要另行解决重放窗口与消费状态。

关于签名计数器#

signCount 可辅助发现某些凭据克隆或状态异常,但不能简单当成“必须从 1 开始、每次严格加一”的业务计数。Level 2 已允许不实现计数的认证器使用零;当新旧值均为零时,不能因此认定克隆。

对于支持计数的情况,异常可作为风险信号;是否拒绝、要求补充验证或记录风险,需要结合认证器行为和业务策略。现代同步型通行密钥还应结合 Level 3 的备份语义,避免机械套用旧式硬件密钥假设。英文原文 §6.1.1 Signature Counter Considerations · Level 3 认证器数据

条件式界面:现代补充#

条件式 mediation 可让通行密钥出现在登录输入框的自动填充选择中。它属于本书的现代补充,不是你提供的 Level 2 示例流程。通常使用带 autocomplete="username webauthn" 的输入框、可发现凭据,以及 mediation: "conditional" 的请求,并先检测能力。

js
const canUseConditionalUI =
  window.PublicKeyCredential &&
  typeof PublicKeyCredential.isConditionalMediationAvailable === "function" &&
  await PublicKeyCredential.isConditionalMediationAvailable();

功能检测成功只表示当前环境报告支持此能力,不证明账户已有通行密钥,也不保证每一种认证器都支持。实际调用仍需服务器挑战、正确序列化和完整验证;保留普通按钮登录路径。英文原文 Level 3 条件式 mediation

取消、重试与错误提示#

页面切换登录方式或用户离开时,可用 AbortController 取消尚未完成的浏览器请求;不要同时挂起多个相互竞争的 get()。取消之后,服务器仍要按自己的过期 / 消费策略处理挑战。英文原文 §5.6 Abort Operations

把“取消 / 超时”“验证失败”“不支持”分别记录便于排错,但对外提示应避免暴露账户是否存在、公钥细节或内部验证错误。重试应获取新的挑战,不要将旧响应再次提交。

学习检查#

  • 把上一次成功响应原样重放,服务器是否拒绝?
  • 使用另一账户的有效凭据,是否会被错误登录到当前预期账户?
  • 修改 origin 或 RP ID,签名或上下文校验是否会失败?
  • 要求 UV 时,响应缺少 UV 位是否被拒绝?
  • 在验证接口成功之前,页面是否就已经显示“已登录”?

通过正常路径仅说明功能能够工作;这些反向检查才能说明关键安全约束确实在服务器生效。

读懂原理之后,用示例验证你的理解。

搜索指南

输入关键词,查找你想了解的内容。

搜索覆盖全部 12 章 · 点击结果定位到相关段落
学习目录