diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 15d3d92..9418047 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -1,6 +1,8 @@ # service.xpcool.com 变更记录 > 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要 +2026-08-27 | FEAT | 全量请求/响应加密(生产 encrypt.fullBody=true / env ENCRYPT_FULL_BODY):①crypto.Service 增 fullBody 开关 + DecryptRequest(RSA 解 AES 会话密钥 + AES-GCM 解 body,返回会话密钥)+ EncryptResponse(用同一会话密钥 AES-GCM 加密响应回传,请求结束即销毁);②新增 middleware/APICrypto——请求整体解密(io.ReadAll 直接读 r.Request.Body 勿用 GetBody,否则缓存密文致 handler Parse 读到密文)+ 响应加密 buffer(HandlerResponse 外层、Recover 内层,500 也加密),public-key 明文豁免,未加密业务请求一律拒绝;③admin 组中间件链改 CORS→APICrypto→Recover→HandlerResponse;④ResolveLogin/resolvePassword 增 fullBody 分支(传输层已整体解密,直接信任明文 username/password);⑤injectEnv 支持 ENCRYPT_FULL_BODY/ENCRYPT_ALLOW_PLAIN(直接跑二进制恒加载 config.yaml,GF_GCFG_ENV 仅 gf run 认,必须 env 注入)。⚠️GF 坑:r.GetBody() 缓存密文到 bodyContent,handler 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);公钥输出 SPKI(x509.MarshalPKIXPublicKey,前端 WebCrypto importKey('spki'),⚠️PKCS#1 会 ASN.1 wrong tag);DecryptLogin 解密 {username,password,ts}(ts 5 分钟窗口防重放)、DecryptField 解密 {value,ts}(创建/重置密码复用);进程级 SetDefault/Get 单例。②公开接口 POST /system/auth/public-key 返回 publicKey;LoginReq 改 encryptedKey+encryptedData(明文字段仅 encrypt.allowPlain=true 时可用,config.yaml/prod 默认 false、dev true);controller.ResolveLogin 统一解析凭据。③AdminCreate/AdminResetPwd 同样支持加密 password(resolvePassword 复用 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 均为启动漏 env),prod 不兜底。端到端 8 项 curl 全过:错密码 30002/登录/伪造 token HTTP401/轮换/旧令牌重放拒绝/登出撤销/登出后拒绝/有效访问 200。注:并行 notice/job 会话提交的 admin_user entity 为 bool/string 映射(公司 gen 模板),已按旧风格手工恢复 int/gtime 并补两列 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index b360c42..62ca2d3 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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=true):CORS → 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) { // 仅登录端点:个人资料 / 权限码 / 菜单路由。 diff --git a/internal/controller/admin/admin.go b/internal/controller/admin/admin.go index fd32aab..ab05e2f 100644 --- a/internal/controller/admin/admin.go +++ b/internal/controller/admin/admin.go @@ -12,8 +12,12 @@ import ( ) // resolvePassword 解析创建/重置管理员时的密码: -// 密文(encryptedKey+encryptedData,RSA+AES 混合加密)优先;明文仅开发环境 allowPlain 时可用。 +// - 全量加密模式(fullBody):传输层已整体解密,直接使用明文 password; +// - 仅密码加密模式:密文(encryptedKey+encryptedData,RSA+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 { diff --git a/internal/library/crypto/crypto.go b/internal/library/crypto/crypto.go index 9550510..b7d1b16 100644 --- a/internal/library/crypto/crypto.go +++ b/internal/library/crypto/crypto.go @@ -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) || ciphertext,GCM 自带完整性校验。 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)。 diff --git a/internal/middleware/encrypt.go b/internal/middleware/encrypt.go new file mode 100644 index 0000000..83f5327 --- /dev/null +++ b/internal/middleware/encrypt.go @@ -0,0 +1,94 @@ +// 全量请求/响应加密中间件(生产环境 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 片段匹配),此类端点明文收发。 +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) + } + } +} diff --git a/internal/service/admin/admin/login/login.go b/internal/service/admin/admin/login/login.go index 788f90b..308b4ad 100644 --- a/internal/service/admin/admin/login/login.go +++ b/internal/service/admin/admin/login/login.go @@ -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 { diff --git a/manifest/config/config.dev.yaml b/manifest/config/config.dev.yaml index 2274902..04606c2 100644 --- a/manifest/config/config.dev.yaml +++ b/manifest/config/config.dev.yaml @@ -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 diff --git a/manifest/config/config.prod.yaml b/manifest/config/config.prod.yaml index 7806839..bef0346 100644 --- a/manifest/config/config.prod.yaml +++ b/manifest/config/config.prod.yaml @@ -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}"