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
@@ -39,6 +39,16 @@ type RestoreDefaultReq struct {
type RestoreDefaultRes struct {
Id int64 `json:"id"`
}
type LoginLockoutConfigGetReq struct {
g.Meta `path:"/imSecurityPolicy/loginLockoutConfig" method:"get" tags:"IM安全策略" summary:"获取登录失败策略配置"`
sysin.ImLoginLockoutConfigGetInp
}
type LoginLockoutConfigGetRes struct{ *sysin.ImLoginLockoutConfigModel }
type LoginLockoutConfigSaveReq struct {
g.Meta `path:"/imSecurityPolicy/loginLockoutConfig" method:"post" tags:"IM安全策略" summary:"保存登录失败策略配置"`
sysin.ImLoginLockoutConfigSaveInp
}
type LoginLockoutConfigSaveRes struct{}
type IPRuleListReq struct {
g.Meta `path:"/imSecurityPolicy/ipRuleList" method:"get" tags:"IM安全策略" summary:"获取IP黑白名单"`
sysin.ImSecurityIPRuleListInp
@@ -49,6 +49,17 @@ func (c *cImSecurityPolicy) RestoreDefault(ctx context.Context, req *imsecurityp
}
return &imsecuritypolicy.RestoreDefaultRes{Id: id}, nil
}
func (c *cImSecurityPolicy) GetLoginLockoutConfig(ctx context.Context, req *imsecuritypolicy.LoginLockoutConfigGetReq) (res *imsecuritypolicy.LoginLockoutConfigGetRes, err error) {
data, err := service.SysImSecurityPolicy().GetLoginLockoutConfig(ctx, &req.ImLoginLockoutConfigGetInp)
if err != nil {
return nil, err
}
return &imsecuritypolicy.LoginLockoutConfigGetRes{ImLoginLockoutConfigModel: data}, nil
}
func (c *cImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, req *imsecuritypolicy.LoginLockoutConfigSaveReq) (res *imsecuritypolicy.LoginLockoutConfigSaveRes, err error) {
err = service.SysImSecurityPolicy().SaveLoginLockoutConfig(ctx, &req.ImLoginLockoutConfigSaveInp)
return
}
func (c *cImSecurityPolicy) IPRuleList(ctx context.Context, req *imsecuritypolicy.IPRuleListReq) (res *imsecuritypolicy.IPRuleListRes, err error) {
list, total, err := service.SysImSecurityPolicy().IPRuleList(ctx, &req.ImSecurityIPRuleListInp)
if err != nil {
@@ -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
@@ -222,7 +222,7 @@ func (in *ImSecurityPolicySaveInp) Filter(ctx context.Context) error {
// {enabled, password_max_attempts, lock_seconds, window_seconds}。
// enabled=false 时输出仅含 enabled,行为与未配置一致。
func normalizeLoginRateLimit(value *gjson.Json) (*gjson.Json, error) {
policy := map[string]any{"enabled": false, "password_max_attempts": 5, "lock_seconds": 900, "window_seconds": 1800}
policy := map[string]any{"enabled": false, "password_max_attempts": 5, "lock_seconds": 86400, "window_seconds": 3600}
if value != nil && value.String() != "" && value.String() != "null" {
for key, item := range value.Map() {
policy[key] = item
@@ -240,14 +240,14 @@ func normalizeLoginRateLimit(value *gjson.Json) (*gjson.Json, error) {
}
lockSeconds := gconv.Int(policy["lock_seconds"])
if lockSeconds == 0 {
lockSeconds = 900
lockSeconds = 86400
}
if lockSeconds < 60 || lockSeconds > 86400 {
return nil, gerror.New("账号锁定时长必须在1至1440分钟之间")
}
windowSeconds := gconv.Int(policy["window_seconds"])
if windowSeconds == 0 {
windowSeconds = 1800
windowSeconds = 3600
}
if windowSeconds < 60 || windowSeconds > 14400 {
return nil, gerror.New("失败计数窗口必须在1至240分钟之间")
@@ -293,6 +293,53 @@ func (in *ImSecurityPolicyRestoreInp) Filter(ctx context.Context) error {
return nil
}
type ImLoginLockoutConfigModel struct {
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"passwordMaxAttempts"`
LockMinutes int `json:"lockMinutes"`
WindowMinutes int `json:"windowMinutes"`
PolicyId int64 `json:"policyId"`
PolicyVersion uint `json:"policyVersion"`
}
type ImLoginLockoutConfigGetInp struct {
TenantId int64 `json:"tenantId"`
}
func (in *ImLoginLockoutConfigGetInp) Filter(ctx context.Context) error {
if in.TenantId < 0 {
return gerror.New("租户ID不合法")
}
return nil
}
type ImLoginLockoutConfigSaveInp struct {
TenantId int64 `json:"tenantId"`
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"passwordMaxAttempts"`
LockMinutes int `json:"lockMinutes"`
WindowMinutes int `json:"windowMinutes"`
}
func (in *ImLoginLockoutConfigSaveInp) Filter(ctx context.Context) error {
if in.TenantId < 0 {
return gerror.New("租户ID不合法")
}
if !in.Enabled {
return nil
}
if in.PasswordMaxAttempts < 1 || in.PasswordMaxAttempts > 20 {
return gerror.New("登录失败最大次数必须在1至20之间")
}
if in.LockMinutes < 1 || in.LockMinutes > 1440 {
return gerror.New("账号锁定时长必须在1至1440分钟之间")
}
if in.WindowMinutes < 1 || in.WindowMinutes > 240 {
return gerror.New("失败计数窗口必须在1至240分钟之间")
}
return nil
}
type ImSecurityIPRuleListInp struct {
form.PageReq
TenantId int64 `json:"tenantId"`
+2
View File
@@ -418,6 +418,8 @@ type (
Save(ctx context.Context, in *sysin.ImSecurityPolicySaveInp) (id int64, err error)
Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) (err error)
RestoreDefault(ctx context.Context, in *sysin.ImSecurityPolicyRestoreInp) (id int64, err error)
GetLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigGetInp) (res *sysin.ImLoginLockoutConfigModel, err error)
SaveLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigSaveInp) (err error)
IPRuleList(ctx context.Context, in *sysin.ImSecurityIPRuleListInp) (list []*sysin.ImSecurityIPRuleModel, totalCount int, err error)
IPRuleEdit(ctx context.Context, in *sysin.ImSecurityIPRuleEditInp) (err error)
IPRuleDelete(ctx context.Context, in *sysin.ImSecurityIPRuleDeleteInp) (err error)