Mars Admin 接口加密功能学习文档

整体设计思路

Mars Admin 的接口加密采用 RSA + AES-GCM 混合加密 方案,形成”前端获取配置 → 请求加密 → 后端解密 → 后端加密响应 → 前端解密”的完整闭环。

为什么用混合加密?

  • RSA(非对称加密):安全性高,但加密大数据速度慢,适合加密少量数据(如密码字段、AES 密钥)
  • AES-GCM(对称加密):速度快,适合加密大量响应数据,且 GCM 模式自带完整性校验

两种加密范围:

  • 全局加密encryptScope = "global"):除白名单路径外,所有接口响应自动加密
  • 局部加密encryptScope = "partial"):仅对标注 @EncryptResponse 注解的接口加密

核心文件一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mars-admin
├── mars-common/
│ └── util/RsaUtils.java # RSA/AES 工具类(分段加密、混合加密)
├── mars-infra/mars-crypto/
│ ├── CryptoService.java # 加密服务接口
│ ├── CryptoConfigProvider.java # 加密配置提供者接口(解耦用)
│ ├── DecryptRequestBodyAdvice.java # 请求解密拦截器
│ ├── EncryptResponseBodyAdvice.java # 响应加密拦截器
│ └── EncryptResponse.java # 局部加密注解
├── mars-core/mars-system/
│ ├── service/impl/CryptoServiceImpl.java # 加密服务实现(密钥管理 + 加解密)
│ └── helper/SystemConfigHelper.java # 配置读取(实现 CryptoConfigProvider)
├── mars-api/mars-admin-api/
│ └── controller/auth/CryptoController.java # 前端获取加密配置的接口
└── mars-ui/src/utils/
├── crypto.ts # 前端 RSA 加密工具
└── request.ts # Axios 封装(含 AES 响应解密)

加密架构分层设计

项目通过接口解耦,避免基础设施层(mars-infra)直接依赖业务层(mars-core):

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────┐
│ mars-infra/mars-crypto(基础设施层) │
│ 定义接口: CryptoService, CryptoConfigProvider │
│ 定义拦截器: DecryptRequestBodyAdvice │
│ EncryptResponseBodyAdvice │
└──────────────────┬──────────────────────────┘
│ 依赖接口,不依赖实现
┌──────────────────▼──────────────────────────┐
│ mars-core/mars-system(业务核心层) │
│ 实现类: CryptoServiceImpl │
│ 配置: SystemConfigHelper (impl CryptoConfig) │
└─────────────────────────────────────────────┘

关键接口说明:

  • CryptoService — 加密能力的统一入口,定义了 isEnabled()decrypt()encryptResponse() 等方法
  • CryptoConfigProvider — 只有一个方法 isGlobalEncrypt(),由 SystemConfigHelper 实现,让拦截器能判断加密范围而不需要直接引用业务类

密钥管理机制

RSA 密钥对

