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 }"
/>
-
+
+
+
+
+
+
+
+
+
+
+ 同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。
+
+
+