跳到主要内容

Passkeys 与 WebAuthn:无密码认证的未来

Passkeys 是基于 WebAuthn 标准的新一代认证方式,旨在取代传统密码。它使用公钥密码学实现更安全、更便捷的身份验证,是目前最有希望终结密码时代的方案。

为什么需要 Passkeys?

传统密码认证存在诸多问题:

安全层面

  • 用户倾向于设置简单密码或重复使用密码
  • 密码可能被钓鱼网站窃取
  • 数据库泄露后密码可能被破解
  • 多因素认证增加了复杂性但并未完全消除风险

用户体验层面

  • 需要记忆多个密码
  • 密码重置流程繁琐
  • 在不同设备间切换困难

Passkeys 通过公钥密码学从根本上解决了这些问题。

核心概念:理解 WebAuthn

什么是 WebAuthn?

WebAuthn(Web Authentication API)是 W3C 制定的 Web 认证标准,定义了使用公钥凭证进行强认证的 API。它支持两种使用场景:

  1. 无密码认证:完全替代密码,使用设备绑定的凭证登录
  2. 双因素认证:作为密码的第二因素

关键角色

在 WebAuthn 体系中有三个关键角色:

┌─────────────────┐                    ┌─────────────────┐
│ │ 1. 注册/认证请求 │ │
│ 用户代理 │ ←────────────────→ │ 依赖方 │
│ (浏览器) │ │ (服务器) │
│ │ 2. 凭证操作 │ │
└────────┬────────┘ └─────────────────┘

│ 3. 密钥生成/签名

┌─────────────────┐
│ 认证器 │
│ (安全密钥/TPM) │
└─────────────────┘

依赖方(Relying Party, RP):你的网站或应用服务器,负责验证用户身份。

用户代理(User Agent):浏览器或操作系统,作为用户和认证器之间的桥梁。

认证器(Authenticator):生成和存储密钥对的设备或软件,负责签名认证。

认证器类型

类型说明示例
平台认证器内置于设备中Windows Hello、Touch ID、Face ID
漫游认证器独立的物理设备YubiKey、Titan Key
混合认证器手机作为电脑的认证器使用 iPhone 解锁 Mac

工作原理:公钥密码学的应用

注册流程

用户                    浏览器                    服务器                认证器
│ │ │ │
│ 1. 点击注册 │ │ │
│ ─────────────────────→ │ │ │
│ │ 2. 请求注册选项 │ │
│ │ ──────────────────────→│ │
│ │ │ │
│ │ 3. 返回 Challenge │ │
│ │ + 用户信息 + RP信息 │ │
│ │ ←──────────────────────│ │
│ │ │ │
│ │ 4. 调用 create() │ │
│ │ ──────────────────────────────────────────→ │
│ 5. 用户验证 │ │ │
│ (指纹/面容/PIN) │ │ │
│ ←────────────────────────────────────────────────────────────────── │
│ │ │ │
│ │ 6. 返回公钥+凭证ID │ │
│ │ ←───────────────────────────────────────── │
│ │ │ │
│ │ 7. 发送凭证数据 │ │
│ │ ──────────────────────→│ │
│ │ │ │
│ │ 8. 验证并存储公钥 │ │
│ │ 返回成功 │ │
│ │ ←──────────────────────│ │
│ 9. 注册成功 │ │ │
│ ←───────────────────── │ │ │

认证流程

用户                    浏览器                    服务器                认证器
│ │ │ │
│ 1. 点击登录 │ │ │
│ ─────────────────────→ │ │ │
│ │ 2. 请求认证选项 │ │
│ │ ──────────────────────→│ │
│ │ │ │
│ │ 3. 返回 Challenge │ │
│ │ ←──────────────────────│ │
│ │ │ │
│ │ 4. 调用 get() │ │
│ │ ──────────────────────────────────────────→ │
│ 5. 用户验证 │ │ │
│ (指纹/面容/PIN) │ │ │
│ ←────────────────────────────────────────────────────────────────── │
│ │ │ │
│ │ 6. 返回签名断言 │ │
│ │ ←───────────────────────────────────────── │
│ │ │ │
│ │ 7. 发送签名 │ │
│ │ ──────────────────────→│ │
│ │ │ │
│ │ 8. 用公钥验证签名 │ │
│ │ 返回成功 │ │
│ │ ←──────────────────────│ │
│ 9. 登录成功 │ │ │
│ ←───────────────────── │ │ │

