1. 项目概述与核心价值

最近在做一个前后端分离的项目,后端用的是SpringBoot,前端是Vue 3。项目里有个需求,要把一些敏感的用户信息,比如手机号、身份证号,在传输和存储时做加密处理。选型的时候,我们团队内部讨论过,像RSA这种非对称加密,虽然安全,但加解密速度慢,不适合频繁传输的数据体。最后拍板用了AES,也就是高级加密标准,它是一种对称加密算法,速度快,安全性也经过了时间的考验,是目前业界最主流的对称加密方案。

这个“SpringBoot + Vue 实现 AES 加密和 AES 工具类总结”的项目,说白了,就是要把AES这套加解密的逻辑,在后端Java和前端的JavaScript里都完整、一致地实现一遍。这可不是简单调个库就完事了,里面坑不少。比如,前后端用的加密库可能不同,默认的填充模式、工作模式、字符编码如果没对齐,前端加密的字符串后端死活解不开,这种“前后端加密结果不一致”的问题太常见了。所以,这个项目的核心价值,在于提供一个经过实战检验的、前后端加解密结果完全互通的AES工具类解决方案,让你在SpringBoot和Vue的项目里,能安全、便捷地处理敏感数据,避免踩我们踩过的那些坑。

2. AES核心原理与前后端对齐的关键点

在动手写代码之前,我们必须把AES的几个核心概念掰扯清楚,这是保证前后端能“对上暗号”的基础。AES加密不是简单地把密码和原文混在一起,它涉及几个必须一致的参数。

2.1 密钥、工作模式与填充模式

首先说 密钥(Key) 。AES支持128位、192位和256位三种密钥长度。我们项目里选的是AES-256,也就是256位(32字节)的密钥,理论上安全性更高。这里第一个坑就来了:Java的 Cipher 类对密钥长度有严格的政策限制。如果你直接用超过128位的密钥,很可能会抛 java.security.InvalidKeyException: Invalid AES key length 这个异常。解决方法是使用一个无限制强度管辖策略文件(JCE)替换掉JDK默认的,或者更常见的做法是,我们通过一个固定的字符串(密码),利用密钥派生函数(如PBKDF2)来生成一个符合长度的、安全的密钥,而不是直接使用字符串作为密钥。

其次是 工作模式(Mode) 。我们最常用的是CBC(密码分组链接)模式。CBC模式需要一个 初始化向量(IV) 。你可以把IV理解成加密的“盐值”或者“起始扰动值”,它的作用是让同样的明文、同样的密钥,每次加密出来的密文都不一样,大大增强了安全性。这个IV不需要保密,但需要和密文一起传输给解密方。 前后端的IV必须完全一致 ,通常我们把它和密文拼接在一起传输,或者约定一个固定的生成方式。

最后是 填充模式(Padding) 。因为AES是分组加密,一次处理固定长度(128位,16字节)的数据,如果原文长度不是16的倍数,就需要填充。最常用的是PKCS5Padding(在Java里叫PKCS5Padding,对应到某些其他库可能是PKCS7Padding,本质在AES上是一样的)。这里有个巨坑:JavaScript的CryptoJS库默认使用的是PKCS7Padding,而Java的 Cipher 类写的是PKCS5Padding。幸运的是,在AES的上下文中,PKCS5Padding和PKCS7Padding是兼容的,可以互通。但你必须明确指定,避免使用某些库的默认值导致不一致。

2.2 字符编码与数据格式

这是另一个容易导致“乱码”或解密失败的重灾区。加密操作本质是对字节数组(byte[])进行的。所以,我们需要把字符串(无论是密钥、IV还是明文)转换成字节数组,这里就涉及到字符编码。 前后端必须使用相同的字符编码 ,强烈推荐使用UTF-8,它是Web领域的标准。

加密后得到的字节数组,如果直接转成字符串传输,很可能会因为包含不可打印字符而出错。因此,我们需要对加密后的字节数组进行二次编码,转换成纯文本格式。最常用的两种方式是Base64和Hex(十六进制)。Base64更紧凑,转换后字符串更短;Hex更直观,便于调试。 前后端必须约定使用同一种输出格式 ,我们项目统一用了Base64。

