认证使用 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 只有在完整验证通过后才建立会话。
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 的学习性归纳,生产代码应使用成熟验证器完成数据解析和密码学校验,并在其外层实现正确的业务状态管理:
- 检查响应结构、类型、大小与编码,确认对应一个本服务器发起且尚未过期的认证流程。
- 若曾下发非空
allowCredentials,确认返回 ID 在列表中;从数据库找到凭据、公钥及其账户关系。 - 验证账户识别规则,包括预期账户归属和需要时的
userHandle一致性。 - 解析
clientDataJSON,要求类型是webauthn.get;challenge 与服务器保存值一致;origin 属于精确允许列表;根据部署策略处理跨来源上下文。 - 验证
authenticatorData.rpIdHash等于预期 RP ID 的 SHA-256;检查 UP,以及策略要求时的 UV。 - 处理请求的扩展结果,使用该凭据登记的公钥和算法,验证
authenticatorData || SHA-256(clientDataJSON)上的签名。 - 按计数器与现代凭据的备份能力策略处理风险及状态更新;原子消费挑战,完成必要记录,然后建立会话。
“原子消费”是为了防止两个并发请求使用同一挑战都通过。短有效期和服务端签名的 token 也不能自动保证一次性;若采用无状态挑战,需要另行解决重放窗口与消费状态。
关于签名计数器#
signCount 可辅助发现某些凭据克隆或状态异常,但不能简单当成“必须从 1 开始、每次严格加一”的业务计数。Level 2 已允许不实现计数的认证器使用零;当新旧值均为零时,不能因此认定克隆。
对于支持计数的情况,异常可作为风险信号;是否拒绝、要求补充验证或记录风险,需要结合认证器行为和业务策略。现代同步型通行密钥还应结合 Level 3 的备份语义,避免机械套用旧式硬件密钥假设。英文原文 §6.1.1 Signature Counter Considerations · Level 3 认证器数据
条件式界面:现代补充#
条件式 mediation 可让通行密钥出现在登录输入框的自动填充选择中。它属于本书的现代补充,不是你提供的 Level 2 示例流程。通常使用带 autocomplete="username webauthn" 的输入框、可发现凭据,以及 mediation: "conditional" 的请求,并先检测能力。
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 位是否被拒绝?
- 在验证接口成功之前,页面是否就已经显示“已登录”?
通过正常路径仅说明功能能够工作;这些反向检查才能说明关键安全约束确实在服务器生效。