指南/第 09 章

CHAPTER 09 / 12

从学习示例到生产系统

Passkey 解决凭据证明问题;账号归属、会话、恢复和凭据管理,仍然是应用自己的责任。本章把规范中的安全与隐私要求整理成开发检查项。

英文原文:§13.4 RP 的安全考量 · §14.6 RP 的隐私考量 · §15 无障碍考量。以下架构与 SQL 为学习补充。

首先保护“添加凭据”这一步#

攻击者不一定要伪造签名。如果他能给受害者的账号绑定自己的 Passkey,以后就能正常签名登录。因此,添加凭据必须绑定到服务端已经确认的账号身份。

给现有账号添加凭据时,从经过认证的会话取账号 ID,视风险要求近期重新认证;不要直接相信请求体里的 userId、邮箱或用户名。新用户注册则先完成你的账号创建和身份归属流程,再把新凭据关联进去。验证码认证只能证明该次验证对应的控制权,不能自动替代所有高风险操作的身份要求。

配套示例采用独立的随机演示账号,让你练习协议;它没有实现邮箱身份验证,也不允许靠输入已有用户名给别人添加凭据。

Challenge 的完整生命周期#

§13.4.3 的中文释义:注册和认证的 challenge 必须在 RP 信任的环境中随机生成;返回值必须与发出的值匹配。不要依靠客户端维护这项约束。规范建议至少 16 字节,以提供足够的不可预测性。

实际开发可以生成 32 个密码学随机字节,并为每次操作保存以下上下文:

字段 作用
challenge 关联这次响应与服务端发起的操作
sessionId 或不透明事务标识 防止跨会话挪用
ceremony 区分注册和登录,不能相互替换
accountId 注册绑定当前账号;已知账号登录则绑定目标账号
expiresAt 限制有效时间;页面上的 timeout 不是服务端过期校验
固定的 origin、RP ID、UV 策略 验证时使用可信配置,不能从响应反推

应以原子操作消费 challenge,避免两个请求同时通过。下面是 PostgreSQL 的设计示意,不是配套示例的运行依赖:

sql
DELETE FROM webauthn_challenges
WHERE transaction_id = $1
  AND session_id = $2
  AND ceremony = 'authentication'
  AND expires_at > now()
RETURNING challenge, account_id;

取不到记录就拒绝;取到以后,无论本次验证成功或失败,都不能再次使用。失败的用户重新申请一个 challenge。登录成功后的凭据计数更新也要处理并发,不能让旧响应覆盖新状态。

不要把 challenge 当作第二份密码:它可以经过浏览器,但必须不可预测、有上下文、限时且只能用一次。对签名验证、安全标志和 origin 的检查仍然必不可少。

数据库实际保存什么#

一个账号通常对应多条凭据。以下为关系模型示意:

sql
CREATE TABLE passkey_credentials (
  credential_id TEXT PRIMARY KEY,
  account_id UUID NOT NULL REFERENCES accounts(id),
  public_key BYTEA NOT NULL,
  sign_count BIGINT NOT NULL DEFAULT 0,
  transports JSONB NOT NULL DEFAULT '[]',
  device_type TEXT,
  backed_up BOOLEAN,
  label TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);
CREATE INDEX passkey_account_idx ON passkey_credentials(account_id);

credential_id 用统一的 base64url 文本编码,公钥保存验证库给出的字节。还应按所用库保存它要求的算法、备份状态等元数据。transports 是交互提示,不是可信的身份断言。label 是用户起的名字,也不能据此判断设备可信度。

账号表另存稳定、随机且无个人信息的 user_handle,长度不得超过 64 字节。一个账号的多个凭据使用同一个 user handle。不要把邮箱、用户名或可猜测的无盐哈希塞进 user.id。§14.6.1 原文

服务器不保存 Passkey 私钥,也不需要收到用户的指纹、面容或设备 PIN。现代凭据提供方可能在其自己的安全体系中同步私钥;这不等于你的 RP 服务端获得私钥。

RP ID 是长期的身份边界#

