service.xpcool.com/internal/middleware/encrypt.go
夏犀麟 d136c23874 fix(encrypt): 全量加密豁免 multipart 上传,修复文件上传被拒
问题:encrypt.fullBody=true 时,APICrypto 中间件会把所有非豁免请求的
body 当 JSON 解析取 {encryptedKey, encryptedData}。文件上传走
multipart/form-data,body 是二进制流,json.Unmarshal 必然失败,于是
被判定为「未加密的业务请求」直接拒绝(encrypted request required)。

而前端的请求拦截器对 FormData 是一律跳过加密的
(admin request.ts:`config.data instanceof FormData` 直接 return),
即上传请求本就以明文 multipart 发出。两端语义不一致,导致
fullBody 一开启,后台壁纸上传必然报错。

修复:新增 isMultipartRequest 判断,命中 multipart/form-data 的请求
跳过整体解密;响应侧因拿不到会话密钥(aesKeyB64 为空),保持明文
回传,与前端「FormData 请求不解密响应」的行为对齐。

dev 环境 fullBody=false,该路径不会被触发,故此前联调未暴露。
上传接口仍受 JWT 鉴权与 RBAC(POST + path 精确匹配)保护,豁免
整体加密不降低鉴权强度。
2026-09-14 02:33:43 +08:00

114 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 全量请求/响应加密中间件(生产环境 encrypt.fullBody=true 时启用)。
//
// 传输模型(对称+非对称结合):
// - 请求:前端随机 AES-256 密钥 → RSA-OAEP 公钥加密密钥encryptedKey+
// AES-GCM 加密整个业务 JSON bodyencryptedData含 nonce/tag→ 后端私钥解出密钥与明文。
// - 响应:后端用同一 AES 会话密钥加密响应 JSON回传 {encryptedData},前端持密钥解密。
//
// 会话密钥生命周期:请求解密时生成/提取 → 存入请求上下文 → 响应加密时取出使用,请求结束即销毁。
// 豁免路由public-key必须先明文拿到公钥才能发起加密请求
package middleware
import (
"bytes"
"encoding/json"
"io"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/text/gstr"
"service.xpcool.com/internal/consts"
cryptolib "service.xpcool.com/internal/library/crypto"
"service.xpcool.com/internal/library/response"
)
// aesKeyCtxVar 请求上下文中 AES 会话密钥base64 字符串)的键名。
const aesKeyCtxVar = "__crypto_aes_key"
// plainRoutes 全量加密豁免路由path 片段匹配):
// - /public-key必须先明文拿到公钥才能发起加密请求
// - /system/auth/login登录永远只加密密码字段username 明文 + 密码字段混合加密),
// 便于客户端兼容与登录联调,登录响应因此保持明文(不含会话密钥)。
var plainRoutes = []string{"/public-key", "/system/auth/login"}
// isPlainRoute 判断当前请求是否属于明文豁免端点。
func isPlainRoute(r *ghttp.Request) bool {
for _, frag := range plainRoutes {
if gstr.Contains(r.URL.Path, frag) {
return true
}
}
return false
}
// isMultipartRequest 判断是否为 multipart/form-data 请求(文件上传)。
//
// 必须豁免的原因:上传体是二进制文件流,无法整体做 JSON 加解密;前端的
// 请求拦截器对 FormData 一律跳过加密(见 admin 的 request.ts
// `config.data instanceof FormData` 直接 return两端必须保持一致
// 否则上传会被当成「未加密的业务请求」直接拒绝,
// 表现为「选了文件、点了上传,报 encrypted request required」。
//
// 豁免不影响安全:上传接口同样挂在 /api/service/admin 分组下,
// 仍受 JWT 鉴权与 RBAC 菜单权限POST + path 精确匹配)双重保护。
func isMultipartRequest(r *ghttp.Request) bool {
return gstr.Contains(r.Header.Get("Content-Type"), "multipart/form-data")
}
// APICrypto 全量请求/响应加密中间件。
// 必须注册在 ghttp.MiddlewareHandlerResponse 外层(先于其执行),
// 且建议置于 Recover 外层,使 500 等异常响应同样被加密。
// 开发环境fullBody=false该中间件为空操作仅保留密码字段加密。
func APICrypto(svc *cryptolib.Service) ghttp.HandlerFunc {
return func(r *ghttp.Request) {
// ---- 请求解密 ----
// multipart/form-data文件上传整体豁免二进制流无法 JSON 加密,
// 前端对 FormData 同样跳过加密,此处必须同步放行。
if svc.FullBody() && !isPlainRoute(r) && !isMultipartRequest(r) {
// 直接读原始 body勿用 r.GetBody():会缓存密文到 bodyContent
// 而 handler 的 Parse 走 GetBody 缓存,导致收到密文而非解密后的明文)。
body, _ := io.ReadAll(r.Request.Body)
r.Request.Body = io.NopCloser(bytes.NewReader(body))
if len(body) > 0 {
var req struct {
EncryptedKey string `json:"encryptedKey"`
EncryptedData string `json:"encryptedData"`
}
if err := json.Unmarshal(body, &req); err != nil || req.EncryptedKey == "" || req.EncryptedData == "" {
// 生产强制全量加密:未加密的业务请求一律拒绝。
response.JSON(r, consts.CodeInvalidParam, "encrypted request required", nil)
return
}
aesKeyB64, plain, err := svc.DecryptRequest(req.EncryptedKey, req.EncryptedData)
if err != nil {
response.JSON(r, consts.CodeInvalidParam, "request decrypt failed", nil)
return
}
// 替换请求体供 handler 解析,并暂存会话密钥供响应加密。
r.Request.Body = io.NopCloser(bytes.NewReader(plain))
r.SetCtxVar(aesKeyCtxVar, aesKeyB64)
}
}
// ---- 执行业务链(含认证中间件与 HandlerResponse 响应写出) ----
r.Middleware.Next()
// ---- 响应加密 ----
if svc.FullBody() && r.Response.BufferLength() > 0 {
buf := r.Response.Buffer()
r.Response.ClearBuffer()
aesKeyB64 := r.GetCtxVar(aesKeyCtxVar).String()
if aesKeyB64 != "" {
if enc, err := svc.EncryptResponse(aesKeyB64, buf); err == nil {
// 客户端持有同一会话密钥,仅需回传密文。
r.Response.WriteJson(g.Map{"encryptedData": enc})
return
}
}
// 无会话密钥(如 public-key 明文端点)或加密失败:原样输出。
r.Response.Write(buf)
}
}
}