凛ノブログ

Eat, Sleep & Daydream
img of 如何实现一个简单的 Passkey 登录

如何实现一个简单的 Passkey 登录

最近糊了个 meow-counterFooter 那个,魔改自 moco),但是统计面板不想公开,所以自然需要一个鉴权方式。我选择了 Passkey,也借此搞通了 Passkey 的验证流程。

为什么选择 Passkey?

我不喜欢输密码,基于公钥认证的系统让我感觉更安心。就像使用 SSH,我也是仅支持公钥验证,完全不需要密码,安全性更高且劝退暴力破解者。

对于普通用户,Passkey 解决了密码泄露、钓鱼攻击等问题,简化了身份验证流程(无需额外的 2FA)。

不足之处

Passkey 通常绑定到设备或其生态系统(如 iCloud、Google 账号)。目前,除了使用 KeePassXC 外,几乎没有导出和导入的手段。 更换设备或丢失绑定设备时,可能会面临身份验证困难。 未来或许会有更多密码管理器支持1

Passkey 必需依赖 Javascript,不能纯 HTML 了。 2

注册与登录流程

注册流程

注册流程

登录流程

登录流程

数据库

登录流程

sign_count 用于防止 passkey 被克隆。比如 yubikey 每次签名都会计数,key 返回的计数如果小于记录的计数,则说明存在克隆的 key。

但是水果和一众密码管理器都没实现这个特征(返回 0),或许是同步计数器比较麻烦。3

容易被克隆的软件实现都没实现(密码管理器的同步功能做的事情确实是在克隆),难以克隆的硬件反而有

API 使用指南

环境要求

  • 服务端需要支持 HTTPS,也可以是 localhost(WebAuthn 需要 Secure contexts)。
  • 后端推荐使用现成的库,比如 Node.js 的 simplewebauthn。不然验证挺繁琐的。

下面都算伪代码,实际操作中 ArrayBuffer 编码为 Base64URL

用户注册

前端需要输入用户名,请求 /api/registerStart,获取 publicKeyCredentialCreationOptions

const publicKeyCredentialCreationOptions = {
    challenge: Uint8Array.from(
        randomStringFromServer,
        (c) => c.charCodeAt(0),
    ),
    rp: {
        name: "Meow Counter",
        id: "example.com", // 浏览器会验证当前域名与 RP ID 是否匹配
    },
    user: {
        id: Uint8Array.from(
            "UZSL85T9AFC",
            (c) => c.charCodeAt(0),
        ),
        name: "lee@example.com",
        displayName: "Lee",
    },
    pubKeyCredParams: [{ alg: -7, type: "public-key" }], // 接受的算法
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform", // 平台外部认证器
    },
    timeout: 60000,
    attestation: "direct",
};

challenge:

challenge 应该是一次性且短期有效的,使用后作废

  • challenge 在 KV 中存储,应使用 Expired key。
  • 每次请求新的 challenge,旧 challenge 立即作废。

rp:

假设你的 RP ID 设置为 example.com,只有在 example.com 或其子域 login.example.com 上调用 Passkey,验证会成功。

authenticatorAttachment:

  • platform:使用本地认证器(如 Windows Hello、Face ID)。
  • cross-platform:使用外部认证器(如 YubiKey,密码管理器)。

前端创建凭据

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});

console.log(credential);

PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAttestationResponse {
        clientDataJSON: ArrayBuffer(121),
        attestationObject: ArrayBuffer(306),
    },
    type: 'public-key'
}

post('/api/registerFinish', { credential });

服务端验证注册

服务端需要保存 challenge,以供验证使用。

const { verifyRegistrationResponse } = require("@simplewebauthn/server");

const verification = verifyRegistrationResponse({
    response: clientResponse, // 前端返回的凭证数据
    expectedChallenge: storedChallenge,
    expectedOrigin: "https://example.com",
    expectedRPID: "example.com",
});

if (verification.verified) {
    saveToDatabase({
        credentialID: verification.registrationInfo.credentialID,
        publicKey: verification.registrationInfo.credentialPublicKey,
    });
}

verifyRegistrationResponse 会解析 clientDataJSON,将 challenge 与之前保存的进行比较以验证是否对应于当前注册事件。然后从 attestationObject 中获取 credential.idpublicKey,并将它们存储到数据库中。

用户登录

服务端生成 challenge

const publicKeyCredentialRequestOptions = {
    challenge: Uint8Array.from(
        randomStringFromServer,
        (c) => c.charCodeAt(0),
    ),
    allowCredentials: [{
        id: Uint8Array.from(
            credentialId,
            (c) => c.charCodeAt(0),
        ), // optional,注册时保存的 credentialID
        type: "public-key",
        transports: ["usb", "ble", "nfc"], // 支持的认证器传输方式
    }],
    timeout: 60000,
};

前端触发认证

const assertion = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});

console.log(assertion);

PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(191),
        clientDataJSON: ArrayBuffer(118),
        signature: ArrayBuffer(70),
        userHandle: ArrayBuffer(10), // optional, 注册时提供的用户ID。用于与服务器上的用户关联起来。服务端未提供 allowCredentialDescriptorList 的时候,服务端可以通过 userHandle 确定用户。
    },
    type: 'public-key'
}

post('/api/loginFinish', { assertion });

服务端验证登录

const { verifyAuthenticationResponse } = require("@simplewebauthn/server");

const verification = verifyAuthenticationResponse({
    response: clientResponse, // 前端返回的数据
    expectedChallenge: storedChallenge,
    expectedOrigin: "https://example.com",
    expectedRPID: "example.com",
    credential: storedCredential, // 注册时保存的 credentialId 和 publicKey
});

if (verification.verified) {
    // 用户验证成功
    grantAccessToUser();
}

verifyAuthenticationResponse 验证 signature 和 clientDataJSON 是否与存储的 publicKey 匹配,确认登录成功。

Footnotes

  1. Credential Exchange Format

  2. Add a way to use webauthn without Javascript · Issue #1255 · w3c/webauthn

  3. Passkeys and the signature counter : r/Bitwarden

本作品采用知识共享署名-非商业性使用-相同方式共享 (CC BY-NC-SA) 协议进行许可。
由于是静态页面,评论提交后不会立即显示,这里 查看提交的评论。