核心安全特性

防钓鱼攻击:私钥绑定了网站的源(Origin),攻击者创建的伪造网站无法获得有效签名。

防重放攻击:每次认证都使用服务器生成的随机挑战值(Challenge),签名包含该挑战值。

数据泄露影响有限:服务器只存储公钥,泄露后攻击者无法伪造认证。

代码实现

服务端:注册选项生成

// Node.js - 生成注册选项
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';

// 生成注册选项
export async function getRegistrationOptions(req, res) {
// 从数据库获取用户已注册的凭证ID
const userAuthenticators = await getAuthenticators(req.user.id);

const options = await generateRegistrationOptions({
rpName: 'My Application',
rpID: 'example.com',
userID: req.user.id,
userName: req.user.email,
userDisplayName: req.user.name,
// 排除已注册的认证器
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
})),
// 认证器类型
authenticatorSelection: {
authenticatorAttachment: 'platform', // 'platform' 或 'cross-platform'
userVerification: 'preferred',
},
// 支持的算法
supportedAlgorithmIDs: [-7, -257], // ES256, RS256
});

// 保存 challenge 用于后续验证
req.session.currentChallenge = options.challenge;

res.json(options);
}

服务端:注册响应验证

// 验证注册响应
export async function verifyRegistration(req, res) {
const { credential } = req.body;
const currentChallenge = req.session.currentChallenge;

try {
const verification = await verifyRegistrationResponse({
credential,
expectedChallenge: currentChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});

if (verification.verified) {
const { registrationInfo } = verification;

// 保存凭证信息到数据库
await saveAuthenticator({
userID: req.user.id,
credentialID: registrationInfo.credentialID,
publicKey: registrationInfo.credentialPublicKey,
counter: registrationInfo.counter,
transports: credential.response.transports,
});

res.json({ verified: true });
} else {
res.status(400).json({ verified: false });
}
} catch (error) {
console.error('Registration verification failed:', error);
res.status(400).json({ error: error.message });
}
}

服务端:认证选项生成

import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';

// 生成认证选项
export async function getAuthenticationOptions(req, res) {
const userAuthenticators = await getAuthenticators(req.user.id);

const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports,
})),
});

req.session.currentChallenge = options.challenge;
res.json(options);
}

服务端:认证响应验证

// 验证认证响应
export async function verifyAuthentication(req, res) {
const { credential } = req.body;
const currentChallenge = req.session.currentChallenge;

// 从数据库获取用户凭证
const authenticator = await getAuthenticator(credential.id);

if (!authenticator) {
return res.status(400).json({ error: 'Credential not found' });
}

try {
const verification = await verifyAuthenticationResponse({
credential,
expectedChallenge: currentChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
authenticator: {
credentialID: authenticator.credentialID,
credentialPublicKey: authenticator.publicKey,
counter: authenticator.counter,
},
});

if (verification.verified) {
// 更新计数器(用于检测认证器克隆)
await updateAuthenticatorCounter(
authenticator.id,
verification.authenticationInfo.newCounter
);

// 设置登录状态
req.session.userId = req.user.id;

res.json({ verified: true });
} else {
res.status(400).json({ verified: false });
}
} catch (error) {
console.error('Authentication verification failed:', error);
res.status(400).json({ error: error.message });
}
}

前端:注册流程