来源:从数据库 sys_config_group 表的 security 分组读取(字段 encryptPublicKey / encryptPrivateKey

自动生成:如果数据库中未配置密钥,启动时自动生成 2048 位 RSA 密钥对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// CryptoServiceImpl.java 启动时加载
@PostConstruct
public void init() {
loadKeys(); // 尝试从数据库加载 RSA 密钥
generateAesKey(); // 每次启动生成新的 AES 密钥
}

/**
* 加载密钥
*/
private void loadKeys() {
try {
JsonNode config = getSecurityConfig();
if (config != null) {
publicKey = getStringValue(config, "encryptPublicKey");
privateKey = getStringValue(config, "encryptPrivateKey");
}

// 如果没有配置密钥,则生成新的密钥对
if (publicKey == null || privateKey == null || publicKey.isEmpty() || privateKey.isEmpty()) {
log.info("RSA密钥未配置,正在生成新密钥对...");
generateAndSaveKeys();
} else {
log.info("RSA密钥加载成功");
}
} catch (Exception e) {
log.error("加载RSA密钥失败", e);
}
}

/**
* 生成并保存密钥对
*/
private void generateAndSaveKeys() {
try {
Map<String, String> keyPair = RsaUtils.generateKeyPair(); // 2048 位
publicKey = keyPair.get(RsaUtils.PUBLIC_KEY);
privateKey = keyPair.get(RsaUtils.PRIVATE_KEY);
log.info("RSA密钥对生成成功");
} catch (Exception e) {
log.error("生成RSA密钥对失败", e);
}
}

AES 密钥

生成方式:每次服务启动时随机生成 AES-256 密钥

下发方式:以 Base64 编码通过 /api/crypto/config 接口下发给前端(依赖 HTTPS 保护传输安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// CryptoServiceImpl.java
/**
* 生成AES密钥
*/
private void generateAesKey() {
try {
KeyGenerator keyGen = KeyGenerator.getInstance(AES_ALGORITHM);
keyGen.init(256, new SecureRandom());
aesKey = keyGen.generateKey();

// 直接返回AES密钥的Base64编码(通过HTTPS安全传输)
encryptedAesKey = Base64.getEncoder().encodeToString(aesKey.getEncoded());

log.info("AES密钥生成成功");
} catch (Exception e) {
log.error("生成AES密钥失败", e);
}
}

前端获取加密配置

1
2
3
4
5
6
7
8
9
10
11
12
// CryptoController.java
/**
* 获取加密配置(公钥、AES密钥和是否启用)
*/
@GetMapping("/config")
public Result<Map<String, Object>> getConfig() {
Map<String, Object> result = new HashMap<>();
result.put("enabled", cryptoService.isEnabled());
result.put("publicKey", cryptoService.getPublicKey());
result.put("aesKey", cryptoService.getEncryptedAesKey());
return Result.ok(result);
}

注意:AES 密钥在服务重启后会重新生成,前端缓存的旧密钥会失效,此时前端会自动清除缓存并重新获取。


请求加密流程(前端 → 后端)

前端加密密码字段

前端使用 jsencrypt 库进行 RSA 公钥加密:

1
用户输入密码 → 获取 RSA 公钥 → JSEncrypt 加密 → 发送加密后的 Base64 字符串

核心代码在 mars-ui/src/utils/crypto.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import JSEncrypt from 'jsencrypt'
import { fetchCryptoConfig, clearCryptoConfigCache } from './request'

/**
* RSA加密
*/
export function rsaEncrypt(data: string, publicKey: string): string {
const encrypt = new JSEncrypt()
encrypt.setPublicKey(publicKey)
const encrypted = encrypt.encrypt(data)
if (!encrypted) {
throw new Error('RSA加密失败')
}
return encrypted
}

/**
* 加密密码字段
*/
export async function encryptPassword(password: string): Promise<string> {
const config = await getCryptoConfig()

if (!config.enabled || !config.publicKey) {
return password // 未启用加密,直接返回明文
}

return rsaEncrypt(password, config.publicKey)
}

/**
* 加密对象中的密码字段
*/
export async function encryptPasswordFields<T extends Record<string, any>>(
data: T,
fields: string[] = ['password', 'oldPassword', 'newPassword']
): Promise<T> {
const config = await getCryptoConfig()

if (!config.enabled || !config.publicKey) {
return data
}

const result = { ...data }

// 遍历 fields,对每个存在的字符串字段进行 RSA 加密
for (const field of fields) {
if (result[field] && typeof result[field] === 'string') {
result[field] = rsaEncrypt(result[field], config.publicKey)
}
}

return result
}

后端解密请求

DecryptRequestBodyAdvice 是一个 @ControllerAdvice,通过 Spring 的 RequestBodyAdvice 机制在 Controller 收到请求之前自动解密:

1
请求进入 → supports() 判断是否启用 → beforeBodyRead() 解密

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class DecryptRequestBodyAdvice extends RequestBodyAdviceAdapter {

private final CryptoService cryptoService;
private final ObjectMapper objectMapper;

@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return cryptoService.isEnabled();
}

@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
// 跳过小程序请求
if (isMiniProgramRequest()) {
return inputMessage;
}

String body = new String(inputMessage.getBody().readAllBytes(), StandardCharsets.UTF_8);
if (body == null || body.isEmpty()) {
return new DecryptedHttpInputMessage(inputMessage, "");
}

try {
JsonNode jsonNode = objectMapper.readTree(body);

// 模式 A:整体解密(encryptedData 字段)
if (jsonNode.has("encryptedData")) {
String encryptedData = jsonNode.get("encryptedData").asText();
String decryptedBody = cryptoService.decrypt(encryptedData);
log.debug("请求体已解密");
return new DecryptedHttpInputMessage(inputMessage, decryptedBody);
}

// 模式 B:字段级解密(password、oldPassword、newPassword)
if (jsonNode.isObject()) {
ObjectNode objectNode = (ObjectNode) jsonNode;
boolean modified = false;

for (String field : new String[]{"password", "oldPassword", "newPassword"}) {
if (objectNode.has(field) && objectNode.get(field).isTextual()) {
String encrypted = objectNode.get(field).asText();
if (isEncrypted(encrypted)) { // 长度 >= 100 且是合法 Base64
try {
objectNode.put(field, cryptoService.decrypt(encrypted));
modified = true;
log.debug("{}字段已解密", field);
} catch (Exception e) {
log.debug("{}字段解密失败,可能是明文", field);
}
}
}
}

if (modified) {
return new DecryptedHttpInputMessage(inputMessage, objectMapper.writeValueAsString(objectNode));
}
}

return new DecryptedHttpInputMessage(inputMessage, body);
} catch (Exception e) {
log.error("请求解密处理异常", e);
return new DecryptedHttpInputMessage(inputMessage, body);
}
}

/**
* 判断是否是加密数据
*/
private boolean isEncrypted(String data) {
if (data == null || data.length() < 100) {
return false;
}
try {
java.util.Base64.getDecoder().decode(data);
return true;
} catch (Exception e) {
return false;
}
}

/**
* 判断是否是小程序请求
*/
private boolean isMiniProgramRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return false;
String path = attributes.getRequest().getRequestURI();
if (path == null) return false;
return path.startsWith("/api/mall/") || path.startsWith("/api/wechat/miniprogram/");
}
}

