不用密码也能登陆?React中的通行密钥实现

你可能已经注意到了,近两年有些网站提供了一种名为 通行密钥/Passkey 的东西作为用户身份认证方式,或者辅助验证。今天我们来了解下什么是通行密钥。screenshot-20241012-012826

什么是通行密钥?

2022 年 6 月,苹果公司宣布将在 iOS 和 macOS 中加入对通行密钥标准的支持,“通行密钥”这个术语首次被广泛提及。除了 Apple,目前 Google 和 Microsoft 也提供了对通行密钥的支持。

简单的说,通行密钥是一种用于网站或者 App 身份验证的方式,它使用非对称加密方式,使用存储在个人设备(手机或者电脑)中的私钥进行签名,由服务器端使用对应的公钥进行验证,确认用户身份完成登录过程。

要实现这个过程需要浏览器和操作系统的共同支持。这个技术在现代的设备上已经有了比较好的支持,如果你使用 iOS/MacOS 系统,那么更妙,iCloud 会自动同步存储的信息,方便你跨设备使用。

为什么使用通行密钥?

  1. 安全。传统的用户名/密码登陆方式早已不安全,容易受到撞库、密码泄漏影响。
  2. 良好的体验。两步验证虽然安全,但是使用上比较麻烦。而使用通行密钥用户无需记住复杂的密码,还可以使用生物识别等方式快速登录。
  3. 跨平台兼容。通行密钥可在不同设备和平台间无缝使用。

如何在 React 应用中实现通行密钥?

要在代码中实现通行密钥的支持,需要客户端和服务端的共同配合。我试着实现了一下,这个流程其实并不复杂,客户端的核心 API 只有navigator.credentials.createnavigator.credentials.get,但是因为涉及数据格式转换、签名验证等,还是有些繁琐的。所以我推荐使用simplewebauthn这个库来帮助实现,它提供了客户端和服务端实用函数的封装,十分方便。

流程

我用 Claude 生成了两个图方便理解,

passkey-registration-flow

passkey-login-flow

两个流程很类似,都是先向服务器请求包含 challenge 的配置信息,然后和navigator.credentials API 交互,最后把结果传给服务器用于注册或者登陆。

示例代码

因为我使用 Next.js 的 Server Action,所以避免了手动调用接口,直接调用后端方法即可。代码比较长,我省略了函数的实现,完整代码以文件形式提供。

完整代码passkey.tsx passkey.ts

首先在数据库 User 表中添加了三个字段用于存储通行密钥信息,以下是 prisma 的 schema 文件的修改。

credentialId String? @unique @map("credential_id")
publicKey    String? @map("public_key")
counter      Int     @default(0) @map("counter")
// passkey.ts 文件,用于服务器端

// 生成通行密钥创建选项
// 返回包含challenge等信息的选项对象,用于客户端调用navigator.credentials.create
export async function generateCreateOptions() {
  ...
}

// 验证通行密钥创建响应
// 参数attResponse: 客户端创建通行密钥后的响应数据
// 返回验证结果,成功则返回true
export async function verifyCreateResponse(
  attResponse: RegistrationResponseJSON,
) {
   ...
}

// 生成通行密钥登录选项
// 返回包含challenge等信息的选项对象,用于客户端调用navigator.credentials.get
export async function generateLoginOptions() {
  ...
}

// 验证通行密钥登录响应
// 参数attResponse: 客户端使用通行密钥登录后的响应数据
// 返回验证结果,成功则返回true
export async function verifyLoginResponse(
  attResponse: AuthenticationResponseJSON,
) {
  ...
}
// passkey.tsx React组件,用于客户端

export function PasskeyBindButton() {
  const handleBindPasskey = async () => {
    const options = await generateCreateOptions();
    const credential = await startRegistration(options);
    const verified = await verifyCreateResponse(credential as any);
    if (verified) {
      toast.success("绑定通行密钥成功");
    } else {
      toast.error("绑定通行密钥失败");
    }
  };

  return (
    <div>
      <Button onClick={handleBindPasskey}>绑定通行密钥</Button>
    </div>
  );
}

export function PasskeyLoginButton() {
  const router = useRouter();
  const handlePasskeyLogin = async () => {
    const options = await generateLoginOptions();
    const credential = await startAuthentication(options);

    try {
      const verified = await verifyLoginResponse(credential as any);
      if (verified) {
        toast.success("登录成功");
        router.push(siteConfig.adminUrl);
      } else {
        toast.error("登录失败");
      }
    } catch (error) {
      console.error(error);
      toast.error((error as Error).message);
      return;
    }
  };

  return (
    <Button className="w-full" onClick={handlePasskeyLogin}>
      使用通行密钥登录
    </Button>
  );
}

如果客户端和服务端都正确实现的话,点击页面上的绑定或者登陆按钮,就会唤起浏览器的注册/登陆界面。

一些注意事项

  1. 我这里是在用户登陆以后,提供了绑定通行密钥的按钮,所以"注册流程"走完以后会绑定到当前用户。你也可以在其他流程中调用,只需要修改验证流程即可。
  2. 实际上可以允许用户绑定多个通行密钥,这样在登陆的时候,可以选择使用哪个通行密钥登录。我这里只实现了一个,你可以在代码中修改。
  3. 创建 options 和验证响应数据时,都使用到了 challenge,这个值应该是一个随机数,并且只能使用一次,避免重放攻击,这一点 simplewebauthn 已经帮我们做到了,我们需要将其存储在 session 中用于后续验证。
  4. 通行密钥的API提供了很多选项用于详细的设置,可以查看相关文档。

结论

通行密钥提供了一种现代、安全、便捷的身份验证方法。通过利用 WebAuthn API,开发者可以轻松地将这一技术整合到他们的应用中,从而提高安全性并改善用户体验。随着更多浏览器和设备支持这一技术,通行密钥有望成为未来身份验证的标准方式。

参考资料