feat(imLoginFailureLog): 用户登录密码错误次数锁定与解锁能力(管理端)

- 安全策略登录限频由 JSON 文本改为结构化表单(次数/锁定分钟/窗口分钟),编辑校验同步扩展
- 失败日志新增解锁入口:/imLoginFailureLog/unlock 经 internal 通道调用 im-server 释放锁定
- 失败日志模型补充 accountHash 字段,解锁按钮按 risk_action=account_locked 展示
- 租户业务库初始化模板补充 im_login_lockouts 等表;新增解锁按钮菜单权限 SQL
This commit is contained in:
Austin
2026-09-20 17:04:19 +08:00
parent fd4ff85191
commit 8675fe333d
13 changed files with 238 additions and 19 deletions
@@ -88,6 +88,7 @@ type ImLoginFailureLogModel struct {
UserId string `json:"userId" dc:"用户ID"`
AccountType string `json:"accountType" dc:"账号类型"`
AccountMask string `json:"accountMask" dc:"脱敏账号"`
AccountHash string `json:"accountHash" dc:"账号哈希"`
FailureType string `json:"failureType" dc:"失败类型"`
Reason string `json:"reason" dc:"原因码"`
Detail string `json:"detail" dc:"失败详情"`
@@ -130,6 +131,27 @@ func (in *ImLoginFailureAddBlacklistInp) Filter(ctx context.Context) error {
return nil
}
type ImLoginFailureUnlockInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
AccountHash string `json:"accountHash" dc:"账号哈希"`
}
func (in *ImLoginFailureUnlockInp) Filter(ctx context.Context) error {
in.AccountHash = strings.ToLower(strings.TrimSpace(in.AccountHash))
if in.AccountHash == "" {
return gerror.New("账号哈希不能为空")
}
if len(in.AccountHash) != 64 {
return gerror.New("账号哈希格式不正确")
}
for _, c := range in.AccountHash {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return gerror.New("账号哈希格式不正确")
}
}
return nil
}
type ImLoginFailureBanUserInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId int64 `json:"userId" dc:"用户ID"`
@@ -12,6 +12,7 @@ import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
type ImSecurityPolicyListInp struct {
@@ -164,6 +165,13 @@ func (in *ImSecurityPolicySaveInp) Filter(ctx context.Context) error {
if in.GlobalFriendEnabled != 0 && in.GlobalFriendEnabled != 1 {
return gerror.New("全局好友开关不合法")
}
if in.LoginRateLimit != nil {
normalized, err := normalizeLoginRateLimit(in.LoginRateLimit)
if err != nil {
return err
}
in.LoginRateLimit = normalized
}
if in.MessageCurfewEnabled != 0 && in.MessageCurfewEnabled != 1 {
return gerror.New("宵禁开关不合法")
}
@@ -210,6 +218,43 @@ func (in *ImSecurityPolicySaveInp) Filter(ctx context.Context) error {
return nil
}
// normalizeLoginRateLimit 校验并归一化登录失败锁定配置:
// {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}
if value != nil && value.String() != "" && value.String() != "null" {
for key, item := range value.Map() {
policy[key] = item
}
}
if enabled, _ := policy["enabled"].(bool); !enabled {
return gjson.New(map[string]any{"enabled": false}), nil
}
maxAttempts := gconv.Int(policy["password_max_attempts"])
if maxAttempts == 0 {
maxAttempts = 5
}
if maxAttempts < 1 || maxAttempts > 20 {
return nil, gerror.New("登录失败最大次数必须在1至20之间")
}
lockSeconds := gconv.Int(policy["lock_seconds"])
if lockSeconds == 0 {
lockSeconds = 900
}
if lockSeconds < 60 || lockSeconds > 86400 {
return nil, gerror.New("账号锁定时长必须在1至1440分钟之间")
}
windowSeconds := gconv.Int(policy["window_seconds"])
if windowSeconds == 0 {
windowSeconds = 1800
}
if windowSeconds < 60 || windowSeconds > 14400 {
return nil, gerror.New("失败计数窗口必须在1至240分钟之间")
}
return gjson.New(map[string]any{"enabled": true, "password_max_attempts": maxAttempts, "lock_seconds": lockSeconds, "window_seconds": windowSeconds}), nil
}
type ImSecurityPolicyPublishInp struct {
Id int64 `json:"id" v:"required|min:1#策略ID不能为空|策略ID不合法"`
Reason string `json:"reason" v:"required#发布原因不能为空"`