两种解密模式:

模式 A — 整体解密:请求体包含 encryptedData 字段时,整体 RSA 解密

1
2
// 请求体格式
{ "encryptedData": "Base64编码的RSA加密数据" }

模式 B — 字段级解密:逐个检查 passwordoldPasswordnewPassword 字段,如果字段值长度 >= 100 且能成功 Base64 解码,则认为是加密数据并进行 RSA 解密。

小程序请求跳过:路径以 /api/mall//api/wechat/miniprogram/ 开头的请求不做解密处理。


响应加密流程(后端 → 前端)

后端加密响应

EncryptResponseBodyAdvice 通过 Spring 的 ResponseBodyAdvice 机制在响应写出之前自动加密:

1
Controller 返回数据 → supports() 判断是否需要加密 → beforeBodyWrite() AES-GCM 加密

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> {

private final CryptoService cryptoService;
private final ObjectMapper objectMapper;
private final CryptoConfigProvider configProvider;

/**
* 不需要加密的公开接口路径
*/
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/crypto/",
"/auth/login",
"/auth/register",
"/auth/captcha",
"/auth/sms-code",
"/api/mall/",
"/api/wechat/miniprogram/",
"/sys/config-group/public",
"/file/",
"/sys/file/"
);

@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
if (!cryptoService.isEnabled()) {
return false; // 未启用加密
}

if (configProvider.isGlobalEncrypt()) {
return true; // 全局模式:全部加密
} else {
return returnType.hasMethodAnnotation(EncryptResponse.class); // 局部模式:看注解
}
}

@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
try {
if (body == null || body instanceof byte[]) {
return body;
}

// 全局模式下,检查是否在排除路径中
if (configProvider.isGlobalEncrypt()) {
String path = request.getURI().getPath();
for (String excludePath : EXCLUDE_PATHS) {
if (path.contains(excludePath)) {
return body;
}
}
}

// 处理 Result 包装的响应
if (body instanceof Result<?> result) {
Object data = result.getData();
if (data != null) {
String jsonData = objectMapper.writeValueAsString(data);
String encryptedData = cryptoService.encryptResponse(jsonData);
return Result.ok(encryptedData);
}
return body;
}

// 处理普通响应
String jsonData;
if (body instanceof String) {
jsonData = (String) body;
} else {
jsonData = objectMapper.writeValueAsString(body);
}

String encryptedData = cryptoService.encryptResponse(jsonData);
return Result.ok(encryptedData);

} catch (Exception e) {
log.error("响应加密失败", e);
return body;
}
}
}

