
关键词:接口安全 / 传输加密 / SM4 国密 / 动态密钥协商 / 请求体加解密
一、问题场景:你的接口正在"裸奔"
打开浏览器 DevTools 的 Network 面板,找到你后台的登录接口,看一眼 Request Payload:
perl
{
"username": "admin",
"password": "MyPass@2025"
}
明文。谁能看到?公司 WiFi 网的任何抓包工具、中间代理、甚至浏览器插件的恶意脚本。
有人说"我上了 HTTPS,传输层加密就够了"。但 HTTPS 只保护传输过程,到了客户端(浏览器内存)和收到之后的反向代理(Nginx 日志、网关日志)依然是明文。如果你的网关会把请求体打到日志里------运维同学转头就能看到所有用户的密码。
更关键的是:前端加密不是防黑客,是防"中间人" ------包括日志系统、监控系统、CDN 缓存、甚至是合法的内部运维人员。
Forge Admin 的 forge-starter-crypto 模块就是为此而生。它提供了一套从注解声明到底层密钥协商的全链路接口加解密方案,开发者只需要加两个注解,剩下的全部自动完成。
二、解决方案:三个注解解决 90% 的加密需求
2.1 响应加密:@ApiEncrypt
在 Controller 类或方法上加 @ApiEncrypt,该接口的返回值会自动加密:
less
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
@ApiEncrypt // 返回的 UserDTO 自动加密
public RespInfo<UserDTO> getProfile() {
return RespInfo.success(userService.getCurrentUser());
}
}
前端收到的响应不再是一个 JSON 对象,而是一个加密信封:
json
{
"data": "SM4加密后的密文Base64字符串...",
"algorithm": "SM4"
}
前端的 crypto-interceptor.js 拦截器会自动解密,业务代码完全无感。
2.2 请求解密:@ApiDecrypt
对称的,在需要接收加密请求体的接口上加 @ApiDecrypt:
less
@PostMapping("/update-password")
@ApiDecrypt // 请求体自动解密后再进 Controller
public RespInfo<Void> updatePassword(@RequestBody UpdatePasswordRequest request) {
userService.updatePassword(request);
return RespInfo.success();
}
前端的 encrypt-request.js 拦截器会在发起请求前将 config.data 加密成 { data: "...", algorithm: "SM4" },后端 DecryptRequestBodyAdvice 在 @RequestBody 反序列化之前先解密。
2.3 字段级加密:@CryptoField
有些场景不需要全接口加密,只需要对个别敏感字段(身份证、银行卡号)加密:
typescript
@Data
public class UserProfileVO {
private String username;
@CryptoField(algorithm = "SM4")
private String idCard; // 序列化时自动加密,反序列化时自动解密
@Desensitize(type = DesensitizeType.PHONE)
private String phone; // 仅脱敏,不加密
}
@CryptoField 通过 Jackson 的 ContextualSerializer/ContextualDeserializer 在 JSON 转换时自动介入。更妙的是,它可以和 @Desensitize 配合使用------脱敏只改展示(日志里看到的还是明文),加密才是真正的数据保护。
@Desensitize 内置了 8 种脱敏策略:
| 类型 | 策略 | 示例 |
|---|---|---|
PHONE |
保留前3后4 | 138****1234 |
ID_CARD |
保留前6后4 | 110101********1234 |
EMAIL |
@前保留首字符 | t**@example.com |
BANK_CARD |
保留后4位 | ****1234 |
NAME |
保留姓氏 | 张* |
PASSWORD |
全部替换 | ****** |
CUSTOM |
prefixKeep/suffixKeep 自定义 | 灵活配置 |
三、数据结构:加密信封协议
整个加解密方案的核心是一个简单的信封协议,前后端统一遵循:
请求信封(前端→后端) :
json
{
"data": "加密后的请求体 Base64 字符串",
"algorithm": "SM4"
}
响应信封(后端→前端) :
json
{
"data": "加密后的响应体 Base64 字符串",
"algorithm": "SM4"
}
注意 algorithm 字段的存在------它允许前端在单个请求中覆盖算法,在后端配置默认 SM4 的情况下,某个接口可以单独指定使用 AES。这个字段是整个协议"协商性"的体现。
对应的后端数据结构:
arduino
// 请求信封
public class EncryptedRequest {
private String data; // 密文
private String algorithm; // 加密算法标识
}
// 响应信封
public class EncryptedResponse {
private String data;
private String algorithm;
}
四、实现链路:从注解到密文的完整旅程
4.1 请求解密链路
scss
前端 encryptRequest()
→ HTTP POST { data: "密文", algorithm: "SM4" }
→ DecryptRequestBodyAdvice.supports() 判断是否需要解密
→ 检查 X-Inner-Call 头(内部调用跳过)
→ IApiConfigManager 查 API 配置(数据库配置优先)
→ 降级到 @ApiDecrypt 注解
→ beforeBodyRead() 读取原始 JSON → 解析为 EncryptedRequest
→ EncryptorFactory.getEncryptor("SM4") 获取加密器
→ SessionKeyStore 优先拿会话密钥,降级用默认密钥
→ 解密 data 字段 → 得到明文 JSON 字符串
→ Controller 方法接收到已反序列化的 Java 对象
关键设计 :解密发生在 @RequestBody 反序列化之前 ,也就是说 Controller 收到的 @RequestBody 对象已经是明文,业务代码完全不感知加密层的存在。
4.2 响应加密链路
scss
Controller 返回 RespInfo<UserDTO>
→ EncryptResponseBodyAdvice.supports() 判断是否需要加密
→ 自动跳过 ResponseEntity<byte[]>(文件下载不加密)
→ 同上:API 配置 > @ApiEncrypt 注解
→ beforeBodyWrite() 将响应体序列化为 JSON 字符串
→ 获取加密器 → 加密 → 包装为 EncryptedResponse
→ 写入输出流
→ 前端 decryptResponse() 拦截器自动解密
4.3 密钥管理:默认密钥 + 动态协商双层机制
这是整个方案安全的基石。如果所有请求用同一个固定密钥,那和不用加密区别不大------密钥一旦从配置文件泄露,历史数据全部可解密。
默认密钥(降级方案):
vbnet
forge:
crypto:
secret-key: "Base64编码的16字节密钥"
仅当动态密钥不可用时使用,例如:
- 前端还没完成密钥协商就发起了加密请求
- Redis 中会话密钥已过期
- 内部服务间调用
动态密钥协商(核心机制):
vbnet
┌─────────┐ ┌─────────┐
│ 前端 │ │ 后端 │
└────┬────┘ └────┬────┘
│ GET /crypto/public-key │
│─────────────────────────────────────→│ 返回 RSA 公钥
│ │
│ 生成 16 字节随机会话密钥 │
│ 用 RSA 公钥加密 │
│ │
│ POST /crypto/exchange │
│ { encryptedKey: "..." } │
│─────────────────────────────────────→│ RSA 私钥解密
│ │ 存入 Redis
│ │ crypto:session:{sessionId}
│ 后续所有请求用会话密钥加密 │
│─────────────────────────────────────→│ 从 Redis 取出会话密钥解密
密钥的生命周期:
- 会话密钥过期时间默认 7200 秒(2 小时),超时自动失效
- 前端解密失败(padding error)时自动触发重新协商
- sessionId 优先从
Authorization: Bearer {token}提取,降级到X-Session-Id头
4.4 内部调用绕过
你不是每时每刻都需要加密。微服务间的内部调用如果也要加密解密,纯属浪费 CPU。模块通过请求头 X-Inner-Call: true 自动跳过加解密流程,零配置。
五、设计取舍:我们做了哪些决定,为什么
5.1 默认算法:为什么是 SM4 而不是 AES?
AES 是全球标准,库支持好,硬件加速成熟。但我们选择了 SM4 作为默认算法,原因:
- 合规要求:国密算法是国内政务、金融系统的硬性要求,SM4 是其中之一
- 安全性等价:SM4 是 128 位分组密码,安全强度与 AES-128 对等,已通过国家密码管理局认证
- 可切换 :我们保留了 AES 作为可选项,
@ApiEncrypt(algorithm = "AES")一行切换
这个决策的代价是引入了 BouncyCastle 依赖(约 4MB),对于不需要国密的项目是额外负担。但考虑到 Forge Admin 的目标用户群体(国内政企),这个取舍是值得的。
5.2 为什么不用 HTTPS 就完事?
这是个经典问题。HTTPS 保护的是传输信道 ,而我们的方案保护的是数据本身:
| 保护点 | HTTPS | forge-starter-crypto |
|---|---|---|
| 网络抓包 | ✅ | ✅ |
| 代理/网关日志 | ❌ | ✅ |
| 浏览器内存 | ❌ | ✅(加密后传输) |
| CDN 缓存 | ❌ | ✅ |
| 数据库存储 | ❌ | ✅(@CryptoField + TypeHandler) |
二者是互补关系,不是替代关系。
5.3 防重放:默认关闭的设计哲学
防重放攻击(Replay Attack)的 ReplayAttackFilter 我们默认是关闭 的,需要手动 enable-replay-protection: true 开启。
原因很简单:防重放需要前端配合发送 X-Timestamp + X-Nonce 头,且 Redis 要存储大量 nonce。对于大部分内部管理系统来说,这是过度设计。只有面向公网暴露的接口(如支付回调、开放 API)才需要开启。
5.4 已知的可优化点
EncryptTypeHandler中的密钥硬编码(forge_client_secret_key_16b)仅适用于客户端数据保护场景,服务端生产环境应改为从配置读取- 动态密钥协商的 RSA 密钥对默认自动生成,建议生产环境通过配置固定
六、二开指南:怎么接入你自己的项目
6.1 引入依赖
xml
<dependency>
<groupId>com.mdframe.forge</groupId>
<artifactId>forge-starter-crypto</artifactId>
</dependency>
6.2 基础配置
yaml
forge:
crypto:
enabled: true
secret-key: "your-base64-encoded-16-byte-key"
algorithm: SM4 # 默认算法
enable-replay-protection: false
6.3 三步接入
第一步:在需要的 Controller 上加注解:
less
@ApiEncrypt // 类级别,所有接口响应加密
@RestController
public class SensitiveController {
@ApiDecrypt // 方法级别,单个接口请求解密
@PostMapping("/submit")
public RespInfo<Void> submit(@RequestBody SensitiveData data) {
// 业务代码完全不用改
}
}
第二步 :前端配置拦截器(forge-admin-ui 已内置,新项目需引入 crypto-interceptor.js):
javascript
import { setupCrypto } from '@/utils/crypto/crypto-interceptor'
setupCrypto(axiosInstance)
第三步:扩展自定义算法(如果需要):
less
@Component
public class SM2Encryptor implements Encryptor {
@Override
public String encrypt(String plainText, byte[] key) { /* SM2 实现 */ }
@Override
public String decrypt(String cipherText, byte[] key) { /* SM2 实现 */ }
@Override
public CryptoAlgorithm getAlgorithm() { return CryptoAlgorithm.SM2; }
}
七、体验预告
forge-starter-crypto 的设计目标是"加两个注解就能工作"。从实际接入效果来看,一个标准 CRUD 接口的接入成本是 3 行代码(1 行注解 + 2 行前端拦截器配置),无需改动任何业务逻辑。
下一步我们将推出基于数据库配置的动态 API 加密策略------在管理后台界面勾选哪些接口需要加密、使用什么算法,无需重启服务即可生效。
下一篇预告:B05《文件访问不能只返回 URL:Forge Admin 鉴权图片和文件存储设计》------私有文件怎么在后台安全展示,为什么直接返回 OSS URL 是安全漏洞
体验 Forge Admin
- 在线演示 :Forge Admin 后台管理
- 默认账号:admin / 123456
- 多租户体验:登录后查看不同租户的数据隔离效果
- Gitee :ForgeLab/forge-admin
- GitHub :yaomindong1996/forge-admin