feat(imLoginFailureLog): 用户登录密码错误次数锁定与解锁能力(管理端)
- 安全策略登录限频由 JSON 文本改为结构化表单(次数/锁定分钟/窗口分钟),编辑校验同步扩展 - 失败日志新增解锁入口:/imLoginFailureLog/unlock 经 internal 通道调用 im-server 释放锁定 - 失败日志模型补充 accountHash 字段,解锁按钮按 risk_action=account_locked 展示 - 租户业务库初始化模板补充 im_login_lockouts 等表;新增解锁按钮菜单权限 SQL
This commit is contained in:
@@ -99,13 +99,21 @@ 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"`
|
||||
CurfewRules []*SecurityCurfewRuleRes `json:"curfew_rules"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type SecurityLoginRateLimitRes struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PasswordMaxAttempts int `json:"password_max_attempts"`
|
||||
LockSeconds int `json:"lock_seconds"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
}
|
||||
|
||||
type SecurityCurfewRuleRes struct {
|
||||
|
||||
@@ -35,3 +35,8 @@ type BanUserReq struct {
|
||||
sysin.ImLoginFailureBanUserInp
|
||||
}
|
||||
type BanUserRes struct{ *sysin.ImUserCommandModel }
|
||||
type UnlockReq struct {
|
||||
g.Meta `path:"/imLoginFailureLog/unlock" method:"post" tags:"IM登录失败日志" summary:"解锁登录失败锁定"`
|
||||
sysin.ImLoginFailureUnlockInp
|
||||
}
|
||||
type UnlockRes struct{}
|
||||
|
||||
@@ -37,3 +37,6 @@ func (c *cImLoginFailureLog) BanUser(ctx context.Context, req *imloginfailurelog
|
||||
data, err := service.SysImLoginFailureLog().BanUser(ctx, &req.ImLoginFailureBanUserInp)
|
||||
return &imloginfailurelog.BanUserRes{ImUserCommandModel: data}, err
|
||||
}
|
||||
func (c *cImLoginFailureLog) Unlock(ctx context.Context, req *imloginfailurelog.UnlockReq) (*imloginfailurelog.UnlockRes, error) {
|
||||
return &imloginfailurelog.UnlockRes{}, service.SysImLoginFailureLog().Unlock(ctx, &req.ImLoginFailureUnlockInp)
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func (c *cConfig) SecurityPolicy(ctx context.Context, req *api.SecurityPolicyReq
|
||||
}
|
||||
columns := dao.ImSecurityPolicy.Columns()
|
||||
record, err := imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Fields(columns.Id, columns.PolicyVersion, columns.GlobalFriendEnabled, "CAST("+columns.FriendDisabledPolicy+" AS CHAR) friend_disabled_policy", columns.MessageCurfewEnabled).
|
||||
Fields(columns.Id, columns.PolicyVersion, columns.GlobalFriendEnabled, columns.MessageCurfewEnabled, "CAST("+columns.LoginRateLimit+" AS CHAR) login_rate_limit", "CAST("+columns.FriendDisabledPolicy+" AS CHAR) friend_disabled_policy").
|
||||
Where(columns.TenantId, tenant.Id).Where(columns.PublishStatus, consts.ImSecurityPublishPublished).
|
||||
WhereNull(columns.DeletedAt).OrderDesc(columns.PolicyVersion).One()
|
||||
if err != nil {
|
||||
@@ -239,6 +239,22 @@ func (c *cConfig) SecurityPolicy(ctx context.Context, req *api.SecurityPolicyReq
|
||||
res.PolicyVersion = record[columns.PolicyVersion].Uint()
|
||||
res.GlobalFriendEnabled = record[columns.GlobalFriendEnabled].Int() == 1
|
||||
res.MessageCurfewEnabled = record[columns.MessageCurfewEnabled].Int() == 1
|
||||
if raw := strings.TrimSpace(record["login_rate_limit"].String()); raw != "" && raw != "null" {
|
||||
var limit struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PasswordMaxAttempts int `json:"password_max_attempts"`
|
||||
LockSeconds int `json:"lock_seconds"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &limit) == nil && limit.Enabled {
|
||||
res.LoginRateLimit = &api.SecurityLoginRateLimitRes{
|
||||
Enabled: true,
|
||||
PasswordMaxAttempts: limit.PasswordMaxAttempts,
|
||||
LockSeconds: limit.LockSeconds,
|
||||
WindowSeconds: limit.WindowSeconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(record["friend_disabled_policy"].String()); raw != "" && raw != "null" {
|
||||
_ = json.Unmarshal([]byte(raw), &res.FriendDisabledPolicy)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imadmin"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
@@ -128,6 +130,26 @@ func (s *sSysImLoginFailureLog) AddBlacklist(ctx context.Context, in *sysin.ImLo
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysImLoginFailureLog) Unlock(ctx context.Context, in *sysin.ImLoginFailureUnlockInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/login-lockouts/release"
|
||||
var out struct {
|
||||
AccountHash string `json:"account_hash"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]string{"account_hash": in.AccountHash}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "登录锁定已解锁 tenantId:%d accountHash:%s adminId:%d status:%s", tenant.Id, in.AccountHash, contexts.GetUserId(ctx), out.Status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImLoginFailureLog) BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (*sysin.ImUserCommandModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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#发布原因不能为空"`
|
||||
|
||||
@@ -448,6 +448,7 @@ type (
|
||||
Export(ctx context.Context, in *sysin.ImLoginFailureLogListInp) (err error)
|
||||
AddBlacklist(ctx context.Context, in *sysin.ImLoginFailureAddBlacklistInp) (err error)
|
||||
BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (res *sysin.ImUserCommandModel, err error)
|
||||
Unlock(ctx context.Context, in *sysin.ImLoginFailureUnlockInp) (err error)
|
||||
}
|
||||
ISysImExternalConfig interface {
|
||||
// List 获取IM外部服务配置列表
|
||||
|
||||
Reference in New Issue
Block a user