全部文章

WebAuthn 与 Passkeys

无密码认证原理、Passkeys 实现、浏览器 API 与服务端集成

目录 16 节

WebAuthn 与 Passkeys

这页适合作为“无密码登录方案入门与落地说明”。Passkeys 的优势很明显,但它不是“加个前端 API 就完事”,真正决定成败的是:挑战值管理、RP ID / Origin 一致性、凭证存储、回退登录方式和多设备体验。

什么是 Passkeys

Passkeys 是基于 WebAuthn 标准的无密码认证方案,使用公钥加密替代传统密码。

  • 不可钓鱼:绑定域名,假网站无法获取凭证
  • 无密码泄露风险:服务端只存公钥
  • 跨设备同步:通过 iCloud Keychain / Google Password Manager

工作流程

注册:
1. 服务端生成 challenge
2. 浏览器调用 navigator.credentials.create()
3. 设备生成密钥对,用私钥签名 challenge
4. 服务端存储公钥和凭证 ID

登录:
1. 服务端生成 challenge
2. 浏览器调用 navigator.credentials.get()
3. 设备用私钥签名 challenge
4. 服务端用公钥验证签名

前端实现

注册

async function register(username: string) {
  // 1. 从服务端获取注册选项
  const options = await fetch("/api/auth/register-options", {
    method: "POST",
    body: JSON.stringify({ username }),
  }).then((r) => r.json());

  // 2. 创建凭证
  const credential = (await navigator.credentials.create({
    publicKey: {
      challenge: base64ToBuffer(options.challenge),
      rp: { name: "DomiVault", id: window.location.hostname },
      user: {
        id: base64ToBuffer(options.userId),
        name: username,
        displayName: username,
      },
      pubKeyCredParams: [
        { alg: -7, type: "public-key" }, // ES256
        { alg: -257, type: "public-key" }, // RS256
      ],
      authenticatorSelection: {
        residentKey: "required",
        userVerification: "preferred",
      },
      timeout: 60000,
    },
  })) as PublicKeyCredential;

  // 3. 发送到服务端验证
  await fetch("/api/auth/register-verify", {
    method: "POST",
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64(credential.rawId),
      response: {
        attestationObject: bufferToBase64(
          (credential.response as AuthenticatorAttestationResponse)
            .attestationObject,
        ),
        clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
      },
    }),
  });
}

登录

async function login() {
  const options = await fetch("/api/auth/login-options").then((r) => r.json());

  const credential = (await navigator.credentials.get({
    publicKey: {
      challenge: base64ToBuffer(options.challenge),
      rpId: window.location.hostname,
      userVerification: "preferred",
      timeout: 60000,
    },
  })) as PublicKeyCredential;

  const result = await fetch("/api/auth/login-verify", {
    method: "POST",
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64(credential.rawId),
      response: {
        authenticatorData: bufferToBase64(
          (credential.response as AuthenticatorAssertionResponse)
            .authenticatorData,
        ),
        clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
        signature: bufferToBase64(
          (credential.response as AuthenticatorAssertionResponse).signature,
        ),
      },
    }),
  }).then((r) => r.json());

  return result;
}

服务端(SimpleWebAuthn)

pnpm add @simplewebauthn/server @simplewebauthn/browser
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const rpName = "DomiVault";
const rpID = "example.com";
const origin = "https://example.com";

// 注册选项
const options = await generateRegistrationOptions({
  rpName,
  rpID,
  userName: user.name,
  attestationType: "none",
  authenticatorSelection: {
    residentKey: "required",
    userVerification: "preferred",
  },
});

// 验证注册
const verification = await verifyRegistrationResponse({
  response: body,
  expectedChallenge: challenge,
  expectedOrigin: origin,
  expectedRPID: rpID,
});

// 登录选项
const authOptions = await generateAuthenticationOptions({ rpID });

// 验证登录
const authVerification = await verifyAuthenticationResponse({
  response: body,
  expectedChallenge: challenge,
  expectedOrigin: origin,
  expectedRPID: rpID,
  credential: {
    id: storedCredential.id,
    publicKey: storedCredential.publicKey,
    counter: storedCredential.counter,
  },
});

浏览器支持

所有现代浏览器均已支持:Chrome、Safari、Firefox、Edge。

// 检测支持
const supported = window.PublicKeyCredential !== undefined;
const platformSupported =
  await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
const conditionalSupported =
  await PublicKeyCredential.isConditionalMediationAvailable();

推荐落地顺序

如果你准备把 Passkeys 真接进业务,建议按下面顺序:

  1. 先梳理账号体系和现有登录流程
  2. 明确注册、绑定、登录、解绑、找回这几条链路
  3. 用成熟库先跑通最小可用版本
  4. 再补多设备、条件式 UI、回退登录和风控

不要一开始就自己手搓全部底层细节,成熟库会帮你避开很多编码和验证坑。

几个关键概念

  • RP ID:通常是你的主域名或可控子域名范围
  • Origin:必须与实际站点来源精确匹配
  • Challenge:一次性随机挑战,必须短时有效、不可复用
  • Credential ID / Public Key:服务端需要安全保存,用于后续验证

这几个概念只要有一个配置错,登录就很容易“看起来流程都对,但验证就是失败”。

体验与产品建议

  • Passkeys 很适合作为主登录方式,但最好保留恢复和回退方案
  • 多设备同步场景下,用户往往并不理解“凭证存在设备还是云同步里”,UI 文案要尽量直白
  • 企业账号、共享设备、临时设备场景要特别注意登录确认与解绑流程

常见问题

本地能注册,线上不能登录

优先检查:

  • expectedOrigin 是否和实际线上域名完全一致
  • expectedRPID 是否正确
  • challenge 是否被正确保存和比对
  • 凭证 counter、publicKey、credential id 是否持久化正确

浏览器支持,但用户看不到创建入口

这通常和以下因素有关:

  • 页面上下文不是安全来源(非 HTTPS)
  • 平台或设备不支持当前认证器能力
  • UI 没处理条件式中介或可用性检测结果

只上 Passkeys,会不会把用户锁在门外

有这个风险。比较稳妥的方式通常是:

  • Passkeys 作为主登录方式
  • 同时保留一条安全的恢复路径
  • 对高风险操作再结合额外校验

延伸阅读

参考链接

阅读建议
  • - 先读标题和摘要,再结合目录决定从哪个章节开始精读。
  • - 看到具体命令、配置或步骤时,尽量在自己的环境里同步验证。
  • - 如果你只是快速查资料,可先看目录和相关文档,再决定是否深入全文。
适合谁看
  • - 希望把零散经验整理成长期可复用工作流的人
  • - 需要处理网络链路或基础安全配置的人
  • - 希望阅读时顺手建立自己的操作清单或收藏体系的人
执行前检查
  • - 先浏览标题、摘要和目录,带着问题阅读会更高效
  • - 确认当前网络拓扑、路由权限以及是否会影响其他设备访问
  • - 如果页面里提到相关文档,尽量一起打开对照,效果通常更完整
同类内容
← 上一篇S3 对象存储