FIDO WebAuthn 可以让系统支持安全密钥、指纹、面容识别、系统密码管理器等无密码登录方式。它适合用在控制台、管理后台、本地服务 Web UI、内部工具等场景中,既能减少长期访问令牌的暴露,也能让新设备接入时具备明确的审批流程。
本文介绍一种完整的 WebAuthn 登录设计:用户优先使用 FIDO 登录,新设备首次登记后进入待批准状态,已登录设备通过实时通知进行审批,也可以通过命令行工具完成 approve/block 操作。
目标体验
登录页面默认只展示一个按钮:
使用 FIDO 登录
用户点击后,浏览器会尝试使用当前站点下已有的 passkey 或安全密钥完成认证。
如果认证成功:
- 设备已批准:直接登录成功,后端下发 session cookie。
- 设备待批准:提示新设备需要 approve。
- 设备已阻止:提示该设备已被禁用。
如果认证失败:
- 如果后端明确知道当前系统还没有登记过任何 WebAuthn 设备,则引导用户注册当前设备。
- 如果浏览器只返回
NotAllowedError这类不透明错误,则显示“注册此设备”按钮,同时保留“使用 FIDO 登录”按钮,允许用户重试。
注册成功后,设备不会立即获得登录权限,而是进入 pending approve 状态。页面展示两种批准方式:
- 在其它已登录设备上确认。
- 使用命令行工具批准该设备。
pending 期间,页面每 2 秒刷新一次设备状态。如果设备被批准,提示“设备已认证,请重新登录”;如果被阻止,切换到 blocked 提示。
为什么先尝试登录,再引导注册
WebAuthn 的一个重要特性是:凭据属于浏览器、操作系统、密码管理器或安全密钥,而不是网站前端本地存储。
例如用户在一台设备上把 passkey 保存到云端钥匙串或密码管理器后,另一台同步过的设备也可能直接完成认证。此时即使新设备上没有 localStorage 记录,也不代表它没有可用凭据。
因此不要用 localStorage 判断“当前设备是否已经注册过”。更可靠的做法是:
- 先发起 WebAuthn 认证。
- 让浏览器根据
rpID自己查找可用凭据。 - 如果认证成功,再由后端根据 credential id 判断设备状态。
- 如果认证失败,再引导用户注册。
认证 options:不返回 allowCredentials
传统 WebAuthn 登录通常会由后端返回 allowCredentials,也就是允许使用的一组 credential id。浏览器只能从这些 credential 中选择。
但如果希望实现“用户无需先选择账号或设备,浏览器自己根据站点识别 passkey”,可以使用 discoverable credential,也就是 passkey 登录方式。
认证 options 中不返回所有已登记设备的 allowCredentials,只提供 rpID、challenge、user verification 等信息。浏览器会根据当前站点的 rpID 查找可用凭据。
后端流程:
- 查询系统中是否存在任何 WebAuthn credential。
- 如果没有,返回明确错误,例如
webauthn_no_registered_devices。 - 如果存在,创建 discoverable login challenge。
- 不返回
allowCredentials。 - 保存 challenge 到短期内存存储。
浏览器流程:
- 调用
navigator.credentials.get()。 - 浏览器根据
rpID查找 passkey 或安全密钥。 - 用户完成生物识别、PIN 或安全密钥触摸。
- 浏览器返回 assertion。
- 前端提交 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:
pending、approved、blocked。 - 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,因为它还没有登录权限。
但它需要做两件事:
- 查询自己的审批状态。
- 修改自己的显示名称,方便其它设备判断是否批准。
因此后端可以在注册成功或 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 请求发送,真正的管理逻辑由正在运行的服务进程处理。
一种实现方式是:
- 服务启动时在 state 目录创建 Unix socket。
- 服务端监听本地 RPC。
- CLI 连接 Unix socket。
- CLI 调用登录设备管理方法。
- 服务端执行 approve/block/remove,并广播 SSE 通知。
这样可以避免 CLI 直接读写数据库,也方便后续其它管理命令复用同一套本地 RPC 机制。
API 流程
FIDO 登录
- 前端请求认证 options。
- 后端检查是否有已登记 credential。
- 如果没有,返回
webauthn_no_registered_devices。 - 如果有,返回 discoverable login options。
- 前端调用浏览器 WebAuthn API。
- 浏览器返回 assertion。
- 前端提交 assertion。
- 后端验证 assertion。
- 后端根据 credential id 查设备。
- 如果设备 approved,下发 session cookie。
- 如果设备 pending,返回 pending 状态和临时 device token,并重新广播审批通知。
- 如果设备 blocked,返回 blocked 状态和临时 device token。
设备注册
- 用户点击“注册此设备”。
- 前端请求注册 options。
- 后端生成 registration challenge,并要求 resident key。
- 前端调用浏览器 WebAuthn 注册 API。
- 浏览器创建 credential。
- 前端提交注册结果。
- 后端验证 credential。
- 后端创建 pending 设备记录。
- 后端返回 registration id、设备状态和临时 device token。
- 后端广播新设备审批通知。
- 前端展示 pending approve 状态。
pending 状态刷新
- 前端每 2 秒使用临时 device token 请求状态。
- 如果仍是 pending,继续轮询。
- 如果变为 approved,停止轮询,提示用户重新登录。
- 如果变为 blocked,停止轮询,展示 blocked 提示。
- 如果设备被删除或 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 的便利性,也能保留新设备接入时必要的安全控制。