AES-GCM 加密实现(CryptoServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public String encryptResponse(String data) {
if (data == null || data.isEmpty()) {
return data;
}
try {
// 1. 生成 12 字节随机 IV
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);

// 2. AES-GCM 加密
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); // 128 位认证标签
cipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);
byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));

// 3. 拼接格式: Base64(IV) + "." + Base64(密文+认证标签)
return Base64.getEncoder().encodeToString(iv) + "." +
Base64.getEncoder().encodeToString(encryptedData);
} catch (Exception e) {
log.error("AES加密响应失败", e);
throw new RuntimeException("数据加密失败");
}
}

输出格式Base64(IV).Base64(密文+认证标签),例如:AAAAAAAAAAAAAAAA.xyzabc123...

排除路径(全局模式下不加密的接口):

路径 原因
/crypto/ 加密配置接口本身
/auth/login 登录接口
/auth/register 注册接口
/auth/captcha 验证码接口
/auth/sms-code 短信验证码
/api/mall/ 商城接口
/api/wechat/miniprogram/ 小程序接口
/sys/config-group/public 公开配置
/file//sys/file/ 文件接口

局部加密示例

在 Controller 方法上添加 @EncryptResponse 注解即可:

1
2
3
4
5
6
@GetMapping("/page")
@SaCheckPermission("sys:user:list")
@EncryptResponse // ← 标注此注解,局部模式下该接口响应会被 AES 加密
public Result<PageResult<SysUser>> page(...) {
return Result.ok(userService.page(...));
}

前端解密响应

前端在 Axios 响应拦截器中自动检测并解密(mars-ui/src/utils/request.ts):

1
收到响应 → isAesEncryptedData() 判断格式 → aesDecrypt() 使用 Web Crypto API 解密

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// 判断是否是AES加密的响应数据(格式:iv.encryptedData)
function isAesEncryptedData(data: any): boolean {
if (typeof data !== 'string') {
return false
}
const parts = data.split('.')
if (parts.length !== 2) {
return false
}
// 检查两部分是否都是有效的Base64,且IV长度正确
try {
atob(parts[0])
atob(parts[1])
// IV是12字节,Base64后是16字符
return parts[0].length === 16 && parts[1].length > 10
} catch {
return false
}
}

// AES-GCM解密
async function aesDecrypt(encryptedData: string, aesKeyBase64: string): Promise<string> {
const parts = encryptedData.split('.')
if (parts.length !== 2) {
throw new Error('加密数据格式错误')
}

// 解码 IV 和密文
const iv = Uint8Array.from(atob(parts[0]), c => c.charCodeAt(0))
const data = Uint8Array.from(atob(parts[1]), c => c.charCodeAt(0))
const keyBytes = Uint8Array.from(atob(aesKeyBase64), c => c.charCodeAt(0))

// 使用浏览器原生 Web Crypto API 导入 AES 密钥
const aesKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
)

// AES-GCM 解密
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
aesKey,
data
)

return new TextDecoder().decode(decrypted)
}

// 解密响应数据
async function decryptResponseData(data: string): Promise<any> {
const config = await fetchCryptoConfig()

if (!config.aesKey) {
return data
}

try {
const decryptedStr = await aesDecrypt(data, config.aesKey)
return JSON.parse(decryptedStr)
} catch (error) {
console.error('响应解密失败', error)
// 解密失败可能是密钥过期,清除缓存
cryptoConfigCache = null
return data
}
}

// 响应拦截器
service.interceptors.response.use(
async (response: AxiosResponse<ApiResponse>) => {
const res = response.data

if (res.code !== 200) {
// 错误处理...
return Promise.reject(new Error(res.message || '请求失败'))
}

// 检查响应数据是否是AES加密的,自动解密
if (isAesEncryptedData(res.data)) {
try {
return await decryptResponseData(res.data)
} catch (error) {
console.error('解密响应失败', error)
return res.data
}
}

return res.data
},
(error) => {
const message = error.response?.data?.message || error.message || '网络错误'
window.$message?.error(message)
return Promise.reject(error)
}
)

判断逻辑:数据是字符串,按 . 分割为 2 段,第一段(IV)Base64 解码后长度为 12 字节(编码后 16 字符),两段都是合法 Base64。


