From 8675fe333dfca5ac834cf63350c643c9e5639fee Mon Sep 17 00:00:00 2001 From: Austin Date: Sun, 20 Sep 2026 17:04:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(imLoginFailureLog):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AF=86=E7=A0=81=E9=94=99=E8=AF=AF=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E9=94=81=E5=AE=9A=E4=B8=8E=E8=A7=A3=E9=94=81=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=EF=BC=88=E7=AE=A1=E7=90=86=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安全策略登录限频由 JSON 文本改为结构化表单(次数/锁定分钟/窗口分钟),编辑校验同步扩展 - 失败日志新增解锁入口:/imLoginFailureLog/unlock 经 internal 通道调用 im-server 释放锁定 - 失败日志模型补充 accountHash 字段,解锁按钮按 risk_action=account_locked 展示 - 租户业务库初始化模板补充 im_login_lockouts 等表;新增解锁按钮菜单权限 SQL --- .../sql/IM-租户业务库初始化.sql | 16 +++++ docs/im/IM-登录失败锁定菜单权限.sql | 11 ++++ .../api/admin/imconfiginternal/im_config.go | 22 +++++-- .../imloginfailurelog/im_login_failure_log.go | 5 ++ .../admin/sys/im_login_failure_log.go | 3 + .../internalapi/imconfig/im_config.go | 18 +++++- server/internal/logic/sys/im_login_audit.go | 22 +++++++ .../model/input/sysin/im_login_audit.go | 22 +++++++ .../model/input/sysin/im_security_policy.go | 45 +++++++++++++ server/internal/service/sys.go | 1 + web/src/api/imLoginFailureLog/index.ts | 3 + web/src/views/imLoginFailureLog/index.vue | 25 +++++++- web/src/views/imSecurityPolicy/index.vue | 64 ++++++++++++++++--- 13 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 docs/im/IM-登录失败锁定菜单权限.sql diff --git a/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql index cb0d033..d4bf58a 100644 --- a/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql +++ b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql @@ -947,6 +947,22 @@ CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( KEY `idx_im_login_failure_request` (`request_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +CREATE TABLE IF NOT EXISTS `im_login_lockouts` ( + `id` bigint NOT NULL, + `user_id` bigint DEFAULT NULL, + `account_hash` char(64) NOT NULL, + `failed_attempts` int NOT NULL DEFAULT '0', + `window_started_at` datetime(3) DEFAULT NULL, + `locked_until` datetime(3) DEFAULT NULL, + `last_attempt_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_login_lockouts_account` (`account_hash`), + KEY `idx_im_login_lockouts_user` (`user_id`), + KEY `idx_im_login_lockouts_locked` (`locked_until`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( `id` bigint NOT NULL AUTO_INCREMENT, `enabled` tinyint NOT NULL DEFAULT '1', diff --git a/docs/im/IM-登录失败锁定菜单权限.sql b/docs/im/IM-登录失败锁定菜单权限.sql new file mode 100644 index 0000000..e0e4d33 --- /dev/null +++ b/docs/im/IM-登录失败锁定菜单权限.sql @@ -0,0 +1,11 @@ +-- ---------------------------------------------------------------- +-- 登录失败锁定功能:登录失败日志页新增"解锁"按钮权限点 +-- 依赖:im_adfahi_batch2_menu.sql(imLoginFailureLogIndex 菜单已存在) +-- ---------------------------------------------------------------- +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @loginFailureId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imLoginFailureLogIndex' LIMIT 1); + +INSERT INTO `hg_admin_menu` (`id`,`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 NULL,@loginFailureId,'解锁登录锁定','imLoginFailureLogUnlock','','3','','','/imLoginFailureLog/unlock','','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@loginFailureId,' '),50,'','1',@now,@now +WHERE @loginFailureId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imLoginFailureLogUnlock'); diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 424c429..d36b5b5 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -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 { diff --git a/server/api/admin/imloginfailurelog/im_login_failure_log.go b/server/api/admin/imloginfailurelog/im_login_failure_log.go index ce95a03..b913770 100644 --- a/server/api/admin/imloginfailurelog/im_login_failure_log.go +++ b/server/api/admin/imloginfailurelog/im_login_failure_log.go @@ -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{} diff --git a/server/internal/controller/admin/sys/im_login_failure_log.go b/server/internal/controller/admin/sys/im_login_failure_log.go index e6693b1..1c10a1b 100644 --- a/server/internal/controller/admin/sys/im_login_failure_log.go +++ b/server/internal/controller/admin/sys/im_login_failure_log.go @@ -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) +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index 206bcfa..c6e0618 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -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) } diff --git a/server/internal/logic/sys/im_login_audit.go b/server/internal/logic/sys/im_login_audit.go index e82854a..702e289 100644 --- a/server/internal/logic/sys/im_login_audit.go +++ b/server/internal/logic/sys/im_login_audit.go @@ -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 diff --git a/server/internal/model/input/sysin/im_login_audit.go b/server/internal/model/input/sysin/im_login_audit.go index c94b0b5..80670b8 100644 --- a/server/internal/model/input/sysin/im_login_audit.go +++ b/server/internal/model/input/sysin/im_login_audit.go @@ -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"` diff --git a/server/internal/model/input/sysin/im_security_policy.go b/server/internal/model/input/sysin/im_security_policy.go index 020a456..36645fc 100644 --- a/server/internal/model/input/sysin/im_security_policy.go +++ b/server/internal/model/input/sysin/im_security_policy.go @@ -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#发布原因不能为空"` diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 5c8ca57..00b8a21 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -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外部服务配置列表 diff --git a/web/src/api/imLoginFailureLog/index.ts b/web/src/api/imLoginFailureLog/index.ts index 6cea803..33f6928 100644 --- a/web/src/api/imLoginFailureLog/index.ts +++ b/web/src/api/imLoginFailureLog/index.ts @@ -15,3 +15,6 @@ export function AddBlacklist(params) { export function BanUser(params) { return http.request({ url: '/imLoginFailureLog/banUser', method: 'POST', params }); } +export function Unlock(params) { + return http.request({ url: '/imLoginFailureLog/unlock', method: 'POST', params }); +} diff --git a/web/src/views/imLoginFailureLog/index.vue b/web/src/views/imLoginFailureLog/index.vue index 46900d0..659aec6 100644 --- a/web/src/views/imLoginFailureLog/index.vue +++ b/web/src/views/imLoginFailureLog/index.vue @@ -79,7 +79,7 @@ import { BasicTable, TableAction } from '@/components/Table'; import { usePermission } from '@/hooks/web/usePermission'; import { adaTableScrollX } from '@/utils/hotgo'; - import { AddBlacklist, BanUser, Export, List, View } from '@/api/imLoginFailureLog'; + import { AddBlacklist, BanUser, Export, List, Unlock, View } from '@/api/imLoginFailureLog'; import { imQueryTenantOptions, loadImQueryTenantOptions } from '@/views/imAdminQueryShared'; import { columns, loadOptions, schemas } from './model'; @@ -119,6 +119,13 @@ ifShow: () => Number(record.userId) > 0, onClick: () => openBan(record), }, + { + label: '解锁', + type: 'primary', + auth: ['/imLoginFailureLog/unlock'], + ifShow: () => record.riskAction === 'account_locked' && !!record.accountHash, + onClick: () => unlockLockout(record), + }, ], }); }, @@ -131,8 +138,7 @@ }); const loadDataTable = async (res) => { const params = { ...searchFormRef.value?.formModel, ...res }; - if (!params.tenantId) - return { list: [], page: 1, pageCount: 0, totalCount: 0 }; + if (!params.tenantId) return { list: [], page: 1, pageCount: 0, totalCount: 0 }; return List(params); }; function reloadTable() { @@ -177,6 +183,19 @@ banReason.value = ''; showBan.value = true; } + function unlockLockout(record) { + dialog.warning({ + title: '解锁账号', + content: `确认解除 ${record.accountMask || '该账号'} 的登录锁定吗?解锁后可立即重新登录。`, + positiveText: '确认解锁', + negativeText: '取消', + onPositiveClick: async () => { + await Unlock({ tenantId: record.tenantId, accountHash: record.accountHash }); + message.success('已解锁'); + reloadTable(); + }, + }); + } async function submitBan() { if (!banReason.value.trim()) { message.warning('请输入封禁原因'); diff --git a/web/src/views/imSecurityPolicy/index.vue b/web/src/views/imSecurityPolicy/index.vue index 1bdf10a..d48a600 100644 --- a/web/src/views/imSecurityPolicy/index.vue +++ b/web/src/views/imSecurityPolicy/index.vue @@ -219,12 +219,43 @@ type="textarea" :autosize="{ minRows: 3, maxRows: 8 }" /> - +