Passkeys 与 WebAuthn:无密码认证的未来
Passkeys 是基于 WebAuthn 标准的新一代认证方式,旨在取代传统密码。它使用公钥密码学实现更安全、更便捷的身份验证,是目前最有希望终结密码时代的方案。
为什么需要 Passkeys?
传统密码认证存在诸多问题:
安全层面:
- 用户倾向于设置简单密码或重复使用密码
- 密码可能被钓鱼网站窃取
- 数据库泄露后密码可能被破解
- 多因素认证增加了复杂性但并未完全消除风险
用户体验层面:
- 需要记忆多个密码
- 密码重置流程繁琐
- 在不同设备间切换困难
Passkeys 通过公钥密码学从根本上解决了这些问题。
核心概念:理解 WebAuthn
什么是 WebAuthn?
WebAuthn(Web Authentication API)是 W3C 制定的 Web 认证标准,定义了使用公钥凭证进行强认证的 API。它支持两种使用场景:
- 无密码认证:完全替代密码,使用设备绑定的凭证登录
- 双因素认证:作为密码的第二因素
关键角色
在 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 增强了混合认证器支持,允许手机作为电脑的认证器:
工作原理:
- 电脑上的网站发起认证请求
- 浏览器检测到没有本地 Passkey,提示使用手机
- 用户扫描二维码或点击通知
- 手机完成生物识别验证
- 认证结果返回给电脑
这种模式结合了平台认证器的便捷性和漫游认证器的便携性。
客户端能力探测
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 调用方式不变
- 新特性为可选增强
浏览器兼容性
| 功能 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| WebAuthn 基础支持 | ✓ 67+ | ✓ 60+ | ✓ 13+ | ✓ 18+ |
| 平台认证器 | ✓ | ✓ | ✓ | ✓ |
| 条件 UI | ✓ 108+ | ✗ | ✓ 16+ | ✓ 108+ |
| 混合认证 | ✓ | ✓ | ✓ | ✓ |
| 同步 Passkeys | ✓ 108+ | ✓ | ✓ 16+ | ✓ 108+ |
参考资料
- W3C Web Authentication Level 3 Specification
- MDN Web Authentication API
- Google Passkeys 开发者指南
- Apple Passkeys 文档
- simplewebauthn 库文档
- FIDO Alliance Passkeys Overview
下一步
- 多因素认证 (MFA) - 了解如何实现 TOTP 和短信验证
- OAuth 2.1 - 学习第三方登录和授权