feat(auth): 登录密码 RSA+AES 混合加密传输与加密字段支持

- 新增 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 防止私钥入库
This commit is contained in:
夏犀麟 2026-08-27 23:43:41 +08:00
parent 5e517a2d95
commit 06484774ad
11 changed files with 374 additions and 21 deletions

3
.gitignore vendored
View File

@ -29,6 +29,9 @@ log/
# 本地环境变量含数据库口令禁止提交
.env*
# 运行时数据目录含自动生成的 RSA 登录加密私钥禁止提交
data/
# GoLand 项目级运行配置含数据库口令禁止提交
.run/
service_test.exe

View File

@ -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公钥输出 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 并补两列
2026-08-27 | CHG | 通知模块+自动任务+用户渠道字段016 SQLadmin_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/job11 接口全 POST URL 参数service/noticeSend 统一发送Bark 路径式 POST /{key} + pushplus 官方接口渠道/规则/日志/测试service/jobDB 驱动 gcronList/Save/Trigger/LogList + Register 业务函数注册 + 完成/失败自动通知 job_done/job_failrecruitment scheduler RegisterTasks 纳管 StartScheduler 废弃用户 admin 接口支持渠道字段已部署容器重建 api.json 20011 路由全注册调度器无 handler 缺失告警种子数据验证 OK
2026-08-27 | FIX | 菜单种子主键冲突修复743683erecruitment/003_menu.sql 招聘中心一级菜单 id=95 014_house_presale_menu.sql 新房预售 id=95挂看房中心 90ON DUPLICATE 互相覆盖后导者赢本地实际发生新房预售菜单被顶掉致 /house/presale 404生产库同样风险改为 id=96 后发现 015_server_security.sql并行会话生产已部署也用 96=安全日志挂系统监控5再改 **97**95=新房预售(挂90)96=安全日志(挂5)97=招聘中心三模块错开本地按 014015003 重放routes 接口 22 节点验证全部正确e18b25f 生产库需按 014015003 顺序重放修复当前生产 95=招聘中心新房预售菜单丢失

View File

@ -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 的响应。

View File

@ -4,10 +4,17 @@ package admin_admin_login
import "github.com/gogf/gf/v2/frame/g"
// LoginReq 管理员登录请求。
// 密码采用「RSA + AES-GCM」混合加密传输encryptedKey/encryptedData 必填,生产强制):
// - encryptedKeyRSA-OAEP 加密的 AES-256 会话密钥base64
// - encryptedDataAES-GCM 加密的明文载荷 {username,password,ts}base64nonce 前缀)
//
// 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:"当前管理员资料"`

View File

@ -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())

View File

@ -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+encryptedDataRSA+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

View File

@ -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)

View File

@ -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.privateKeyPEM→ 本地文件 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 生成 SPKIPKIX/SubjectPublicKeyInfoDER前端按 "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) || ciphertextGCM 自带完整性校验。
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)}))
}

View File

@ -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")

View File

@ -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

View File

@ -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}"