问题: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 精确匹配)保护,豁免
整体加密不降低鉴权强度。
114 lines
4.9 KiB
Go
114 lines
4.9 KiB
Go
// 全量请求/响应加密中间件(生产环境 encrypt.fullBody=true 时启用)。
|
||
//
|
||
// 传输模型(对称+非对称结合):
|
||
// - 请求:前端随机 AES-256 密钥 → RSA-OAEP 公钥加密密钥(encryptedKey)+
|
||
// AES-GCM 加密整个业务 JSON body(encryptedData,含 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)
|
||
}
|
||
}
|
||
}
|