WebAuthn(Web Authentication)是 W3C 推出的网页认证标准,它允许网页通过浏览器调用平台认证器(如指纹识别器、面容识别、Windows Hello)或外部安全密钥,完成基于公私钥的身份验证。相比传统的账号密码体系,WebAuthn 的私钥永远不出设备、每个站点绑定独立的密钥对、天然抵抗钓鱼攻击,安全性提升了一大截。本文将以 Vue 3 项目为例,讲解如何在工程化场景下封装并使用这套 API。

WebAuthn 的核心概念与工作流程
在动手写代码之前,先理清几个关键概念。WebAuthn 中有三个角色:认证器(Authenticator,即设备上的安全模块)、依赖方(Relying Party,即你的网站服务端)和浏览器(作为中间桥梁)。整个体系依赖非对称加密:注册时认证器生成一对密钥,私钥留在设备里,公钥发给服务端保存;登录时服务端下发一个随机挑战值,认证器用私钥对其签名,服务端再用之前保存的公钥验签。
流程上分为两个阶段。第一个阶段是注册(navigator.credentials.create),用户完成生物识别后,浏览器返回一个 PublicKeyCredential 对象,其中包含公钥、凭据 ID 等信息,前端把它转成可传输格式发给后端存储。第二个阶段是认证(navigator.credentials.get),后端生成新的挑战值,认证器对挑战值签名后返回断言结果,后端验签通过即完成登录。整个过程没有密码参与,也就不存在密码泄露的问题。
有两个容易踩坑的点需要注意:一是挑战值必须是服务端随机生成并且一次性有效,绝不能由前端生成,否则整个安全模型就失效了;二是域名要求,WebAuthn 只能在安全上下文(HTTPS 或 localhost)下工作,本地开发时建议直接使用 localhost 访问,不要用局域网 IP。
在 Vue 3 中封装可复用的认证模块
直接调用原生 API 的代码比较冗长,尤其是 ArrayBuffer 与 Base64URL 之间的转换逻辑非常繁琐。推荐的做法是用 Vue 3 的 Composition API 把这些细节封装成 useWebAuthn 组合式函数,供登录页、注册页等多个组件复用。
先看转换工具函数的实现,这是整个模块的基础:
// ArrayBuffer 转 Base64URL 字符串
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
let str = ''
bytes.forEach(b => (str += String.fromCharCode(b)))
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// Base64URL 字符串转 Uint8Array
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const pad = '='.repeat((4 - (base64.length % 4)) % 4)
const raw = atob(base64 + pad)
return Uint8Array.from(raw, c => c.charCodeAt(0))
}接下来封装注册逻辑。注册前需要先向后端请求生成挑战值,拿到后再构造 PublicKeyCredentialCreationOptions 调用浏览器 API:
import { ref } from 'vue'
export function useWebAuthn() {
const loading = ref(false)
const error = ref(null)
// 注册新的认证器凭据
async function registerCredential(username) {
loading.value = true
error.value = null
try {
// 第一步:向后端请求注册参数(包含挑战值和用户 ID)
const options = await fetch('/api/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
}).then(res => res.json())
// 第二步:构造浏览器 API 需要的参数
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
user: {
...options.user,
id: base64urlToBuffer(options.user.id)
},
excludeCredentials: options.excludeCredentials.map(item => ({
...item,
id: base64urlToBuffer(item.id)
}))
}
// 第三步:调用认证器完成注册
const credential = await navigator.credentials.create({ publicKey })
// 第四步:把结果转成 Base64URL 发回后端存储
const payload = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
}
await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
return true
} catch (e) {
error.value = e.message
return false
} finally {
loading.value = false
}
}
return { loading, error, registerCredential }
}这个封装有几个工程上的考量。首先,loading 和 error 用 ref 管理,组件里可以直接绑定到按钮的禁用状态和错误提示,无需额外状态管理。其次,所有编码转换都收敛在模块内部,调用方完全不接触 ArrayBuffer 这种底层类型。最后,函数内部严格按照“请求选项、调用认证器、回传结果”三步走,与后端的接口契约清晰,方便联调排查问题。
登录认证与异常降级处理
登录阶段的封装思路类似,调用的是 navigator.credentials.get。区别在于这次传的是 allowCredentials 列表(可选,用于指定用哪个凭据登录),返回的是签名断言:
// 使用已有凭据完成登录
async function authenticateCredential() {
loading.value = true
try {
const options = await fetch('/api/webauthn/login/options')
.then(res => res.json())
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map(item => ({
...item,
id: base64urlToBuffer(item.id)
}))
}
const assertion = await navigator.credentials.get({ publicKey })
const payload = {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
}
const result = await fetch('/api/webauthn/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => res.json())
return result.success
} finally {
loading.value = false
}
}异常处理是工程化中容易被忽视的部分。浏览器层面可能抛出的典型错误包括:NotAllowedError(用户取消或超时)、InvalidStateError(该认证器已注册过,通常被 excludeCredentials 拦截)、SecurityError(域名不在 RP ID 范围内)。建议针对不同错误给出中文提示,而不是直接把英文异常信息抛给用户。
兼容性方面,主流浏览器的现代版本都已支持 WebAuthn,但仍建议做能力检测降级。可以在应用启动时判断 window.PublicKeyCredential 是否存在,不支持时回退到传统密码登录或短信验证码。示例:
const webauthnAvailable = 'PublicKeyCredential' in window
// 在登录页根据能力切换认证方式
if (webauthnAvailable) {
showWebAuthnLoginButton.value = true
} else {
// 降级为密码表单
showPasswordForm.value = true
}此外,移动端 Safari 和 Chrome 对平台认证器的调用体验略有差异,iPhone 需要 iOS 16 以上才支持 Touch ID 登录网页,Android 则依赖 Google 密码管理器。如果业务面向的用户群较广,最好把“安全密钥”作为备选方案,通过 USB 或 NFC 连接的外部密钥在所有桌面浏览器上表现最为一致。
最后一个实践建议:凭据与用户的绑定关系要支持“多设备”场景。用户换手机后需要能在新设备上注册新凭据,同时注销丢失设备上的旧凭据,这就要求后端提供凭据列表管理接口,前端在账户安全设置页中渲染这些凭据并允许用户删除。把这一块做好,无密码登录的用户体验才真正完整。