爱折腾的孩纸

WebAuthn 登录方式的实现和踩坑

FIDO WebAuthn 可以让系统支持安全密钥、指纹、面容识别、系统密码管理器等无密码登录方式。它适合用在控制台、管理后台、本地服务 Web UI、内部工具等场景中,既能减少长期访问令牌的暴露,也能让新设备接入时具备明确的审批流程。

本文介绍一种完整的 WebAuthn 登录设计:用户优先使用 FIDO 登录,新设备首次登记后进入待批准状态,已登录设备通过实时通知进行审批,也可以通过命令行工具完成 approve/block 操作。

目标体验

登录页面默认只展示一个按钮:

使用 FIDO 登录

用户点击后,浏览器会尝试使用当前站点下已有的 passkey 或安全密钥完成认证。

如果认证成功:

  • 设备已批准:直接登录成功,后端下发 session cookie。
  • 设备待批准:提示新设备需要 approve。
  • 设备已阻止:提示该设备已被禁用。

如果认证失败:

  • 如果后端明确知道当前系统还没有登记过任何 WebAuthn 设备,则引导用户注册当前设备。
  • 如果浏览器只返回 NotAllowedError 这类不透明错误,则显示“注册此设备”按钮,同时保留“使用 FIDO 登录”按钮,允许用户重试。

注册成功后,设备不会立即获得登录权限,而是进入 pending approve 状态。页面展示两种批准方式:

  1. 在其它已登录设备上确认。
  2. 使用命令行工具批准该设备。

pending 期间,页面每 2 秒刷新一次设备状态。如果设备被批准,提示“设备已认证,请重新登录”;如果被阻止,切换到 blocked 提示。

为什么先尝试登录,再引导注册

WebAuthn 的一个重要特性是:凭据属于浏览器、操作系统、密码管理器或安全密钥,而不是网站前端本地存储。

例如用户在一台设备上把 passkey 保存到云端钥匙串或密码管理器后,另一台同步过的设备也可能直接完成认证。此时即使新设备上没有 localStorage 记录,也不代表它没有可用凭据。

因此不要用 localStorage 判断“当前设备是否已经注册过”。更可靠的做法是:

  1. 先发起 WebAuthn 认证。
  2. 让浏览器根据 rpID 自己查找可用凭据。
  3. 如果认证成功,再由后端根据 credential id 判断设备状态。
  4. 如果认证失败,再引导用户注册。

认证 options:不返回 allowCredentials

传统 WebAuthn 登录通常会由后端返回 allowCredentials,也就是允许使用的一组 credential id。浏览器只能从这些 credential 中选择。

但如果希望实现“用户无需先选择账号或设备,浏览器自己根据站点识别 passkey”,可以使用 discoverable credential,也就是 passkey 登录方式。

认证 options 中不返回所有已登记设备的 allowCredentials,只提供 rpID、challenge、user verification 等信息。浏览器会根据当前站点的 rpID 查找可用凭据。

后端流程:

  1. 查询系统中是否存在任何 WebAuthn credential。
  2. 如果没有,返回明确错误,例如 webauthn_no_registered_devices
  3. 如果存在,创建 discoverable login challenge。
  4. 不返回 allowCredentials
  5. 保存 challenge 到短期内存存储。

浏览器流程:

  1. 调用 navigator.credentials.get()
  2. 浏览器根据 rpID 查找 passkey 或安全密钥。
  3. 用户完成生物识别、PIN 或安全密钥触摸。
  4. 浏览器返回 assertion。
  5. 前端提交 assertion 给后端验证。

注册必须创建 discoverable credential

如果登录时不提供 allowCredentials,浏览器只能发现 discoverable credential。也就是说,注册阶段必须要求创建 resident key/passkey。

注册 options 应包含类似语义:

  • resident key required
  • user verification preferred 或 required
  • excludeCredentials 包含已有 credential,避免重复注册

这样新注册的凭据才能在后续无 allowCredentials 的登录流程中被浏览器自动发现。

