HTTP 接口加解密
这篇解决什么
接口报文可能被中间人读走,开放给第三方的接口还可能被改参、重放。读完能打开加解密开关、对齐前后端密钥、在方法上挂 @ApiEncrypt / @ApiSignature,以及把调用方 appSecret 写进 Redis。
默认端口 48080,管理端前缀 /admin-api。加解密只处理请求体和响应体,不改库、不替代登录 Token。签名验的是「谁在调、有没有被改」。
示意图:请求先过框架过滤器与切面,再进 Controller。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 加解密 Starter | ApiEncryptFilter 解密请求、加密响应 | ruoyi-office/yudao-framework/yudao-spring-boot-starter-web/ |
| 签名 Starter | @ApiSignature + ApiSignatureAspect | ruoyi-office/yudao-framework/yudao-spring-boot-starter-protection/ |
| 单体入口 | 已引入 web、protection | ruoyi-office/yudao-server/pom.xml |
| 管理端拦截 | isEncrypt 为真才加密 body | ruoyi-office-vben/apps/web-antd/src/api/request.ts |
| 移动端拦截 | 同样看 isEncrypt | ruoyi-office-uniapp/src/http/interceptor.ts |
为什么用 Filter,不用 RequestBodyAdvice
访问日志、异常日志、签名切面都要读明文 body。过滤器挂在 REQUEST_BODY_CACHE_FILTER 之后,比 Controller 更早解密,后面的组件读到的是明文。
请求与响应加解密
yudao.api-encrypt.enable 为 true 时,才注册 ApiEncryptFilter。过滤器只拦 /admin-api、/app-api。
示意图:有加密头就解密;注解要求加密却没带头,直接拒绝。响应加密只看注解。
过滤器与包装
| 名称 | 说明 | 仓库路径 |
|---|---|---|
YudaoApiEncryptAutoConfiguration | enable=true 才装配过滤器 | .../encrypt/config/YudaoApiEncryptAutoConfiguration.java |
ApiEncryptFilter | 解析注解、解密、包装响应 | .../encrypt/core/filter/ApiEncryptFilter.java |
ApiDecryptRequestWrapper | 读密文 body,AES 或 RSA 私钥解密 | 同目录 ApiDecryptRequestWrapper.java |
ApiEncryptResponseWrapper | 缓存输出,最后 Base64 加密 | 同目录 ApiEncryptResponseWrapper.java |
| 顺序 | API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1 | yudao-framework/yudao-common/.../enums/WebFilterOrderEnum.java |
| 作用范围 | 继承 ApiRequestFilter,只过管理端 / App 前缀 | .../web/core/filter/ApiRequestFilter.java |
构造函数里只认 AES、RSA。其它算法名会抛 IllegalArgumentException。ApiEncryptProperties 注释里的 SM2、SM4 要自己在构造函数补实例和 Maven 依赖。
GET 不走解密。解密失败交给 GlobalExceptionHandler,按 CommonResult 写出。
响应加密后会加 X-Api-Encrypt: true,并 Access-Control-Expose-Headers 暴露这个头,否则浏览器里前端读不到、无法解密。
注解
@ApiEncrypt 可标在类或方法。方法优先,没有再看类。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
@ApiEncrypt | 声明「必须」加解密 | .../encrypt/core/annotation/ApiEncrypt.java |
request | 默认 true。POST / PUT / DELETE 必须带加密头 | 同上 |
response | 默认 true。响应体加密后再写出 | 同上 |
没有注解时,只要请求头 X-Api-Encrypt 非空,过滤器仍会解密。注解的含义是:不允许明文进来。
当前仓库里还没有业务 Controller 挂这个注解。登录接口也是明文。
@PostMapping("/create")
@ApiEncrypt
public CommonResult<Long> createXxx(@Valid @RequestBody XxxSaveReqVO reqVO) {
return success(xxxService.createXxx(reqVO));
}只要请求加密、不要响应加密:
@ApiEncrypt(request = true, response = false)先改前端再挂必须加密
方法上加了 @ApiEncrypt 且 request = true,却没带加密头,过滤器抛「请求未包含加密标头」。登录现在是 isEncrypt: false,不要先给 AuthController#login 挂注解。
后端配置
单体 application.yaml 默认打开,并写了 AES 示例密钥。生产文件只覆写开关,密钥仍继承这份。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
enable | true 才创建过滤器 | ApiEncryptProperties |
header | 默认 X-Api-Encrypt。yaml 里可改 | 同上 |
algorithm | AES 或 RSA | 同上 |
request-key | 后端解请求:AES 密钥,或 RSA 私钥 | 同上 |
response-key | 后端加密响应:AES 密钥,或 RSA 公钥 | 同上 |
| 单体默认 | enable: true,AES,32 位数字密钥 | ruoyi-office/yudao-server/src/main/resources/application.yaml |
| 生产开关 | enable: true,注释要求按合同关闭时同步前端 | .../application-prod.yaml |
| 微服务 system | enable: false | yudao-module-system/yudao-module-system-server/src/main/resources/application.yaml |
yudao:
api-encrypt:
enable: true
algorithm: AES
request-key: 52549111389893486934626385991395
response-key: 96103715984234343991809655248883AES 密钥长度按注释是 16、24 或 32。RSA 的示例公私钥写在同一段 yaml 的注释里,换算法时取消注释并改 algorithm。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| AES | 前后端 request-key 同一把,response-key 同一把 | ApiEncryptProperties |
| RSA | 后端 request-key 是私钥、response-key 是公钥;前端对调 | 同上 |
RSA 要两对密钥:请求一对、响应一对。请求和响应不能共用同一对。AES 可以让两边用同一把,也可以分开。
示例密钥不要上生产
yaml 和前端 .env 里的 AES 数字串是仓库自带样例。上线前用单测换一套,前后端一起改。
前端配置
管理端主应用读 VITE_APP_API_ENCRYPT_*。工具类在 @vben/utils 的 createApiEncrypt。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 环境变量 | ENABLE / HEADER / ALGORITHM / 两个 KEY | ruoyi-office-vben/apps/web-antd/.env |
| 类型声明 | 五个 VITE_APP_API_ENCRYPT_* | ruoyi-office-vben/packages/types/global.d.ts |
createApiEncrypt | 读 env,AES 用 CryptoJS ECB/PKCS7,RSA 用 JSEncrypt | ruoyi-office-vben/packages/@core/base/shared/src/utils/encrypt.ts |
| 请求拦截 | headers.isEncrypt 为真:加密 data,写加密头 | ruoyi-office-vben/apps/web-antd/src/api/request.ts |
| 响应拦截 | 响应头为 true 且 body 是字符串,再解密 | 同上 |
| 登录 | 显式 isEncrypt: false | ruoyi-office-vben/apps/web-antd/src/api/core/auth.ts |
| UniApp | 同样的 env 名和 isEncrypt | ruoyi-office-uniapp/env/.env、src/utils/encrypt.ts |
VITE_APP_API_ENCRYPT_ENABLE = true
VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt
VITE_APP_API_ENCRYPT_ALGORITHM = AES
VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395
VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883RSA 时:前端 REQUEST_KEY 是请求公钥,RESPONSE_KEY 是响应私钥,和后端刚好对调。
开关打开只表示「具备加解密能力」。真正加密某次请求,还要在调用处加头:
return requestClient.post('/xxx/create', data, {
headers: {
isEncrypt: true,
},
});前端 enable 关、isEncrypt 开
encryptRequest 在 enable !== true 时原样返回。拦截器仍会写加密头,后端会按密文去解,对不上。两边开关和密钥必须一起改。
PC 端 AES 加密允许密钥 16 或 32 位,解密要求 32 位。当前默认密钥是 32 位数字。仓库里还没有 isEncrypt: true 的调用点。
如何生成密钥
ApiEncryptTest#testGenerateAsymmetric 按算法打印四段。requestClientKey、responseClientKey 给前端;requestServerKey、responseServerKey 给后端。
路径:ruoyi-office/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java。
同文件还有 testEncrypt_aes、testEncrypt_rsa,可拿登录 JSON 样例打出密文,方便抓包对照。
HTTP 签名
给第三方接口防篡改、防重放。切面读 Header 里的 appId、timestamp、nonce、sign,用 Redis 里的 appSecret 重算 SHA256。
管理端请求层没有加签拦截器。签名字符串要调用方自己算,或跑 ApiSignatureTest 对照。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
@ApiSignature | 标在方法上才生效 | .../signature/core/annotation/ApiSignature.java |
ApiSignatureAspect | @Before("@annotation(signature)") | .../signature/core/aop/ApiSignatureAspect.java |
ApiSignatureRedisDAO | 读密钥、记 nonce | .../signature/core/redis/ApiSignatureRedisDAO.java |
| 自动配置 | Redis 之后装配切面 | .../signature/config/YudaoApiSignatureAutoConfiguration.java |
| 单测 | 拼串并验 GET 样例 | .../src/test/java/.../signature/core/ApiSignatureTest.java |
单体 yudao-server 已引入 protection。微服务 yudao-module-system-server 的 protection 依赖是注释掉的,那个进程要用签名先打开依赖。
验签过程
示意图:先对时限和随机数,再对签名,最后用 Redis 挡住重放。
签名字符串(顺序固定):
排序后的 query/form + 原始 body + 排序后的 appId/nonce/timestamp + appSecret对应代码:MapUtil.join(parameterMap) + ServletUtils.getBody + MapUtil.join(headerMap) + appSecret,再 DigestUtil.sha256Hex。sign 本身不进拼接。Header 用 TreeMap,所以是 appId、nonce、timestamp 字典序。
四个字段走 Header,避免和 Query、Body 重名。注解可改字段名,默认就是这四个。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
timeout / timeUnit | 默认 60 秒。` | now - timestamp |
message | 验签失败文案,默认「签名不正确」 | 同上;错误码 BAD_REQUEST |
nonce | 长度小于 10 直接失败 | ApiSignatureAspect#verifyHeaders |
| 重放 | nonce TTL = timeout * 2 | setNonce;错误码 900 |
找不到 appSecret 时,Assert.notNull 直接失败,不会落到业务方法。
加解密和签名一起用
过滤器先解 body,切面再读 body。调用方必须对明文 body 签名,再把 body 加密发出。对密文签名会验不过。
Redis 调用方
| 名称 | 说明 | 仓库路径 |
|---|---|---|
api_signature_app | HASH。field 是 appId,value 是 appSecret,不过期 | ApiSignatureRedisDAO |
api_signature_nonce:{appId}:{nonce} | 用过的随机数,SETNX | 同上 |
HSET api_signature_app test 123456本机 Redis 默认 127.0.0.1:6379。没有这条 HASH,对应 appId 验不过。
如何加签
切面只匹配方法上的注解。@Target 含 TYPE,但 @annotation 扫不到类上的声明。
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:user:list')")
@ApiSignature
public CommonResult<PageResult<UserRespVO>> getUserPage(@Valid UserPageReqVO pageReqVO) {
return success(userService.getUserPage(pageReqVO));
}联调可把超时放宽,例如 @ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES)。生产保持默认秒级即可。
GET /admin-api/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {accessToken}
appId: test
timestamp: 1717494535932
nonce: e7eb4265-885d-40eb-ace3-2ecfc34bd639
sign: {sha256hex}sign 算不准时,在 ApiSignatureAspect#verifySignature 里看 serverSignature,或跑 ApiSignatureTest#testSignatureGet。
当前仓库没有业务方法挂 @ApiSignature。加上之后,缺头、超时、nonce 复用都会在进 Service 之前被挡住。
配置与操作
API 加解密改 yudao.api-encrypt 后重启。签名注解当前没有业务方法在用,加上后缺头会被挡在 Service 前。
开启见 框架层。
