From 6afeb65b91e9b41dadd52f6be16f5c9224300e14 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 18:31:46 +0800 Subject: [PATCH 01/10] feat: add tenant realname review policy --- .../api/admin/imconfiginternal/im_config.go | 24 +++++++---- .../internalapi/imconfig/im_config.go | 30 +++++++++++++ server/internal/logic/sys/im_tenant.go | 42 +++++++++++-------- .../internal/model/input/sysin/im_tenant.go | 8 ++++ web/src/views/imTenant/edit.vue | 13 ++++++ web/src/views/imTenant/model.ts | 14 +++++++ 6 files changed, 107 insertions(+), 24 deletions(-) diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index d36b5b5..2c495a3 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -99,14 +99,24 @@ type SecurityPolicyReq struct { } type SecurityPolicyRes struct { - TenantID int64 `json:"tenant_id"` - TenantCode string `json:"tenant_code"` - PolicyVersion uint `json:"policy_version"` - GlobalFriendEnabled bool `json:"global_friend_enabled"` - FriendDisabledPolicy map[string]interface{} `json:"friend_disabled_policy"` - MessageCurfewEnabled bool `json:"message_curfew_enabled"` + TenantID int64 `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + PolicyVersion uint `json:"policy_version"` + GlobalFriendEnabled bool `json:"global_friend_enabled"` + FriendDisabledPolicy map[string]interface{} `json:"friend_disabled_policy"` + MessageCurfewEnabled bool `json:"message_curfew_enabled"` LoginRateLimit *SecurityLoginRateLimitRes `json:"login_rate_limit"` - CurfewRules []*SecurityCurfewRuleRes `json:"curfew_rules"` + CurfewRules []*SecurityCurfewRuleRes `json:"curfew_rules"` +} + +type RealnamePolicyReq struct { + g.Meta `path:"/internal/im-config/tenants/by-id/{id}/realname-policy" method:"get" tags:"IM内部配置" summary:"获取租户实名认证策略"` + Id int64 `json:"id" in:"path" dc:"租户ID"` +} + +type RealnamePolicyRes struct { + TenantID int64 `json:"tenant_id"` + RealnameReviewPolicy int `json:"realname_review_policy"` } type SecurityLoginRateLimitRes struct { diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index c6e0618..d218fa9 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -302,6 +302,29 @@ func (c *cConfig) SecurityPolicy(ctx context.Context, req *api.SecurityPolicyReq return res, nil } +func (c *cConfig) RealnamePolicy(ctx context.Context, req *api.RealnamePolicyReq) (res *api.RealnamePolicyRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Id, req.Id) + if err != nil { + return nil, err + } + res = defaultRealnamePolicy(tenant.Id) + var row *struct { + RealnameReviewPolicy int `json:"realname_review_policy"` + } + if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Fields("realname_review_policy"). + Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&row); err != nil { + return nil, gerror.Wrap(err, "获取租户实名认证策略失败") + } + if row == nil { + return res, nil + } + if row.RealnameReviewPolicy == 1 { + res.RealnameReviewPolicy = 1 + } + return res, nil +} + func parseStringArray(raw string) []string { values := []string{} if strings.TrimSpace(raw) == "" { @@ -366,6 +389,13 @@ func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes { BlockMessage: "内容不符合平台规范,请修改后重试"} } +func defaultRealnamePolicy(tenantID int64) *api.RealnamePolicyRes { + return &api.RealnamePolicyRes{ + TenantID: tenantID, + RealnameReviewPolicy: 0, + } +} + func cloneBoolMap(src map[string]bool) map[string]bool { dst := make(map[string]bool, len(src)) for k, v := range src { diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index f43db62..aa3bc19 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -28,16 +28,18 @@ type sSysImTenant struct{} const ( tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" tenantConfigLocalModerationEnabled = "local_moderation_enabled" + tenantConfigRealnameReviewPolicy = "realname_review_policy" ) type imTenantConfigModeration struct { - Id int64 `json:"id"` - TenantId int64 `json:"tenant_id"` - MaxUsers uint64 `json:"max_users"` - MaxGroups uint64 `json:"max_groups"` - MaxGroupMembers uint `json:"max_group_members"` - AliyunModerationEnabled int `json:"aliyun_moderation_enabled"` - LocalModerationEnabled int `json:"local_moderation_enabled"` + Id int64 `json:"id" orm:"id"` + TenantId int64 `json:"tenant_id" orm:"tenant_id"` + MaxUsers uint64 `json:"max_users" orm:"max_users"` + MaxGroups uint64 `json:"max_groups" orm:"max_groups"` + MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` + AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` + LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` + RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` } func NewSysImTenant() *sSysImTenant { @@ -157,6 +159,7 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan item.MaxGroupMembers = conf.MaxGroupMembers item.AliyunModerationEnabled = conf.AliyunModerationEnabled item.LocalModerationEnabled = conf.LocalModerationEnabled + item.RealnameReviewPolicy = conf.RealnameReviewPolicy } item.DbDsnConfigured = dsnMap[item.Id] != "" } @@ -210,7 +213,7 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp WherePri(in.Id).Data(in).Update(); err != nil { return gerror.Wrap(err, "修改IM租户失败,请稍后重试!") } - return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId) + return s.saveConfig(ctx, in, userId) } in.CreatedBy = userId @@ -222,7 +225,8 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp return gerror.Wrap(err, "新增IM租户失败,请稍后重试!") } tenantID = id - return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId) + in.Id = id + return s.saveConfig(ctx, in, userId) }) if err != nil { return err @@ -246,20 +250,21 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin return } -func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, aliyunModerationEnabled, localModerationEnabled int, userId int64) (err error) { +func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp, userId int64) (err error) { var conf *entity.ImTenantConfig if err = s.configModel(ctx). - Where(dao.ImTenantConfig.Columns().TenantId, tenantId). + Where(dao.ImTenantConfig.Columns().TenantId, in.Id). Scan(&conf); err != nil { return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") } data := g.Map{ - dao.ImTenantConfig.Columns().MaxUsers: maxUsers, - dao.ImTenantConfig.Columns().MaxGroups: maxGroups, - dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers, - tenantConfigAliyunModerationEnabled: aliyunModerationEnabled, - tenantConfigLocalModerationEnabled: localModerationEnabled, + dao.ImTenantConfig.Columns().MaxUsers: in.MaxUsers, + dao.ImTenantConfig.Columns().MaxGroups: in.MaxGroups, + dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers, + tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled, + tenantConfigLocalModerationEnabled: in.LocalModerationEnabled, + tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy, } if conf != nil && conf.Id > 0 { data[dao.ImTenantConfig.Columns().UpdatedBy] = userId @@ -270,6 +275,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().UpdatedBy, ). WherePri(conf.Id).Data(data).Update(); err != nil { @@ -278,7 +284,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers return } - data[dao.ImTenantConfig.Columns().TenantId] = tenantId + data[dao.ImTenantConfig.Columns().TenantId] = in.Id data[dao.ImTenantConfig.Columns().CreatedBy] = userId data[dao.ImTenantConfig.Columns().UpdatedBy] = userId if _, err = s.configModel(ctx). @@ -289,6 +295,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().CreatedBy, dao.ImTenantConfig.Columns().UpdatedBy, ). @@ -324,6 +331,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res res.MaxGroupMembers = conf.MaxGroupMembers res.AliyunModerationEnabled = conf.AliyunModerationEnabled res.LocalModerationEnabled = conf.LocalModerationEnabled + res.RealnameReviewPolicy = conf.RealnameReviewPolicy } res.DbDsnConfigured = res.DbDsn != "" return diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index 274be13..69a350c 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -48,6 +48,7 @@ type ImTenantConfigUpdateFields struct { MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` UpdatedBy int64 `json:"updatedBy" dc:"更新者"` } @@ -59,6 +60,7 @@ type ImTenantConfigInsertFields struct { MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` CreatedBy int64 `json:"createdBy" dc:"创建者"` } @@ -70,6 +72,7 @@ type ImTenantEditInp struct { MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` } func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { @@ -89,6 +92,9 @@ func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) { return gerror.New("内容审核开关值不正确") } + if in.RealnameReviewPolicy != 0 && in.RealnameReviewPolicy != 1 { + return gerror.New("实名审核策略不正确") + } return } @@ -110,6 +116,7 @@ type ImTenantViewModel struct { MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` @@ -144,6 +151,7 @@ type ImTenantListModel struct { MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` CreatedBy int64 `json:"createdBy" dc:"创建者"` CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` diff --git a/web/src/views/imTenant/edit.vue b/web/src/views/imTenant/edit.vue index d83018f..280502e 100644 --- a/web/src/views/imTenant/edit.vue +++ b/web/src/views/imTenant/edit.vue @@ -100,6 +100,15 @@ /> + + + + + (row.realnameReviewPolicy === 1 ? '自动审核' : '人工审核') } + ); + }, + }, { title: '创建者', key: 'createdBy', From e2ca0607af0fc5f0bddbad8d04f36ec8345d6912 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 20:05:08 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=E5=B9=B3=E5=8F=B0=E8=B6=85?= =?UTF-8?q?=E7=AE=A1=E6=8B=A5=E6=9C=89=E5=85=A8=E9=83=A8=E5=95=86=E6=88=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/consts/debris.go | 1 + server/internal/logic/sys/im_admin_scope.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/server/internal/consts/debris.go b/server/internal/consts/debris.go index 531c34c..1d91b51 100644 --- a/server/internal/consts/debris.go +++ b/server/internal/consts/debris.go @@ -13,6 +13,7 @@ const ( RegionSpilt = " / " // 地区分隔符 Unknown = "Unknown" // Unknown SuperRoleKey = "super" // 超管角色唯一标识符,通过角色验证超管 + ImPlatformRoleKey = "supreme_manager" // IM平台超管角色唯一标识符 MaxServeLogContentLen = 2048 // 最大保留服务日志内容大小 SysDefaultLanguage = "zh_CN" // 系统默认语言,当配置文件没有国际化配置时生效 ) diff --git a/server/internal/logic/sys/im_admin_scope.go b/server/internal/logic/sys/im_admin_scope.go index 6091cd5..054e367 100644 --- a/server/internal/logic/sys/im_admin_scope.go +++ b/server/internal/logic/sys/im_admin_scope.go @@ -71,6 +71,9 @@ func isImPlatformAdmin(ctx context.Context, operatorId int64) bool { if service.AdminMember().VerifySuperId(ctx, operatorId) { return true } + if contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey { + return true + } return contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) <= 0 } From f154b9e3c3d299f64b0a9c88b53b2675be821508 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 21:03:54 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E6=8C=89=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7=E5=95=86?= =?UTF-8?q?=E6=88=B7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/logic/admin/member.go | 49 ++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/server/internal/logic/admin/member.go b/server/internal/logic/admin/member.go index 06f5683..b4431b5 100644 --- a/server/internal/logic/admin/member.go +++ b/server/internal/logic/admin/member.go @@ -468,7 +468,7 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err return } - if service.AdminMember().VerifySuperId(ctx, opMemberId) { + if service.AdminMember().VerifySuperId(ctx, opMemberId) || contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey { if in.TenantId > 0 { count, queryErr := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). Where(dao.ImTenant.Columns().Id, in.TenantId). @@ -508,6 +508,9 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err if err = service.AdminRole().VerifyRoleId(ctx, in.RoleId); err != nil { return } + if err = s.normalizeTenantByRole(ctx, in); err != nil { + return + } // 验证部门ID if err = service.AdminDept().VerifyDeptId(ctx, in.DeptId); err != nil { @@ -606,6 +609,50 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err }) } +func (s *sAdminMember) normalizeTenantByRole(ctx context.Context, in *adminin.MemberEditInp) error { + var role *entity.AdminRole + if err := dao.AdminRole.Ctx(ctx). + Fields(dao.AdminRole.Columns().Id, dao.AdminRole.Columns().Key). + WherePri(in.RoleId). + Scan(&role); err != nil { + return gerror.Wrap(err, "获取角色信息失败,请稍后重试!") + } + if role == nil || role.Id <= 0 { + return gerror.New("角色不存在") + } + switch role.Key { + case consts.ImPlatformRoleKey: + deptId, err := s.getCompanyDeptId(ctx) + if err != nil { + return err + } + in.TenantId = 0 + in.DeptId = deptId + case "imManage": + if in.TenantId <= 0 { + return gerror.New("IM管理员必须选择所属商户") + } + } + return nil +} + +func (s *sAdminMember) getCompanyDeptId(ctx context.Context) (int64, error) { + value, err := dao.AdminDept.Ctx(ctx). + Fields(dao.AdminDept.Columns().Id). + Where(dao.AdminDept.Columns().Type, consts.DeptTypeCompany). + Where(dao.AdminDept.Columns().Status, consts.StatusEnabled). + OrderAsc(dao.AdminDept.Columns().Id). + Limit(1). + Value() + if err != nil { + return 0, gerror.Wrap(err, "获取公司部门失败,请稍后重试!") + } + if value.IsEmpty() { + return 0, gerror.New("请先配置公司部门") + } + return value.Int64(), nil +} + // View 获取用户信息 func (s *sAdminMember) View(ctx context.Context, in *adminin.MemberViewInp) (res *adminin.MemberViewModel, err error) { if err = s.FilterAuthModel(ctx, contexts.GetUserId(ctx)).Hook(hook.MemberInfo).WherePri(in.Id).Scan(&res); err != nil { From b68c048383ae4bde3e1ce794d67c42f8d1fb0d2c Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 21:48:10 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E5=B9=B3=E5=8F=B0=E8=B6=85?= =?UTF-8?q?=E7=AE=A1=E5=8F=AF=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E8=A7=92?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/logic/admin/member.go | 7 ++++--- server/internal/logic/admin/role.go | 27 ++++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/server/internal/logic/admin/member.go b/server/internal/logic/admin/member.go index b4431b5..d10b442 100644 --- a/server/internal/logic/admin/member.go +++ b/server/internal/logic/admin/member.go @@ -959,9 +959,6 @@ func (s *sAdminMember) ClusterSyncSuperAdmin(ctx context.Context, message *gredi // 非超管用户只能操作自己的下级角色用户,并且需要满足自身角色的数据权限设置 func (s *sAdminMember) FilterAuthModel(ctx context.Context, memberId int64) *gdb.Model { m := dao.AdminMember.Ctx(ctx) - if s.VerifySuperId(ctx, memberId) { - return m - } var roleId int64 if contexts.GetUserId(ctx) == memberId { @@ -976,6 +973,10 @@ func (s *sAdminMember) FilterAuthModel(ctx context.Context, memberId int64) *gdb roleId = ro.Int64() } + if isAdminScopeSuper(ctx, memberId, roleId) { + return m + } + roleIds, err := service.AdminRole().GetSubRoleIds(ctx, roleId, false) if err != nil { g.Log().Panicf(ctx, "get the subordinate role permission exception, err:%+v", err) diff --git a/server/internal/logic/admin/role.go b/server/internal/logic/admin/role.go index 8dd1511..bdc25b6 100644 --- a/server/internal/logic/admin/role.go +++ b/server/internal/logic/admin/role.go @@ -37,6 +37,27 @@ func init() { service.RegisterAdminRole(NewAdminRole()) } +func isAdminScopeSuper(ctx context.Context, memberId, roleId int64) bool { + if service.AdminMember().VerifySuperId(ctx, memberId) { + return true + } + + if memberId == contexts.GetUserId(ctx) && contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey { + return true + } + + if roleId <= 0 { + return false + } + + value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Key).WherePri(roleId).Value() + if err != nil { + g.Log().Warningf(ctx, "query admin role key failed, roleId:%d err:%+v", roleId, err) + return false + } + return value.String() == consts.ImPlatformRoleKey +} + // Verify 验证权限 func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool { var ( @@ -49,7 +70,7 @@ func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool { return false } - if service.AdminMember().VerifySuperId(ctx, user.Id) { + if isAdminScopeSuper(ctx, user.Id, user.RoleId) { return true } @@ -70,7 +91,7 @@ func (s *sAdminRole) List(ctx context.Context, in *adminin.RoleListInp) (res *ad ) // 非超管只获取下级角色 - if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) { + if !isAdminScopeSuper(ctx, contexts.GetUserId(ctx), contexts.GetRoleId(ctx)) { pid = contexts.GetRoleId(ctx) mod = mod.WhereLike(dao.AdminRole.Columns().Tree, "%"+tree.GetIdLabel(pid)+"%") } @@ -326,7 +347,7 @@ func (s *sAdminRole) VerifyRoleId(ctx context.Context, id int64) (err error) { return } - ids, err := s.GetSubRoleIds(ctx, mb.RoleId, service.AdminMember().VerifySuperId(ctx, mb.Id)) + ids, err := s.GetSubRoleIds(ctx, mb.RoleId, isAdminScopeSuper(ctx, mb.Id, mb.RoleId)) if err != nil { err = gerror.New("验证角色信息失败!") return From 97990785cbe10f0242b4dbcb79c0c353be262937 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 21:50:43 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=E5=B9=B3=E5=8F=B0=E8=B6=85?= =?UTF-8?q?=E7=AE=A1=E6=9D=83=E9=99=90=E8=B5=B0=E8=A7=92=E8=89=B2=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/logic/admin/role.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/logic/admin/role.go b/server/internal/logic/admin/role.go index bdc25b6..68d5df4 100644 --- a/server/internal/logic/admin/role.go +++ b/server/internal/logic/admin/role.go @@ -70,7 +70,7 @@ func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool { return false } - if isAdminScopeSuper(ctx, user.Id, user.RoleId) { + if service.AdminMember().VerifySuperId(ctx, user.Id) { return true } From 2b93d27a4e17f8245865e78bad3232ab911c450a Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 21:53:38 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E8=A7=92=E8=89=B2=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=9A=90=E8=97=8F=E5=86=85=E7=BD=AE=E8=B6=85=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/logic/admin/role.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/logic/admin/role.go b/server/internal/logic/admin/role.go index 68d5df4..1fff050 100644 --- a/server/internal/logic/admin/role.go +++ b/server/internal/logic/admin/role.go @@ -85,7 +85,7 @@ func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool { // List 获取列表 func (s *sAdminRole) List(ctx context.Context, in *adminin.RoleListInp) (res *adminin.RoleListModel, totalCount int, err error) { var ( - mod = dao.AdminRole.Ctx(ctx) + mod = dao.AdminRole.Ctx(ctx).WhereNot(dao.AdminRole.Columns().Key, consts.SuperRoleKey) models []*entity.AdminRole pid int64 = 0 ) From 3ef328a5a35bf70713af9aa5b1a1fe7bac5aa8b5 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 22:03:51 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=E9=9A=90=E8=97=8F=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E8=B6=85=E7=AE=A1=E6=97=B6=E4=BF=9D=E7=95=99=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/logic/admin/role.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/server/internal/logic/admin/role.go b/server/internal/logic/admin/role.go index 1fff050..d671d29 100644 --- a/server/internal/logic/admin/role.go +++ b/server/internal/logic/admin/role.go @@ -58,6 +58,14 @@ func isAdminScopeSuper(ctx context.Context, memberId, roleId int64) bool { return value.String() == consts.ImPlatformRoleKey } +func getSuperRoleId(ctx context.Context) (int64, error) { + value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Id).Where(dao.AdminRole.Columns().Key, consts.SuperRoleKey).Value() + if err != nil { + return 0, err + } + return value.Int64(), nil +} + // Verify 验证权限 func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool { var ( @@ -90,8 +98,14 @@ func (s *sAdminRole) List(ctx context.Context, in *adminin.RoleListInp) (res *ad pid int64 = 0 ) - // 非超管只获取下级角色 - if !isAdminScopeSuper(ctx, contexts.GetUserId(ctx), contexts.GetRoleId(ctx)) { + // 超管隐藏内置根角色,但从内置根角色的子级开始展示角色树。 + if isAdminScopeSuper(ctx, contexts.GetUserId(ctx), contexts.GetRoleId(ctx)) { + pid, err = getSuperRoleId(ctx) + if err != nil { + err = gerror.Wrap(err, consts.ErrorORM) + return + } + } else { pid = contexts.GetRoleId(ctx) mod = mod.WhereLike(dao.AdminRole.Columns().Tree, "%"+tree.GetIdLabel(pid)+"%") } From 4ae69706a546d8b4f1458652b6fc8ed0f34ab62b Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 22:31:54 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86IM=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E8=8F=9C=E5=8D=95=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/storage/data/generate/im_menu_hierarchy.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/storage/data/generate/im_menu_hierarchy.sql b/server/storage/data/generate/im_menu_hierarchy.sql index 287aa3f..3c5ff7c 100644 --- a/server/storage/data/generate/im_menu_hierarchy.sql +++ b/server/storage/data/generate/im_menu_hierarchy.sql @@ -18,7 +18,7 @@ SELECT @imRootId, '日志审计', 'imLogAudit', 'audit', 'AuditOutlined', 1, '/i WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imLogAudit'); INSERT INTO `hg_admin_menu` (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) -SELECT @imRootId, '系统设置', 'imSystemSettings', 'settings', 'SettingOutlined', 1, '/im/settings/tenant/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 40, '', 1, @now, @now +SELECT @imRootId, 'IM系统设置', 'imSystemSettings', 'settings', 'SettingOutlined', 1, '/im/settings/tenant/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 40, '', 1, @now, @now WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings'); SET @peopleId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imUserAndGroup' LIMIT 1); @@ -29,7 +29,7 @@ SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSet UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='用户与群组', `path`='people', `icon`='TeamOutlined', `type`=1, `redirect`='/im/people/user/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=10, `updated_at`=@now WHERE `id`=@peopleId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='内容审核', `path`='content', `icon`='FileSearchOutlined', `type`=1, `redirect`='/im/content/private-message/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=20, `updated_at`=@now WHERE `id`=@contentId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='日志审计', `path`='audit', `icon`='AuditOutlined', `type`=1, `redirect`='/im/audit/operation/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=30, `updated_at`=@now WHERE `id`=@auditId; -UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='系统设置', `path`='settings', `icon`='SettingOutlined', `type`=1, `redirect`='/im/settings/tenant/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@settingsId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='IM系统设置', `path`='settings', `icon`='SettingOutlined', `type`=1, `redirect`='/im/settings/tenant/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@settingsId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `icon`='DashboardOutlined', `type`=2, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=5, `updated_at`=@now WHERE `name`='imDashboardIndex'; UPDATE `hg_admin_menu` SET `redirect`='/im/dashboard/index', `updated_at`=@now WHERE `id`=@imRootId; From 42cfe232fe5f09ac856d4d32f586ce2195910457 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 22:43:57 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20IM=E8=AE=BE=E7=BD=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=9B=B4=E5=90=8D=E4=B8=BA=E5=B9=B3=E5=8F=B0=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/storage/data/generate/im_menu_hierarchy.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/storage/data/generate/im_menu_hierarchy.sql b/server/storage/data/generate/im_menu_hierarchy.sql index 3c5ff7c..40185d9 100644 --- a/server/storage/data/generate/im_menu_hierarchy.sql +++ b/server/storage/data/generate/im_menu_hierarchy.sql @@ -18,7 +18,7 @@ SELECT @imRootId, '日志审计', 'imLogAudit', 'audit', 'AuditOutlined', 1, '/i WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imLogAudit'); INSERT INTO `hg_admin_menu` (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) -SELECT @imRootId, 'IM系统设置', 'imSystemSettings', 'settings', 'SettingOutlined', 1, '/im/settings/tenant/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 40, '', 1, @now, @now +SELECT @imRootId, '平台设置', 'imSystemSettings', 'settings', 'SettingOutlined', 1, '/im/settings/tenant/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 40, '', 1, @now, @now WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings'); SET @peopleId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imUserAndGroup' LIMIT 1); @@ -29,7 +29,7 @@ SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSet UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='用户与群组', `path`='people', `icon`='TeamOutlined', `type`=1, `redirect`='/im/people/user/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=10, `updated_at`=@now WHERE `id`=@peopleId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='内容审核', `path`='content', `icon`='FileSearchOutlined', `type`=1, `redirect`='/im/content/private-message/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=20, `updated_at`=@now WHERE `id`=@contentId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='日志审计', `path`='audit', `icon`='AuditOutlined', `type`=1, `redirect`='/im/audit/operation/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=30, `updated_at`=@now WHERE `id`=@auditId; -UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='IM系统设置', `path`='settings', `icon`='SettingOutlined', `type`=1, `redirect`='/im/settings/tenant/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@settingsId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='平台设置', `path`='settings', `icon`='SettingOutlined', `type`=1, `redirect`='/im/settings/tenant/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@settingsId; UPDATE `hg_admin_menu` SET `pid`=@imRootId, `icon`='DashboardOutlined', `type`=2, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=5, `updated_at`=@now WHERE `name`='imDashboardIndex'; UPDATE `hg_admin_menu` SET `redirect`='/im/dashboard/index', `updated_at`=@now WHERE `id`=@imRootId; From e8dd1eaa7d6466781c75b598a87d5bcb2a21f24b Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 23:43:33 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=95=86?= =?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E5=8A=A0=E5=AF=86=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/消息加密密钥管理.sql | 137 ++++++++ .../api/admin/imconfiginternal/im_config.go | 33 +- .../im_message_encryption.go | 36 +++ .../internal/consts/im_message_encryption.go | 12 + .../admin/sys/im_message_encryption.go | 41 +++ .../internalapi/imconfig/im_config.go | 22 ++ .../internal/library/immessagecrypto/key.go | 76 +++++ server/internal/logic/sys/dict_data.go | 18 +- server/internal/logic/sys/dict_type.go | 19 +- .../logic/sys/im_message_encryption.go | 298 ++++++++++++++++++ server/internal/logic/sys/im_tenant.go | 41 ++- .../input/sysin/im_message_encryption.go | 78 +++++ .../internal/model/input/sysin/im_tenant.go | 112 +++---- .../router/genrouter/im_message_encryption.go | 7 + server/internal/service/sys.go | 128 ++++---- .../data/generate/im_message_encryption.sql | 137 ++++++++ server/utility/encrypt/aes.go | 46 +++ server/utility/encrypt/aes_gcm_test.go | 34 ++ web/src/api/imMessageEncryption/index.ts | 17 + web/src/views/imMessageEncryption/index.vue | 137 ++++++++ web/src/views/imMessageEncryption/model.ts | 87 +++++ web/src/views/imTenant/edit.vue | 9 + web/src/views/imTenant/model.ts | 14 + 23 files changed, 1417 insertions(+), 122 deletions(-) create mode 100644 docs/admin-adfahi-research/sql/消息加密密钥管理.sql create mode 100644 server/api/admin/immessageencryption/im_message_encryption.go create mode 100644 server/internal/consts/im_message_encryption.go create mode 100644 server/internal/controller/admin/sys/im_message_encryption.go create mode 100644 server/internal/library/immessagecrypto/key.go create mode 100644 server/internal/logic/sys/im_message_encryption.go create mode 100644 server/internal/model/input/sysin/im_message_encryption.go create mode 100644 server/internal/router/genrouter/im_message_encryption.go create mode 100644 server/storage/data/generate/im_message_encryption.sql create mode 100644 server/utility/encrypt/aes_gcm_test.go create mode 100644 web/src/api/imMessageEncryption/index.ts create mode 100644 web/src/views/imMessageEncryption/index.vue create mode 100644 web/src/views/imMessageEncryption/model.ts diff --git a/docs/admin-adfahi-research/sql/消息加密密钥管理.sql b/docs/admin-adfahi-research/sql/消息加密密钥管理.sql new file mode 100644 index 0000000..219690d --- /dev/null +++ b/docs/admin-adfahi-research/sql/消息加密密钥管理.sql @@ -0,0 +1,137 @@ +-- 消息加密密钥管理。 +-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。 + +SET NAMES utf8mb4; +SET @now := NOW(); + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'message_encryption_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT '商户ID', + `key_version` int unsigned NOT NULL COMMENT '密钥版本', + `algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法', + `encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK', + `dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled', + `activated_at` datetime DEFAULT NULL, + `retired_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`), + KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本'; + +INSERT INTO `hg_sys_dict_type` + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999, + '仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret' +); + +INSERT INTO `hg_sys_dict_data` + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret', + '', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_data` + WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01' +); + +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index', + 'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index', + 1, 'imManager', 0, 0, '', 0, 0, 0, 3, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80, + '商户消息加密开关及密钥版本管理', 1, @now, @now +WHERE @settingsId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex'); + +SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @settingsId, + `title` = '消息密钥管理', + `path` = 'message-encryption/index', + `icon` = 'KeyOutlined', + `permissions` = '/imMessageEncryption/list', + `component` = '/imMessageEncryption/index', + `active_menu` = 'imManager', + `level` = 3, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), + `sort` = 80, + `hidden` = 0, + `status` = 1, + `updated_at` = @now +WHERE `id` = @keyMenuId; + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '', + '/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '', + '/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '', + '/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch'); + +UPDATE `hg_admin_menu` +SET `level` = 4, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), + `updated_at` = @now +WHERE `pid` = @keyMenuId AND `type` = 3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT 1, `id` FROM `hg_admin_menu` +WHERE `name` IN ( + 'imManager', + 'imSystemSettings', + 'imMessageEncryptionIndex', + 'imMessageEncryptionInitialize', + 'imMessageEncryptionRotate', + 'imMessageEncryptionSwitch' +); diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 2c495a3..7692a16 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -1,6 +1,10 @@ package imconfiginternal -import "github.com/gogf/gf/v2/frame/g" +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) type TenantReq struct { g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"` @@ -155,6 +159,33 @@ type SensitiveWordsRes struct { Items []*SensitiveWordModel `json:"items"` } +type MessageKeyActiveReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/active" method:"get" tags:"IM内部配置" summary:"获取商户当前消息密钥"` + TenantCode string `json:"tenantCode" in:"path"` +} + +type MessageKeyActiveRes struct { + *sysin.ImMessageKeyModel +} + +type MessageKeyVersionReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/{version}" method:"get" tags:"IM内部配置" summary:"获取商户指定版本消息密钥"` + TenantCode string `json:"tenantCode" in:"path"` + Version uint `json:"version" in:"path"` +} + +type MessageKeyVersionRes struct { + *sysin.ImMessageKeyModel +} + +type MessageKeyAckReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/ack" method:"post" tags:"IM内部配置" summary:"确认商户消息密钥可用"` + TenantCode string `json:"tenantCode" in:"path"` + Version uint `json:"version" v:"required|min:1#密钥版本不能为空|密钥版本不正确"` +} + +type MessageKeyAckRes struct{} + type SensitiveWordModel struct { ID int64 `json:"id"` Word string `json:"word"` diff --git a/server/api/admin/immessageencryption/im_message_encryption.go b/server/api/admin/immessageencryption/im_message_encryption.go new file mode 100644 index 0000000..064b8d7 --- /dev/null +++ b/server/api/admin/immessageencryption/im_message_encryption.go @@ -0,0 +1,36 @@ +package immessageencryption + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imMessageEncryption/list" method:"get" tags:"消息加密密钥" summary:"获取商户消息加密状态"` + sysin.ImMessageEncryptionListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImMessageEncryptionListModel `json:"list" dc:"数据列表"` +} + +type InitializeReq struct { + g.Meta `path:"/imMessageEncryption/initialize" method:"post" tags:"消息加密密钥" summary:"初始化商户消息密钥"` + sysin.ImMessageEncryptionActionInp +} +type InitializeRes struct{} + +type RotateReq struct { + g.Meta `path:"/imMessageEncryption/rotate" method:"post" tags:"消息加密密钥" summary:"轮换商户消息密钥"` + sysin.ImMessageEncryptionActionInp +} +type RotateRes struct{} + +type SwitchReq struct { + g.Meta `path:"/imMessageEncryption/switch" method:"post" tags:"消息加密密钥" summary:"切换商户消息加密"` + sysin.ImMessageEncryptionSwitchInp +} +type SwitchRes struct{} diff --git a/server/internal/consts/im_message_encryption.go b/server/internal/consts/im_message_encryption.go new file mode 100644 index 0000000..a3e85bf --- /dev/null +++ b/server/internal/consts/im_message_encryption.go @@ -0,0 +1,12 @@ +package consts + +const ( + ImInternalSecretDictType = "im_internal_secret" + ImMessageRootKEKLabel = "core_material_01" + + ImMessageKeyAlgorithm = "AES-256-GCM" + ImMessageKeyStatusPending = "pending" + ImMessageKeyStatusActive = "active" + ImMessageKeyStatusRetired = "retired" + ImMessageKeyStatusDisabled = "disabled" +) diff --git a/server/internal/controller/admin/sys/im_message_encryption.go b/server/internal/controller/admin/sys/im_message_encryption.go new file mode 100644 index 0000000..00e560e --- /dev/null +++ b/server/internal/controller/admin/sys/im_message_encryption.go @@ -0,0 +1,41 @@ +package sys + +import ( + "context" + "hotgo/api/admin/immessageencryption" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImMessageEncryption = cImMessageEncryption{} + +type cImMessageEncryption struct{} + +func (c *cImMessageEncryption) List(ctx context.Context, req *immessageencryption.ListReq) (res *immessageencryption.ListRes, err error) { + list, total, err := service.SysImMessageEncryption().List(ctx, &req.ImMessageEncryptionListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImMessageEncryptionListModel{} + } + res = new(immessageencryption.ListRes) + res.List = list + res.PageRes.Pack(req, total) + return res, nil +} + +func (c *cImMessageEncryption) Initialize(ctx context.Context, req *immessageencryption.InitializeReq) (res *immessageencryption.InitializeRes, err error) { + err = service.SysImMessageEncryption().Initialize(ctx, &req.ImMessageEncryptionActionInp) + return +} + +func (c *cImMessageEncryption) Rotate(ctx context.Context, req *immessageencryption.RotateReq) (res *immessageencryption.RotateRes, err error) { + err = service.SysImMessageEncryption().Rotate(ctx, &req.ImMessageEncryptionActionInp) + return +} + +func (c *cImMessageEncryption) Switch(ctx context.Context, req *immessageencryption.SwitchReq) (res *immessageencryption.SwitchRes, err error) { + err = service.SysImMessageEncryption().Switch(ctx, &req.ImMessageEncryptionSwitchInp) + return +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index d218fa9..2532d9d 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -9,6 +9,7 @@ import ( "hotgo/internal/library/immoderation" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" "hotgo/internal/service" "strings" @@ -382,6 +383,27 @@ func (c *cConfig) SensitiveWords(ctx context.Context, req *api.SensitiveWordsReq return res, nil } +func (c *cConfig) MessageKeyActive(ctx context.Context, req *api.MessageKeyActiveReq) (res *api.MessageKeyActiveRes, err error) { + key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode}) + if err != nil { + return nil, err + } + return &api.MessageKeyActiveRes{ImMessageKeyModel: key}, nil +} + +func (c *cConfig) MessageKeyVersion(ctx context.Context, req *api.MessageKeyVersionReq) (res *api.MessageKeyVersionRes, err error) { + key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode, Version: req.Version}) + if err != nil { + return nil, err + } + return &api.MessageKeyVersionRes{ImMessageKeyModel: key}, nil +} + +func (c *cConfig) MessageKeyAck(ctx context.Context, req *api.MessageKeyAckReq) (res *api.MessageKeyAckRes, err error) { + err = service.SysImMessageEncryption().Ack(ctx, &sysin.ImMessageKeyAckInp{TenantCode: req.TenantCode, Version: req.Version}) + return +} + func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes { return &api.ModerationPolicyRes{TenantID: tenant.Id, TenantCode: tenant.Code, Enabled: false, AliyunEnabled: false, LocalWordsEnabled: false, Scenes: cloneBoolMap(defaultModerationScenes), diff --git a/server/internal/library/immessagecrypto/key.go b/server/internal/library/immessagecrypto/key.go new file mode 100644 index 0000000..9d1b16f --- /dev/null +++ b/server/internal/library/immessagecrypto/key.go @@ -0,0 +1,76 @@ +package immessagecrypto + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/utility/encrypt" + + "github.com/gogf/gf/v2/errors/gerror" +) + +func GenerateDEK() ([]byte, error) { + key := make([]byte, encrypt.AESGCMKeySize) + if _, err := rand.Read(key); err != nil { + return nil, gerror.Wrap(err, "生成商户消息密钥失败") + } + return key, nil +} + +func WrapDEK(ctx context.Context, tenantID int64, version uint, dek []byte) (ciphertext, nonce []byte, err error) { + kek, err := loadRootKEK(ctx) + if err != nil { + return nil, nil, err + } + return encrypt.AESGCMEncrypt(dek, kek, keyAAD(tenantID, version)) +} + +func UnwrapDEK(ctx context.Context, tenantID int64, version uint, ciphertext, nonce []byte) ([]byte, error) { + kek, err := loadRootKEK(ctx) + if err != nil { + return nil, err + } + dek, err := encrypt.AESGCMDecrypt(ciphertext, nonce, kek, keyAAD(tenantID, version)) + if err != nil { + return nil, gerror.Wrap(err, "解包商户消息密钥失败") + } + if len(dek) != encrypt.AESGCMKeySize { + return nil, gerror.New("商户消息密钥长度不正确") + } + return dek, nil +} + +func EncodeDEK(dek []byte) string { + return base64.StdEncoding.EncodeToString(dek) +} + +func loadRootKEK(ctx context.Context) ([]byte, error) { + columns := dao.SysDictData.Columns() + value, err := dao.SysDictData.Ctx(ctx). + Fields(columns.Value). + Where(columns.Type, consts.ImInternalSecretDictType). + Where(columns.Label, consts.ImMessageRootKEKLabel). + Where(columns.Status, consts.StatusEnabled). + Value() + if err != nil { + return nil, gerror.Wrap(err, "读取消息根密钥失败") + } + if value.IsEmpty() { + return nil, gerror.New("消息根密钥未初始化") + } + kek, err := base64.StdEncoding.DecodeString(value.String()) + if err != nil || len(kek) != encrypt.AESGCMKeySize { + return nil, gerror.New("消息根密钥格式不正确") + } + return kek, nil +} + +func keyAAD(tenantID int64, version uint) []byte { + aad := make([]byte, 16) + binary.BigEndian.PutUint64(aad[:8], uint64(tenantID)) + binary.BigEndian.PutUint64(aad[8:], uint64(version)) + return aad +} diff --git a/server/internal/logic/sys/dict_data.go b/server/internal/logic/sys/dict_data.go index fe5b9d9..c85fad9 100644 --- a/server/internal/logic/sys/dict_data.go +++ b/server/internal/logic/sys/dict_data.go @@ -30,6 +30,11 @@ func init() { // Delete 删除 func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error { + if count, err := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); err != nil { + return gerror.Wrap(err, consts.ErrorORM) + } else if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err := dao.SysDictData.Ctx(ctx).Where("id", in.Id).Delete() if err != nil { err = gerror.Wrap(err, consts.ErrorORM) @@ -42,6 +47,11 @@ func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err error) { // 修改 if in.Id > 0 { + if count, queryErr := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); queryErr != nil { + return gerror.Wrap(queryErr, consts.ErrorORM) + } else if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataUpdateFields{}).WherePri(in.Id).Data(in).Update() if err != nil { err = gerror.Wrap(err, consts.ErrorORM) @@ -60,6 +70,9 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err if in.Type == "" { return gerror.Wrap(err, "类型选择无效,请检查") } + if in.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataInsertFields{}).Data(in).OmitEmptyData().Insert() if err != nil { @@ -71,7 +84,7 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err // List 获取列表 func (s *sSysDictData) List(ctx context.Context, in *sysin.DictDataListInp) (list []*sysin.DictDataListModel, totalCount int, err error) { - mod := dao.SysDictData.Ctx(ctx) + mod := dao.SysDictData.Ctx(ctx).WhereNot(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType) // 类型ID if in.TypeID > 0 { types, err := s.GetTypes(ctx, in.TypeID) @@ -151,6 +164,9 @@ func (s *sSysDictData) GetTypes(ctx context.Context, id int64) (types []string, // Select 获取列表 func (s *sSysDictData) Select(ctx context.Context, in *sysin.DataSelectInp) (list sysin.DataSelectModel, err error) { + if in.Type == consts.ImInternalSecretDictType { + return nil, gerror.New("内部密钥字典禁止通过通用接口读取") + } // 优先从内置字典中获取 options, err := dict.GetOptions(ctx, in.Type) if err == nil { diff --git a/server/internal/logic/sys/dict_type.go b/server/internal/logic/sys/dict_type.go index c45154d..3b8811f 100644 --- a/server/internal/logic/sys/dict_type.go +++ b/server/internal/logic/sys/dict_type.go @@ -31,7 +31,7 @@ func init() { // Tree 树 func (s *sSysDictType) Tree(ctx context.Context) (list []*sysin.DictTypeTree, err error) { var ( - mod = dao.SysDictType.Ctx(ctx) + mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType) models []*entity.SysDictType ) @@ -55,6 +55,9 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp) err = gerror.New("数据不存在或已删除!") return } + if models.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } exist, err := dao.SysDictData.Ctx(ctx).Where("type", models.Type).One() if err != nil { @@ -83,6 +86,18 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp) // Edit 修改/新增 func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err error) { + if in.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } + if in.Id > 0 { + count, queryErr := dao.SysDictType.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, consts.ErrorORM) + } + if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } + } if err = hgorm.IsUnique(ctx, &dao.SysDictType, g.Map{dao.SysDictType.Columns().Name: in.Name}, "名称已存在", in.Id); err != nil { return } @@ -105,7 +120,7 @@ func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err // TreeSelect 获取类型关系树选项 func (s *sSysDictType) TreeSelect(ctx context.Context, in *sysin.DictTreeSelectInp) (list []*sysin.DictTypeTree, err error) { var ( - mod = dao.SysDictType.Ctx(ctx) + mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType) models []*entity.SysDictType ) diff --git a/server/internal/logic/sys/im_message_encryption.go b/server/internal/logic/sys/im_message_encryption.go new file mode 100644 index 0000000..e0f5710 --- /dev/null +++ b/server/internal/logic/sys/im_message_encryption.go @@ -0,0 +1,298 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/immessagecrypto" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +const ( + imMessageEncryptionKeyTable = "im_message_encryption_keys" + imTenantMessageEncryptionColumn = "message_encryption_enabled" +) + +type sSysImMessageEncryption struct{} + +func NewSysImMessageEncryption() *sSysImMessageEncryption { return &sSysImMessageEncryption{} } + +func init() { + service.RegisterSysImMessageEncryption(NewSysImMessageEncryption()) +} + +func (s *sSysImMessageEncryption) List(ctx context.Context, in *sysin.ImMessageEncryptionListInp) (list []*sysin.ImMessageEncryptionListModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + if err = s.requireSuperAdmin(ctx); err != nil { + return nil, 0, err + } + db := imtenant.RegistryDB() + mod := db.Model(dao.ImTenant.Table()+" t").Safe().Ctx(ctx). + LeftJoin(dao.ImTenantConfig.Table()+" c", "c.tenant_id=t.id"). + Fields( + "t.id tenant_id", "t.code tenant_code", "t.name tenant_name", + "COALESCE(c."+imTenantMessageEncryptionColumn+",0) message_encryption_enabled", + "COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='active'),0) active_version", + "COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='pending'),0) pending_version", + "(SELECT COUNT(1) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) key_count", + "(SELECT MAX(COALESCE(k.activated_at,k.created_at)) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) last_rotated_at", + ) + if in.TenantId > 0 { + mod = mod.Where("t.id", in.TenantId) + } + if keyword := strings.TrimSpace(in.Keyword); keyword != "" { + mod = mod.Where("t.code LIKE ? OR t.name LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + if in.Enabled != nil { + mod = mod.Where("COALESCE(c."+imTenantMessageEncryptionColumn+",0)=?", *in.Enabled) + } + if err = mod.Page(in.Page, in.PerPage).OrderAsc("t.id").ScanAndCount(&list, &total, false); err != nil { + return nil, 0, gerror.Wrap(err, "获取商户消息加密配置失败") + } + return +} + +func (s *sSysImMessageEncryption) Initialize(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + dek, err := immessagecrypto.GenerateDEK() + if err != nil { + return err + } + ciphertext, nonce, err := immessagecrypto.WrapDEK(ctx, tenant.Id, 1, dek) + if err != nil { + return err + } + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + count, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count > 0 { + return gerror.New("该商户已经初始化消息密钥") + } + _, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{ + "tenant_id": tenant.Id, "key_version": 1, "algorithm": consts.ImMessageKeyAlgorithm, + "encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusActive, + "created_at": gtime.Now(), "activated_at": gtime.Now(), "created_by": contexts.GetUserId(ctx), + "remark": strings.TrimSpace(in.Remark), + }).Insert() + if insertErr != nil { + return gerror.Wrap(insertErr, "初始化商户消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_key_initialize", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": 1}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", 1) +} + +func (s *sSysImMessageEncryption) Rotate(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + var version uint + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + pending, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusPending).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查待激活消息密钥失败") + } + if pending > 0 { + return gerror.New("该商户已有待激活密钥,请等待IM Server确认") + } + value, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Fields("COALESCE(MAX(key_version),0)").Where("tenant_id", tenant.Id).Value() + if queryErr != nil { + return gerror.Wrap(queryErr, "获取消息密钥版本失败") + } + if value.Uint() == 0 { + return gerror.New("请先初始化商户消息密钥") + } + version = value.Uint() + 1 + dek, generateErr := immessagecrypto.GenerateDEK() + if generateErr != nil { + return generateErr + } + ciphertext, nonce, wrapErr := immessagecrypto.WrapDEK(ctx, tenant.Id, version, dek) + if wrapErr != nil { + return wrapErr + } + _, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{ + "tenant_id": tenant.Id, "key_version": version, "algorithm": consts.ImMessageKeyAlgorithm, + "encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusPending, + "created_at": gtime.Now(), "created_by": contexts.GetUserId(ctx), "remark": strings.TrimSpace(in.Remark), + }).Insert() + if insertErr != nil { + return gerror.Wrap(insertErr, "创建待激活消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_key_rotate", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": version, "status": consts.ImMessageKeyStatusPending}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", version) +} + +func (s *sSysImMessageEncryption) Switch(ctx context.Context, in *sysin.ImMessageEncryptionSwitchInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + if in.Enabled == 1 { + count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count == 0 { + return gerror.New("启用消息加密前必须先初始化有效密钥") + } + } + result, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id). + Data(g.Map{imTenantMessageEncryptionColumn: in.Enabled, dao.ImTenantConfig.Columns().UpdatedBy: contexts.GetUserId(ctx)}).Update() + if err != nil { + return gerror.Wrap(err, "更新商户消息加密开关失败") + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("商户配置不存在") + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_encryption_switch", "im_tenant_config", tenant.Id, "success", g.Map{"enabled": in.Enabled}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "tenant.features.updated", "message_encryption", 0) +} + +func (s *sSysImMessageEncryption) GetKey(ctx context.Context, in *sysin.ImMessageKeyGetInp) (*sysin.ImMessageKeyModel, error) { + tenant, err := s.tenantByCode(ctx, in.TenantCode) + if err != nil { + return nil, err + } + enabledValue, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Fields(imTenantMessageEncryptionColumn).Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Value() + if err != nil { + return nil, gerror.Wrap(err, "获取商户消息加密开关失败") + } + mod := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id) + if in.Version > 0 { + mod = mod.Where("key_version", in.Version).WhereIn("status", []string{consts.ImMessageKeyStatusPending, consts.ImMessageKeyStatusActive, consts.ImMessageKeyStatusRetired}) + } else { + mod = mod.Where("status", consts.ImMessageKeyStatusActive).OrderDesc("key_version") + } + row, err := mod.One() + if err != nil { + return nil, gerror.Wrap(err, "获取商户消息密钥失败") + } + if row.IsEmpty() { + return nil, gerror.New("商户消息密钥不存在") + } + version := row["key_version"].Uint() + dek, err := immessagecrypto.UnwrapDEK(ctx, tenant.Id, version, row["encrypted_dek"].Bytes(), row["dek_nonce"].Bytes()) + if err != nil { + return nil, err + } + return &sysin.ImMessageKeyModel{ + TenantId: tenant.Id, TenantCode: tenant.Code, Enabled: enabledValue.Int() == 1, + Version: version, Algorithm: row["algorithm"].String(), Status: row["status"].String(), Key: immessagecrypto.EncodeDEK(dek), + }, nil +} + +func (s *sSysImMessageEncryption) Ack(ctx context.Context, in *sysin.ImMessageKeyAckInp) error { + tenant, err := s.tenantByCode(ctx, in.TenantCode) + if err != nil { + return err + } + if in.Version == 0 { + return gerror.New("消息密钥版本不能为空") + } + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("key_version", in.Version).One() + if queryErr != nil { + return gerror.Wrap(queryErr, "获取待激活消息密钥失败") + } + if row.IsEmpty() || row["status"].String() != consts.ImMessageKeyStatusPending { + return gerror.New("待激活消息密钥不存在") + } + if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive). + Data(g.Map{"status": consts.ImMessageKeyStatusRetired, "retired_at": gtime.Now()}).Update(); updateErr != nil { + return gerror.Wrap(updateErr, "退役旧消息密钥失败") + } + if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("key_version", in.Version). + Data(g.Map{"status": consts.ImMessageKeyStatusActive, "activated_at": gtime.Now()}).Update(); updateErr != nil { + return gerror.Wrap(updateErr, "激活新消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.activated", "message_encryption", in.Version) +} + +func (s *sSysImMessageEncryption) requireSuperAdmin(ctx context.Context) error { + if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) { + return gerror.New("只有最高权限平台管理员可以管理消息密钥") + } + return nil +} + +func (s *sSysImMessageEncryption) tenantByID(ctx context.Context, tenantID int64) (*entity.ImTenant, error) { + var tenant *entity.ImTenant + if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantID).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取商户失败") + } + if tenant == nil || tenant.Id == 0 { + return nil, gerror.New("商户不存在") + } + return tenant, nil +} + +func (s *sSysImMessageEncryption) tenantByCode(ctx context.Context, code string) (*entity.ImTenant, error) { + var tenant *entity.ImTenant + if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + Where(dao.ImTenant.Columns().Code, strings.TrimSpace(code)).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取商户失败") + } + if tenant == nil || tenant.Id == 0 { + return nil, gerror.New("商户不存在") + } + return tenant, nil +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index aa3bc19..6479886 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -26,20 +26,22 @@ import ( type sSysImTenant struct{} const ( - tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" - tenantConfigLocalModerationEnabled = "local_moderation_enabled" - tenantConfigRealnameReviewPolicy = "realname_review_policy" + tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" + tenantConfigLocalModerationEnabled = "local_moderation_enabled" + tenantConfigMessageEncryptionEnabled = "message_encryption_enabled" + tenantConfigRealnameReviewPolicy = "realname_review_policy" ) type imTenantConfigModeration struct { - Id int64 `json:"id" orm:"id"` - TenantId int64 `json:"tenant_id" orm:"tenant_id"` - MaxUsers uint64 `json:"max_users" orm:"max_users"` - MaxGroups uint64 `json:"max_groups" orm:"max_groups"` - MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` - AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` - LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` - RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` + Id int64 `json:"id" orm:"id"` + TenantId int64 `json:"tenant_id" orm:"tenant_id"` + MaxUsers uint64 `json:"max_users" orm:"max_users"` + MaxGroups uint64 `json:"max_groups" orm:"max_groups"` + MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` + AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` + LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` + MessageEncryptionEnabled int `json:"message_encryption_enabled" orm:"message_encryption_enabled"` + RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` } func NewSysImTenant() *sSysImTenant { @@ -159,6 +161,7 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan item.MaxGroupMembers = conf.MaxGroupMembers item.AliyunModerationEnabled = conf.AliyunModerationEnabled item.LocalModerationEnabled = conf.LocalModerationEnabled + item.MessageEncryptionEnabled = conf.MessageEncryptionEnabled item.RealnameReviewPolicy = conf.RealnameReviewPolicy } item.DbDsnConfigured = dsnMap[item.Id] != "" @@ -251,6 +254,18 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin } func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp, userId int64) (err error) { + if in.MessageEncryptionEnabled == 1 { + count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", in.Id). + Where("status", consts.ImMessageKeyStatusActive). + Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count == 0 { + return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥") + } + } var conf *entity.ImTenantConfig if err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, in.Id). @@ -264,6 +279,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers, tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled, tenantConfigLocalModerationEnabled: in.LocalModerationEnabled, + tenantConfigMessageEncryptionEnabled: in.MessageEncryptionEnabled, tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy, } if conf != nil && conf.Id > 0 { @@ -275,6 +291,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().UpdatedBy, ). @@ -295,6 +312,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().CreatedBy, dao.ImTenantConfig.Columns().UpdatedBy, @@ -331,6 +349,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res res.MaxGroupMembers = conf.MaxGroupMembers res.AliyunModerationEnabled = conf.AliyunModerationEnabled res.LocalModerationEnabled = conf.LocalModerationEnabled + res.MessageEncryptionEnabled = conf.MessageEncryptionEnabled res.RealnameReviewPolicy = conf.RealnameReviewPolicy } res.DbDsnConfigured = res.DbDsn != "" diff --git a/server/internal/model/input/sysin/im_message_encryption.go b/server/internal/model/input/sysin/im_message_encryption.go new file mode 100644 index 0000000..5aacd01 --- /dev/null +++ b/server/internal/model/input/sysin/im_message_encryption.go @@ -0,0 +1,78 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImMessageEncryptionListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"商户ID"` + Keyword string `json:"keyword" dc:"商户编码或名称"` + Enabled *int `json:"enabled" dc:"消息加密开关"` +} + +func (in *ImMessageEncryptionListInp) Filter(ctx context.Context) error { + return nil +} + +type ImMessageEncryptionListModel struct { + TenantId int64 `json:"tenantId" dc:"商户ID"` + TenantCode string `json:"tenantCode" dc:"商户编码"` + TenantName string `json:"tenantName" dc:"商户名称"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + ActiveVersion uint `json:"activeVersion" dc:"当前密钥版本"` + PendingVersion uint `json:"pendingVersion" dc:"待激活密钥版本"` + KeyCount int `json:"keyCount" dc:"密钥版本数"` + LastRotatedAt *gtime.Time `json:"lastRotatedAt" dc:"最后轮换时间"` +} + +type ImMessageEncryptionActionInp struct { + TenantId int64 `json:"tenantId" v:"required|min:1#请选择商户|商户不正确" dc:"商户ID"` + Remark string `json:"remark" dc:"操作备注"` +} + +func (in *ImMessageEncryptionActionInp) Filter(ctx context.Context) error { + if in.TenantId <= 0 { + return gerror.New("请选择商户") + } + return nil +} + +type ImMessageEncryptionSwitchInp struct { + TenantId int64 `json:"tenantId" v:"required|min:1#请选择商户|商户不正确" dc:"商户ID"` + Enabled int `json:"enabled" dc:"是否启用"` +} + +func (in *ImMessageEncryptionSwitchInp) Filter(ctx context.Context) error { + if in.TenantId <= 0 { + return gerror.New("请选择商户") + } + if in.Enabled != 0 && in.Enabled != 1 { + return gerror.New("消息加密开关值不正确") + } + return nil +} + +type ImMessageKeyGetInp struct { + TenantCode string `json:"tenantCode" dc:"商户编码"` + Version uint `json:"version" dc:"密钥版本,0表示当前版本"` +} + +type ImMessageKeyModel struct { + TenantId int64 `json:"tenantId" dc:"商户ID"` + TenantCode string `json:"tenantCode" dc:"商户编码"` + Enabled bool `json:"enabled" dc:"消息加密是否启用"` + Version uint `json:"version" dc:"密钥版本"` + Algorithm string `json:"algorithm" dc:"算法"` + Status string `json:"status" dc:"密钥状态"` + Key string `json:"key" dc:"Base64编码DEK,仅内部接口返回"` +} + +type ImMessageKeyAckInp struct { + TenantCode string `json:"tenantCode" dc:"商户编码"` + Version uint `json:"version" dc:"密钥版本"` +} diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index 69a350c..b94d20c 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -43,36 +43,39 @@ type ImTenantInsertFields struct { // ImTenantConfigUpdateFields 修改IM租户配置字段过滤 type ImTenantConfigUpdateFields struct { - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` } // ImTenantConfigInsertFields 新增IM租户配置字段过滤 type ImTenantConfigInsertFields struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` } // ImTenantEditInp 修改/新增IM租户 type ImTenantEditInp struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` } func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { @@ -89,8 +92,9 @@ func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { return gerror.New("租户状态不正确") } if (in.AliyunModerationEnabled != 0 && in.AliyunModerationEnabled != 1) || - (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) { - return gerror.New("内容审核开关值不正确") + (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) || + (in.MessageEncryptionEnabled != 0 && in.MessageEncryptionEnabled != 1) { + return gerror.New("商户功能开关值不正确") } if in.RealnameReviewPolicy != 0 && in.RealnameReviewPolicy != 1 { return gerror.New("实名审核策略不正确") @@ -111,17 +115,18 @@ func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) { type ImTenantViewModel struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` - TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` + TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` } // ImTenantListInp 获取IM租户列表 @@ -140,25 +145,26 @@ func (in *ImTenantListInp) Filter(ctx context.Context) (err error) { } type ImTenantListModel struct { - Id int64 `json:"id" dc:"ID"` - Code string `json:"code" dc:"租户编码"` - Name string `json:"name" dc:"租户名称"` - TenantDesc string `json:"tenantDesc" dc:"租户描述"` - DbName string `json:"dbName" dc:"业务库名"` - State int `json:"state" dc:"状态"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + State int `json:"state" dc:"状态"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } // ImTenantStatusInp 更新IM租户状态 diff --git a/server/internal/router/genrouter/im_message_encryption.go b/server/internal/router/genrouter/im_message_encryption.go new file mode 100644 index 0000000..84525d7 --- /dev/null +++ b/server/internal/router/genrouter/im_message_encryption.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImMessageEncryption) +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index da72af2..55de95a 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -211,6 +211,14 @@ type ( // Restore 恢复IM租户 Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) } + ISysImMessageEncryption interface { + List(ctx context.Context, in *sysin.ImMessageEncryptionListInp) (list []*sysin.ImMessageEncryptionListModel, total int, err error) + Initialize(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error + Rotate(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error + Switch(ctx context.Context, in *sysin.ImMessageEncryptionSwitchInp) error + GetKey(ctx context.Context, in *sysin.ImMessageKeyGetInp) (*sysin.ImMessageKeyModel, error) + Ack(ctx context.Context, in *sysin.ImMessageKeyAckInp) error + } ISysImRegistration interface { // GetConfig 获取IM注册配置 GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) @@ -779,60 +787,61 @@ type ( ) var ( - localSysAddons ISysAddons - localSysAddonsConfig ISysAddonsConfig - localSysAttachment ISysAttachment - localSysBlacklist ISysBlacklist - localSysConfig ISysConfig - localSysCron ISysCron - localSysCronGroup ISysCronGroup - localSysCurdDemo ISysCurdDemo - localSysCultivateFlower ISysCultivateFlower - localSysCultivateContent ISysCultivateContent - localSysImTenant ISysImTenant - localSysImRegistration ISysImRegistration - localSysImInvite ISysImInvite - localSysImUser ISysImUser - localSysImGroup ISysImGroup - localSysImRealname ISysImRealname - localSysImWorkOrder ISysImWorkOrder - localSysImOutbox ISysImOutbox - localSysImOrg ISysImOrg - localSysImFile ISysImFile - localSysImFilePolicy ISysImFilePolicy - localSysImUserPolicyGroup ISysImUserPolicyGroup - localSysImSensitiveWord ISysImSensitiveWord - localSysImSecurityPolicy ISysImSecurityPolicy - localSysImDashboard ISysImDashboard - localSysImPrivateMessage ISysImPrivateMessage - localSysImGroupMessage ISysImGroupMessage - localSysImUserLoginHistory ISysImUserLoginHistory - localSysImLoginFailureLog ISysImLoginFailureLog - localSysImExternalConfig ISysImExternalConfig - localSysImNotification ISysImNotification - localSysImAppRelease ISysImAppRelease - localSysImAbuseReason ISysImAbuseReason - localSysImMailboxConfig ISysImMailboxConfig - localSysImFeedbackConfig ISysImFeedbackConfig - localSysImWorkbench ISysImWorkbench - localSysImTaskDashboard ISysImTaskDashboard - localSysImAuditLog ISysImAuditLog - localSysImKafkaAudit ISysImKafkaAudit - localSysImKafkaCommand ISysImKafkaCommand - localSysImDeadLetter ISysImDeadLetter - localSysDictData ISysDictData - localSysDictType ISysDictType - localSysEmsLog ISysEmsLog - localSysGenCodes ISysGenCodes - localSysLog ISysLog - localSysLoginLog ISysLoginLog - localSysNormalTreeDemo ISysNormalTreeDemo - localSysOptionTreeDemo ISysOptionTreeDemo - localSysProvinces ISysProvinces - localSysServeLicense ISysServeLicense - localSysServeLog ISysServeLog - localSysSmsLog ISysSmsLog - localSysTestCategory ISysTestCategory + localSysAddons ISysAddons + localSysAddonsConfig ISysAddonsConfig + localSysAttachment ISysAttachment + localSysBlacklist ISysBlacklist + localSysConfig ISysConfig + localSysCron ISysCron + localSysCronGroup ISysCronGroup + localSysCurdDemo ISysCurdDemo + localSysCultivateFlower ISysCultivateFlower + localSysCultivateContent ISysCultivateContent + localSysImTenant ISysImTenant + localSysImMessageEncryption ISysImMessageEncryption + localSysImRegistration ISysImRegistration + localSysImInvite ISysImInvite + localSysImUser ISysImUser + localSysImGroup ISysImGroup + localSysImRealname ISysImRealname + localSysImWorkOrder ISysImWorkOrder + localSysImOutbox ISysImOutbox + localSysImOrg ISysImOrg + localSysImFile ISysImFile + localSysImFilePolicy ISysImFilePolicy + localSysImUserPolicyGroup ISysImUserPolicyGroup + localSysImSensitiveWord ISysImSensitiveWord + localSysImSecurityPolicy ISysImSecurityPolicy + localSysImDashboard ISysImDashboard + localSysImPrivateMessage ISysImPrivateMessage + localSysImGroupMessage ISysImGroupMessage + localSysImUserLoginHistory ISysImUserLoginHistory + localSysImLoginFailureLog ISysImLoginFailureLog + localSysImExternalConfig ISysImExternalConfig + localSysImNotification ISysImNotification + localSysImAppRelease ISysImAppRelease + localSysImAbuseReason ISysImAbuseReason + localSysImMailboxConfig ISysImMailboxConfig + localSysImFeedbackConfig ISysImFeedbackConfig + localSysImWorkbench ISysImWorkbench + localSysImTaskDashboard ISysImTaskDashboard + localSysImAuditLog ISysImAuditLog + localSysImKafkaAudit ISysImKafkaAudit + localSysImKafkaCommand ISysImKafkaCommand + localSysImDeadLetter ISysImDeadLetter + localSysDictData ISysDictData + localSysDictType ISysDictType + localSysEmsLog ISysEmsLog + localSysGenCodes ISysGenCodes + localSysLog ISysLog + localSysLoginLog ISysLoginLog + localSysNormalTreeDemo ISysNormalTreeDemo + localSysOptionTreeDemo ISysOptionTreeDemo + localSysProvinces ISysProvinces + localSysServeLicense ISysServeLicense + localSysServeLog ISysServeLog + localSysSmsLog ISysSmsLog + localSysTestCategory ISysTestCategory ) func SysAddons() ISysAddons { @@ -954,6 +963,17 @@ func RegisterSysImTenant(i ISysImTenant) { localSysImTenant = i } +func SysImMessageEncryption() ISysImMessageEncryption { + if localSysImMessageEncryption == nil { + panic("implement not found for interface ISysImMessageEncryption, forgot register?") + } + return localSysImMessageEncryption +} + +func RegisterSysImMessageEncryption(i ISysImMessageEncryption) { + localSysImMessageEncryption = i +} + func SysImRegistration() ISysImRegistration { if localSysImRegistration == nil { panic("implement not found for interface ISysImRegistration, forgot register?") diff --git a/server/storage/data/generate/im_message_encryption.sql b/server/storage/data/generate/im_message_encryption.sql new file mode 100644 index 0000000..219690d --- /dev/null +++ b/server/storage/data/generate/im_message_encryption.sql @@ -0,0 +1,137 @@ +-- 消息加密密钥管理。 +-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。 + +SET NAMES utf8mb4; +SET @now := NOW(); + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'message_encryption_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT '商户ID', + `key_version` int unsigned NOT NULL COMMENT '密钥版本', + `algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法', + `encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK', + `dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled', + `activated_at` datetime DEFAULT NULL, + `retired_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`), + KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本'; + +INSERT INTO `hg_sys_dict_type` + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999, + '仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret' +); + +INSERT INTO `hg_sys_dict_data` + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret', + '', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_data` + WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01' +); + +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index', + 'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index', + 1, 'imManager', 0, 0, '', 0, 0, 0, 3, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80, + '商户消息加密开关及密钥版本管理', 1, @now, @now +WHERE @settingsId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex'); + +SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @settingsId, + `title` = '消息密钥管理', + `path` = 'message-encryption/index', + `icon` = 'KeyOutlined', + `permissions` = '/imMessageEncryption/list', + `component` = '/imMessageEncryption/index', + `active_menu` = 'imManager', + `level` = 3, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), + `sort` = 80, + `hidden` = 0, + `status` = 1, + `updated_at` = @now +WHERE `id` = @keyMenuId; + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '', + '/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '', + '/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '', + '/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch'); + +UPDATE `hg_admin_menu` +SET `level` = 4, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), + `updated_at` = @now +WHERE `pid` = @keyMenuId AND `type` = 3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT 1, `id` FROM `hg_admin_menu` +WHERE `name` IN ( + 'imManager', + 'imSystemSettings', + 'imMessageEncryptionIndex', + 'imMessageEncryptionInitialize', + 'imMessageEncryptionRotate', + 'imMessageEncryptionSwitch' +); diff --git a/server/utility/encrypt/aes.go b/server/utility/encrypt/aes.go index a38f4c8..01e7cec 100644 --- a/server/utility/encrypt/aes.go +++ b/server/utility/encrypt/aes.go @@ -6,10 +6,56 @@ package encrypt import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" "encoding/base64" + "fmt" "github.com/forgoer/openssl" + "io" ) +const AESGCMKeySize = 32 + +// AESGCMEncrypt encrypts plaintext with AES-256-GCM and a fresh random nonce. +func AESGCMEncrypt(plaintext, key, additionalData []byte) (ciphertext, nonce []byte, err error) { + if len(key) != AESGCMKeySize { + return nil, nil, fmt.Errorf("AES-256-GCM key must be %d bytes", AESGCMKeySize) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err + } + nonce = make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, err + } + return gcm.Seal(nil, nonce, plaintext, additionalData), nonce, nil +} + +// AESGCMDecrypt authenticates and decrypts ciphertext encrypted by AESGCMEncrypt. +func AESGCMDecrypt(ciphertext, nonce, key, additionalData []byte) ([]byte, error) { + if len(key) != AESGCMKeySize { + return nil, fmt.Errorf("AES-256-GCM key must be %d bytes", AESGCMKeySize) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(nonce) != gcm.NonceSize() { + return nil, fmt.Errorf("AES-GCM nonce must be %d bytes", gcm.NonceSize()) + } + return gcm.Open(nil, nonce, ciphertext, additionalData) +} + // AesECBEncrypt 加密 func AesECBEncrypt(src, key []byte) (dst []byte, err error) { return openssl.AesECBEncrypt(src, key, openssl.PKCS7_PADDING) diff --git a/server/utility/encrypt/aes_gcm_test.go b/server/utility/encrypt/aes_gcm_test.go new file mode 100644 index 0000000..320688c --- /dev/null +++ b/server/utility/encrypt/aes_gcm_test.go @@ -0,0 +1,34 @@ +package encrypt + +import ( + "bytes" + "testing" +) + +func TestAESGCMRoundTripAndRandomNonce(t *testing.T) { + key := bytes.Repeat([]byte{7}, AESGCMKeySize) + plain := []byte("message encryption key") + aad := []byte("tenant:1:version:1") + first, firstNonce, err := AESGCMEncrypt(plain, key, aad) + if err != nil { + t.Fatal(err) + } + second, secondNonce, err := AESGCMEncrypt(plain, key, aad) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(firstNonce, secondNonce) || bytes.Equal(first, second) { + t.Fatal("repeated encryption must use a different nonce and ciphertext") + } + decoded, err := AESGCMDecrypt(first, firstNonce, key, aad) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, plain) { + t.Fatalf("unexpected plaintext: %q", decoded) + } + first[0] ^= 1 + if _, err = AESGCMDecrypt(first, firstNonce, key, aad); err == nil { + t.Fatal("tampered ciphertext must fail authentication") + } +} diff --git a/web/src/api/imMessageEncryption/index.ts b/web/src/api/imMessageEncryption/index.ts new file mode 100644 index 0000000..802940a --- /dev/null +++ b/web/src/api/imMessageEncryption/index.ts @@ -0,0 +1,17 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imMessageEncryption/list', method: 'GET', params }); +} + +export function Initialize(params) { + return http.request({ url: '/imMessageEncryption/initialize', method: 'POST', params }); +} + +export function Rotate(params) { + return http.request({ url: '/imMessageEncryption/rotate', method: 'POST', params }); +} + +export function Switch(params) { + return http.request({ url: '/imMessageEncryption/switch', method: 'POST', params }); +} diff --git a/web/src/views/imMessageEncryption/index.vue b/web/src/views/imMessageEncryption/index.vue new file mode 100644 index 0000000..eb47a17 --- /dev/null +++ b/web/src/views/imMessageEncryption/index.vue @@ -0,0 +1,137 @@ + + + diff --git a/web/src/views/imMessageEncryption/model.ts b/web/src/views/imMessageEncryption/model.ts new file mode 100644 index 0000000..ddde41e --- /dev/null +++ b/web/src/views/imMessageEncryption/model.ts @@ -0,0 +1,87 @@ +import { h, ref } from 'vue'; +import { NTag } from 'naive-ui'; +import { FormSchema } from '@/components/Form'; + +export class State { + public tenantId = 0; + public tenantCode = ''; + public tenantName = ''; + public messageEncryptionEnabled = 0; + public activeVersion = 0; + public pendingVersion = 0; + public keyCount = 0; + public lastRotatedAt = ''; +} + +export const schemas = ref([ + { + field: 'keyword', + component: 'NInput', + label: '商户', + componentProps: { + clearable: true, + placeholder: '商户编码/名称', + }, + }, + { + field: 'enabled', + component: 'NSelect', + label: '加密状态', + componentProps: { + clearable: true, + options: [ + { label: '已启用', value: 1 }, + { label: '未启用', value: 0 }, + ], + placeholder: '全部', + }, + }, +]); + +export const columns = [ + { title: '商户编码', key: 'tenantCode', align: 'left', width: 150 }, + { title: '商户名称', key: 'tenantName', align: 'left', width: 180 }, + { + title: '消息加密', + key: 'messageEncryptionEnabled', + align: 'left', + width: 110, + render(row: State) { + return h( + NTag, + { type: row.messageEncryptionEnabled ? 'success' : 'default', bordered: false }, + { default: () => (row.messageEncryptionEnabled ? '已启用' : '未启用') } + ); + }, + }, + { + title: '当前版本', + key: 'activeVersion', + align: 'left', + width: 110, + render(row: State) { + return row.activeVersion > 0 ? `v${row.activeVersion}` : '--'; + }, + }, + { + title: '待激活版本', + key: 'pendingVersion', + align: 'left', + width: 120, + render(row: State) { + return row.pendingVersion > 0 + ? h(NTag, { type: 'warning', bordered: false }, { default: () => `v${row.pendingVersion}` }) + : '--'; + }, + }, + { title: '版本数', key: 'keyCount', align: 'left', width: 90 }, + { + title: '最后轮换时间', + key: 'lastRotatedAt', + align: 'left', + width: 180, + render(row: State) { + return row.lastRotatedAt || '--'; + }, + }, +]; diff --git a/web/src/views/imTenant/edit.vue b/web/src/views/imTenant/edit.vue index 280502e..5c54a19 100644 --- a/web/src/views/imTenant/edit.vue +++ b/web/src/views/imTenant/edit.vue @@ -100,6 +100,15 @@ /> + + + + + (row.messageEncryptionEnabled === 1 ? '已启用' : '未启用') } + ); + }, + }, { title: '实名审核', key: 'realnameReviewPolicy',