feat(auth): 生产全量请求/响应加密中间件

- crypto.Service 增 fullBody 开关 + DecryptRequest/EncryptResponse(会话密钥双向加密)
- 新增 middleware/APICrypto:请求整体解密 + 响应加密(public-key 豁免),未加密请求拒绝
- ResolveLogin/resolvePassword 增 fullBody 分支;admin 组中间件链调整
- injectEnv 支持 ENCRYPT_FULL_BODY/ENCRYPT_ALLOW_PLAIN(直接跑二进制需 env 注入)
This commit is contained in:
夏犀麟 2026-08-28 00:17:47 +08:00
parent 06484774ad
commit ebc507ee6c
8 changed files with 179 additions and 21 deletions

View File

@ -1,6 +1,8 @@
# service.xpcool.com 变更记录
> 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | FEAT | 全量请求/响应加密生产 encrypt.fullBody=true / env ENCRYPT_FULL_BODYcrypto.Service fullBody 开关 + DecryptRequestRSA AES 会话密钥 + AES-GCM body返回会话密钥+ EncryptResponse用同一会话密钥 AES-GCM 加密响应回传请求结束即销毁新增 middleware/APICrypto请求整体解密io.ReadAll 直接读 r.Request.Body 勿用 GetBody否则缓存密文致 handler Parse 读到密文+ 响应加密 bufferHandlerResponse 外层Recover 内层500 也加密public-key 明文豁免未加密业务请求一律拒绝admin 组中间件链改 CORSAPICryptoRecoverHandlerResponseResolveLogin/resolvePassword fullBody 分支传输层已整体解密直接信任明文 username/passwordinjectEnv 支持 ENCRYPT_FULL_BODY/ENCRYPT_ALLOW_PLAIN直接跑二进制恒加载 config.yamlGF_GCFG_ENV gf run 必须 env 注入GF r.GetBody() 缓存密文到 bodyContenthandler Parse 走缓存 中间件必须 io.ReadAll(r.Request.Body)dev仅密码加密E2E 15 prod全量加密E2E 9 项全过
2026-08-27 | FEAT | 登录密码RSA + AES-GCM混合加密传输对称+非对称结合+ 创建/重置密码加密字段新建 internal/library/crypto密钥优先级 配置 encrypt.privateKey(PEM) > data/crypto/rsa_private.pem > 自动生成落盘data/ gitignore公钥输出 SPKIx509.MarshalPKIXPublicKey前端 WebCrypto importKey('spki')PKCS#1 ASN.1 wrong tagDecryptLogin 解密 {username,password,ts}ts 5 分钟窗口防重放DecryptField 解密 {value,ts}创建/重置密码复用进程级 SetDefault/Get 单例公开接口 POST /system/auth/public-key 返回 publicKeyLoginReq encryptedKey+encryptedData明文字段仅 encrypt.allowPlain=true 时可用config.yaml/prod 默认 falsedev truecontroller.ResolveLogin 统一解析凭据AdminCreate/AdminResetPwd 同样支持加密 passwordresolvePassword 复用 DecryptField密文结构encryptedData=base64(nonce(12B)||ciphertext||tag)encryptedKey=base64(RSA-OAEP(SHA-256) 加密 AES-256 密钥)端到端 Node 模拟 WebCrypto 15 项全过公钥/加密登录/错密码30002/篡改密文/过期载荷/受保护接口/加密创建/加密重置/旧密码失效/登出撤销/登出后刷新拒绝数据库密码本就是 bcrypt 哈希保存bcrypt.GenerateFromPassword存储安全已达标
2026-08-27 | FEAT | 认证链路联调登录/登出/超时3d24dbd未授权响应 HTTP 200+code10002 401 语义化response.JSONWithStatus三处中间件切换vben authenticateResponseInterceptor 只认 HTTP 401此前 token 过期永不触发静默刷新LogoutReq 支持 refreshToken登出撤销刷新会话IAdminAuth.Revoke 幂等Refresh 顺带清理过期会话行injectEnv 开发兜底${DB_DSN}/${RECRUITMENT_DB_DSN}/${JWT_SECRET} 占位符未注入 env 时回填本地默认 DSN任何启动方式零配置可跑当日两起登录 500 均为启动漏 envprod 不兜底端到端 8 curl 全过错密码 30002/登录/伪造 token HTTP401/轮换/旧令牌重放拒绝/登出撤销/登出后拒绝/有效访问 200并行 notice/job 会话提交的 admin_user entity bool/string 映射公司 gen 模板已按旧风格手工恢复 int/gtime 并补两列

