# 框架横评第 4 期:后台接口加密别只会 HTTPS,Forge 的 RSA + SM4/AES 源码拆解 > 前两期拆了多租户和数据权限,解决的是“谁能看哪些数据”。这一期拆接口加解密,解决的是“数据在路上怎么不裸奔”。 很多后台项目一谈接口安全,第一反应就是:**不是已经上 HTTPS 了吗?还要接口加密干嘛?** 这句话只对了一半。 HTTPS 解决的是传输链路加密,但企业项目里经常还有这些场景: - 登录、密码修改、支付确认等敏感接口,希望请求体本身加密。 - 前端埋点、抓包工具、代理层日志里,不希望直接看到明文参数。 - 政务、金融、运营商项目要求“应用层加密”。 - 内部网关、日志平台、第三方代理可能记录请求响应体。 所以 Forge Admin 的接口安全不是只靠 HTTPS,而是做了一套应用层 API 加解密: ```text 前端获取 RSA 公钥 ↓ 前端生成 SM4/AES 会话密钥 ↓ 用 RSA 公钥加密会话密钥并交换 ↓ 后续请求/响应用会话密钥做对称加解密 ↓ 可选 nonce + timestamp 防重放 ``` 这一期就拆这套源码。 --- ## 一、为什么不是直接用 RSA 加密所有数据? 很多人一听“加密”,第一反应就是 RSA。 但真实工程里,RSA 不适合直接加密大体积请求体: 1. RSA 性能比对称加密慢得多。 2. RSA 有明文长度限制。 3. 大量接口都用 RSA 加密响应体,会拖慢整个系统。 所以更合理的方案是: - **RSA 只用来做密钥交换**。 - **SM4/AES 用来加密业务数据**。 这也是 HTTPS/TLS 的经典思路:非对称加密解决“安全交换密钥”,对称加密解决“高效传输数据”。 Forge 也是这么做的。 --- ## 二、第一步:前端获取 RSA 公钥 入口在 `KeyExchangeController`: ```java @GetMapping("/public-key") @SaIgnore public ResponseEntity> getPublicKey() { Map result = new HashMap<>(); result.put("code", 200); result.put("data", new PublicKeyResponse(keyExchangeService.getPublicKey(), "RSA")); result.put("msg", "success"); return ResponseEntity.ok(result); } ``` 接口路径是: ```text GET /crypto/public-key ``` 这个接口有三个细节: 1. `@SaIgnore`:获取公钥不需要登录。 2. `@ApiPermissionIgnore`:跳过 API 权限控制。 3. `@IgnoreTenant`:密钥交换是平台级能力,不受租户隔离影响。 前端拿到公钥后,生成一个随机的 SM4/AES 会话密钥,然后用 RSA 公钥加密这个会话密钥。 --- ## 三、第二步:RSA 交换会话密钥 前端把加密后的会话密钥提交到: ```text POST /crypto/exchange ``` 核心源码: ```java @PostMapping("/exchange") @SaIgnore public ResponseEntity> exchangeKey( @RequestBody KeyExchangeRequest request, HttpServletRequest httpRequest) { String sessionId = getSessionId(httpRequest); if (sessionId == null || sessionId.isEmpty()) { return ResponseEntity.badRequest().body(result); } boolean success = keyExchangeService.exchangeKey(sessionId, request.getEncryptedKey()); // ... 返回结果 } ``` 这里关键是 `sessionId`。Forge 取会话标识的顺序是: 1. 优先取 `Authorization: Bearer xxx` 里的 token。 2. 其次取 `X-Session-Id`。 3. 最后用 HTTP Session ID。 也就是说,会话密钥不是全局一把钥匙,而是**绑定到当前会话**。 真正解密和存储在 `KeyExchangeService`: ```java public boolean exchangeKey(String sessionId, String encryptedKey) { String sessionKey = rsaKeyPairHolder.decryptByPrivateKey(encryptedKey); sessionKeyStore.storeKey(sessionId, sessionKey); return true; } ``` RSA 私钥只负责解开“会话密钥”,不会直接处理业务请求体。 --- ## 四、会话密钥放哪?Redis,默认 2 小时 会话密钥如果只存在内存里,多实例部署就麻烦了:A 机器交换密钥,B 机器处理请求,B 拿不到密钥。 Forge 的 `SessionKeyStore` 用缓存服务存储会话密钥,底层可接 Redis: ```java private static final String KEY_PREFIX = "crypto:session:"; public void storeKey(String sessionId, String secretKey) { String cacheKey = KEY_PREFIX + sessionId; cacheService.set(cacheKey, secretKey, expireSeconds, TimeUnit.SECONDS); } public String getKey(String sessionId) { String cacheKey = KEY_PREFIX + sessionId; Object value = cacheService.get(cacheKey); return value != null ? value.toString() : null; } ``` 默认过期时间来自配置: ```java private Long sessionKeyExpire = 7200L; // 2小时 ``` 这点很实用: - 多实例部署没问题。 - 用户登出可以清理密钥。 - 密钥过期后需要重新协商。 - 不需要把会话密钥写进数据库。 --- ## 五、请求怎么自动解密?RequestBodyAdvice 很多人写接口加密,最后会变成这样: ```java @PostMapping("/login") public Result login(@RequestBody EncryptedRequest request) { String json = cryptoService.decrypt(request.getData()); LoginDTO dto = JSON.parseObject(json, LoginDTO.class); // 业务逻辑... } ``` 这样写的问题是:每个接口都要手动解密,业务代码被加密逻辑污染。 Forge 用的是 Spring MVC 的 `RequestBodyAdvice`,在 Controller 参数绑定之前自动解密。 核心类是 `DecryptRequestBodyAdvice`: ```java public boolean supports(MethodParameter methodParameter, Type targetType, Class> converterType) { if (!properties.getEnabled() || !properties.getEnableApiCrypto()) { return false; } if ("true".equalsIgnoreCase(request.getHeader("X-Inner-Call"))) { return false; } ApiConfigInfo apiConfig = apiConfigManager.getApiConfig(request.getRequestURI(), request.getMethod()); if (apiConfig != null) { return apiConfig.getNeedEncrypt(); } return hasAnnotation(methodOrClass, ApiDecrypt.class); } ``` 它判断是否需要解密有两条路: 1. **动态 API 配置**:后台配置某个接口是否需要加密。 2. **注解方式**:Controller 类或方法上标 `@ApiDecrypt`。 这就比单纯注解更灵活:上线后想把某个接口改成加密,不一定要改代码发布。 真正解密发生在 `beforeBodyRead`: ```java EncryptedRequest request = objectMapper.readValue(encryptedBody, EncryptedRequest.class); Encryptor encryptor = encryptorFactory.getEncryptor(algorithm); String sessionKey = getSessionKey(); if (sessionKey != null) { decryptedData = encryptor.decrypt(request.getData(), sessionKey); } else { decryptedData = encryptor.decrypt(request.getData()); } return new DecryptedHttpInputMessage(headers, new ByteArrayInputStream(decryptedData.getBytes(UTF_8))); ``` 最终 Controller 拿到的仍然是正常 DTO,业务代码完全不知道请求曾经是密文。 --- ## 六、响应怎么自动加密?ResponseBodyAdvice 请求能自动解密,响应也要自动加密。 Forge 用 `EncryptResponseBodyAdvice` 在响应写出前处理: ```java public boolean supports(MethodParameter returnType, Class> converterType) { if (!properties.getEnabled() || !properties.getEnableApiCrypto()) { return false; } if (isBinaryResponseType(returnType)) { return false; } if ("true".equalsIgnoreCase(request.getHeader("X-Inner-Call"))) { return false; } ApiConfigInfo apiConfig = apiConfigManager.getApiConfig(request.getRequestURI(), request.getMethod()); if (apiConfig != null) { return apiConfig.getNeedEncrypt(); } return hasAnnotation(methodOrClass, ApiEncrypt.class); } ``` 几个细节值得看: 1. **二进制响应不加密**:比如文件、图片、验证码,避免把 `ResponseEntity` 搞坏。 2. **内部调用跳过**:`X-Inner-Call: true` 的 FlowClient 等内部服务调用,直接明文 JSON。 3. **支持动态配置和注解两种方式**。 真正加密逻辑: ```java String jsonBody = objectMapper.writeValueAsString(body); String sessionKey = getSessionKey(request); if (sessionKey != null) { encryptedData = encryptor.encrypt(jsonBody, sessionKey); } else { encryptedData = encryptor.encrypt(jsonBody); } return new EncryptedResponse(encryptedData, algorithm); ``` 响应格式会变成统一的密文对象,而不是直接返回原始 JSON。 --- ## 七、算法不是写死的:SM4 / AES 可切换 Forge 的默认算法是 SM4: ```java private String algorithm = "SM4"; ``` 但代码里用的是 `EncryptorFactory`: ```java Encryptor encryptor = encryptorFactory.getEncryptor(algorithm); ``` 这意味着算法不是写死在 Advice 里的。默认走 SM4,想换 AES 也可以通过配置或注解指定: ```java @ApiEncrypt(algorithm = "AES") @ApiDecrypt(algorithm = "AES") ``` 这对不同合规要求的项目很有用:有的项目要求国密 SM4,有的项目只需要 AES。 --- ## 八、防重放:timestamp + nonce 加密只能保证“看不懂”,不能阻止别人把同一个密文请求重复发一遍。 比如支付确认、审批通过、转账提交,如果攻击者截到一次请求后重复发送,即使看不懂内容,也可能造成重复操作。 Forge 提供了 `ReplayAttackFilter` 做防重放: ```java String timestamp = httpRequest.getHeader("X-Timestamp"); String nonce = httpRequest.getHeader("X-Nonce"); long requestTime = Long.parseLong(timestamp); long currentTime = System.currentTimeMillis(); long timeWindow = properties.getReplayTimeWindow() * 1000; if (Math.abs(currentTime - requestTime) > timeWindow) { sendError(response, "请求已过期"); return; } if (tokenCache.exists(nonce)) { sendError(response, "重复的请求"); return; } tokenCache.cache(nonce, properties.getReplayTimeWindow()); ``` 防重放的逻辑很清楚: 1. 请求必须带 `X-Timestamp` 和 `X-Nonce`。 2. 时间戳必须在允许窗口内,默认 300 秒。 3. nonce 只能使用一次,使用后写入缓存。 4. 同一个 nonce 再来,直接拒绝。 默认配置里,防重放是关闭的,需要按项目风险打开: ```java private Boolean enableReplayProtection = false; private Long replayTimeWindow = 300L; ``` 这点我反而觉得合理:不是所有内部后台都需要防重放,只有支付、审批、资金、状态流转类接口需要强保护。 --- ## 九、配置开关:不是所有接口都必须加密 Forge 的加密配置集中在 `CryptoProperties`: ```java private Boolean enabled = true; private String algorithm = "SM4"; private String secretKey; private Boolean enableDynamicKey = true; private Long sessionKeyExpire = 7200L; private Boolean enableApiCrypto = true; private Boolean enableFieldCrypto = true; private Boolean enableReplayProtection = false; private Boolean enableDesensitize = true; ``` 几个设计点: - `enableDynamicKey`:是否启用动态密钥协商。 - `secretKey`:动态密钥不可用时的降级密钥。 - `enableApiCrypto`:是否启用 API 请求/响应加解密。 - `enableFieldCrypto`:是否启用字段级加解密。 - `enableDesensitize`:是否启用字段脱敏。 这说明 Forge 的安全能力不是硬编码在业务里,而是做成了 Starter 能力,可开可关、可配置、可组合。 --- ## 十、和普通“接口加密工具类”有什么区别? 很多项目也有接口加密,但只是一个工具类: ```java String data = AesUtil.decrypt(request.getData()); ``` 这种做法只能算“能用”,不算体系化。 Forge 的完整度体现在这几层: | 层级 | Forge 做法 | |------|------------| | 密钥交换 | RSA 公钥获取 + RSA 私钥解密会话密钥 | | 会话密钥 | 按 token/session 维度存 Redis,默认 2 小时 | | 请求解密 | `RequestBodyAdvice` 自动解密,不污染 Controller | | 响应加密 | `ResponseBodyAdvice` 自动加密,统一返回密文结构 | | 算法扩展 | `EncryptorFactory` 支持 SM4 / AES 可切换 | | 动态配置 | API 配置表 + `@ApiEncrypt` / `@ApiDecrypt` 双模式 | | 内部调用 | `X-Inner-Call` 跳过,避免服务间调用互相加密 | | 防重放 | timestamp + nonce + cache,一次性请求校验 | | 二进制保护 | 文件/图片响应自动跳过加密 | 这就是“工具类”和“框架能力”的区别。 --- ## 十一、有没有短板? 有。 第一,应用层加密会增加前后端复杂度。前端必须完成公钥获取、会话密钥生成、密钥交换、请求加密、响应解密。新手调试时会比普通 JSON 接口麻烦。 第二,接口加密不是万能的。它不能替代权限校验、不能替代幂等、不能阻止业务逻辑漏洞。它只是保护传输内容和防止简单重放。 第三,动态密钥依赖缓存服务。Redis 不可用时,如果没有合理降级和监控,会影响加密接口调用。 所以我的建议是:**不要全站无脑加密,只给敏感接口加密。** 登录、修改密码、支付确认、审批流转、个人隐私提交,这些值得加;普通列表查询没必要都加。 --- ## 十二、总结:Forge 的 API 安全强在哪? 一句话:**它不是“加个 AES 工具类”,而是把接口加密做成了 Spring MVC 层的基础设施。** 它的亮点: 1. RSA 只做密钥交换,SM4/AES 加密业务数据。 2. 会话密钥绑定 token/session,并存 Redis。 3. 请求体解密在 Controller 参数绑定前完成。 4. 响应体加密在返回前自动完成。 5. API 配置和注解两种方式都支持。 6. 防重放用 timestamp + nonce 控制。 7. 内部调用、二进制响应、排除路径都有跳过机制。 多租户解决“租户之间不能串”,数据权限解决“租户内部谁能看什么”,接口加密解决“数据传输过程不裸奔”。这三件事连起来,才是一个企业后台真正的安全底座。 --- ## 下一期拆什么? 横评第 5 期可以继续拆: - `1`:AI 代码生成,为什么 Forge 不是让 AI 直接写一坨代码 - `2`:Flowable 工作流,流程怎么和业务绑定 - `3`:低代码 AiCrudPage,为什么协议驱动比生成代码更适合长期产品 评论区扣数字,我按票数写。 --- **源码自取**: - Gitee:https://gitee.com/ForgeLab/forge-admin - GitHub:https://github.com/yaomindong1996/forge-admin - 在线演示:http://www.dlforgelab.com:8084/forge/login (admin / 123456) > 你们项目的敏感接口怎么保护?只靠 HTTPS,还是也做应用层加密?评论区聊聊。