总结一下,前后端AES对齐,必须确保以下五点完全一致:

  1. 加密算法 :AES。
  2. 密钥长度与生成方式 :例如,AES-256,并通过PBKDF2从密码派生。
  3. 工作模式与IV :例如,CBC模式,并使用相同的IV生成/传递逻辑。
  4. 填充模式 :例如,PKCS5Padding/PKCS7Padding。
  5. 字符编码与输出格式 :例如,UTF-8编码,Base64输出。

3. 后端SpringBoot AES工具类实现详解

理解了原理,我们开始动手实现。后端的核心是一个健壮的AES工具类。我们不建议在业务代码里到处写 Cipher.getInstance(“AES/CBC/PKCS5Padding”) ,而是封装起来。

3.1 工具类设计与依赖

首先,在 pom.xml 里,我们不需要引入特殊的加密依赖,JDK自带的 javax.crypto 包就足够了。但为了更方便地处理Base64,可以使用Apache Commons Codec或Java 8+自带的 java.util.Base64

下面是我们封装的一个 AesUtil 工具类,它采用了“密码+盐”的方式派生密钥,增强了安全性。

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.spec.KeySpec;
import java.util.Base64;

public class AesUtil {
    // 这些是核心参数,可以放在配置文件中
    private static final String ALGORITHM = “AES/CBC/PKCS5Padding”;
    private static final String SECRET_KEY_ALGORITHM = “PBKDF2WithHmacSHA256”;
    private static final int KEY_LENGTH = 256; // AES-256
    private static final int ITERATION_COUNT = 65536; // 哈希迭代次数,增加暴力破解难度
    private static final String SALT = “YourFixedSaltHere!”; // 一个固定的盐值,可配置
    private static final int IV_LENGTH = 16; // AES块大小是16字节

    /**
     * 根据密码和盐生成安全的AES密钥
     * @param password 密码字符串
     * @return SecretKey
     */
    private static SecretKey generateKey(String password) throws Exception {
        SecretKeyFactory factory = SecretKeyFactory.getInstance(SECRET_KEY_ALGORITHM);
        KeySpec spec = new PBEKeySpec(password.toCharArray(), SALT.getBytes(StandardCharsets.UTF_8), ITERATION_COUNT, KEY_LENGTH);
        SecretKey tmp = factory.generateSecret(spec);
        return new SecretKeySpec(tmp.getEncoded(), “AES”);
    }

    /**
     * 生成一个随机的初始化向量(IV)
     * @return Base64编码的IV字符串
     */
    public static String generateIv() {
        byte[] iv = new byte[IV_LENGTH];
        new java.security.SecureRandom().nextBytes(iv);
        return Base64.getEncoder().encodeToString(iv);
    }

    /**
     * AES加密
     * @param plainText 明文
     * @param password  密码
     * @param ivBase64  Base64编码的初始化向量
     * @return Base64编码的密文
     */
    public static String encrypt(String plainText, String password, String ivBase64) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        SecretKey key = generateKey(password);
        IvParameterSpec iv = new IvParameterSpec(Base64.getDecoder().decode(ivBase64));
        cipher.init(Cipher.ENCRYPT_MODE, key, iv);
        byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }

    /**
     * AES解密
     * @param cipherTextBase64 Base64编码的密文
     * @param password         密码
     * @param ivBase64         Base64编码的初始化向量
     * @return 明文
     */
    public static String decrypt(String cipherTextBase64, String password, String ivBase64) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        SecretKey key = generateKey(password);
        IvParameterSpec iv = new IvParameterSpec(Base64.getDecoder().decode(ivBase64));
        cipher.init(Cipher.DECRYPT_MODE, key, iv);
        byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(cipherTextBase64));
        return new String(decryptedBytes, StandardCharsets.UTF_8);
    }
}

注意 SALT (盐值)和 PASSWORD (密码)是两个概念。盐值是固定的,用于和密码一起派生密钥,可以公开存储(如写在代码或配置里)。密码才是真正的秘密,应该通过安全的渠道(如配置中心、环境变量)传输和存储,绝不能硬编码在代码中。

3.2 在SpringBoot服务中的使用示例

在实际的SpringBoot控制器或服务中,我们可以这样使用这个工具类。假设我们有一个接口,接收前端加密后的数据。

@RestController
@RequestMapping(“/api/user”)
public class UserController {

    @Value(“${aes.password}”) // 从配置文件读取密码
    private String aesPassword;