// 前端注册
async function registerPasskey() {
// 1. 从服务器获取注册选项
const optionsResponse = await fetch('/api/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await optionsResponse.json();

// 2. 将 Base64 字符串转换为 ArrayBuffer
options.challenge = base64ToArrayBuffer(options.challenge);
options.user.id = base64ToArrayBuffer(options.user.id);

// 3. 调用 WebAuthn API 创建凭证
let credential;
try {
credential = await navigator.credentials.create({
publicKey: options,
});
} catch (error) {
console.error('Failed to create credential:', error);
throw error;
}

// 4. 将凭证数据发送到服务器
const credentialForServer = {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
attestationObject: arrayBufferToBase64(credential.response.attestationObject),
},
};

// 5. 验证注册
const verificationResponse = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credentialForServer }),
});

const result = await verificationResponse.json();
return result.verified;
}

// 工具函数:Base64 转换
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

前端:认证流程

// 前端认证
async function authenticateWithPasskey() {
// 1. 从服务器获取认证选项
const optionsResponse = await fetch('/api/auth/authenticate/options');
const options = await optionsResponse.json();

// 2. 转换数据格式
options.challenge = base64ToArrayBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64ToArrayBuffer(cred.id),
}));
}

// 3. 调用 WebAuthn API 获取断言
let assertion;
try {
assertion = await navigator.credentials.get({
publicKey: options,
});
} catch (error) {
console.error('Failed to get assertion:', error);
throw error;
}

// 4. 发送断言到服务器验证
const assertionForServer = {
id: assertion.id,
rawId: arrayBufferToBase64(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: arrayBufferToBase64(assertion.response.clientDataJSON),
authenticatorData: arrayBufferToBase64(assertion.response.authenticatorData),
signature: arrayBufferToBase64(assertion.response.signature),
userHandle: arrayBufferToBase64(assertion.response.userHandle),
},
};

// 5. 验证认证
const verificationResponse = await fetch('/api/auth/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: assertionForServer }),
});

return await verificationResponse.json();
}

条件 UI:自动填充登录

WebAuthn 支持"条件 UI"模式,可以在用户名输入框中自动提示使用 Passkeys 登录:

<!-- 登录表单 -->
<form id="login-form">
<input
type="text"
name="username"
autocomplete="username webauthn"
placeholder="用户名或邮箱"
>
<button type="submit">登录</button>
</form>

<script>
// 条件 UI 认证
async function conditionalAuthentication() {
const options = await getAuthenticationOptions();
options.challenge = base64ToArrayBuffer(options.challenge);

// 启用条件 UI
options.mediation = 'conditional';

try {
const assertion = await navigator.credentials.get({
publicKey: options,
mediation: 'conditional',
});

// 发送断言验证
await verifyAuthentication(assertion);
} catch (error) {
// 用户取消了操作或没有可用的 Passkey
console.log('Conditional UI cancelled or unavailable');
}
}

// 页面加载时启动条件 UI
document.addEventListener('DOMContentLoaded', conditionalAuthentication);
</script>

数据库设计

用户凭证表

CREATE TABLE authenticators (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),

-- 凭证信息
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,

-- 认证器信息
aaguid UUID,
transports TEXT[],

-- 元数据
device_type TEXT,
backed_up BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,

CONSTRAINT valid_counter CHECK (counter >= 0)
);

CREATE INDEX idx_authenticators_user ON authenticators(user_id);
CREATE INDEX idx_authenticators_credential ON authenticators(credential_id);

安全最佳实践

1. Challenge 生成

Challenge 必须使用加密安全的随机数生成器:

const crypto = require('crypto');

function generateChallenge() {
// 至少 16 字节(推荐 32 字节)
return crypto.randomBytes(32);
}

2. Origin 验证

严格验证请求来源:

const expectedOrigins = [
'https://example.com',
'https://www.example.com',
'android:apk-key-hash:xxx', // Android 应用
'ios:bundle-id:com.example.app', // iOS 应用
];

3. 计数器验证

使用计数器检测认证器克隆:

if (authenticationInfo.newCounter <= storedCounter) {
// 计数器异常,可能存在克隆攻击
// 记录安全事件,可能需要撤销该凭证
logSecurityEvent('authenticator_counter_anomaly', {
credentialID,
expected: storedCounter + 1,
actual: authenticationInfo.newCounter,
});
}

4. 备份状态

检查凭证的备份状态(如果支持):

