feat(im security): add login failure lockout configuration feature

1. add APIs for login lockout config get and save
2. add backend service and controller for login lockout config
3. refactor frontend to separate login lockout config from main policy
4. update default login lockout policy values
5. add permission sql and docking documentation
This commit is contained in:
Austin
2026-09-21 09:28:03 +08:00
parent e5ab79c40d
commit 6348ace38e
10 changed files with 376 additions and 63 deletions
@@ -124,6 +124,10 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
version = uint(maxValue) + 1
}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: normalizedModerationPolicy(in.SensitiveWordPolicy, tenantCode), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
if in.Id > 0 && in.LoginRateLimit == nil {
// 未提交登录失败策略时保留原值(登录失败锁定已迁移至独立配置入口,避免保存草稿清空配置)
delete(data, c.LoginRateLimit)
}
if in.Id > 0 {
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update(); updateErr != nil {
return updateErr
@@ -252,6 +256,90 @@ func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImS
return id, err
}
// GetLoginLockoutConfig 读取租户已发布策略中的登录失败锁定配置,无配置时返回默认值。
func (s *sSysImSecurityPolicy) GetLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigGetInp) (res *sysin.ImLoginLockoutConfigModel, err error) {
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
res = &sysin.ImLoginLockoutConfigModel{Enabled: false, PasswordMaxAttempts: 5, LockMinutes: 1440, WindowMinutes: 60}
c := dao.ImSecurityPolicy.Columns()
var row gdb.Record
if row, err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(c.Id, c.PolicyVersion, "CAST("+c.LoginRateLimit+" AS CHAR) login_rate_limit").
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One(); err != nil {
return nil, gerror.Wrap(err, "获取登录失败策略配置失败")
}
if row.IsEmpty() {
return res, nil
}
res.PolicyId = row[c.Id].Int64()
res.PolicyVersion = row[c.PolicyVersion].Uint()
policy := gjson.New(row["login_rate_limit"].String())
res.Enabled = policy.Get("enabled").Bool()
if v := policy.Get("password_max_attempts").Int(); v >= 1 {
res.PasswordMaxAttempts = int(v)
}
if v := policy.Get("lock_seconds").Int(); v >= 60 {
res.LockMinutes = int(v / 60)
}
if v := policy.Get("window_seconds").Int(); v >= 60 {
res.WindowMinutes = int(v / 60)
}
return res, nil
}
// SaveLoginLockoutConfig 保存租户登录失败锁定配置:直接更新已发布策略,保存后通过策略变更事件即时生效。
func (s *sSysImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigSaveInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return err
}
value := map[string]any{"enabled": false}
if in.Enabled {
value = map[string]any{"enabled": true, "password_max_attempts": in.PasswordMaxAttempts, "lock_seconds": in.LockMinutes * 60, "window_seconds": in.WindowMinutes * 60}
}
loginRateLimit := gjson.New(value).String()
c := dao.ImSecurityPolicy.Columns()
db := imtenant.RegistryDB()
var row gdb.Record
if row, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(c.Id, c.PolicyVersion).
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One(); err != nil {
return gerror.Wrap(err, "获取登录失败策略配置失败")
}
operator := contexts.GetUserId(ctx)
now := gtime.Now()
if !row.IsEmpty() {
policyID := row[c.Id].Int64()
version := row[c.PolicyVersion].Uint()
if _, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, policyID).
Data(g.Map{c.LoginRateLimit: loginRateLimit, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); err != nil {
return gerror.Wrap(err, "保存登录失败策略配置失败")
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", policyID, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version)
}
// 租户尚无已发布策略:创建默认策略草稿并直接发布(Publish 内部已发送策略变更事件)
id, saveErr := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "默认安全策略", LoginRateLimit: gjson.New(value), GlobalFriendEnabled: 1, MessageCurfewEnabled: 0})
if saveErr != nil {
return saveErr
}
if err = s.Publish(ctx, &sysin.ImSecurityPolicyPublishInp{Id: id, Reason: "登录失败策略首次配置"}); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", id, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return nil
}
func (s *sSysImSecurityPolicy) IPRuleList(ctx context.Context, in *sysin.ImSecurityIPRuleListInp) (list []*sysin.ImSecurityIPRuleModel, total int, err error) {
if err = in.Filter(ctx); err != nil {
return