完整数据流图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
┌──────────────────────────────────────────────────────────────────┐
│ 启动阶段 │
│ CryptoServiceImpl.init() │
│ ├── loadKeys() → 从 DB 加载 RSA 密钥(无则自动生成) │
│ └── generateAesKey() → 随机生成 AES-256 密钥 │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ 前端获取加密配置 │
│ GET /api/crypto/config │
│ 响应: { enabled: true, publicKey: "...", aesKey: "..." } │
│ 前端缓存配置,后续请求复用 │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ 请求加密(前端 → 后端) │
│ │
│ 前端: password → RSA公钥加密 → Base64 字符串 │
│ ↓ │
│ 后端: DecryptRequestBodyAdvice │
│ ├── encryptedData 字段? → RSA私钥整体解密 │
│ └── password 等字段? → RSA私钥逐字段解密 │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ 响应加密(后端 → 前端) │
│ │
│ 后端: EncryptResponseBodyAdvice │
│ ├── 全局模式: 排除白名单后全部 AES-GCM 加密 │
│ └── 局部模式: 仅 @EncryptResponse 标注的方法 │
│ 输出: Base64(IV).Base64(密文) │
│ ↓ │
│ 前端: 响应拦截器检测格式 → Web Crypto API AES-GCM 解密 │
│ → JSON.parse() → 返回给业务代码 │
└──────────────────────────────────────────────────────────────────┘

配置说明

在系统管理后台的 系统配置 → security 分组 中设置:

配置项 说明
encryptEnabled true / false 是否启用接口加密
encryptScope "global" / "partial" 加密范围
encryptPublicKey RSA 公钥 Base64 留空则自动生成
encryptPrivateKey RSA 私钥 Base64 留空则自动生成

RsaUtils 工具类详解

位于 mars-common/src/main/java/com/mars/common/util/RsaUtils.java,提供三类能力:

基础 RSA 加解密(支持分段)

RSA 2048 位密钥单次加密上限为 245 字节(256 - 11 填充),工具类通过分段处理支持大数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
public class RsaUtils {
private static final String RSA_ALGORITHM = "RSA";
private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding";
private static final int KEY_SIZE = 2048;

// RSA加密最大块大小(2048位密钥 = 256字节,减去11字节填充)
private static final int MAX_ENCRYPT_BLOCK = 245;
// RSA解密最大块大小
private static final int MAX_DECRYPT_BLOCK = 256;

/**
* 生成RSA密钥对
*/
public static Map<String, String> generateKeyPair() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
keyPairGenerator.initialize(KEY_SIZE, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();

String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());

Map<String, String> keyMap = new HashMap<>();
keyMap.put(PUBLIC_KEY, publicKey);
keyMap.put(PRIVATE_KEY, privateKey);
return keyMap;
}

/**
* 公钥加密(支持分段加密大数据)
*/
public static String encryptByPublicKey(String data, String publicKeyStr) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(keySpec);

byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
byte[] encryptedData = segmentEncrypt(dataBytes, publicKey, Cipher.ENCRYPT_MODE);
return Base64.getEncoder().encodeToString(encryptedData);
}

/**
* 私钥解密(支持分段解密大数据)
*/
public static String decryptByPrivateKey(String encryptedData, String privateKeyStr) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

byte[] dataBytes = Base64.getDecoder().decode(encryptedData);
byte[] decryptedData = segmentDecrypt(dataBytes, privateKey, Cipher.DECRYPT_MODE);
return new String(decryptedData, StandardCharsets.UTF_8);
}

/**
* 分段加密
*/
private static byte[] segmentEncrypt(byte[] data, Key key, int mode) throws Exception {
Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
cipher.init(mode, key);

int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offset = 0;
byte[] cache;
int i = 0;

while (inputLen - offset > 0) {
if (inputLen - offset > MAX_ENCRYPT_BLOCK) {
cache = cipher.doFinal(data, offset, MAX_ENCRYPT_BLOCK);
} else {
cache = cipher.doFinal(data, offset, inputLen - offset);
}
out.write(cache, 0, cache.length);
i++;
offset = i * MAX_ENCRYPT_BLOCK;
}

byte[] encryptedData = out.toByteArray();
out.close();
return encryptedData;
}