View File

@ -90,6 +90,10 @@ func injectEnv(ctx context.Context) {
setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
// 登录加密开关(直接跑二进制始终加载 config.yaml需 env 显式注入):
// ENCRYPT_FULL_BODY=true 启用全量请求/响应加密生产ENCRYPT_ALLOW_PLAIN=true 仅开发联调。
setIfEmpty(adapter, "encrypt.fullBody", "ENCRYPT_FULL_BODY")
setIfEmpty(adapter, "encrypt.allowPlain", "ENCRYPT_ALLOW_PLAIN")
}
var (
@ -135,7 +139,9 @@ var (
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
})
s.Group("/api/service/admin", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
// 全量请求/响应加密(生产 encrypt.fullBody=trueCORS → APICrypto → Recover → HandlerResponse。
// Recover 置于 APICrypto 内层,使 500 异常响应同样被加密public-key 端点明文豁免。
group.Middleware(middleware.CORS, middleware.APICrypto(cryptoSvc), middleware.Recover, ghttp.MiddlewareHandlerResponse)
group.Bind(adminctl.NewAuth()) // Public: login only.
group.Group("/", func(profile *ghttp.RouterGroup) {
// 仅登录端点:个人资料 / 权限码 / 菜单路由。

View File

@ -12,8 +12,12 @@ import (
)
// resolvePassword 解析创建/重置管理员时的密码:
// 密文encryptedKey+encryptedDataRSA+AES 混合加密)优先;明文仅开发环境 allowPlain 时可用。
// - 全量加密模式fullBody传输层已整体解密直接使用明文 password
// - 仅密码加密模式密文encryptedKey+encryptedDataRSA+AES 混合加密)优先;明文仅开发 allowPlain。
func resolvePassword(ctx context.Context, encryptedKey, encryptedData, password string) (string, error) {
if cryptolib.Get().FullBody() {
return password, nil
}
if encryptedKey != "" && encryptedData != "" {
v, err := cryptolib.Get().DecryptField(ctx, encryptedKey, encryptedData)
if err != nil {

View File

@ -60,6 +60,9 @@ type Service struct {
privKey *rsa.PrivateKey
// allowPlain 允许明文密码登录,仅限开发环境联调(生产必须关闭)。
allowPlain bool
// fullBody 全量请求/响应加密开关(生产开启):所有 JSON body 整体加密,
// 响应用请求会话密钥AES加密回传开发环境仅加密密码字段。
fullBody bool
}
// defaultSvc 进程级默认实例供各业务模块复用cmd 启动时经 New 注册)。
@ -79,7 +82,10 @@ func Get() *Service {
// New 加载或生成 RSA 密钥对并构建加密服务。
// 私钥来源encrypt.privateKey 配置 > data/crypto/rsa_private.pem 文件 > 自动生成并落盘。
func New(ctx context.Context) (*Service, error) {
s := &Service{allowPlain: g.Cfg().MustGet(ctx, "encrypt.allowPlain", false).Bool()}
s := &Service{
allowPlain: g.Cfg().MustGet(ctx, "encrypt.allowPlain", false).Bool(),
fullBody: g.Cfg().MustGet(ctx, "encrypt.fullBody", false).Bool(),
}
// 配置项可能是未注入的 ${ENV} 占位符,按未配置处理,走文件/自动生成。
privPEM := g.Cfg().MustGet(ctx, "encrypt.privateKey", "").String()
if gstr.Contains(privPEM, "${") {
@ -120,6 +126,9 @@ func New(ctx context.Context) (*Service, error) {
// AllowPlain 返回是否允许明文密码登录(仅开发联调)。
func (s *Service) AllowPlain() bool { return s.allowPlain }
// FullBody 返回是否启用全量请求/响应加密(生产开启)。
func (s *Service) FullBody() bool { return s.fullBody }
// PublicKey 返回 PEM 格式 RSA 公钥SubjectPublicKeyInfo / SPKI
// 供前端 Web Crypto importKey('spki') 做混合加密。
func (s *Service) PublicKey() string {
@ -135,7 +144,7 @@ func (s *Service) PublicKey() string {
// 1) RSA-OAEP(SHA-256) 私钥解出 AES 密钥2) AES-256-GCM 解出明文 JSON
// 3) 校验时间戳窗口(防重放)与必填字段。
func (s *Service) DecryptLogin(_ context.Context, encryptedKeyB64, encryptedDataB64 string) (*LoginPayload, error) {
plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
_, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
if err != nil {
return nil, err
}
@ -156,7 +165,7 @@ func (s *Service) DecryptLogin(_ context.Context, encryptedKeyB64, encryptedData
// DecryptField 混合解密通用加密字段(创建管理员/重置密码等场景):
// 载荷 JSON 结构为 {"value":"...","ts":...},解密失败或字段缺失返回错误。
func (s *Service) DecryptField(_ context.Context, encryptedKeyB64, encryptedDataB64 string) (string, error) {
plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
_, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
if err != nil {
return "", err
}
@ -174,38 +183,71 @@ func (s *Service) DecryptField(_ context.Context, encryptedKeyB64, encryptedData
return p.Value, nil
}
// decryptPayload 混合解密公共实现RSA-OAEP 解 AES 密钥 → AES-GCM 解明文
// 密文结构encryptedData = base64(nonce(12B) || ciphertext)
func (s *Service) decryptPayload(encryptedKeyB64, encryptedDataB64 string) ([]byte, error) {
encKey, err := base64.StdEncoding.DecodeString(encryptedKeyB64)
// DecryptRequest 全量加密请求解密:返回 AES 会话密钥base64与明文 body
// 会话密钥由中间件暂存,供响应加密回传(前端持同一密钥解密响应)
func (s *Service) DecryptRequest(encryptedKeyB64, encryptedDataB64 string) (aesKeyB64 string, plain []byte, err error) {
aesKey, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
if err != nil {
return nil, gerror.New("加密密钥格式错误")
return "", nil, err
}
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, s.privKey, encKey, nil)
return base64.StdEncoding.EncodeToString(aesKey), plain, nil
}
// EncryptResponse 用请求会话密钥AES-256-GCM加密响应 JSON
// 返回 base64(nonce(12B) || ciphertext || tag),前端用同一会话密钥解密。
func (s *Service) EncryptResponse(aesKeyB64 string, plain []byte) (string, error) {
aesKey, err := base64.StdEncoding.DecodeString(aesKeyB64)
if err != nil {
return nil, gerror.New("解密 AES 会话密钥失败")
return "", gerror.New("会话密钥格式错误")
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, gerror.Wrap(err, "初始化 AES 失败")
return "", gerror.Wrap(err, "初始化 AES 失败")
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, gerror.Wrap(err, "初始化 AES-GCM 失败")
return "", gerror.Wrap(err, "初始化 AES-GCM 失败")
}
nonce := make([]byte, aesNonceSize)
if _, err = rand.Read(nonce); err != nil {
return "", gerror.Wrap(err, "生成随机数失败")
}
sealed := gcm.Seal(nil, nonce, plain, nil)
return base64.StdEncoding.EncodeToString(append(nonce, sealed...)), nil
}
// decryptPayload 混合解密公共实现RSA-OAEP 解 AES 密钥 → AES-GCM 解明文。
// 密文结构encryptedData = base64(nonce(12B) || ciphertext || tag)。
func (s *Service) decryptPayload(encryptedKeyB64, encryptedDataB64 string) ([]byte, []byte, error) {
encKey, err := base64.StdEncoding.DecodeString(encryptedKeyB64)
if err != nil {
return nil, nil, gerror.New("加密密钥格式错误")
}
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, s.privKey, encKey, nil)
if err != nil {
return nil, nil, gerror.New("解密 AES 会话密钥失败")
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, nil, gerror.Wrap(err, "初始化 AES 失败")
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, gerror.Wrap(err, "初始化 AES-GCM 失败")
}
data, err := base64.StdEncoding.DecodeString(encryptedDataB64)
if err != nil {
return nil, gerror.New("密文格式错误")
return nil, nil, gerror.New("密文格式错误")
}
if len(data) <= aesNonceSize {
return nil, gerror.New("密文长度非法")
return nil, nil, gerror.New("密文长度非法")
}
// 密文结构nonce(12B) || ciphertextGCM 自带完整性校验。
plain, err := gcm.Open(nil, data[:aesNonceSize], data[aesNonceSize:], nil)
if err != nil {
return nil, gerror.New("解密载荷失败")
return nil, nil, gerror.New("解密载荷失败")
}
return plain, nil
return aesKey, plain, nil
}
// parsePrivateKeyPEM 解析 PEM 格式 RSA 私钥(兼容 PKCS#1 与 PKCS#8

View File

@ -0,0 +1,94 @@
// 全量请求/响应加密中间件(生产环境 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 片段匹配),此类端点明文收发。
var plainRoutes = []string{"/public-key"}
// isPlainRoute 判断当前请求是否属于明文豁免端点。
func isPlainRoute(r *ghttp.Request) bool {
for _, frag := range plainRoutes {
if gstr.Contains(r.URL.Path, frag) {
return true
}
}
return false
}
// APICrypto 全量请求/响应加密中间件。
// 必须注册在 ghttp.MiddlewareHandlerResponse 外层(先于其执行),
// 且建议置于 Recover 外层,使 500 等异常响应同样被加密。
// 开发环境fullBody=false该中间件为空操作仅保留密码字段加密。
func APICrypto(svc *cryptolib.Service) ghttp.HandlerFunc {
return func(r *ghttp.Request) {
// ---- 请求解密 ----
if svc.FullBody() && !isPlainRoute(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)
}
}
}

View File

@ -71,9 +71,16 @@ func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.Tok
}
// ResolveLogin 解析登录凭据,返回明文用户名与密码:
// - 携带密文encryptedKey + encryptedData时走 RSA+AES 混合解密;
// - 否则仅当服务端 encrypt.allowPlain=true开发联调时接受明文生产一律拒绝。
// - 全量加密模式fullBody传输层 APICrypto 已整体解密,前端提交明文 username/password
// - 仅密码加密模式携带密文encryptedKey + encryptedData时走 RSA+AES 混合解密,
// 否则仅当服务端 encrypt.allowPlain=true开发联调时接受明文生产一律拒绝。
func (s *adminAuth) ResolveLogin(ctx context.Context, encryptedKey, encryptedData, username, password string) (string, string, error) {
if s.crypto.FullBody() {
if username == "" || password == "" {
return "", "", response.Error(consts.CodeInvalidParam, "username or password required")
}
return username, password, nil
}
if encryptedKey != "" && encryptedData != "" {
payload, err := s.crypto.DecryptLogin(ctx, encryptedKey, encryptedData)
if err != nil {

View File

@ -22,6 +22,8 @@ bark:
deviceKey: "${BARK_DEVICE_KEY}"
pushTime: "${BARK_PUSH_TIME}"
# 登录密码混合加密RSA + AES-GCM私钥留空自动生成并持久化allowPlain 仅本地联调开启
# fullBody 保持 false开发环境仅加密密码字段便于抓包联调
encrypt:
privateKey: "${ENCRYPT_PRIVATE_KEY}"
allowPlain: true
fullBody: false

View File

@ -4,6 +4,7 @@ database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime: "${BARK_PUSH_TIME}" }
# 登录密码混合加密RSA + AES-GCM生产必须通过 ENCRYPT_PRIVATE_KEY 注入 PEM 私钥禁止明文登录
encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false }
# fullBody=true所有 admin 接口请求/响应整体加密传输public-key 明文豁免
encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false, fullBody: true }
# 安全日志上报令牌宿主机采集脚本携带环境变量 INTERNAL_TOKEN 注入
internalToken: "${INTERNAL_TOKEN}"