SpringBoot与Vue3前后端AES加密实战:参数对齐与工具类封装
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对齐,必须确保以下五点完全一致:
- 加密算法 :AES。
- 密钥长度与生成方式 :例如,AES-256,并通过PBKDF2从密码派生。
- 工作模式与IV :例如,CBC模式,并使用相同的IV生成/传递逻辑。
- 填充模式 :例如,PKCS5Padding/PKCS7Padding。
- 字符编码与输出格式 :例如,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协议本身已经提供了传输层的加密。
联调检查清单 :
- 密码 :前后端使用的密码字符串是否完全相同?(注意前后空格、不可见字符)。
- 盐值 :前后端的盐值是否完全相同?包括字节表示。
- IV处理 :IV是否每次加密随机生成?是否随密文一起传输?后端解密时是否使用了前端传来的同一个IV?
- 参数对齐 :算法(AES)、模式(CBC)、填充(PKCS5/PKCS7)、密钥长度(256)、迭代次数(65536)、哈希算法(SHA256)是否全部一致?
- 编码与格式 :字符串到字节的转换是否都用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 性能与安全增强建议
- 密钥管理 :项目中的AES密码(
PASSWORD)是最高机密。切忌硬编码。- 推荐 :存储在环境变量、配置中心(如Apollo、Nacos)或密钥管理服务(KMS)中。在SpringBoot中通过
@Value(“${}”)注入,在Vue中通过环境变量注入。
- 推荐 :存储在环境变量、配置中心(如Apollo、Nacos)或密钥管理服务(KMS)中。在SpringBoot中通过
- IV的使用 : 必须每次加密都使用随机生成的IV ,绝对不要使用固定IV。使用固定IV会使得相同的明文和密钥产生相同的密文,这会泄露数据模式,存在安全隐患。将IV视为密文的一部分,公开传输即可。
- 加密模式选择 :对于新项目,可以考虑更安全的模式,如GCM(Galois/Counter Mode),它不仅能提供保密性,还能提供完整性认证。但GCM模式在前端CryptoJS和后端Java的实现上需要更仔细的对接。
- 性能考量 :PBKDF2密钥派生函数的迭代次数(如65536)会消耗一定CPU时间。但这正是设计的目的,增加暴力破解的难度。这个操作在每次加解密时只需要做一次(可以缓存派生出的密钥),对于单次请求的性能影响微乎其微,带来的安全性提升是值得的。
- 不要滥用加密 :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)都打印到控制台或日志里,进行逐字节比对。一旦这些中间状态一致,整个加解密流程就必然畅通无阻。
更多推荐
所有评论(0)