/**
* 分段解密
*/
private static byte[] segmentDecrypt(byte[] data, Key key, int mode) throws Exception {
Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
cipher.init(mode, key);

int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offset = 0;
byte[] cache;
int i = 0;

while (inputLen - offset > 0) {
if (inputLen - offset > MAX_DECRYPT_BLOCK) {
cache = cipher.doFinal(data, offset, MAX_DECRYPT_BLOCK);
} else {
cache = cipher.doFinal(data, offset, inputLen - offset);
}
out.write(cache, 0, cache.length);
i++;
offset = i * MAX_DECRYPT_BLOCK;
}

byte[] decryptedData = out.toByteArray();
out.close();
return decryptedData;
}
}
方法 说明
encryptByPublicKey() 公钥加密,分段处理,每段 245 字节
decryptByPrivateKey() 私钥解密,分段处理,每段 256 字节
encryptByPrivateKey() 私钥加密(用于签名场景)
decryptByPublicKey() 公钥解密(用于验签场景)

混合加密(AES + RSA)

适用于大数据加密场景,格式为 Base64(RSA加密的AES密钥).Base64(IV).Base64(AES密文)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 混合加密(AES加密数据,RSA加密AES密钥)
* 返回格式:Base64(RSA加密的AES密钥) + "." + Base64(IV) + "." + Base64(AES加密的数据)
*/
public static String hybridEncrypt(String data, String publicKeyStr) throws Exception {
// 生成AES密钥
KeyGenerator keyGen = KeyGenerator.getInstance(AES_ALGORITHM);
keyGen.init(AES_KEY_SIZE, new SecureRandom());
SecretKey aesKey = keyGen.generateKey();

// 生成IV
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);

// AES加密数据
Cipher aesCipher = Cipher.getInstance(AES_TRANSFORMATION);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);
byte[] encryptedData = aesCipher.doFinal(data.getBytes(StandardCharsets.UTF_8));

// RSA加密AES密钥
String encryptedAesKey = encryptByPublicKey(
Base64.getEncoder().encodeToString(aesKey.getEncoded()),
publicKeyStr
);

// 组合结果
return encryptedAesKey + "." +
Base64.getEncoder().encodeToString(iv) + "." +
Base64.getEncoder().encodeToString(encryptedData);
}

/**
* 混合解密
*/
public static String hybridDecrypt(String encryptedData, String privateKeyStr) throws Exception {
String[] parts = encryptedData.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("加密数据格式错误");
}

String encryptedAesKey = parts[0];
byte[] iv = Base64.getDecoder().decode(parts[1]);
byte[] data = Base64.getDecoder().decode(parts[2]);

// RSA解密AES密钥
String aesKeyStr = decryptByPrivateKey(encryptedAesKey, privateKeyStr);
byte[] aesKeyBytes = Base64.getDecoder().decode(aesKeyStr);
SecretKey aesKey = new SecretKeySpec(aesKeyBytes, AES_ALGORITHM);

// AES解密数据
Cipher aesCipher = Cipher.getInstance(AES_TRANSFORMATION);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
aesCipher.init(Cipher.DECRYPT_MODE, aesKey, gcmSpec);
byte[] decryptedData = aesCipher.doFinal(data);

return new String(decryptedData, StandardCharsets.UTF_8);
}
方法 说明
hybridEncrypt() 生成随机 AES 密钥 → AES-GCM 加密数据 → RSA 加密 AES 密钥
hybridDecrypt() RSA 解密 AES 密钥 → AES-GCM 解密数据

算法参数

参数 说明
RSA 密钥长度 2048 位 安全强度足够
RSA 填充 PKCS1Padding 兼容性好
AES 密钥长度 256 位 最高安全等级
AES 模式 GCM(Galois/Counter Mode) 同时提供加密和完整性校验
GCM IV 长度 12 字节 NIST 推荐长度
GCM Tag 长度 128 位 完整性校验标签

学习路线建议

  1. 先看配置接口CryptoController.java 了解前端如何获取加密参数
  2. 理解密钥管理CryptoServiceImpl.javainit()loadKeys()generateAesKey()
  3. 跟踪响应加密EncryptResponseBodyAdvice.javaencryptResponse() 方法
  4. 跟踪请求解密DecryptRequestBodyAdvice.javabeforeBodyRead() 方法
  5. 前端对照crypto.ts(RSA 加密)和 request.ts(AES 解密)
  6. 底层工具RsaUtils.java 理解分段加密和混合加密实现

