生物识别认证在移动端原生应用中早已是标配能力,Vue 3 项目想提供同样的体验,靠的不是某个闭源 SDK,而是浏览器原生的 WebAuthn 协议。它允许页面调用设备上的指纹传感器、面容识别模块或系统 PIN 码,配合公钥加密体系完成无密码登录。整套方案落地的关键在于把协议细节封装成可复用的模块,让业务组件只关心状态与结果,这也是本文要展开的工程化思路。

WebAuthn 的安全模型:为什么它比密码更可靠
WebAuthn 是 W3C 制定的网络认证标准,也是 FIDO2 体系的核心组成部分。它的底层逻辑是非对称加密:用户注册时,认证器(手机的安全芯片、系统指纹模块或外接 USB 密钥)生成一对公私钥,私钥永远保存在认证器内部,不会离开设备,只有公钥和凭据 ID 会被上传到服务端。登录时,服务端下发一段随机的挑战值,认证器在用户完成生物识别验证后用私钥对挑战值签名,服务端再用事先保存的公钥验签。
这个模型的安全性体现在三个层面。第一,私钥不出设备,服务端数据库即使被拖库,攻击者拿到的也只是一堆公钥,无法伪造签名。第二,挑战值由服务端随机生成且一次有效,重放攻击与中间人攻击基本失效。第三,凭据与域名强绑定,浏览器会校验当前站点与注册时的 rp ID 一致,钓鱼网站即使页面做得一模一样,也无法请求到用户在真实站点创建的凭据,这从协议层面根治了钓鱼问题。
对前端开发者来说,需要理解的关键点是:生物识别本身只是认证器解锁私钥的手段,真正的认证凭证是那对公私钥。用户指纹比对失败、取消验证弹窗、设备不支持,这些情况都会以异常形式抛出,工程化封装时必须逐一处理,而不是笼统地捕获后吞掉。
组合式 API 封装:useBiometric 的设计与实现
Vue 3 的组合式 API 非常适合封装这类有状态、有异步流程的能力。设计目标有三个:能力检测逻辑集中管理、注册与认证流程对外暴露为简单方法、异常状态响应式暴露给组件层。先看最基础的能力检测,它决定了 UI 上指纹按钮是否渲染:
import { ref, shallowRef } from 'vue'
export function useBiometric() {
const supported = ref(false)
const loading = ref(false)
const error = shallowRef(null)
// 检测环境是否支持平台认证器(指纹、面容等设备内置模块)
async function detect() {
if (!window.PublicKeyCredential) {
supported.value = false
return
}
supported.value =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
}
return { supported, loading, error, detect }
}isUserVerifyingPlatformAuthenticatorAvailable 是标准提供的静态方法,返回布尔值表示设备是否存在可用的平台认证器。注意它检测的是能力而非用户是否已录入指纹,后者浏览器出于隐私考虑不会暴露。这个检测应该放在组件挂载时执行一次,结果缓存到响应式状态里,避免重复调用带来的延迟。
接下来是序列化工具。WebAuthn 的参数里大量使用 ArrayBuffer 类型,而与服务端交互通常走 JSON,两边必须做转换。这是整个封装里最容易踩坑的部分,建议单独抽成工具函数:
// 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 字符串还原为 ArrayBuffer,用于解析服务端下发的参数
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const pad = '='.repeat((4 - (base64.length % 4)) % 4)
const raw = atob(base64 + pad)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i)
}
return bytes.buffer
}base64url 是把标准 base64 中的加号、斜线替换成减号和下划线并去掉填充符,目的是让字符串可以安全出现在 URL 和 JSON 中。如果服务端使用现成的 WebAuthn 库,它下发的参数本身就是 base64url 编码,前端只需按上面的函数解码成 ArrayBuffer 再传给浏览器 API,返回结果再编码回去,整个链路就打通了。
注册与认证:两阶段流程的前后端配合
整个生物识别体系分注册和认证两个阶段。注册阶段用户已通过传统方式登录,目标是把生物识别凭据绑定到账号上;认证阶段则是用已绑定的凭据直接登录。两阶段都是三步走:前端请求参数、浏览器唤起认证器、结果回传服务端校验。先看注册:
async function register(username) {
loading.value = true
try {
// 第一步:向服务端申请注册参数,包含随机挑战值
const options = await fetch(
'/api/webauthn/register/options?username=' + encodeURIComponent(username)
).then(res => res.json())
// 第二步:调用浏览器 API,此时系统弹出指纹或面容验证框
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rp: options.rp,
user: {
...options.user,
id: base64urlToBuffer(options.user.id)
},
pubKeyCredParams: options.pubKeyCredParams,
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required'
},
timeout: 60000
}
})
// 第三步:凭据数据回传服务端登记公钥
const res = await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject)
}
})
})
return res.ok
} catch (e) {
error.value = e
return false
} finally {
loading.value = false
}
}几个参数值得展开。authenticatorAttachment 设为 platform 表示只接受设备内置认证器,也就是指纹和面容,避免浏览器弹出选择外接密钥的窗口;userVerification 设为 required 强制要求本地用户验证,仅凭据在场还不够,必须完成生物识别。timeout 控制验证框的等待时长,超时会抛出 NotAllowedError,前端要捕获并提示用户重试。
认证阶段的代码结构几乎一致,区别在于调用 navigator.credentials.get,且服务端校验通过后通常会下发会话凭证:
async function authenticate() {
loading.value = true
try {
// 服务端生成新的挑战值,可携带 allowCredentials 限定可用凭据
const options = await fetch('/api/webauthn/auth/options').then(res =>
res.json()
)
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
allowCredentials: options.allowCredentials.map(item => ({
type: 'public-key',
id: base64urlToBuffer(item.id)
})),
userVerification: 'required',
timeout: 60000
}
})
const res = await fetch('/api/webauthn/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: bufferToBase64url(assertion.response.userHandle)
}
})
})
return await res.json()
} catch (e) {
error.value = e
return null
} finally {
loading.value = false
}
}服务端校验是安全闭环的另一半:它要验证挑战值确实是自己签发的、签名能被对应公钥解开、origin 与 rp ID 匹配、计数器没有回退(用于防克隆凭据)。这些逻辑不建议手写,Node 服务端可以直接用 @simplewebauthn/server,Java 生态有 java-webauthn-server,Python 有 fido2 库,它们把验签细节都封装好了,前端要做的只是保证数据无损传输。
降级策略与工程化收尾
生物识别再好也不能作为唯一登录方式。桌面端没有指纹模块的电脑、老旧浏览器、隐私模式下的限制,都会导致能力不可用,所以 UI 层的设计原则是:能力检测结果决定指纹入口是否展示,密码登录永远保留。组件层的用法大致如下:
<script setup>
import { onMounted } from 'vue'
import { useBiometric } from '@/composables/useBiometric'
const { supported, loading, error, detect, authenticate } = useBiometric()
onMounted(() => {
detect()
})
async function handleFingerprintLogin() {
const result = await authenticate()
if (result) {
// 处理登录成功后的跳转
}
}
</script>
<template>
<form class="login-form">
<!-- 传统密码表单始终保留 -->
<button v-if="supported" type="button" :disabled="loading"
@click="handleFingerprintLogin">
使用指纹登录
</button>
<p v-if="error" class="error-tip">验证失败,请重试或使用密码登录</p>
</form>
</template>错误处理要区分场景。NotAllowedError 通常意味着用户取消了验证框或等待超时,提示语应引导重试;SecurityError 多半是域名与 rp ID 不匹配,属于配置问题,本地开发时要用 localhost 或配置了 https 的自定义域名,WebAuthn 在非安全上下文中不可用;InvalidStateError 出现在注册环节,表示该账号已绑定过这台设备的凭据,服务端应做幂等处理而不是直接报错。
TypeScript 项目还需要补全类型。DOM 标准类型里 PublicKeyCredential 相关定义已经比较完整,但跨端传输的 JSON 结构需要自己声明接口,把 challenge、user.id 等字段定义为 string 类型,与浏览器 API 的 ArrayBuffer 类型区分开。把这套类型连同序列化函数、组合式函数一起放进项目的 composables 目录,就是一个完整可复用的生物识别模块,后续新项目直接拷贝目录即可接入。
最后提醒一个容易被忽视的点:生物识别凭据绑定的是设备而非账号本身,用户换手机后旧凭据会失效,所以服务端要支持一个账号绑定多个凭据,并在账号设置页提供凭据管理列表,允许用户查看已绑定设备并主动解绑。这些产品层面的配套,和代码封装一样,都是工程化落地不可缺少的组成部分。