RP ID 不带协议、端口或路径;origin 则包括协议、主机以及非默认端口。例如 https://login.example.com:8443 是 origin,example.com 可以是符合域名规则的 RP ID。服务端必须分别验证它们。RP ID 定义

把生产、预发布、本地环境分开配置。不要使用 endsWith('example.com') 自制 origin 白名单,因为 evil-example.com 也满足这个字符串条件。使用完整、明确的 origin 列表,并让验证库完成协议校验。

已有凭据与注册时的 RP ID 绑定。域名迁移前必须设计过渡和重新注册流程,不能修改数据库中的一个字符串就指望旧凭据跟随迁移。Cloudflare Pages 的预览地址也可能与正式地址不同;若以后将示例做成真正线上应用,需要以稳定域名规划 RP ID。本文发布的 Pages 站点仅承载文档与源码下载。

会话仍然需要保护#

验证成功后,由服务器签发或轮换应用会话。生产 Cookie 通常使用 Secure、HttpOnly、适当的 SameSite、明确的有效期和受控作用域。退出时销毁服务端会话,敏感操作校验 CSRF 或来源,并限制尝试频率。

Passkey 抗钓鱼能力依赖正确的域绑定和验证。它不会自动修复你站点的 XSS、被盗会话、越权 API 或错误的账号恢复流程。浏览器调用成功只代表拿到了响应,不代表用户已经通过你的服务端认证。

计数器是风险信号#

L2 描述了签名计数器,用于发现可能的凭据克隆或设备异常;它也允许不支持计数的认证器始终返回零。现代同步 Passkey 进一步要求考虑多设备场景。不能把所有凭据的计数都假定为严格递增。

保存验证库返回的计数和备份信息;结合凭据类型、并发、供应商行为和业务风险制定异常策略。不要为了兼容而关闭签名、challenge 或 origin 校验。本文示例沿用验证库的计数检查,遇到异常拒绝并重新发起;生产系统需明确自己的风险处置策略。L2 §6.1.1 · L3 §6.1.1

恢复流程决定整体安全下限#

§13.4.6 的中文释义:L2 本身没有定义私钥备份或在认证器之间共享私钥的协议;RP 应允许并鼓励用户为同一账号注册多个凭据。这是 2021 年规范的语境,不能据此推断今天所有 Passkey 都不可同步。

生产系统应支持多个凭据、可识别的名称、最近使用时间、逐个撤销,以及丢失全部凭据后的恢复。删除最后一个凭据时,先确认还有可用的恢复或登录途径。删除服务器记录会阻止该凭据登录本站,但通常不会自动删除设备或密码管理器里的副本。

恢复流程要与账号风险相称。一个容易被接管的邮箱恢复入口,仍可能绕过很强的 Passkey 登录。恢复成功、添加或删除凭据时,应记录审计事件并按产品政策通知用户。

隐私、可用性与验证#

用户名存在性、allowCredentials 的数量和长度都可能泄露账号信息。对不存在账号与验证失败给出一致的外部错误;需要时采用无用户名的可发现凭据登录。把精确原因留给受控的服务端日志,不记录原始会话令牌、完整响应或生物信息。§14.6.2 · §14.6.3

上线前至少验证这些行为:

  • 正常注册、退出、重新登录;一个账号绑定第二个凭据。
  • challenge 过期、重放、跨会话使用,以及注册响应用于登录。
  • 错误 origin、RP ID、签名、用户验证标志和凭据归属都被拒绝。
  • 同一响应并发提交只成功一次;旧计数更新不会覆盖新数据。
  • 用户取消、凭据不可用、没有生物识别时有清楚的提示或替代路径。
  • 手机、桌面、外接安全密钥的真实操作,以及键盘和屏幕阅读器使用。

规范建议认证器提供多种用户验证方式,并给用户足够时间完成操作。不要把“人脸或指纹”写成唯一途径,也不要用过短的超时排除行动不便的用户。

学习自测:如果有人成功签名,但返回的是另一个账号的凭据,你的服务端会在哪一步拒绝?如果只能回答“前端不会这么做”,账号归属校验还没有完成。

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

搜索指南

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

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