From 06484774ad6f9e58cddab486b2140b1ff2cff53f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Thu, 27 Aug 2026 23:43:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E5=AF=86?= =?UTF-8?q?=E7=A0=81=20RSA+AES=20=E6=B7=B7=E5=90=88=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E4=B8=8E=E5=8A=A0=E5=AF=86=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 internal/library/crypto:RSA-OAEP(SHA-256)+AES-256-GCM 混合解密, 私钥按 配置PEM > data/crypto/rsa_private.pem > 自动生成 三级来源,公钥 SPKI 输出 - 公开接口 POST /system/auth/public-key;LoginReq 支持 encryptedKey/encryptedData - AdminCreate/AdminResetPwd 密码字段支持加密传输(allowPlain 仅开发联调) - 配置 encrypt.privateKey/allowPlain;data/ 加入 gitignore 防止私钥入库 --- .gitignore | 3 + .workbuddy/memory/CHANGELOG.md | 3 + api/admin/admin/admin/admin.go | 28 ++- api/admin/admin/login/login.go | 24 +- internal/cmd/cmd.go | 8 +- internal/controller/admin/admin.go | 31 ++- internal/controller/admin/auth.go | 22 +- internal/library/crypto/crypto.go | 234 ++++++++++++++++++++ internal/service/admin/admin/login/login.go | 36 ++- manifest/config/config.dev.yaml | 4 + manifest/config/config.prod.yaml | 2 + 11 files changed, 374 insertions(+), 21 deletions(-) create mode 100644 internal/library/crypto/crypto.go diff --git a/.gitignore b/.gitignore index d2fffec..356aeda 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ log/ # 本地环境变量(含数据库口令),禁止提交 .env* +# 运行时数据目录(含自动生成的 RSA 登录加密私钥),禁止提交 +data/ + # GoLand 项目级运行配置(含数据库口令),禁止提交 .run/ service_test.exe diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 2cbe650..15d3d92 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -1,6 +1,9 @@ # service.xpcool.com 变更记录 > 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要 +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 并补两列 2026-08-27 | CHG | 通知模块+自动任务+用户渠道字段:016 SQL(admin_user 加 bark_device_id/pushplus_token;建 notice_channel/rule/log + auto_job/auto_job_log 5 表;菜单 98 通知中心(980规则/981渠道/982日志)/99 自动任务(990任务/991日志) + type=2 权限;渠道/规则/任务种子;人员管理菜单改名用户管理);api/notice + api/job(11 接口全 POST 无 URL 参数);service/notice(Send 统一发送:Bark 路径式 POST /{key} + pushplus 官方接口;渠道/规则/日志/测试);service/job(DB 驱动 gcron:List/Save/Trigger/LogList + Register 业务函数注册 + 完成/失败自动通知 job_done/job_fail);recruitment scheduler 改 RegisterTasks 纳管(原 StartScheduler 废弃);用户 admin 接口支持渠道字段;已部署:容器重建 api.json 200、11 路由全注册、调度器无 handler 缺失告警、种子数据验证 OK。 2026-08-27 | FIX | 菜单种子主键冲突修复(743683e):recruitment/003_menu.sql 招聘中心一级菜单 id=95 与 014_house_presale_menu.sql 新房预售 id=95(挂看房中心 90)ON DUPLICATE 互相覆盖、后导者赢,本地实际发生(新房预售菜单被顶掉致 /house/presale 404、生产库同样风险);改为 id=96 后发现 015_server_security.sql(并行会话,生产已部署)也用 96=安全日志(挂系统监控5),再改 **97**:95=新房预售(挂90)、96=安全日志(挂5)、97=招聘中心,三模块错开;本地按 014→015→003 重放,routes 接口 22 节点验证全部正确(e18b25f)。⚠️ 生产库需按 014→015→003 顺序重放修复(当前生产 95=招聘中心、新房预售菜单丢失) diff --git a/api/admin/admin/admin/admin.go b/api/admin/admin/admin/admin.go index 0fc916b..e0d3404 100644 --- a/api/admin/admin/admin/admin.go +++ b/api/admin/admin/admin/admin.go @@ -31,14 +31,18 @@ type AdminListRes struct { } // AdminCreateReq 创建管理员。 +// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制), +// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。 type AdminCreateReq struct { - g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"` - Username string `json:"username" v:"required"` - Password string `json:"password" v:"required|min-length:6#password required|password too short"` - Nickname string `json:"nickname"` - BarkDeviceId string `json:"barkDeviceId"` - PushplusToken string `json:"pushplusToken"` - RoleIds []uint64 `json:"roleIds"` + g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"` + Username string `json:"username" v:"required"` + Password string `json:"password" v:"min-length:6#password too short"` + EncryptedKey string `json:"encryptedKey"` + EncryptedData string `json:"encryptedData"` + Nickname string `json:"nickname"` + BarkDeviceId string `json:"barkDeviceId"` + PushplusToken string `json:"pushplusToken"` + RoleIds []uint64 `json:"roleIds"` } // AdminCreateRes 是 AdminCreateReq 的响应。 @@ -61,10 +65,14 @@ type AdminUpdateReq struct { type AdminUpdateRes struct{} // AdminResetPwdReq 重置管理员密码。 +// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制), +// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。 type AdminResetPwdReq struct { - g.Meta `path:"/admin/resetPwd/{id}" method:"post" tags:"Admin/Admin" summary:"重置管理员密码"` - Id uint64 `json:"id" in:"path" v:"required"` - Password string `json:"password" v:"required|min-length:6#password required|password too short"` + g.Meta `path:"/admin/resetPwd/{id}" method:"post" tags:"Admin/Admin" summary:"重置管理员密码"` + Id uint64 `json:"id" in:"path" v:"required"` + Password string `json:"password" v:"min-length:6#password too short"` + EncryptedKey string `json:"encryptedKey"` + EncryptedData string `json:"encryptedData"` } // AdminResetPwdRes 是 AdminResetPwdReq 的响应。 diff --git a/api/admin/admin/login/login.go b/api/admin/admin/login/login.go index 8ab1506..1af2bc1 100644 --- a/api/admin/admin/login/login.go +++ b/api/admin/admin/login/login.go @@ -4,10 +4,17 @@ package admin_admin_login import "github.com/gogf/gf/v2/frame/g" // LoginReq 管理员登录请求。 +// 密码采用「RSA + AES-GCM」混合加密传输(encryptedKey/encryptedData 必填,生产强制): +// - encryptedKey:RSA-OAEP 加密的 AES-256 会话密钥(base64) +// - encryptedData:AES-GCM 加密的明文载荷 {username,password,ts}(base64,nonce 前缀) +// +// username/password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。 type LoginReq struct { - g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"管理员登录"` - Username string `json:"username" v:"required"` - Password string `json:"password" v:"required"` + g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"管理员登录"` + EncryptedKey string `json:"encryptedKey"` + EncryptedData string `json:"encryptedData"` + Username string `json:"username"` + Password string `json:"password"` } // LoginRes 管理员登录响应(令牌对 + 管理员 ID)。 @@ -18,6 +25,17 @@ type LoginRes struct { AdminID uint64 `json:"adminId"` } +// PublicKeyReq 获取登录加密公钥(RSA 公钥 PEM,前端用于混合加密密码)。公开接口,免鉴权。 +type PublicKeyReq struct { + g.Meta `path:"/system/auth/public-key" method:"post" tags:"Admin/System/Auth" summary:"获取登录加密公钥"` +} + +// PublicKeyRes 是 PublicKeyReq 的响应。 +type PublicKeyRes struct { + // PublicKey PEM 格式 RSA 公钥(-----BEGIN PUBLIC KEY-----)。 + PublicKey string `json:"publicKey"` +} + // InfoReq 获取当前管理员资料(供 vben getUserInfo 使用)。 type InfoReq struct { g.Meta `path:"/system/auth/info" method:"post" tags:"Admin/System/Auth" summary:"当前管理员资料"` diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 6d73b4d..b360c42 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -19,6 +19,7 @@ import ( jobctl "service.xpcool.com/internal/controller/job" serversecurityctl "service.xpcool.com/internal/controller/serversecurity" userctl "service.xpcool.com/internal/controller/user" + "service.xpcool.com/internal/library/crypto" "service.xpcool.com/internal/library/jwt" "service.xpcool.com/internal/middleware" admin "service.xpcool.com/internal/service/admin/admin/admin" @@ -100,8 +101,13 @@ var ( injectEnv(ctx) s := g.Server() tokens := jwt.New(ctx) + // 登录密码混合加密服务(RSA + AES-GCM):私钥加载/生成失败属致命错误,直接终止启动。 + cryptoSvc, err := crypto.New(ctx) + if err != nil { + panic(err) + } userauth.RegisterUserAuth(userauth.NewUserAuth(tokens, nil, nil)) - adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens)) + adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens, cryptoSvc)) adminmenu.RegisterAdminMenu(adminmenu.NewAdminMenu()) admin.RegisterAdminManage(admin.NewAdminManage()) role.RegisterRoleManage(role.NewRoleManage()) diff --git a/internal/controller/admin/admin.go b/internal/controller/admin/admin.go index aeb6ff2..fd32aab 100644 --- a/internal/controller/admin/admin.go +++ b/internal/controller/admin/admin.go @@ -4,10 +4,29 @@ import ( "context" adminv1 "service.xpcool.com/api/admin/admin/admin" + "service.xpcool.com/internal/consts" + cryptolib "service.xpcool.com/internal/library/crypto" + "service.xpcool.com/internal/library/response" "service.xpcool.com/internal/model/dto" admin "service.xpcool.com/internal/service/admin/admin/admin" ) +// resolvePassword 解析创建/重置管理员时的密码: +// 密文(encryptedKey+encryptedData,RSA+AES 混合加密)优先;明文仅开发环境 allowPlain 时可用。 +func resolvePassword(ctx context.Context, encryptedKey, encryptedData, password string) (string, error) { + if encryptedKey != "" && encryptedData != "" { + v, err := cryptolib.Get().DecryptField(ctx, encryptedKey, encryptedData) + if err != nil { + return "", response.Error(consts.CodeInvalidParam, err.Error()) + } + return v, nil + } + if cryptolib.Get().AllowPlain() { + return password, nil + } + return "", response.Error(consts.CodeInvalidParam, "encrypted payload required") +} + // AdminList 分页查询管理员。 func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (res *adminv1.AdminListRes, err error) { items, total, err := admin.AdminManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword}) @@ -26,8 +45,12 @@ func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) ( // AdminCreate 创建管理员。 func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) { + password, err := resolvePassword(ctx, req.EncryptedKey, req.EncryptedData, req.Password) + if err != nil { + return nil, err + } id, err := admin.AdminManage().Create(ctx, dto.AdminCreateInput{ - Username: req.Username, Password: req.Password, Nickname: req.Nickname, + Username: req.Username, Password: password, Nickname: req.Nickname, BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds, }) if err != nil { @@ -47,7 +70,11 @@ func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateRe // AdminResetPwd 重置管理员密码。 func (c *Controller) AdminResetPwd(ctx context.Context, req *adminv1.AdminResetPwdReq) (res *adminv1.AdminResetPwdRes, err error) { - if err = admin.AdminManage().ResetPassword(ctx, req.Id, req.Password); err != nil { + password, err := resolvePassword(ctx, req.EncryptedKey, req.EncryptedData, req.Password) + if err != nil { + return nil, err + } + if err = admin.AdminManage().ResetPassword(ctx, req.Id, password); err != nil { return nil, err } return &adminv1.AdminResetPwdRes{}, nil diff --git a/internal/controller/admin/auth.go b/internal/controller/admin/auth.go index ba7a10c..4cce9cd 100644 --- a/internal/controller/admin/auth.go +++ b/internal/controller/admin/auth.go @@ -18,6 +18,7 @@ type AuthController struct{} func NewAuth() *AuthController { return &AuthController{} } // Login 校验管理员身份并签发令牌对,成功与失败均写入登录日志。 +// 密码采用「RSA + AES-GCM」混合加密传输:先解出明文用户名/密码再走原校验逻辑。 func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res *authv1.LoginRes, err error) { // 从请求上下文提取来源信息用于登录审计 r := ghttp.RequestFromCtx(ctx) @@ -26,17 +27,32 @@ func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res * ip = r.GetClientIp() ua = r.Header.Get("User-Agent") } - p, id, err := auth.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: req.Username, Password: req.Password}) + // 解析登录凭据:密文(encryptedKey/encryptedData)优先;明文仅开发环境 allowPlain 时可用。 + username, password, err := auth.AdminAuth().ResolveLogin(ctx, req.EncryptedKey, req.EncryptedData, req.Username, req.Password) + if err != nil { + _ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: "(encrypted)", IP: ip, UserAgent: ua, Status: 0, FailReason: err.Error()}) + return nil, err + } + p, id, err := auth.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: username, Password: password}) if err != nil { // 登录失败也落库(含失败原因),便于排查异常登录;日志失败不回传 - _ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: req.Username, IP: ip, UserAgent: ua, Status: 0, FailReason: err.Error()}) + _ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: username, IP: ip, UserAgent: ua, Status: 0, FailReason: err.Error()}) return nil, err } // 登录成功落库;日志落库失败不影响登录结果 - _ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: req.Username, IP: ip, UserAgent: ua, Status: 1}) + _ = loginlog.AdminLoginLog().Record(ctx, loginlog.LoginEvent{Username: username, IP: ip, UserAgent: ua, Status: 1}) return &authv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil } +// PublicKey 返回登录加密 RSA 公钥(公开端点,前端登录前获取用于混合加密密码)。 +func (c *AuthController) PublicKey(ctx context.Context, _ *authv1.PublicKeyReq) (res *authv1.PublicKeyRes, err error) { + pub, err := auth.AdminAuth().PublicKey(ctx) + if err != nil { + return nil, err + } + return &authv1.PublicKeyRes{PublicKey: pub}, nil +} + // Refresh 使用有效刷新令牌轮换管理员令牌对。 func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (res *authv1.RefreshRes, err error) { p, id, err := auth.AdminAuth().Refresh(ctx, req.RefreshToken) diff --git a/internal/library/crypto/crypto.go b/internal/library/crypto/crypto.go new file mode 100644 index 0000000..9550510 --- /dev/null +++ b/internal/library/crypto/crypto.go @@ -0,0 +1,234 @@ +// Package crypto 提供登录密码「RSA + AES-GCM」混合加密服务。 +// +// 设计目标:对称加密(AES-GCM)负责加密实际载荷,非对称加密(RSA-OAEP) +// 负责加密 AES 会话密钥,两者结合兼顾性能与密钥分发安全: +// - 前端:随机生成 AES-256 密钥 → AES-GCM 加密 {username,password,ts} → RSA 公钥加密 AES 密钥 +// - 后端:持有 RSA 私钥 → 解出 AES 密钥 → 解出明文载荷(含时间戳防重放) +// +// 密钥来源优先级:配置 encrypt.privateKey(PEM)→ 本地文件 data/crypto/rsa_private.pem → 自动生成并持久化。 +package crypto + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/text/gstr" +) + +const ( + // rsaKeyBits RSA 密钥位数(2048 可加密 256 位 AES 密钥)。 + rsaKeyBits = 2048 + // aesNonceSize AES-GCM 推荐随机数长度(字节)。 + aesNonceSize = 12 + // payloadTTL 明文载荷时间戳容忍窗口:超出即视为重放/过期。 + payloadTTL = 5 * time.Minute + // keyFilePath 自动生成密钥的持久化路径(相对工作目录)。 + keyFilePath = "data/crypto/rsa_private.pem" +) + +// LoginPayload 是前端混合加密后的明文载荷结构。 +type LoginPayload struct { + Username string `json:"username"` + Password string `json:"password"` + // TS 毫秒时间戳,用于防重放校验。 + TS int64 `json:"ts"` +} + +// FieldPayload 是通用加密字段载荷(创建管理员/重置密码等场景复用)。 +type FieldPayload struct { + Value string `json:"value"` + // TS 毫秒时间戳,用于防重放校验。 + TS int64 `json:"ts"` +} + +// Service 提供登录密码混合加密能力。 +type Service struct { + privKey *rsa.PrivateKey + // allowPlain 允许明文密码登录,仅限开发环境联调(生产必须关闭)。 + allowPlain bool +} + +// defaultSvc 进程级默认实例,供各业务模块复用(cmd 启动时经 New 注册)。 +var defaultSvc *Service + +// SetDefault 注册进程级默认加密服务实例。 +func SetDefault(s *Service) { defaultSvc = s } + +// Get 返回进程级默认加密服务实例,未初始化时 panic(启动必经 New 注册)。 +func Get() *Service { + if defaultSvc == nil { + panic("crypto service not registered") + } + return defaultSvc +} + +// 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()} + // 配置项可能是未注入的 ${ENV} 占位符,按未配置处理,走文件/自动生成。 + privPEM := g.Cfg().MustGet(ctx, "encrypt.privateKey", "").String() + if gstr.Contains(privPEM, "${") { + privPEM = "" + } + if privPEM != "" { + key, err := parsePrivateKeyPEM(privPEM) + if err != nil { + return nil, gerror.Wrap(err, "解析 encrypt.privateKey 失败") + } + s.privKey = key + SetDefault(s) + return s, nil + } + // 尝试读取本地持久化密钥文件。 + if gfile.Exists(keyFilePath) { + if b, err := os.ReadFile(keyFilePath); err == nil { + if key, perr := parsePrivateKeyPEM(string(b)); perr == nil { + s.privKey = key + SetDefault(s) + return s, nil + } + } + } + // 自动生成并尝试持久化(目录不可写时降级为内存持有,重启后公钥变化,前端每次登录重新拉取不受影响)。 + key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, gerror.Wrap(err, "生成 RSA 密钥对失败") + } + s.privKey = key + if err = os.MkdirAll(filepath.Dir(keyFilePath), 0o700); err == nil { + _ = os.WriteFile(keyFilePath, []byte(encodePrivateKeyPEM(key)), 0o600) + } + SetDefault(s) + return s, nil +} + +// AllowPlain 返回是否允许明文密码登录(仅开发联调)。 +func (s *Service) AllowPlain() bool { return s.allowPlain } + +// PublicKey 返回 PEM 格式 RSA 公钥(SubjectPublicKeyInfo / SPKI), +// 供前端 Web Crypto importKey('spki') 做混合加密。 +func (s *Service) PublicKey() string { + // MarshalPKIXPublicKey 生成 SPKI(PKIX/SubjectPublicKeyInfo)DER,前端按 "PUBLIC KEY" PEM 解析。 + der, err := x509.MarshalPKIXPublicKey(&s.privKey.PublicKey) + if err != nil { + panic(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) +} + +// DecryptLogin 混合解密前端登录载荷: +// 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) + if err != nil { + return nil, err + } + var p LoginPayload + if err = json.Unmarshal(plain, &p); err != nil { + return nil, gerror.Wrap(err, "解析载荷失败") + } + now := time.Now() + if p.TS == 0 || p.Username == "" || p.Password == "" { + return nil, gerror.New("载荷字段缺失") + } + if now.Sub(time.UnixMilli(p.TS)) > payloadTTL || time.UnixMilli(p.TS).Sub(now) > payloadTTL { + return nil, gerror.New("载荷已过期,请重试") + } + return &p, nil +} + +// DecryptField 混合解密通用加密字段(创建管理员/重置密码等场景): +// 载荷 JSON 结构为 {"value":"...","ts":...},解密失败或字段缺失返回错误。 +func (s *Service) DecryptField(_ context.Context, encryptedKeyB64, encryptedDataB64 string) (string, error) { + plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64) + if err != nil { + return "", err + } + var p FieldPayload + if err = json.Unmarshal(plain, &p); err != nil { + return "", gerror.Wrap(err, "解析载荷失败") + } + now := time.Now() + if p.TS == 0 || p.Value == "" { + return "", gerror.New("载荷字段缺失") + } + if now.Sub(time.UnixMilli(p.TS)) > payloadTTL || time.UnixMilli(p.TS).Sub(now) > payloadTTL { + return "", gerror.New("载荷已过期,请重试") + } + 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) + if err != nil { + return nil, gerror.New("加密密钥格式错误") + } + aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, s.privKey, encKey, nil) + if err != nil { + return nil, gerror.New("解密 AES 会话密钥失败") + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, gerror.Wrap(err, "初始化 AES 失败") + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, gerror.Wrap(err, "初始化 AES-GCM 失败") + } + data, err := base64.StdEncoding.DecodeString(encryptedDataB64) + if err != nil { + return nil, gerror.New("密文格式错误") + } + if len(data) <= aesNonceSize { + return 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 plain, nil +} + +// parsePrivateKeyPEM 解析 PEM 格式 RSA 私钥(兼容 PKCS#1 与 PKCS#8)。 +func parsePrivateKeyPEM(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, gerror.New("无效的 PEM 数据") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, gerror.Wrap(err, "解析 RSA 私钥失败") + } + rsaKey, ok := k.(*rsa.PrivateKey) + if !ok { + return nil, gerror.New("密钥类型不是 RSA") + } + return rsaKey, nil +} + +// encodePrivateKeyPEM 将 RSA 私钥编码为 PKCS#1 PEM。 +func encodePrivateKeyPEM(key *rsa.PrivateKey) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} diff --git a/internal/service/admin/admin/login/login.go b/internal/service/admin/admin/login/login.go index a07d06b..788f90b 100644 --- a/internal/service/admin/admin/login/login.go +++ b/internal/service/admin/admin/login/login.go @@ -10,6 +10,7 @@ import ( "golang.org/x/crypto/bcrypt" "service.xpcool.com/internal/consts" "service.xpcool.com/internal/dao" + "service.xpcool.com/internal/library/crypto" "service.xpcool.com/internal/library/jwt" "service.xpcool.com/internal/library/response" "service.xpcool.com/internal/model/do" @@ -23,6 +24,10 @@ type IAdminAuth interface { Refresh(context.Context, string) (*dto.TokenPair, uint64, error) // Revoke 撤销刷新令牌会话(登出时调用),幂等:解析失败或已撤销均视为成功。 Revoke(context.Context, string) error + // ResolveLogin 解析登录凭据:优先混合解密(RSA+AES),未带密文且服务端允许明文时回退明文。 + ResolveLogin(context.Context, string, string, string, string) (string, string, error) + // PublicKey 返回登录加密 RSA 公钥(PEM),供前端混合加密。 + PublicKey(context.Context) (string, error) HasPermission(context.Context, uint64, string) (bool, error) Info(context.Context, uint64) (*dto.AdminInfo, error) Codes(context.Context, uint64) ([]string, error) @@ -41,9 +46,14 @@ func AdminAuth() IAdminAuth { } func RegisterAdminAuth(i IAdminAuth) { localAdminAuth = i } -type adminAuth struct{ tokens *jwt.Service } +type adminAuth struct { + tokens *jwt.Service + crypto *crypto.Service +} -func NewAdminAuth(tokens *jwt.Service) IAdminAuth { return &adminAuth{tokens} } +func NewAdminAuth(tokens *jwt.Service, cryptoSvc *crypto.Service) IAdminAuth { + return &adminAuth{tokens: tokens, crypto: cryptoSvc} +} func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.TokenPair, uint64, error) { // 管理端只允许账号密码登录,状态异常或密码错误均返回统一错误,避免枚举账号。 var a entity.AdminUser @@ -60,6 +70,28 @@ func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.Tok return s.issue(ctx, a.Id, "") } +// ResolveLogin 解析登录凭据,返回明文用户名与密码: +// - 携带密文(encryptedKey + encryptedData)时走 RSA+AES 混合解密; +// - 否则仅当服务端 encrypt.allowPlain=true(开发联调)时接受明文,生产一律拒绝。 +func (s *adminAuth) ResolveLogin(ctx context.Context, encryptedKey, encryptedData, username, password string) (string, string, error) { + if encryptedKey != "" && encryptedData != "" { + payload, err := s.crypto.DecryptLogin(ctx, encryptedKey, encryptedData) + if err != nil { + return "", "", response.Error(consts.CodeInvalidParam, err.Error()) + } + return payload.Username, payload.Password, nil + } + if s.crypto.AllowPlain() { + return username, password, nil + } + return "", "", response.Error(consts.CodeInvalidParam, "encrypted payload required") +} + +// PublicKey 返回登录加密 RSA 公钥(PEM),供前端混合加密密码。 +func (s *adminAuth) PublicKey(_ context.Context) (string, error) { + return s.crypto.PublicKey(), nil +} + // Refresh 用有效的刷新令牌轮换管理员令牌对:校验 → 撤销旧会话 → 签发新对并落库。 func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) { c, err := s.tokens.Parse(refresh, "refresh", "admin") diff --git a/manifest/config/config.dev.yaml b/manifest/config/config.dev.yaml index 8f9fae5..2274902 100644 --- a/manifest/config/config.dev.yaml +++ b/manifest/config/config.dev.yaml @@ -21,3 +21,7 @@ bark: baseUrl: "${BARK_BASE_URL}" deviceKey: "${BARK_DEVICE_KEY}" pushTime: "${BARK_PUSH_TIME}" +# 登录密码混合加密(RSA + AES-GCM):私钥留空自动生成并持久化;allowPlain 仅本地联调开启。 +encrypt: + privateKey: "${ENCRYPT_PRIVATE_KEY}" + allowPlain: true diff --git a/manifest/config/config.prod.yaml b/manifest/config/config.prod.yaml index 8f95ff9..7806839 100644 --- a/manifest/config/config.prod.yaml +++ b/manifest/config/config.prod.yaml @@ -3,5 +3,7 @@ logger: { level: "warning", stdout: true } database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } } 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 } # 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。 internalToken: "${INTERNAL_TOKEN}"