Compare commits
3 Commits
5e517a2d95
...
70cb86176e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cb86176e | ||
|
|
ebc507ee6c | ||
|
|
06484774ad |
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,6 +29,9 @@ log/
|
|||||||
# 本地环境变量(含数据库口令),禁止提交
|
# 本地环境变量(含数据库口令),禁止提交
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# 运行时数据目录(含自动生成的 RSA 登录加密私钥),禁止提交
|
||||||
|
data/
|
||||||
|
|
||||||
# GoLand 项目级运行配置(含数据库口令),禁止提交
|
# GoLand 项目级运行配置(含数据库口令),禁止提交
|
||||||
.run/
|
.run/
|
||||||
service_test.exe
|
service_test.exe
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
# service.xpcool.com 变更记录
|
# service.xpcool.com 变更记录
|
||||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||||
|
|
||||||
|
2026-08-27 | CHG | 登录接口从全量加密豁免,永远只加密密码字段:APICrypto plainRoutes 增 /system/auth/login(登录请求不整体加密,handler 直接收 {username, encryptedKey, encryptedData},登录响应因无会话密钥保持明文);ResolveLogin 分支顺序调整——密码字段密文优先解密,其次 fullBody 明文、开发 allowPlain。全量模式验证 5 项(登录明文响应/info 密文/解密成功/未加密拒绝/登出)+ dev 回归 15 项全过
|
||||||
|
|
||||||
|
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 并补两列
|
||||||
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 | 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=招聘中心、新房预售菜单丢失)
|
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=招聘中心、新房预售菜单丢失)
|
||||||
|
|
||||||
|
|||||||
@ -31,10 +31,14 @@ type AdminListRes struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminCreateReq 创建管理员。
|
// AdminCreateReq 创建管理员。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制),
|
||||||
|
// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。
|
||||||
type AdminCreateReq struct {
|
type AdminCreateReq struct {
|
||||||
g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"`
|
g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"`
|
||||||
Username string `json:"username" v:"required"`
|
Username string `json:"username" v:"required"`
|
||||||
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
|
Password string `json:"password" v:"min-length:6#password too short"`
|
||||||
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
|
EncryptedData string `json:"encryptedData"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
BarkDeviceId string `json:"barkDeviceId"`
|
BarkDeviceId string `json:"barkDeviceId"`
|
||||||
PushplusToken string `json:"pushplusToken"`
|
PushplusToken string `json:"pushplusToken"`
|
||||||
@ -61,10 +65,14 @@ type AdminUpdateReq struct {
|
|||||||
type AdminUpdateRes struct{}
|
type AdminUpdateRes struct{}
|
||||||
|
|
||||||
// AdminResetPwdReq 重置管理员密码。
|
// AdminResetPwdReq 重置管理员密码。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:encryptedKey/encryptedData 必填(生产强制),
|
||||||
|
// password 明文字段仅当服务端 encrypt.allowPlain=true(本地联调)时可用。
|
||||||
type AdminResetPwdReq struct {
|
type AdminResetPwdReq struct {
|
||||||
g.Meta `path:"/admin/resetPwd/{id}" method:"post" tags:"Admin/Admin" summary:"重置管理员密码"`
|
g.Meta `path:"/admin/resetPwd/{id}" method:"post" tags:"Admin/Admin" summary:"重置管理员密码"`
|
||||||
Id uint64 `json:"id" in:"path" v:"required"`
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
|
Password string `json:"password" v:"min-length:6#password too short"`
|
||||||
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
|
EncryptedData string `json:"encryptedData"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminResetPwdRes 是 AdminResetPwdReq 的响应。
|
// AdminResetPwdRes 是 AdminResetPwdReq 的响应。
|
||||||
|
|||||||
@ -4,10 +4,17 @@ package admin_admin_login
|
|||||||
import "github.com/gogf/gf/v2/frame/g"
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
// LoginReq 管理员登录请求。
|
// 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 {
|
type LoginReq struct {
|
||||||
g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"管理员登录"`
|
g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"管理员登录"`
|
||||||
Username string `json:"username" v:"required"`
|
EncryptedKey string `json:"encryptedKey"`
|
||||||
Password string `json:"password" v:"required"`
|
EncryptedData string `json:"encryptedData"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRes 管理员登录响应(令牌对 + 管理员 ID)。
|
// LoginRes 管理员登录响应(令牌对 + 管理员 ID)。
|
||||||
@ -18,6 +25,17 @@ type LoginRes struct {
|
|||||||
AdminID uint64 `json:"adminId"`
|
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 使用)。
|
// InfoReq 获取当前管理员资料(供 vben getUserInfo 使用)。
|
||||||
type InfoReq struct {
|
type InfoReq struct {
|
||||||
g.Meta `path:"/system/auth/info" method:"post" tags:"Admin/System/Auth" summary:"当前管理员资料"`
|
g.Meta `path:"/system/auth/info" method:"post" tags:"Admin/System/Auth" summary:"当前管理员资料"`
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import (
|
|||||||
jobctl "service.xpcool.com/internal/controller/job"
|
jobctl "service.xpcool.com/internal/controller/job"
|
||||||
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
|
serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
|
||||||
userctl "service.xpcool.com/internal/controller/user"
|
userctl "service.xpcool.com/internal/controller/user"
|
||||||
|
"service.xpcool.com/internal/library/crypto"
|
||||||
"service.xpcool.com/internal/library/jwt"
|
"service.xpcool.com/internal/library/jwt"
|
||||||
"service.xpcool.com/internal/middleware"
|
"service.xpcool.com/internal/middleware"
|
||||||
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
||||||
@ -89,6 +90,10 @@ func injectEnv(ctx context.Context) {
|
|||||||
setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
|
setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
|
||||||
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
|
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
|
||||||
setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
|
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 (
|
var (
|
||||||
@ -100,8 +105,13 @@ var (
|
|||||||
injectEnv(ctx)
|
injectEnv(ctx)
|
||||||
s := g.Server()
|
s := g.Server()
|
||||||
tokens := jwt.New(ctx)
|
tokens := jwt.New(ctx)
|
||||||
|
// 登录密码混合加密服务(RSA + AES-GCM):私钥加载/生成失败属致命错误,直接终止启动。
|
||||||
|
cryptoSvc, err := crypto.New(ctx)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
userauth.RegisterUserAuth(userauth.NewUserAuth(tokens, nil, nil))
|
userauth.RegisterUserAuth(userauth.NewUserAuth(tokens, nil, nil))
|
||||||
adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens))
|
adminauth.RegisterAdminAuth(adminauth.NewAdminAuth(tokens, cryptoSvc))
|
||||||
adminmenu.RegisterAdminMenu(adminmenu.NewAdminMenu())
|
adminmenu.RegisterAdminMenu(adminmenu.NewAdminMenu())
|
||||||
admin.RegisterAdminManage(admin.NewAdminManage())
|
admin.RegisterAdminManage(admin.NewAdminManage())
|
||||||
role.RegisterRoleManage(role.NewRoleManage())
|
role.RegisterRoleManage(role.NewRoleManage())
|
||||||
@ -129,7 +139,9 @@ var (
|
|||||||
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
|
group.Group("/", func(protected *ghttp.RouterGroup) { protected.Middleware(middleware.UserAuth(tokens)) })
|
||||||
})
|
})
|
||||||
s.Group("/api/service/admin", func(group *ghttp.RouterGroup) {
|
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.Bind(adminctl.NewAuth()) // Public: login only.
|
||||||
group.Group("/", func(profile *ghttp.RouterGroup) {
|
group.Group("/", func(profile *ghttp.RouterGroup) {
|
||||||
// 仅登录端点:个人资料 / 权限码 / 菜单路由。
|
// 仅登录端点:个人资料 / 权限码 / 菜单路由。
|
||||||
|
|||||||
@ -4,10 +4,33 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
adminv1 "service.xpcool.com/api/admin/admin/admin"
|
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"
|
"service.xpcool.com/internal/model/dto"
|
||||||
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
admin "service.xpcool.com/internal/service/admin/admin/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resolvePassword 解析创建/重置管理员时的密码:
|
||||||
|
// - 全量加密模式(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 {
|
||||||
|
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 分页查询管理员。
|
// AdminList 分页查询管理员。
|
||||||
func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (res *adminv1.AdminListRes, err error) {
|
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})
|
items, total, err := admin.AdminManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
|
||||||
@ -26,8 +49,12 @@ func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (
|
|||||||
|
|
||||||
// AdminCreate 创建管理员。
|
// AdminCreate 创建管理员。
|
||||||
func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) {
|
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{
|
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,
|
BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -47,7 +74,11 @@ func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateRe
|
|||||||
|
|
||||||
// AdminResetPwd 重置管理员密码。
|
// AdminResetPwd 重置管理员密码。
|
||||||
func (c *Controller) AdminResetPwd(ctx context.Context, req *adminv1.AdminResetPwdReq) (res *adminv1.AdminResetPwdRes, err error) {
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &adminv1.AdminResetPwdRes{}, nil
|
return &adminv1.AdminResetPwdRes{}, nil
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type AuthController struct{}
|
|||||||
func NewAuth() *AuthController { return &AuthController{} }
|
func NewAuth() *AuthController { return &AuthController{} }
|
||||||
|
|
||||||
// Login 校验管理员身份并签发令牌对,成功与失败均写入登录日志。
|
// Login 校验管理员身份并签发令牌对,成功与失败均写入登录日志。
|
||||||
|
// 密码采用「RSA + AES-GCM」混合加密传输:先解出明文用户名/密码再走原校验逻辑。
|
||||||
func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res *authv1.LoginRes, err error) {
|
func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res *authv1.LoginRes, err error) {
|
||||||
// 从请求上下文提取来源信息用于登录审计
|
// 从请求上下文提取来源信息用于登录审计
|
||||||
r := ghttp.RequestFromCtx(ctx)
|
r := ghttp.RequestFromCtx(ctx)
|
||||||
@ -26,17 +27,32 @@ func (c *AuthController) Login(ctx context.Context, req *authv1.LoginReq) (res *
|
|||||||
ip = r.GetClientIp()
|
ip = r.GetClientIp()
|
||||||
ua = r.Header.Get("User-Agent")
|
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 {
|
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
|
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
|
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 使用有效刷新令牌轮换管理员令牌对。
|
// Refresh 使用有效刷新令牌轮换管理员令牌对。
|
||||||
func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (res *authv1.RefreshRes, err error) {
|
func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (res *authv1.RefreshRes, err error) {
|
||||||
p, id, err := auth.AdminAuth().Refresh(ctx, req.RefreshToken)
|
p, id, err := auth.AdminAuth().Refresh(ctx, req.RefreshToken)
|
||||||
|
|||||||
276
internal/library/crypto/crypto.go
Normal file
276
internal/library/crypto/crypto.go
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
// 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
|
||||||
|
// fullBody 全量请求/响应加密开关(生产开启):所有 JSON body 整体加密,
|
||||||
|
// 响应用请求会话密钥(AES)加密回传;开发环境仅加密密码字段。
|
||||||
|
fullBody 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(),
|
||||||
|
fullBody: g.Cfg().MustGet(ctx, "encrypt.fullBody", 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 }
|
||||||
|
|
||||||
|
// FullBody 返回是否启用全量请求/响应加密(生产开启)。
|
||||||
|
func (s *Service) FullBody() bool { return s.fullBody }
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, err
|
||||||
|
}
|
||||||
|
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 "", gerror.New("会话密钥格式错误")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, "初始化 AES 失败")
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
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, nil, gerror.New("密文格式错误")
|
||||||
|
}
|
||||||
|
if len(data) <= aesNonceSize {
|
||||||
|
return nil, nil, gerror.New("密文长度非法")
|
||||||
|
}
|
||||||
|
// 密文结构:nonce(12B) || ciphertext,GCM 自带完整性校验。
|
||||||
|
plain, err := gcm.Open(nil, data[:aesNonceSize], data[aesNonceSize:], nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, gerror.New("解密载荷失败")
|
||||||
|
}
|
||||||
|
return aesKey, 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)}))
|
||||||
|
}
|
||||||
97
internal/middleware/encrypt.go
Normal file
97
internal/middleware/encrypt.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// 全量请求/响应加密中间件(生产环境 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"service.xpcool.com/internal/consts"
|
"service.xpcool.com/internal/consts"
|
||||||
"service.xpcool.com/internal/dao"
|
"service.xpcool.com/internal/dao"
|
||||||
|
"service.xpcool.com/internal/library/crypto"
|
||||||
"service.xpcool.com/internal/library/jwt"
|
"service.xpcool.com/internal/library/jwt"
|
||||||
"service.xpcool.com/internal/library/response"
|
"service.xpcool.com/internal/library/response"
|
||||||
"service.xpcool.com/internal/model/do"
|
"service.xpcool.com/internal/model/do"
|
||||||
@ -23,6 +24,10 @@ type IAdminAuth interface {
|
|||||||
Refresh(context.Context, string) (*dto.TokenPair, uint64, error)
|
Refresh(context.Context, string) (*dto.TokenPair, uint64, error)
|
||||||
// Revoke 撤销刷新令牌会话(登出时调用),幂等:解析失败或已撤销均视为成功。
|
// Revoke 撤销刷新令牌会话(登出时调用),幂等:解析失败或已撤销均视为成功。
|
||||||
Revoke(context.Context, string) error
|
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)
|
HasPermission(context.Context, uint64, string) (bool, error)
|
||||||
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
||||||
Codes(context.Context, uint64) ([]string, error)
|
Codes(context.Context, uint64) ([]string, error)
|
||||||
@ -41,9 +46,14 @@ func AdminAuth() IAdminAuth {
|
|||||||
}
|
}
|
||||||
func RegisterAdminAuth(i IAdminAuth) { localAdminAuth = i }
|
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) {
|
func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.TokenPair, uint64, error) {
|
||||||
// 管理端只允许账号密码登录,状态异常或密码错误均返回统一错误,避免枚举账号。
|
// 管理端只允许账号密码登录,状态异常或密码错误均返回统一错误,避免枚举账号。
|
||||||
var a entity.AdminUser
|
var a entity.AdminUser
|
||||||
@ -60,6 +70,36 @@ func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.Tok
|
|||||||
return s.issue(ctx, a.Id, "")
|
return s.issue(ctx, a.Id, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveLogin 解析登录凭据,返回明文用户名与密码。
|
||||||
|
// 登录永远只加密密码字段(全量加密模式下同样豁免登录的整体加密):
|
||||||
|
// - 携带密文(encryptedKey + encryptedData,密码字段 RSA+AES 混合加密)时优先解密;
|
||||||
|
// - 否则全量加密模式(fullBody):传输层已整体解密,信任前端提交的明文;
|
||||||
|
// - 开发环境 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.FullBody() {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return "", "", response.Error(consts.CodeInvalidParam, "username or password required")
|
||||||
|
}
|
||||||
|
return username, 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 用有效的刷新令牌轮换管理员令牌对:校验 → 撤销旧会话 → 签发新对并落库。
|
// Refresh 用有效的刷新令牌轮换管理员令牌对:校验 → 撤销旧会话 → 签发新对并落库。
|
||||||
func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) {
|
func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) {
|
||||||
c, err := s.tokens.Parse(refresh, "refresh", "admin")
|
c, err := s.tokens.Parse(refresh, "refresh", "admin")
|
||||||
|
|||||||
@ -21,3 +21,9 @@ bark:
|
|||||||
baseUrl: "${BARK_BASE_URL}"
|
baseUrl: "${BARK_BASE_URL}"
|
||||||
deviceKey: "${BARK_DEVICE_KEY}"
|
deviceKey: "${BARK_DEVICE_KEY}"
|
||||||
pushTime: "${BARK_PUSH_TIME}"
|
pushTime: "${BARK_PUSH_TIME}"
|
||||||
|
# 登录密码混合加密(RSA + AES-GCM):私钥留空自动生成并持久化;allowPlain 仅本地联调开启。
|
||||||
|
# fullBody 保持 false:开发环境仅加密密码字段,便于抓包联调。
|
||||||
|
encrypt:
|
||||||
|
privateKey: "${ENCRYPT_PRIVATE_KEY}"
|
||||||
|
allowPlain: true
|
||||||
|
fullBody: false
|
||||||
|
|||||||
@ -3,5 +3,8 @@ logger: { level: "warning", stdout: true }
|
|||||||
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
|
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
|
||||||
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
|
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
|
||||||
bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime: "${BARK_PUSH_TIME}" }
|
bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime: "${BARK_PUSH_TIME}" }
|
||||||
|
# 登录密码混合加密(RSA + AES-GCM):生产必须通过 ENCRYPT_PRIVATE_KEY 注入 PEM 私钥,禁止明文登录。
|
||||||
|
# fullBody=true:所有 admin 接口请求/响应整体加密传输(public-key 明文豁免)。
|
||||||
|
encrypt: { privateKey: "${ENCRYPT_PRIVATE_KEY}", allowPlain: false, fullBody: true }
|
||||||
# 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。
|
# 安全日志上报令牌(宿主机采集脚本携带,环境变量 INTERNAL_TOKEN 注入)。
|
||||||
internalToken: "${INTERNAL_TOKEN}"
|
internalToken: "${INTERNAL_TOKEN}"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user