    @PostMapping(“/encrypted”)
    public ResponseEntity<String> handleEncryptedData(@RequestBody EncryptedRequest request) {
        try {
            // 假设前端传过来的数据体是 {“data”: “Base64密文”, “iv”: “Base64 IV”}
            String decryptedData = AesUtil.decrypt(request.getData(), aesPassword, request.getIv());
            // 此时decryptedData就是前端加密前的原始明文,例如一个JSON字符串
            // 接下来可以反序列化成对象进行处理
            UserInfo userInfo = objectMapper.readValue(decryptedData, UserInfo.class);
            // ... 业务逻辑处理
            return ResponseEntity.ok(“处理成功”);
        } catch (Exception e) {
            log.error(“AES解密失败”, e);
            // 这里一定要小心,不要将具体的加密异常信息(如BadPaddingException)直接返回给前端,可能泄露信息
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(“数据解密错误”);
        }
    }
}

4. 前端Vue 3 AES工具函数实现详解

前端我们使用Vue 3的组合式API风格,并引入一个强大的加密库: crypto-js 。首先通过npm安装它:

npm install crypto-js

4.1 封装前端AES工具函数

我们在 src/utils 目录下创建一个 aes.js 文件:

import CryptoJS from ‘crypto-js’;

// 这些参数必须与后端完全一致
const KEY_SIZE = 256; // AES-256
const ITERATIONS = 65536;
const SALT = CryptoJS.enc.Utf8.parse(‘YourFixedSaltHere!’); // 盐,需转为WordArray
const DEFAULT_IV = CryptoJS.enc.Utf8.parse(‘0000000000000000’); // 一个默认IV,仅用于演示生成

/**
 * 根据密码和盐派生密钥
 * @param {string} password 密码
 * @returns {CryptoJS.lib.WordArray} 派生出的密钥
 */
function deriveKey(password) {
  return CryptoJS.PBKDF2(password, SALT, {
    keySize: KEY_SIZE / 32, // CryptoJS的keySize是“字数”(4字节为一个字)
    iterations: ITERATIONS,
    hasher: CryptoJS.algo.SHA256
  });
}

/**
 * 生成一个随机的16字节IV,并返回Base64字符串
 * @returns {string} Base64编码的IV
 */
export function generateIv() {
  const iv = CryptoJS.lib.WordArray.random(16); // 16字节 = 128位
  return CryptoJS.enc.Base64.stringify(iv);
}

/**
 * AES加密
 * @param {string} plainText 明文
 * @param {string} password 密码
 * @param {string} ivBase64 Base64编码的IV
 * @returns {string} Base64编码的密文
 */
export function encrypt(plainText, password, ivBase64) {
  const key = deriveKey(password);
  const iv = CryptoJS.enc.Base64.parse(ivBase64);

  const encrypted = CryptoJS.AES.encrypt(plainText, key, {
    iv: iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7 // 注意这里是Pkcs7,与后端的PKCS5Padding兼容
  });

  // 直接返回Base64格式的密文
  return encrypted.toString();
}

/**
 * AES解密
 * @param {string} cipherTextBase64 Base64编码的密文
 * @param {string} password 密码
 * @param {string} ivBase64 Base64编码的IV
 * @returns {string} 明文
 */
export function decrypt(cipherTextBase64, password, ivBase64) {
  const key = deriveKey(password);
  const iv = CryptoJS.enc.Base64.parse(ivBase64);

  const decrypted = CryptoJS.AES.decrypt(cipherTextBase64, key, {
    iv: iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  });

  // 将解密后的WordArray转为UTF-8字符串
  return decrypted.toString(CryptoJS.enc.Utf8);
}

4.2 在Vue组件中的使用示例

假设我们有一个表单,提交前需要加密用户输入的手机号。

<template>
  <div>
    <input v-model=“phone” placeholder=“请输入手机号” />
    <button @click=“submitEncrypted”>加密提交</button>
  </div>
</template>

<script setup>
import { ref } from ‘vue’;
import axios from ‘axios’;
import { encrypt, generateIv } from ‘@/utils/aes’;

const phone = ref(‘’);
const AES_PASSWORD = import.meta.env.VITE_AES_PASSWORD; // 从环境变量读取密码

