Vue 3 与 Web Authentication API
概述
Web Authentication API(也称为 WebAuthn)是一项用于安全身份验证的 Web 标准,允许用户使用生物识别(如指纹、面部识别)、安全密钥或其他设备进行无密码登录。在 Vue 3 应用中集成 Web Authentication API 可以提供更安全、更便捷的用户认证体验,减少密码相关的安全风险。
核心知识
1. Web Authentication API 基本概念
- 作用:提供安全的无密码身份验证机制
- 支持的认证方式:
- 平台认证器(Platform Authenticator):设备内置的认证机制,如指纹识别、面部识别
- 跨平台认证器(Roaming Authenticator):外部设备,如 USB 安全密钥、蓝牙设备
- 关键术语:
- **Relying Party (RP)**:依赖认证的网站或应用
- Authenticator:执行认证的设备或服务
- Credential:认证凭证,包含公钥和其他元数据
- Challenge:服务器生成的随机值,用于防止重放攻击
2. Web Authentication API 工作流程
2.1 注册流程(创建凭证)
- 用户选择注册方式
- 服务器生成挑战(challenge)并发送给客户端
- 客户端调用
navigator.credentials.create()发起注册 - 认证器创建新的密钥对
- 客户端将公钥和其他信息发送给服务器
- 服务器验证并存储公钥
2.2 认证流程(使用凭证登录)
- 用户选择登录方式
- 服务器生成挑战并发送给客户端
- 客户端调用
navigator.credentials.get()发起认证 - 认证器使用私钥签名挑战
- 客户端将签名和其他信息发送给服务器
- 服务器使用存储的公钥验证签名
- 验证成功后,用户登录
3. 前端实现(Vue 3)
3.1 注册功能实现
<template>
<div>
<h2>注册新用户</h2>
<input v-model="username" placeholder="用户名" />
<button @click="register" :disabled="!isSupported">
使用生物识别注册
</button>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const username = ref('');
const error = ref('');
const success = ref('');
const isSupported = ref(false);
onMounted(() => {
// 检查浏览器支持情况
isSupported.value = !!(navigator.credentials && navigator.credentials.create);
});
const register = async () => {
if (!username.value.trim()) {
error.value = '请输入用户名';
return;
}
try {
error.value = '';
success.value = '';
// 1. 从服务器获取挑战
const challengeResponse = await fetch('/api/auth/register/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: username.value })
});
const challengeData = await challengeResponse.json();
const challenge = Uint8Array.from(atob(challengeData.challenge), c => c.charCodeAt(0));
// 2. 调用 WebAuthn API 创建凭证
const credential = await navigator.credentials.create({
publicKey: {
rp: {
name: 'Vue 3 WebAuthn Demo',
id: window.location.hostname
},
user: {
id: Uint8Array.from(challengeData.userId, c => c.charCodeAt(0)),
name: username.value,
displayName: username.value
},
challenge: challenge,
pubKeyCredParams: [{
type: 'public-key',
alg: -7 // ES256 算法
}],
authenticatorSelection: {
authenticatorAttachment: 'platform', // 优先使用平台认证器
userVerification: 'required' // 需要用户验证
},
timeout: 60000,
attestation: 'direct'
}
});
// 3. 处理凭证并发送给服务器
const publicKeyCredential = credential;
const attestationObject = new Uint8Array(publicKeyCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(publicKeyCredential.response.clientDataJSON);
const registrationData = {
id: publicKeyCredential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(publicKeyCredential.rawId))),
type: publicKeyCredential.type,
response: {
attestationObject: btoa(String.fromCharCode(...attestationObject)),
clientDataJSON: btoa(String.fromCharCode(...clientDataJSON))
}
};
// 4. 发送注册数据到服务器
const registerResponse = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registrationData)
});
if (registerResponse.ok) {
success.value = '注册成功!';
username.value = '';
} else {
const errorData = await registerResponse.json();
error.value = errorData.message || '注册失败';
}
} catch (err) {
error.value = `注册失败: ${err.message}`;
console.error('注册错误:', err);
}
};
</script>3.2 登录功能实现
<template>
<div>
<h2>登录</h2>
<button @click="login" :disabled="!isSupported">
使用生物识别登录
</button>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const error = ref('');
const success = ref('');
const isSupported = ref(false);
onMounted(() => {
isSupported.value = !!(navigator.credentials && navigator.credentials.get);
});
const login = async () => {
try {
error.value = '';
success.value = '';
// 1. 从服务器获取挑战
const challengeResponse = await fetch('/api/auth/login/challenge', {
method: 'POST'
});
const challengeData = await challengeResponse.json();
const challenge = Uint8Array.from(atob(challengeData.challenge), c => c.charCodeAt(0));
// 2. 调用 WebAuthn API 获取凭证
const credential = await navigator.credentials.get({
publicKey: {
challenge: challenge,
rpId: window.location.hostname,
userVerification: 'required',
timeout: 60000,
allowCredentials: challengeData.allowCredentials?.map(cred => ({
id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0)),
type: 'public-key',
transports: cred.transports
}))
}
});
// 3. 处理凭证并发送给服务器
const publicKeyCredential = credential;
const authenticatorData = new Uint8Array(publicKeyCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(publicKeyCredential.response.clientDataJSON);
const signature = new Uint8Array(publicKeyCredential.response.signature);
const userHandle = publicKeyCredential.response.userHandle ?
new Uint8Array(publicKeyCredential.response.userHandle) : null;
const authenticationData = {
id: publicKeyCredential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(publicKeyCredential.rawId))),
type: publicKeyCredential.type,
response: {
authenticatorData: btoa(String.fromCharCode(...authenticatorData)),
clientDataJSON: btoa(String.fromCharCode(...clientDataJSON)),
signature: btoa(String.fromCharCode(...signature)),
userHandle: userHandle ? btoa(String.fromCharCode(...userHandle)) : null
}
};
// 4. 发送认证数据到服务器
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(authenticationData)
});
if (loginResponse.ok) {
const loginData = await loginResponse.json();
success.value = `登录成功!欢迎回来,${loginData.username}`;
// 可以在这里存储认证令牌
localStorage.setItem('authToken', loginData.token);
} else {
const errorData = await loginResponse.json();
error.value = errorData.message || '登录失败';
}
} catch (err) {
error.value = `登录失败: ${err.message}`;
console.error('登录错误:', err);
}
};
</script>4. 创建可复用的 WebAuthn Composable
// composables/useWebAuthn.js
import { ref, onMounted } from 'vue';
export function useWebAuthn() {
const isSupported = ref(false);
const isCreatingCredential = ref(false);
const isGettingCredential = ref(false);
onMounted(() => {
isSupported.value = !!(navigator.credentials &&
navigator.credentials.create &&
navigator.credentials.get);
});
// 将 Base64 URL 编码转换为 Uint8Array
const base64UrlToUint8Array = (base64Url) => {
const padding = '='.repeat((4 - base64Url.length % 4) % 4);
const base64 = (base64Url + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
// 将 Uint8Array 转换为 Base64 URL 编码
const uint8ArrayToBase64Url = (arrayBuffer) => {
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = window.btoa(binary);
return base64
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
};
// 创建凭证(注册)
const createCredential = async (options) => {
if (!isSupported.value) {
throw new Error('Web Authentication API 不被支持');
}
isCreatingCredential.value = true;
try {
const credential = await navigator.credentials.create({
publicKey: options
});
return credential;
} finally {
isCreatingCredential.value = false;
}
};
// 获取凭证(登录)
const getCredential = async (options) => {
if (!isSupported.value) {
throw new Error('Web Authentication API 不被支持');
}
isGettingCredential.value = true;
try {
const credential = await navigator.credentials.get({
publicKey: options
});
return credential;
} finally {
isGettingCredential.value = false;
}
};
// 解析创建凭证的响应
const parseCreateCredentialResponse = (credential) => {
const publicKeyCredential = credential;
const attestationObject = new Uint8Array(publicKeyCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(publicKeyCredential.response.clientDataJSON);
return {
id: publicKeyCredential.id,
rawId: uint8ArrayToBase64Url(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
attestationObject: uint8ArrayToBase64Url(attestationObject),
clientDataJSON: uint8ArrayToBase64Url(clientDataJSON)
}
};
};
// 解析获取凭证的响应
const parseGetCredentialResponse = (credential) => {
const publicKeyCredential = credential;
const authenticatorData = new Uint8Array(publicKeyCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(publicKeyCredential.response.clientDataJSON);
const signature = new Uint8Array(publicKeyCredential.response.signature);
const userHandle = publicKeyCredential.response.userHandle ?
new Uint8Array(publicKeyCredential.response.userHandle) : null;
return {
id: publicKeyCredential.id,
rawId: uint8ArrayToBase64Url(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
authenticatorData: uint8ArrayToBase64Url(authenticatorData),
clientDataJSON: uint8ArrayToBase64Url(clientDataJSON),
signature: uint8ArrayToBase64Url(signature),
userHandle: userHandle ? uint8ArrayToBase64Url(userHandle) : null
}
};
};
return {
isSupported,
isCreatingCredential,
isGettingCredential,
createCredential,
getCredential,
parseCreateCredentialResponse,
parseGetCredentialResponse,
base64UrlToUint8Array,
uint8ArrayToBase64Url
};
}5. 后端实现(简化版)
// 后端需要实现的核心功能
// 1. 生成挑战
const generateChallenge = () => {
const challenge = crypto.randomBytes(32);
return challenge.toString('base64url');
};
// 2. 验证注册请求
const verifyRegistration = (registrationData) => {
// 验证 attestationObject
// 验证 clientDataJSON
// 验证挑战是否匹配
// 存储公钥和其他元数据
};
// 3. 验证认证请求
const verifyAuthentication = (authenticationData) => {
// 获取存储的公钥
// 验证 authenticatorData
// 验证 clientDataJSON
// 使用公钥验证签名
// 验证挑战是否匹配
};最佳实践
1. 安全考虑
- 始终使用 HTTPS:Web Authentication API 只能在安全上下文(HTTPS)中使用
- 验证挑战:确保服务器生成的挑战是随机的,并在验证时检查是否匹配
- 安全存储公钥:服务器应安全存储用户的公钥,避免泄露
- 使用适当的算法:推荐使用 ES256 或 RS256 等强加密算法
- 实施用户验证:根据安全需求设置适当的
userVerification级别
2. 用户体验优化
- 提供清晰的指导:向用户解释如何使用生物识别或安全密钥
- 处理错误情况:为不同的错误提供友好的错误信息
- 提供备用登录方式:为不支持 WebAuthn 的浏览器或设备提供传统登录方式
- 进度反馈:在认证过程中提供清晰的进度指示
- 记住用户选择:记住用户偏好的认证方式
3. 跨浏览器兼容性
- 检查浏览器支持:在使用前检查 Web Authentication API 是否可用
- 处理不同认证器类型:支持平台认证器和跨平台认证器
- 测试多种设备:在不同设备和浏览器上测试认证功能
- 提供降级方案:为不支持的浏览器提供传统的用户名/密码登录
4. 服务器端最佳实践
- 安全生成挑战:使用加密安全的随机数生成器
- 实施速率限制:防止暴力攻击
- 定期清理旧凭证:移除长时间未使用的凭证
- 监控认证事件:记录和分析认证成功和失败事件
- 支持凭证管理:允许用户添加、删除和管理认证凭证
常见问题与解决方案
1. 浏览器不支持 Web Authentication API
- 原因:用户使用的浏览器版本过旧或不支持该 API
- 解决方案:提供传统的用户名/密码登录作为备用方案
2. 注册失败,报错 "NotAllowedError"
- 原因:
- 用户取消了认证
- 认证器不可用
- 安全策略限制
- 解决方案:提供清晰的错误信息,引导用户重试或使用其他认证方式
3. 认证失败,报错 "NotAllowedError"
- 原因:
- 没有找到匹配的凭证
- 用户取消了认证
- 解决方案:检查
allowCredentials参数,确保包含正确的凭证 ID
4. 认证失败,签名验证失败
- 原因:
- 公钥不匹配
- 挑战不正确
- 签名算法不匹配
- 解决方案:检查服务器端的公钥存储和验证逻辑
5. 无法使用跨平台认证器
- 原因:浏览器不支持该类型的认证器或传输方式
- 解决方案:在
authenticatorSelection中正确配置transports属性
高级学习资源
1. 官方文档
2. 深度教程
3. 相关库和工具
- simplewebauthn:简化 WebAuthn 实现的库
- webauthn-json:简化 WebAuthn API 调用的库
- YubiKey:流行的安全密钥供应商
4. 视频教程
实践练习
1. 基础练习:实现简单的 WebAuthn 注册和登录
- 创建 Vue 3 应用,实现基本的注册和登录功能
- 使用模拟的后端服务生成挑战和验证凭证
- 测试平台认证器(如指纹识别)
2. 进阶练习:完整的 WebAuthn 认证系统
- 实现完整的后端服务,包括挑战生成、凭证验证和存储
- 支持多种认证器类型
- 实现凭证管理功能(添加、删除凭证)
3. 高级练习:创建 WebAuthn Composable
- 封装可复用的
useWebAuthnComposable - 支持注册和登录流程
- 添加错误处理和状态管理
- 支持 TypeScript 类型
4. 综合练习:多因素认证系统
- 结合 WebAuthn 和传统认证方式
- 实现双因素认证
- 提供灵活的认证策略配置
5. 挑战练习:企业级认证系统
- 实现 SSO(单点登录)集成
- 支持团队和角色管理
- 实现审计日志和报告功能
- 支持多种部署环境(开发、测试、生产)
总结
Web Authentication API 为 Vue 3 应用提供了强大的无密码认证能力,可以显著提升应用的安全性和用户体验。通过合理设计认证流程、优化用户体验、实施安全最佳实践,可以构建一个既安全又便捷的认证系统。掌握 Web Authentication API 的实现原理和最佳实践,对于构建现代化的 Vue 3 应用至关重要。