// 检查凭证是否可备份(同步到云端)
const { backupEligible, backupState } = registrationInfo;

if (backupEligible && backupState) {
// 凭证已备份到云端,可以跨设备使用
console.log('Passkey is synced via cloud');
}

常见问题与解决方案

问题 1:用户更换设备

当用户更换设备时,原有的 Passkey 可能无法使用。

解决方案

  • 鼓励用户注册多个认证器
  • 提供账号恢复机制(如恢复码)
  • 支持云端同步的 Passkeys(Apple iCloud、Google Password Manager)

问题 2:域名变更

Passkeys 绑定到特定域名,域名变更后原凭证失效。

解决方案

  • 使用 appId 扩展支持旧域名
  • 提前通知用户重新注册 Passkeys
  • 保留其他认证方式作为备用

问题 3:无密码账户恢复

如果用户丢失所有认证设备,如何恢复账户?

解决方案

  • 设置恢复码(类似 2FA 备用码)
  • 通过受信任的邮箱或手机验证
  • 管理员协助恢复(企业场景)

WebAuthn Level 3 新特性

WebAuthn Level 3(2025年1月发布)在之前版本基础上引入了多项重要改进,主要是为了更好地支持同步 Passkeys 和增强用户体验。

同步 Passkeys(Synced Passkeys)

WebAuthn Level 3 正式定义了"多设备凭证"(Multi-Device Credentials),即同步 Passkeys。这类凭证可以通过云服务在用户的多台设备间同步:

特点

  • 凭证通过端到端加密同步到云端(如 Apple iCloud Keychain、Google Password Manager)
  • 用户在新设备上登录同一账户后,Passkeys 会自动同步
  • 即使设备丢失,Passkeys 仍可通过云端恢复

认证器类型区分

类型说明典型场景
设备绑定凭证只存储在单个设备企业安全要求高的场景
同步凭证云端同步,多设备可用消费者应用,便捷性优先

在注册时,可以通过 credProps 扩展检查凭证是否支持同步:

const options = {
// ... 其他选项
extensions: {
credProps: true, // 请求凭证属性
}
};

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

// 检查返回的凭证属性
const clientExtensionResults = credential.getClientExtensionResults();
if (clientExtensionResults.credProps?.rk) {
console.log('这是驻留凭证(可同步)');
}

混合认证器(Hybrid Authenticators)

WebAuthn Level 3 增强了混合认证器支持,允许手机作为电脑的认证器:

工作原理

  1. 电脑上的网站发起认证请求
  2. 浏览器检测到没有本地 Passkey,提示使用手机
  3. 用户扫描二维码或点击通知
  4. 手机完成生物识别验证
  5. 认证结果返回给电脑

这种模式结合了平台认证器的便捷性和漫游认证器的便携性。

客户端能力探测

Level 3 提供了新的 API 来探测客户端支持的认证器能力:

// 检查是否支持用户验证的平台认证器
const uvpaAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

// 检查是否支持条件 UI(自动填充 Passkeys)
const conditionUIAvailable = PublicKeyCredential.isConditionalMediationAvailable?.();

// 检查支持的认证器传输方式
const options = await generateRegistrationOptions();
// transports 可能包含: 'internal', 'hybrid', 'usb', 'nfc', 'ble'

PIN 更改和恢复

WebAuthn Level 3 改进了认证器的 PIN 管理:

  • 支持 PIN 更改(用户可在认证器上修改 PIN)
  • 支持 PIN 重置(清除所有凭证后重新设置)
  • 更好的 PIN 策略配置

向后兼容性

WebAuthn Level 3 完全向后兼容 Level 2 和 Level 1:

  • 现有的注册凭证继续有效
  • API 调用方式不变
  • 新特性为可选增强

浏览器兼容性

功能ChromeFirefoxSafariEdge
WebAuthn 基础支持✓ 67+✓ 60+✓ 13+✓ 18+
平台认证器
条件 UI✓ 108+✓ 16+✓ 108+
混合认证
同步 Passkeys✓ 108+✓ 16+✓ 108+

参考资料

下一步