const submitEncrypted = async () => {
  if (!phone.value) return;

  // 1. 生成一个本次加密使用的随机IV
  const iv = generateIv();
  // 2. 加密数据
  const encryptedData = encrypt(phone.value, AES_PASSWORD, iv);

  // 3. 将密文和IV一起发送给后端
  try {
    const response = await axios.post(‘/api/user/encrypted’, {
      data: encryptedData,
      iv: iv
    });
    console.log(‘提交成功’, response.data);
  } catch (error) {
    console.error(‘提交失败’, error);
  }
};
</script>

实操心得 :前端的密码( AES_PASSWORD )绝对不能写死在源码里。必须通过构建时的环境变量(如Vite的 import.meta.env )注入。这样在构建不同环境(开发、测试、生产)时,可以配置不同的密码,且生产环境的密码不会暴露在源码仓库中。

5. 前后端联调与数据格式约定

工具类写好了,前后端联调才是真正的“试金石”。这里需要一个清晰的数据格式约定。

我们约定,前端在发送加密数据时,请求体(RequestBody)是一个JSON对象,包含两个字段:

  • data : Base64编码的AES密文。
  • iv : Base64编码的初始化向量。

后端在响应时,如果也需要返回加密数据,可以采用同样的格式。或者,对于简单的响应,可以直接返回明文,因为HTTPS协议本身已经提供了传输层的加密。

联调检查清单

  1. 密码 :前后端使用的密码字符串是否完全相同?(注意前后空格、不可见字符)。
  2. 盐值 :前后端的盐值是否完全相同?包括字节表示。
  3. IV处理 :IV是否每次加密随机生成?是否随密文一起传输?后端解密时是否使用了前端传来的同一个IV?
  4. 参数对齐 :算法(AES)、模式(CBC)、填充(PKCS5/PKCS7)、密钥长度(256)、迭代次数(65536)、哈希算法(SHA256)是否全部一致?
  5. 编码与格式 :字符串到字节的转换是否都用UTF-8?加密输出是否都是Base64?

一个典型的失败案例是,前端用CryptoJS默认的“字符串密码”直接加密,而后端用PBKDF2派生密钥,这必然导致密钥不同,无法解密。所以, 前后端必须使用完全相同的密钥派生逻辑

6. 常见问题排查与性能安全考量

在实际开发中,你几乎一定会遇到下面这些问题。

6.1 典型错误与解决方案

错误现象 可能原因 排查步骤与解决方案
后端解密失败,报 javax.crypto.BadPaddingException: Given final block not properly padded 1. 前后端密钥不一致。
2. IV不一致。
3. 密文在传输过程中被篡改或编码出错。
4. 填充模式不匹配。
1. 核对密钥派生 :确保密码、盐、迭代次数、密钥长度完全一致。可以前后端分别打印(或日志记录)派生出的密钥的Hex或Base64值进行比对。
2. 核对IV :确保前端发送的IV和后端解密使用的IV是同一个Base64字符串。
3. 检查密文 :确保前端生成的Base64密文完整地传到了后端,没有被URL编码等额外处理。可以在后端收到后先打印出来,和前端生成的对比。
4. 确认填充模式 :Java端为 PKCS5Padding ,CryptoJS端为 Pkcs7
后端解密失败,报 java.security.InvalidKeyException: Invalid AES key length JDK默认限制了AES密钥长度。 1. 检查是否使用了256位密钥。如果是,确保使用了PBKDF2等密钥派生函数,而不是直接用长字符串。
2. 或者为JDK安装JCE无限制强度管辖策略文件(生产环境慎用,需符合合规要求)。
解密后得到乱码 字符编码不一致。 确保在加密前和解密后,字符串与字节数组的转换都明确指定了 UTF-8 编码。在Java中 String.getBytes(“UTF-8”) new String(bytes, “UTF-8”) ;在JS中 CryptoJS.enc.Utf8.parse toString(CryptoJS.enc.Utf8)
CryptoJS报错 Malformed UTF-8 data 尝试用 CryptoJS.enc.Utf8.stringify 去解析非UTF-8格式的密文,或者密文本身损坏。 确保解密函数的输入 cipherTextBase64 是完整的、未损坏的Base64字符串。使用 CryptoJS.enc.Base64.parse 来解析Base64密文。

