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 注册流程(创建凭证)

  1. 用户选择注册方式
  2. 服务器生成挑战(challenge)并发送给客户端
  3. 客户端调用 navigator.credentials.create() 发起注册
  4. 认证器创建新的密钥对
  5. 客户端将公钥和其他信息发送给服务器
  6. 服务器验证并存储公钥

2.2 认证流程(使用凭证登录)

  1. 用户选择登录方式
  2. 服务器生成挑战并发送给客户端
  3. 客户端调用 navigator.credentials.get() 发起认证
  4. 认证器使用私钥签名挑战
  5. 客户端将签名和其他信息发送给服务器
  6. 服务器使用存储的公钥验证签名
  7. 验证成功后,用户登录

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. 相关库和工具

4. 视频教程

实践练习

1. 基础练习:实现简单的 WebAuthn 注册和登录

  • 创建 Vue 3 应用,实现基本的注册和登录功能
  • 使用模拟的后端服务生成挑战和验证凭证
  • 测试平台认证器(如指纹识别)

2. 进阶练习:完整的 WebAuthn 认证系统

  • 实现完整的后端服务,包括挑战生成、凭证验证和存储
  • 支持多种认证器类型
  • 实现凭证管理功能(添加、删除凭证)

3. 高级练习:创建 WebAuthn Composable

  • 封装可复用的 useWebAuthn Composable
  • 支持注册和登录流程
  • 添加错误处理和状态管理
  • 支持 TypeScript 类型

4. 综合练习:多因素认证系统

  • 结合 WebAuthn 和传统认证方式
  • 实现双因素认证
  • 提供灵活的认证策略配置

5. 挑战练习:企业级认证系统

  • 实现 SSO(单点登录)集成
  • 支持团队和角色管理
  • 实现审计日志和报告功能
  • 支持多种部署环境(开发、测试、生产)

总结

Web Authentication API 为 Vue 3 应用提供了强大的无密码认证能力,可以显著提升应用的安全性和用户体验。通过合理设计认证流程、优化用户体验、实施安全最佳实践,可以构建一个既安全又便捷的认证系统。掌握 Web Authentication API 的实现原理和最佳实践,对于构建现代化的 Vue 3 应用至关重要。

« 上一篇 Vue 3与Web Push API - 实现服务器推送通知的核心技术 下一篇 » Vue 3与Web Crypto API - 实现前端加密安全的核心技术