需要注意:旧的非 discoverable credential 可能无法用于这种登录方式。系统切换到该方案后,旧凭据可能需要重新登记。

设备状态模型

可以用一张设备表承载 WebAuthn credential 和设备审批状态。

核心字段包括:

  • 设备 ID:系统内部使用。
  • registration id:给用户、CLI、审批流程使用的外部 ID。
  • status:pendingapprovedblocked
  • display name:设备显示名称。
  • credential id:WebAuthn credential id。
  • credential JSON:WebAuthn credential 原始信息。
  • first seen IP、last seen IP。
  • first seen time、last seen time。
  • approved time、approved by、approved IP。
  • blocked time、blocked by、blocked IP、blocked reason。
  • last login time。
  • sign count。
  • temporary device token hash。
  • temporary device token expires at。

这种设计可以避免拆出过多表。credential 作为 JSON 存在设备记录里即可,审批信息也直接落在设备记录上。

临时 device token

pending 或 blocked 设备不能获得正式 session cookie,因为它还没有登录权限。

但它需要做两件事:

  1. 查询自己的审批状态。
  2. 修改自己的显示名称,方便其它设备判断是否批准。

因此后端可以在注册成功或 pending/blocked 设备完成 FIDO 验证后,返回一个短期临时 token。

这个 token 只用于有限接口,例如:

  • 查询当前设备状态。
  • 修改 pending/blocked 设备名称。

它不应该能访问系统其它 API,也不应该被当作正式登录态。

建议:

  • 只存 token hash。
  • 设置较短有效期。
  • 只允许 pending/blocked 状态使用改名接口。
  • approved 后必须重新 FIDO 登录,换取正式 session cookie。

Session Cookie

WebAuthn 认证成功且设备状态为 approved 后,后端下发 session cookie。

建议策略:

  • Cookie 有效期 12 小时。
  • HttpOnly。
  • SameSite 根据部署方式选择。
  • HTTPS 环境使用 Secure。
  • 后端在处理请求时刷新 cookie 有效期。
  • 刷新动作做节流,例如 5 分钟内最多刷新一次,避免频繁写库。
  • 过期或失效 session 定期扫描删除。

这样用户不会每次刷新页面都重新进行 WebAuthn,同时 session 表也不会无限增长。

SSE 实时通知

SSE,也就是 Server-Sent Events,适合用来把服务端安全事件推送给所有在线页面。

这里可以建立一个通知流接口,例如:

GET /api/notifications/stream

所有已登录 session 打开页面后订阅该流。

当新设备注册成功进入 pending 状态时,后端广播一条通知:

  • 类型:新设备请求批准。
  • 设备显示名称。
  • IP 地址。
  • User-Agent。
  • 首次出现时间。
  • 设备指纹,如果有。
  • 当前状态。
  • 设备审批 ID。

前端收到通知后弹出全局模态窗,让用户选择:

  • 批准。
  • 阻止。

如果 pending 设备修改了显示名称,后端可以重新广播同一类 approval notification。前端如果当前已经显示审批弹窗,直接用新通知内容覆盖弹窗即可,这样其它在线设备能看到最新名称。

状态变化也可以通过 SSE 广播,例如:

  • 设备已批准。
  • 设备已阻止。
  • 设备已删除。

CLI 审批

除了在线设备审批,还应该提供命令行审批方式,避免用户只有一台设备或没有其它在线 session 时无法完成首次授权。

CLI 可以支持:

  • 列出登录设备。
  • 批准设备。
  • 阻止设备。
  • 删除设备。

设计上建议 CLI 只做参数解析和 RPC 请求发送,真正的管理逻辑由正在运行的服务进程处理。

一种实现方式是:

  1. 服务启动时在 state 目录创建 Unix socket。
  2. 服务端监听本地 RPC。
  3. CLI 连接 Unix socket。
  4. CLI 调用登录设备管理方法。
  5. 服务端执行 approve/block/remove,并广播 SSE 通知。

这样可以避免 CLI 直接读写数据库,也方便后续其它管理命令复用同一套本地 RPC 机制。

