整体设计思路 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 ├── 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 ├── mars-api/ mars-admin-api/ │ └── controller/auth/CryptoController.java └── mars-ui/ src/ utils/ ├── crypto.ts └── request.ts
加密架构分层设计 项目通过接口解耦,避免基础设施层(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 @PostConstruct public void init () { loadKeys(); generateAesKey(); }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(); 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 private void generateAesKey () { try { KeyGenerator keyGen = KeyGenerator.getInstance(AES_ALGORITHM); keyGen.init(256 , new SecureRandom ()); aesKey = keyGen.generateKey(); 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 @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' 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 } 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); if (jsonNode.has("encryptedData" )) { String encryptedData = jsonNode.get("encryptedData" ).asText(); String decryptedBody = cryptoService.decrypt(encryptedData); log.debug("请求体已解密" ); return new DecryptedHttpInputMessage (inputMessage, decryptedBody); } 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)) { 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 — 字段级解密 :逐个检查 password、oldPassword、newPassword 字段,如果字段值长度 >= 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; } } } 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 { byte [] iv = new byte [GCM_IV_LENGTH]; new SecureRandom ().nextBytes(iv); Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); GCMParameterSpec gcmSpec = new GCMParameterSpec (GCM_TAG_LENGTH, iv); cipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec); byte [] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); 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 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 function isAesEncryptedData (data : any ): boolean { if (typeof data !== 'string' ) { return false } const parts = data.split ('.' ) if (parts.length !== 2 ) { return false } try { atob (parts[0 ]) atob (parts[1 ]) return parts[0 ].length === 16 && parts[1 ].length > 10 } catch { return false } }async function aesDecrypt (encryptedData : string , aesKeyBase64 : string ): Promise <string > { const parts = encryptedData.split ('.' ) if (parts.length !== 2 ) { throw new Error ('加密数据格式错误' ) } 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 )) const aesKey = await crypto.subtle .importKey ( 'raw' , keyBytes, { name : 'AES-GCM' }, false , ['decrypt' ] ) 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 || '请求失败' )) } 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 ; private static final int MAX_ENCRYPT_BLOCK = 245 ; private static final int MAX_DECRYPT_BLOCK = 256 ; 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 public static String hybridEncrypt (String data, String publicKeyStr) throws Exception { KeyGenerator keyGen = KeyGenerator.getInstance(AES_ALGORITHM); keyGen.init(AES_KEY_SIZE, new SecureRandom ()); SecretKey aesKey = keyGen.generateKey(); byte [] iv = new byte [GCM_IV_LENGTH]; new SecureRandom ().nextBytes(iv); 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)); 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 ]); String aesKeyStr = decryptByPrivateKey(encryptedAesKey, privateKeyStr); byte [] aesKeyBytes = Base64.getDecoder().decode(aesKeyStr); SecretKey aesKey = new SecretKeySpec (aesKeyBytes, AES_ALGORITHM); 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 位
完整性校验标签
学习路线建议
先看配置接口 — CryptoController.java 了解前端如何获取加密参数
理解密钥管理 — CryptoServiceImpl.java 看 init()、loadKeys()、generateAesKey()
跟踪响应加密 — EncryptResponseBodyAdvice.java → encryptResponse() 方法
跟踪请求解密 — DecryptRequestBodyAdvice.java → beforeBodyRead() 方法
前端对照 — crypto.ts(RSA 加密)和 request.ts(AES 解密)
底层工具 — 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 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) { 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" , "encryptPublicKey" : "..." , "encryptPrivateKey" : "..." }
全局模式(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 提供的扩展能力,可用于特殊场景。