4. WebAuthn注册流程

WebAuthn 的注册流程,本质上是把“用户持有的认证器”与“服务端账户”建立一次可信绑定。浏览器通过 navigator.credentials.create() 触发认证器生成一对非对称密钥,私钥永远留在认证器内部,公钥则随 attestation 对象一起返回给服务端。服务端完成校验后,把公钥与凭证 ID 存入数据库,后续登录时再用它验证签名。理解这条链路,是做好支付强认证风控的基础。

核心原理:注册阶段到底发生了什么

注册时,服务端先下发一个 PublicKeyCredentialCreationOptions,其中包含 challenge、rp、user、pubKeyCredParams 等字段。浏览器调用 navigator.credentials.create() 后,认证器会:

  • 生成新的密钥对,私钥不可导出;
  • 把 clientDataJSON、authenticatorData、attestationObject 组装成响应;
  • 由认证器对 authenticatorData || SHA-256(clientDataJSON) 签名,证明私钥确实由该认证器持有;
  • 返回 PublicKeyCredential,其中 rawId 是凭证 ID,response 包含 attestation 数据。

服务端校验的核心是四件事:challenge 是否匹配且未被重放、origin 是否合法、rpIdHash 是否与预期 RP ID 一致、attestation 是否可信。只有这四步都通过,才能把公钥写入账户。

关键点:注册阶段返回的是公钥和凭证 ID,不是私钥。私钥永远不会离开认证器,这正是 WebAuthn 抗钓鱼、抗撞库的根本原因。

方法流程:从浏览器到服务端的完整链路

下面给出一个可落地的注册流程,分为前端调用与服务端校验两段。

// 前端:发起注册
const options = await fetch('/webauthn/register/options', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ username: 'alice' })
}).then(r => r.json());

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: base64urlToBuffer(options.challenge),
    rp: { id: 'pay.example.com', name: 'Example Pay' },
    user: {
      id: base64urlToBuffer(options.userId),
      name: 'alice',
      displayName: 'Alice'
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 },   // ES256
      { type: 'public-key', alg: -257 }  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'required',
      residentKey: 'preferred'
    },
    timeout: 60000,
    attestation: 'direct'
  }
});

await fetch('/webauthn/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
      attestationObject: bufferToBase64url(credential.response.attestationObject)
    }
  })
});

服务端收到响应后,需要按顺序完成校验。以 Node.js 为例,可使用 @simplewebauthn/server:

import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: req.body,
  expectedChallenge: storedChallenge,
  expectedOrigin: 'https://pay.example.com',
  expectedRPID: 'pay.example.com',
  requireUserVerification: true
});

if (!verification.verified) {
  return res.status(400).json({ error: 'attestation verify failed' });
}

const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
await db.saveCredential({
  userId: user.id,
  credentialID: Buffer.from(credentialID).toString('base64url'),
  publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
  counter,
  createdAt: new Date()
});

校验通过后,服务端应把 credentialID、publicKey、counter、transports 等字段持久化。其中 counter 用于后续登录时检测克隆认证器,transports 用于优化后续认证体验。

案例场景:支付强认证中的注册落地

在支付场景中,注册通常发生在用户首次绑定设备或开启免密支付时。此时风控系统会关注:

  • 设备可信度:是否使用平台认证器(如 Touch ID、Windows Hello),attestation 是否来自可信厂商;
  • 用户验证:是否要求 userVerification: 'required',确保是本人操作;
  • 凭证绑定:一个账户可绑定多个凭证,但每个凭证必须唯一,防止凭证 ID 冲突;
  • 风控联动:注册成功后触发一次低风险交易验证,确认凭证可用。

例如,某支付平台在注册时要求 attestation: 'direct',并校验 attestation 证书链是否来自 Apple、Google、Microsoft 等可信根。若校验失败,则降级为普通登录,不允许开启免密支付。

校验项 作用 失败处理
challenge 防重放,确保本次注册唯一 拒绝注册,重新下发
origin 防钓鱼,确保来源合法 拒绝并告警
rpIdHash 确保 RP ID 匹配 拒绝注册
attestation 验证认证器可信度 降级或拒绝
userVerification 确认本人操作 要求重新验证

误区与复盘:注册阶段最容易踩的坑

误区一:把 attestation 当成“必须可信”。很多场景下 attestation: 'none' 已足够,强制 direct 会增加隐私与兼容成本。支付强认证可按风险分级选择。

误区二:challenge 复用或未绑定会话。challenge 必须一次性、随机、与用户会话绑定,否则攻击者可重放注册响应。

误区三:公钥存储格式混乱。建议统一使用 base64url 存储,并保留 credentialID 与 counter,避免后续登录校验时无法解析。

复盘建议:注册成功后立即做一次“验证性登录”,用同一凭证完成一次签名验证,确保公钥存储正确、counter 可递增。这一步能提前暴露 80% 的集成问题。

最后,注册流程的健壮性直接决定后续认证的安全上限。把 navigator.credentials.create() 的调用、attestation 校验、公钥存储与服务端校验串成一条可观测的链路,并在每个环节记录日志与风控指标,才能让 WebAuthn 在支付强认证中真正发挥价值。