API 流程

FIDO 登录

  1. 前端请求认证 options。
  2. 后端检查是否有已登记 credential。
  3. 如果没有,返回 webauthn_no_registered_devices
  4. 如果有,返回 discoverable login options。
  5. 前端调用浏览器 WebAuthn API。
  6. 浏览器返回 assertion。
  7. 前端提交 assertion。
  8. 后端验证 assertion。
  9. 后端根据 credential id 查设备。
  10. 如果设备 approved,下发 session cookie。
  11. 如果设备 pending,返回 pending 状态和临时 device token,并重新广播审批通知。
  12. 如果设备 blocked,返回 blocked 状态和临时 device token。

设备注册

  1. 用户点击“注册此设备”。
  2. 前端请求注册 options。
  3. 后端生成 registration challenge,并要求 resident key。
  4. 前端调用浏览器 WebAuthn 注册 API。
  5. 浏览器创建 credential。
  6. 前端提交注册结果。
  7. 后端验证 credential。
  8. 后端创建 pending 设备记录。
  9. 后端返回 registration id、设备状态和临时 device token。
  10. 后端广播新设备审批通知。
  11. 前端展示 pending approve 状态。

pending 状态刷新

  1. 前端每 2 秒使用临时 device token 请求状态。
  2. 如果仍是 pending,继续轮询。
  3. 如果变为 approved,停止轮询,提示用户重新登录。
  4. 如果变为 blocked,停止轮询,展示 blocked 提示。
  5. 如果设备被删除或 token 过期,引导用户重新开始 FIDO 登录或注册流程。

浏览器错误处理

WebAuthn 的浏览器错误并不总是可解释。尤其是 NotAllowedError,可能代表:

  • 用户取消。
  • 没有可用凭据。
  • 超时。
  • 用户验证失败。
  • 平台认证器不可用。
  • 安全密钥未插入或未触摸。

因此前端不要把 NotAllowedError 直接等同于“未注册”。

推荐处理:

  • 后端明确 webauthn_no_registered_devices:引导注册。
  • 浏览器 NotAllowedError:显示“注册此设备”,同时保留“使用 FIDO 登录”。
  • 注册失败:提示错误,返回登录/注册按钮状态。
  • 登录成功后,无论设备状态如何,都隐藏注册按钮。

最终用户体验

最终体验可以保持非常简洁:

首次进入登录页:

  • 只看到“使用 FIDO 登录”。

如果已有 approved passkey:

  • 点击后完成认证并登录。

如果没有可用 passkey:

  • 显示“注册此设备”。

注册成功后:

  • 显示新设备等待批准。
  • 展示其它设备确认方式。
  • 展示 CLI 审批方式。
  • 允许修改设备名称。
  • 自动刷新审批状态。

其它已登录设备:

  • 收到 SSE 通知。
  • 弹出新设备审批窗口。
  • 用户根据名称、IP、浏览器、时间等信息判断是否批准。

设备被批准后:

  • 新设备页面提示“设备已认证,请重新登录”。
  • 用户再次点击 FIDO 登录。
  • 后端下发正式 session cookie。

设备被阻止后:

  • 页面展示 blocked 提示。
  • 设备不能登录。
  • 需要管理员或用户通过设备管理能力删除后才能重新登记。

小结

给系统增加 FIDO WebAuthn 登录,不只是接入浏览器 API。真正重要的是把 credential、设备审批、登录态、实时通知和命令行管理串成一个完整闭环。

比较稳妥的设计是:

  • 默认先尝试 FIDO 登录。
  • 使用 discoverable credential,避免暴露所有 credential id。
  • 注册时强制 resident key。
  • 新设备默认 pending,不直接登录。
  • approved 才下发 session cookie。
  • pending/blocked 只使用短期 device token。
  • SSE 通知所有在线 session。
  • CLI 作为兜底审批路径。
  • 不依赖 localStorage 判断设备是否注册。

这样既能获得 passkey 的便利性,也能保留新设备接入时必要的安全控制。

评论