指南/第 07 章

CHAPTER 07 / 12

完整示例:本地注册与无用户名登录

下载并运行一套完整的 Node.js 示例,从发出 challenge、调用浏览器 API,到服务器验签和建立会话。它创建独立的匿名学习账号,无需邮箱或密码,也不会给输入的任意用户名绑定凭据。

下载完整示例 ZIP · 查看运行说明 · 服务端源代码 · 浏览器代码 · 自动测试

这是在电脑本地运行的实验。本站 Pages 服务提供文档与源码下载;线上文档页不运行认证服务,也不保存读者的通行密钥记录。

运行条件与命令#

需要 Node.js 22 或更高版本,以及支持 WebAuthn 的浏览器和能执行本地用户验证的认证器。实际是否出现指纹、面容、PIN、安全密钥或手机选项,取决于浏览器、系统和设备配置。

ZIP 解压后,进入含有 package.json 的 examples 目录;项目源码中的目录也叫 examples:

sh
cd examples
npm ci
npm test
npm start

浏览器打开 http://localhost:3000。示例固定校验这个 origin 和 localhost RP ID,因此不要替换为 127.0.0.1、局域网 IP 或别的端口。端口被占用时先停止占用服务。Node.js 版本下限依据 SimpleWebAuthn 官方文档;安装文件锁定 @simplewebauthn/server 14.0.3 和 Express 5.3.0,便于复现。

按这四步练习#

  1. 点击“创建实验账号与通行密钥”,完成系统提示。页面显示匿名账号和凭据元数据。
  2. 点击“退出当前账号”。这会替换会话,保留服务端内存中的凭据记录。
  3. 点击“用通行密钥登录”,选择刚才创建的凭据并完成验证。
  4. 对照登录前后的账号 ID;应回到同一个实验账号。可以在浏览器开发者工具的 Network 面板查看四个协议接口。

进程结束后,账号、公钥和 challenge 全部清空;设备上的通行密钥可能仍在。重启服务后旧凭据会找不到对应账号,可在设备的密码 / 通行密钥管理器中删除该实验记录,再重新注册。

文件职责#

文件 阅读重点
server.js challenge 生命周期、服务端验证、账号归属、会话与计数更新
public/app.js 按钮交互、两个原生 WebAuthn API、发送 JSON 响应
public/browser.js JSON 中 base64url 与 WebAuthn 二进制字段转换
public/index.html / style.css 本地实验页面,无额外构建步骤
server.test.js 软件生成 ES256 密钥,测试真实验签与拒绝路径
package-lock.json 固定本次可复现的依赖树

四个接口如何串起来#

接口 服务器做什么 浏览器接着做什么
POST /api/registration/options 分配随机账号 ID,生成并保存注册 challenge 转换二进制字段,调用 create()
POST /api/registration/verify 消费 challenge,验证后保存账号与凭据 显示注册成功
POST /api/authentication/options 生成并保存登录 challenge,不限制凭据列表 调用 get(),让用户选择账号
POST /api/authentication/verify 验证归属与签名,更新计数并轮换会话 显示已登录账号

所有 POST 都要求本地 origin 和 JSON 内容类型。HttpOnly、SameSite=Strict 的 Cookie 保存不透明随机会话 ID;challenge 保留在服务器中。注册时每个浏览器会话只拥有自己的待创建账号,无法指定已有账号来添加公钥。

注册代码讲解#

服务端创建注册选项:

js
const options = await generateRegistrationOptions({
  rpName: 'Passkey 中文学习实验',
  rpID: 'localhost',
  userID: Buffer.from(userId, 'base64url'),
  userName: `learner-${userId.slice(0, 8)}`,
  challenge: randomBytes(32),
  attestationType: 'none',
  authenticatorSelection: {
    residentKey: 'required',
    userVerification: 'required',
  },
  supportedAlgorithmIDs: [-7, -257],
});

userId 是服务器产生的 32 字节随机标识。residentKey: 'required' 使示例能够采用可发现凭据的无用户名登录;userVerification: 'required' 同时在服务端验证中强制执行。库会生成适合 JSON 的结构,并补充 requireResidentKey 与 credProps 等选项;参数解释见注册详解。

浏览器把选项转换后,创建凭据并提交结果:

js
const options = await request('registration/options');
const credential = await navigator.credentials.create({
  publicKey: registrationOptions(options),
});
if (!credential) throw new Error('没有创建凭据');
return request('registration/verify', credentialJSON(credential));

request() 是示例 app.js 中的同源 POST 辅助函数;registrationOptions() 和 credentialJSON() 来自 browser.js。上面的片段来自完整程序,需要在其上下文中阅读。

无用户名登录如何确认账号#

服务端发出的请求没有 allowCredentials 限制。用户选中凭据后,服务器先根据凭据 ID 查记录,再检查 response.userHandle 是否与记录中的随机账号 ID 相等,最后使用已保存的公钥验证签名:

js
const credential = credentials.get(req.body.id);
if (!credential || req.body.response?.userHandle !== credential.userId) {
  throw new Error('凭据与账号不匹配');
}

查到记录不代表已登录。此处之后仍要验证 challenge、origin、RP ID、UP / UV、签名与计数器,成功后才轮换会话。已登录用户点注册按钮会被阻止;想创建另一个独立实验账号时先退出。

测试验证了什么#

npm test 使用 Node.js 自带测试运行器,无需额外测试框架。测试生成软件认证器响应,由真正的验证库解析 CBOR、处理 COSE 公钥并验证 ES256 签名;覆盖以下行为:

  • 有效注册、退出、登录,以及成功后拒绝旧响应重放。
  • 失败后 challenge 失效、操作类型约束、跨会话不可使用、五分钟过期。
  • 篡改 challenge、origin、RP ID、UV 标志、签名或 user handle 均不能登录。
  • 非零计数回退被拒绝,始终为零的计数器可以多次登录。
  • Host / HTTP Origin 限制、请求频率上限,以及浏览器二进制转换。

软件响应测试能验证后端路径,不代替真实设备、系统 UI、同步提供方与跨设备流程测试。人工练习时仍应完成上面的四步;上线前的验证范围见生产检查与排错指南。

实验边界#

示例只监听本机,最多存放 500 个凭据、1,000 个会话;同一 IP 每分钟最多 120 个 API 请求。会话一小时过期,challenge 五分钟过期;客户端 timeout: 60000 是另一项提示。

它没有持久化、多实例事务、真实身份验证、恢复、添加第二个凭据、删除凭据或线上 HTTPS 会话配置。将它移到服务器时,不能仅把 localhost 替换成域名就视为生产接入。继续阅读生产系统设计。

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

搜索指南

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

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