后台接口加密别只会 HTTPS,ForgeAdmin 的 RSA + SM4/AES 源码拆解

很多后台项目一谈接口安全,第一反应就是:不是已经上 HTTPS 了吗?还要接口加密干嘛?

这句话只对了一半。

HTTPS 解决的是传输链路加密,但企业项目里经常还有这些场景:

  • 登录、密码修改、支付确认等敏感接口,希望请求体本身加密。
  • 前端埋点、抓包工具、代理层日志里,不希望直接看到明文参数。
  • 政务、金融、运营商项目要求"应用层加密"。
  • 内部网关、日志平台、第三方代理可能记录请求响应体。

所以 Forge Admin 的接口安全不是只靠 HTTPS,而是做了一套应用层 API 加解密:

sql 复制代码
前端获取 RSA 公钥
   ↓
前端生成 SM4/AES 会话密钥
   ↓
用 RSA 公钥加密会话密钥并交换
   ↓
后续请求/响应用会话密钥做对称加解密
   ↓
可选 nonce + timestamp 防重放

这一期就拆这套源码。


一、为什么不是直接用 RSA 加密所有数据?

很多人一听"加密",第一反应就是 RSA。

但真实工程里,RSA 不适合直接加密大体积请求体:

  1. RSA 性能比对称加密慢得多。
  2. RSA 有明文长度限制。
  3. 大量接口都用 RSA 加密响应体,会拖慢整个系统。

所以更合理的方案是:

  • RSA 只用来做密钥交换
  • SM4/AES 用来加密业务数据

这也是 HTTPS/TLS 的经典思路:非对称加密解决"安全交换密钥",对称加密解决"高效传输数据"。

Forge 也是这么做的。


二、第一步:前端获取 RSA 公钥

入口在 KeyExchangeController

less 复制代码
@GetMapping("/public-key")
@SaIgnore
public ResponseEntity<Map<String, Object>> getPublicKey() {
    Map<String, Object> result = new HashMap<>();
    result.put("code", 200);
    result.put("data", new PublicKeyResponse(keyExchangeService.getPublicKey(), "RSA"));
    result.put("msg", "success");
    return ResponseEntity.ok(result);
}

接口路径是:

vbnet 复制代码
GET /crypto/public-key

这个接口有三个细节:

  1. @SaIgnore:获取公钥不需要登录。
  2. @ApiPermissionIgnore:跳过 API 权限控制。
  3. @IgnoreTenant:密钥交换是平台级能力,不受租户隔离影响。

前端拿到公钥后,生成一个随机的 SM4/AES 会话密钥,然后用 RSA 公钥加密这个会话密钥。


三、第二步:RSA 交换会话密钥

前端把加密后的会话密钥提交到:

bash 复制代码
POST /crypto/exchange

核心源码:

less 复制代码
@PostMapping("/exchange")
@SaIgnore
public ResponseEntity<Map<String, Object>> 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

typescript 复制代码
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:

typescript 复制代码
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;
}

默认过期时间来自配置:

ini 复制代码
private Long sessionKeyExpire = 7200L; // 2小时

这点很实用:

  • 多实例部署没问题。
  • 用户登出可以清理密钥。
  • 密钥过期后需要重新协商。
  • 不需要把会话密钥写进数据库。

五、请求怎么自动解密?RequestBodyAdvice

很多人写接口加密,最后会变成这样:

less 复制代码
@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

kotlin 复制代码
public boolean supports(MethodParameter methodParameter, Type targetType,
        Class<? extends HttpMessageConverter<?>> 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

ini 复制代码
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 在响应写出前处理:

kotlin 复制代码
public boolean supports(MethodParameter returnType,
        Class<? extends HttpMessageConverter<?>> 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<byte[]> 搞坏。
  2. 内部调用跳过X-Inner-Call: true 的 FlowClient 等内部服务调用,直接明文 JSON。
  3. 支持动态配置和注解两种方式

真正加密逻辑:

ini 复制代码
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:

ini 复制代码
private String algorithm = "SM4";

但代码里用的是 EncryptorFactory

ini 复制代码
Encryptor encryptor = encryptorFactory.getEncryptor(algorithm);

这意味着算法不是写死在 Advice 里的。默认走 SM4,想换 AES 也可以通过配置或注解指定:

less 复制代码
@ApiEncrypt(algorithm = "AES")
@ApiDecrypt(algorithm = "AES")

这对不同合规要求的项目很有用:有的项目要求国密 SM4,有的项目只需要 AES。


八、防重放:timestamp + nonce

加密只能保证"看不懂",不能阻止别人把同一个密文请求重复发一遍。

比如支付确认、审批通过、转账提交,如果攻击者截到一次请求后重复发送,即使看不懂内容,也可能造成重复操作。

Forge 提供了 ReplayAttackFilter 做防重放:

ini 复制代码
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-TimestampX-Nonce
  2. 时间戳必须在允许窗口内,默认 300 秒。
  3. nonce 只能使用一次,使用后写入缓存。
  4. 同一个 nonce 再来,直接拒绝。

默认配置里,防重放是关闭的,需要按项目风险打开:

ini 复制代码
private Boolean enableReplayProtection = false;
private Long replayTimeWindow = 300L;

这点我反而觉得合理:不是所有内部后台都需要防重放,只有支付、审批、资金、状态流转类接口需要强保护。


九、配置开关:不是所有接口都必须加密

Forge 的加密配置集中在 CryptoProperties

ini 复制代码
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 能力,可开可关、可配置、可组合。


十、和普通"接口加密工具类"有什么区别?

很多项目也有接口加密,但只是一个工具类:

ini 复制代码
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,为什么协议驱动比生成代码更适合长期产品

评论区扣数字,我按票数写。


源码自取

你们项目的敏感接口怎么保护?只靠 HTTPS,还是也做应用层加密?评论区聊聊。

相关推荐
极光技术熊1 小时前
Spring AI 从入门到精通:构建你的 AI 开发知识体系
后端·github
程序员cxuan1 小时前
一句话,让你用上 GPT-5.6
人工智能·后端·程序员
远航_1 小时前
OpenSpec 完整详细介绍
前端·后端
AskHarries2 小时前
不用公网 IP,把 Windows 和 Linux 服务器放进同一个局域网:Tailscale 组网实战
后端
Randyliu2 小时前
20260508-Agent搭建记录以及对ReAct框架的理解
面试·agent
神奇小汤圆2 小时前
Java 的1 亿次对象创建:JVM 开启 / 关闭逃逸分析,GC 性能差距巨大
后端
tangdou3690986552 小时前
AI真好玩系列-2分钟快速了解DeepAgents | Quick Guide to DeepAgents in 2 Minutes
前端·javascript·后端
神奇小汤圆2 小时前
面试官:MySQL 为什么要是使用 MVCC?原理是什么?
后端
像我这样帅的人丶你还2 小时前
Java 后端详解(五):Redis 缓存
java·后端·全栈