6.2 性能与安全增强建议

  1. 密钥管理 :项目中的AES密码( PASSWORD )是最高机密。切忌硬编码。
    • 推荐 :存储在环境变量、配置中心(如Apollo、Nacos)或密钥管理服务(KMS)中。在SpringBoot中通过 @Value(“${}”) 注入,在Vue中通过环境变量注入。
  2. IV的使用 必须每次加密都使用随机生成的IV ,绝对不要使用固定IV。使用固定IV会使得相同的明文和密钥产生相同的密文,这会泄露数据模式,存在安全隐患。将IV视为密文的一部分,公开传输即可。
  3. 加密模式选择 :对于新项目,可以考虑更安全的模式,如GCM(Galois/Counter Mode),它不仅能提供保密性,还能提供完整性认证。但GCM模式在前端CryptoJS和后端Java的实现上需要更仔细的对接。
  4. 性能考量 :PBKDF2密钥派生函数的迭代次数(如65536)会消耗一定CPU时间。但这正是设计的目的,增加暴力破解的难度。这个操作在每次加解密时只需要做一次(可以缓存派生出的密钥),对于单次请求的性能影响微乎其微,带来的安全性提升是值得的。
  5. 不要滥用加密 :HTTPS已经解决了传输过程中的安全问题。AES加密主要用于补充性的、应用层的敏感数据保护,例如对存入数据库的某些字段进行加密。避免对所有数据传输都进行应用层加密,增加不必要的复杂性和性能开销。

7. 工具类的扩展与项目集成

基本的加解密功能实现后,我们可以把这个工具类集成得更优雅一些。

7.1 后端集成:自定义注解与AOP

我们可以创建一个自定义注解 @EncryptField ,标记在DTO类的字段上,然后通过AOP或Jackson序列化器,在数据返回给前端前自动加密这些字段。

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField {
}

然后,实现一个 ResponseBodyAdvice ,在控制器返回数据后,遍历对象,对带有 @EncryptField 注解的字段进行加密处理。这种方式对业务代码侵入性最小。

7.2 前端集成:请求/响应拦截器

在前端,我们可以使用axios的请求拦截器,对特定接口的请求数据进行自动加密。同样,使用响应拦截器对返回的加密数据进行自动解密。

// request拦截器
axios.interceptors.request.use(config => {
  if (config.encrypt) { // 自定义一个config属性来判断是否需要加密
    const iv = generateIv();
    const encryptedData = encrypt(JSON.stringify(config.data), AES_PASSWORD, iv);
    config.data = { data: encryptedData, iv: iv }; // 替换为加密后的格式
    config.headers[‘Content-Type’] = ‘application/json’;
  }
  return config;
});

// response拦截器
axios.interceptors.response.use(response => {
  if (response.config.decrypt) { // 判断是否需要解密
    const respData = response.data;
    if (respData.data && respData.iv) {
      const decryptedStr = decrypt(respData.data, AES_PASSWORD, respData.iv);
      response.data = JSON.parse(decryptedStr); // 将解密后的字符串解析为对象
    }
  }
  return response;
}, error => {
  // 错误处理
  return Promise.reject(error);
});

这样,在业务代码中,你只需要在发起请求时配置一个 encrypt: true 的标志位,就能实现无感的加密传输。

7.3 应对特殊场景:文件与大数据流

上述方案针对文本数据(JSON、字符串)很有效。但如果要加密文件或大数据流,则需要不同的策略:

  • 分块加密 :将大文件分割成多个小块(如每1MB一块),分别用相同的IV和密钥进行CBC加密(注意CBC模式需要链式处理,或者使用CTR等流加密模式更合适)。
  • 使用流式API :在后端使用 CipherInputStream CipherOutputStream ;在前端可以使用 crypto-js WordArray 分块处理,或者寻找支持流的Web Crypto API。
  • 性能权衡 :加密解密大文件非常消耗CPU和内存,需要评估是否真的有必要在应用层做全量加密。很多时候,确保存储介质和传输通道(HTTPS)的安全更为关键。

整个实现过程走下来,最大的体会就是“细节决定成败”。AES加密本身不复杂,但让前后端两个不同语言、不同运行环境的部分协同工作,任何一个参数的对齐、一个编码的疏忽都会导致失败。最好的调试方式就是“打印中间值”:分别在前端和后端,把密钥(派生后)、IV、加密前的明文、加密后的密文(Hex或Base64)都打印到控制台或日志里,进行逐字节比对。一旦这些中间状态一致,整个加解密流程就必然畅通无阻。

更多推荐