实际使用示例

后端:标注接口需要加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RestController
@RequestMapping("/sys/user")
@RequiredArgsConstructor
public class SysUserController {

private final SysUserService userService;

/**
* 分页查询用户列表(响应加密)
*/
@GetMapping("/page")
@SaCheckPermission("sys:user:list")
@EncryptResponse // ← 标注此注解,局部模式下该接口响应会被 AES 加密
public Result<PageResult<SysUser>> page(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
return Result.ok(userService.page(page, size));
}

/**
* 添加用户(请求自动解密密码字段)
*/
@PostMapping
@SaCheckPermission("sys:user:add")
@Log(title = "用户管理", businessType = BusinessType.INSERT)
public Result<Void> add(@RequestBody SysUser user) {
// password 字段已被 DecryptRequestBodyAdvice 自动解密
userService.save(user);
return Result.ok();
}
}

前端:登录时加密密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { encryptPassword } from '@/utils/crypto'

// 登录表单
const loginForm = reactive({
username: '',
password: '',
captcha: ''
})

// 登录方法
async function handleLogin() {
try {
// 加密密码
const encryptedPassword = await encryptPassword(loginForm.password)

// 发送登录请求
await authApi.login({
username: loginForm.username,
password: encryptedPassword, // 发送加密后的密码
captcha: loginForm.captcha
})

window.$message.success('登录成功')
router.push('/')
} catch (error) {
window.$message.error('登录失败')
}
}

前端:批量加密多个密码字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { encryptPasswordFields } from '@/utils/crypto'

// 修改密码表单
const changePasswordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})

// 提交修改密码
async function handleChangePassword() {
// 批量加密密码字段
const encryptedData = await encryptPasswordFields(changePasswordForm, [
'oldPassword',
'newPassword'
])

await userApi.changePassword(encryptedData)
window.$message.success('密码修改成功')
}

配置加密范围

在系统管理后台的 系统配置 → security 分组 中设置:

1
2
3
4
5
6
{
"encryptEnabled": true, // 启用加密
"encryptScope": "partial", // 加密范围:global(全局)或 partial(局部)
"encryptPublicKey": "...", // RSA 公钥(留空自动生成)
"encryptPrivateKey": "..." // RSA 私钥(留空自动生成)
}

全局模式(global)

  • 除白名单路径外,所有接口响应自动加密
  • 适合对安全性要求极高的场景
  • 白名单包括:登录、注册、验证码、文件访问等公开接口

局部模式(partial)

  • 仅对标注 @EncryptResponse 的接口加密
  • 适合大多数场景,灵活控制
  • 推荐用于敏感数据接口(用户列表、详情等)

常见问题

Q1: 为什么请求用 RSA 加密,响应用 AES 加密?

A:

  • 请求加密:主要加密密码等敏感字段,数据量小,RSA 足够
  • 响应加密:可能包含大量数据(如列表、详情),AES 速度快,适合大数据

Q2: AES 密钥如何安全传输?

A: AES 密钥通过 /api/crypto/config 接口以 Base64 明文下发,依赖 HTTPS 保护传输安全。生产环境必须启用 HTTPS。

Q3: 服务重启后前端解密失败怎么办?

A: 服务重启会重新生成 AES 密钥,前端缓存的旧密钥失效。前端响应拦截器会自动检测解密失败,清除缓存并重新获取配置。

Q4: 如何判断某个字段是否被加密?

A: DecryptRequestBodyAdvice 通过以下规则判断:

  • 字段值长度 >= 100
  • 能成功 Base64 解码
  • 满足以上条件则尝试 RSA 解密

Q5: 混合加密(hybridEncrypt)什么时候用?

A: 当需要加密超大数据(如文件内容)时使用。项目当前主要用于:

  • 请求:RSA 加密密码字段
  • 响应:AES-GCM 加密整个响应体

混合加密是 RsaUtils 提供的扩展能力,可用于特殊场景。


Mars Admin 接口加密功能学习文档
https://blog.newpon.top/2026/02/16/接口加密设计/
作者
John Doe
发布于
2026年2月16日
许可协议