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
@@ -0,0 +1,100 @@
# IM 登录失败锁定 - 移动端对接文档
> 功能:用户登录密码连续输错达到配置阈值后,账号暂时锁定;锁定期间登录请求被服务端直接拒绝。本文档面向原生移动端(Android / iOS,基于 Tinode SDK)对接者。
> 版本:v1(2026-09-20) | 服务端版本:im_server(已发布至 dev)
---
## 1. 功能交互总览
```
移动端登录(basic 认证,协议不变)
├─ 密码正确 → 计数清零,登录成功(行为与现状完全一致)
├─ 密码错误(未达阈值)→ 401 authentication failed(现状不变)
├─ 密码错误达到阈值 → 429 账号锁定(message 含剩余时间)
└─ 锁定期间再次登录(无论密码对错)→ 429,服务端不校验密码直接拒绝
```
- **登录协议与请求结构零变更**:锁定逻辑全部在服务端(im_server basic 认证链路),移动端只做错误展示适配。
- **阈值/时长由管理后台配置**:【安全管理→安全策略】的登录限频策略,按租户下发(不同租户配置可能不同),客户端不可配置、无需缓存。
- **解锁途径**:① 锁定时间自然到期自动解锁;② 用户联系客服/管理员,在管理后台【日志审计→登录失败日志】手动解锁。两种途径均无需客户端参与。
## 2. 错误响应契约(核心对接点)
### 2.1 响应结构(WebSocket 传输)
登录 `{login}` 消息的 ctrl 响应:
| 场景 | code | text | params |
|---|---|---|---|
| 密码错误,未达阈值 | 401 | `authentication failed` | — |
| 密码错误达到阈值,触发锁定 | **429** | `登录错误次数过多,账号已锁定,请 N 分钟后再试` | `{"what":"auth","retry_after_seconds":60}` |
| 锁定期间再次登录 | **429** | 同上(N 随剩余时间递减) | 同上 |
| 登录成功 | 200 | `ok` | 正常 auth 信息 |
实测样例(连错 2 次触发锁定,阈值为 2 的租户):
```json
// 第 1 次错误密码
{"ctrl": {"id": "t1", "code": 401, "text": "authentication failed", "ts": "..."}}
// 第 2 次错误密码(达到阈值)
{"ctrl": {"id": "t1", "code": 429,
"text": "登录错误次数过多,账号已锁定,请 1 分钟内再试",
"params": {"what": "auth", "retry_after_seconds": 60},
"ts": "..."}}
// 锁定期间(约 12 秒后)正确密码登录,同样被拒绝且剩余时间递减
{"ctrl": {"id": "t1", "code": 429,
"text": "登录错误次数过多,账号已锁定,请 1 分钟内再试",
"params": {"what": "auth", "retry_after_seconds": 48},
"ts": "..."}}
```
### 2.2 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | int | HTTP 语义状态码。**429 = 账号锁定**,与 401(密码错误)区分 |
| `text` | string | **面向用户展示的完整中文文案,含剩余分钟数(向下取整);不足 1 分钟显示「1 分钟内再试」**。客户端直接展示即可,无需自行拼接时间 |
| `params.retry_after_seconds` | int | 剩余锁定秒数(向上取整),可用于 UI 倒计时/禁用登录按钮 |
| `params.what` | string | 固定 `auth`,表示错误发生在认证环节 |
### 2.3 gRPC 传输(Android SDK 默认)
gRPC 通道下服务端返回相同的 ctrl 结构(code/text/params 逐字段保留),SDK 会将其封装为带 code 的登录异常抛出。**识别方式不变:code == 429**。
## 3. 客户端处理建议
1. **错误识别**:登录失败回调中判断 `code == 429`(或 SDK 异常携带的 ctrl code)。仅 `429` 按锁定处理,`401`/`403` 维持现有文案与逻辑。
2. **文案展示**:
- 优先展示服务端 `text`(已含剩余分钟数,中文);
- `text` 为空时的兜底文案:`登录错误次数过多,请稍后再试`。
3. **禁止静默重试**:锁定期间重试不会带来额外后果(不刷新锁定时间、不计新失败),但每次都得到 429 提示,建议拿到 429 后**禁用登录按钮或展示倒计时**(用 `retry_after_seconds`),引导用户等待或走找回密码。
4. **倒计时精度**:`retry_after_seconds` 为剩余秒数(比真实剩余时间多算 1 秒余量),客户端倒计时结束即可恢复登录,无需与服务端时钟同步。
5. **多端一致**:Web 端(im_webapp)已按本契约实现(`errors.ts` 的 429 分支),移动端保持相同交互语义。
## 4. 服务端行为细节(影响测试预期)
| 行为 | 说明 |
|---|---|
| 计数对象 | 按**账号名**(username,不区分大小写)哈希计数,与设备/IP 无关:换设备登录同一账号同样累计 |
| 计数窗口 | 滑动窗口 `window_seconds`(默认 30 分钟):窗口内累计达到阈值即锁定;窗口过期后重新计数 |
| 锁定时长 | `lock_seconds`(默认 15 分钟),从触发锁定的那次失败请求起算 |
| 锁定期内正确密码 | 仍返回 429(校验前置于密码比对),**且不刷新锁定时间**(剩余秒数持续递减) |
| 锁定期内请求 | 不写入失败审计日志(避免日志膨胀) |
| 成功登录 | 计数与锁定记录立即清零 |
| 仅 basic 认证 | 锁定只作用于 `basic` scheme(账号密码)登录;token 重连不受影响 |
| 策略关闭 | 租户未配置或 `enabled=false` 时,行为与旧版完全一致(无 429) |
## 5. 自测要点
| 用例 | 预期 |
|---|---|
| 错误密码 × (阈值-1) 次 | 每次 401,文案「账号或密码错误」 |
| 错误密码第 N 次(达阈值) | 429 + 锁定文案 + `retry_after_seconds` ≈ lock_seconds |
| 锁定期内用**正确**密码 | 仍 429,`retry_after_seconds` 递减 |
| 等待锁定到期(或联系管理员解锁) | 正确密码登录成功 200 |
| 登录成功后再连错 (阈值-1) 次 | 均 401(成功登录已清零历史计数) |
> 注:测试环境的阈值/时长以【安全管理→安全策略】当前发布版本为准,联调时可临时调小(如 2 次/60 秒)加速验证。
@@ -7,5 +7,5 @@ SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LI
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
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');
@@ -0,0 +1,15 @@
-- ----------------------------------------------------------------
-- 登录失败策略独立配置:安全策略菜单下新增"保存登录失败策略"按钮权限点
-- 依赖:im_adfahi_policy_menu.sql(imSecurityPolicyIndex 菜单已存在)
-- 幂等:重复执行不产生重复行
-- ----------------------------------------------------------------
USE hotgo;
SET @now := NOW();
SET @securityPolicyId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyPid := (SELECT `pid` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyLevel := (SELECT `level` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyTree := (SELECT `tree` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' 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,@securityPolicyId,'保存登录失败策略','imSecurityPolicyLoginLockoutConfig','','','3','','/imSecurityPolicy/loginLockoutConfig','','','1','','0','0','','0','0','0',IFNULL(@policyLevel,2)+1,CONCAT(IFNULL(@policyTree,''),'tr_',@securityPolicyId,' '),80,'','1',@now,@now
WHERE @securityPolicyId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyLoginLockoutConfig');
@@ -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)
+4
View File
@@ -10,6 +10,10 @@ export const Publish = (params) =>
http.request({ url: '/imSecurityPolicy/publish', method: 'POST', params });
export const RestoreDefault = (params) =>
http.request({ url: '/imSecurityPolicy/restoreDefault', method: 'POST', params });
export const GetLoginLockoutConfig = (params) =>
http.request({ url: '/imSecurityPolicy/loginLockoutConfig', method: 'GET', params });
export const SaveLoginLockoutConfig = (params) =>
http.request({ url: '/imSecurityPolicy/loginLockoutConfig', method: 'POST', params });
export const IPRuleList = (params) =>
http.request({ url: '/imSecurityPolicy/ipRuleList', method: 'GET', params });
export const IPRuleEdit = (params) =>
+95 -59
View File
@@ -64,11 +64,11 @@
ref="policyFormRef"
:model="policyForm"
:rules="policyRules"
:disabled="policyReadOnly"
:disabled="policyReadOnly && policyActiveTab !== 'lockout'"
label-placement="left"
label-width="132"
>
<n-tabs type="segment" animated>
<n-tabs v-model:value="policyActiveTab" type="segment" animated>
<n-tab-pane name="friend" tab="好友控制">
<n-form-item label="策略名称" path="policyName"
><n-input v-model:value="policyForm.policyName"
@@ -212,6 +212,51 @@
/>
</n-form-item>
</n-tab-pane>
<n-tab-pane name="lockout" tab="登录失败策略">
<n-form-item label="登录失败锁定"
><n-switch v-model:value="lockoutForm.enabled"
/></n-form-item>
<template v-if="lockoutForm.enabled">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="最大失败次数">
<n-input-number
v-model:value="lockoutForm.passwordMaxAttempts"
:min="1"
:max="20"
:precision="0"
/> </n-form-item-gi
><n-form-item-gi label="锁定时长(分钟)">
<n-input-number
v-model:value="lockoutForm.lockMinutes"
:min="1"
:max="1440"
:precision="0"
/> </n-form-item-gi
><n-form-item-gi label="计数窗口(分钟)">
<n-input-number
v-model:value="lockoutForm.windowMinutes"
:min="1"
:max="240"
:precision="0"
/>
</n-form-item-gi>
</n-grid>
</template>
<n-form-item label=" ">
<n-text depth="3">
同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。保存后立即生效,无需发布策略。
</n-text>
</n-form-item>
<n-space justify="end">
<n-button
v-if="hasPermission(['/imSecurityPolicy/loginLockoutConfig'])"
type="primary"
:loading="lockoutSaving"
@click="saveLockoutConfig"
>保存</n-button
>
</n-space>
</n-tab-pane>
<n-tab-pane name="advanced" tab="其他策略">
<n-form-item label="密码策略JSON"
><n-input
@@ -219,43 +264,6 @@
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
/></n-form-item>
<n-form-item label="登录失败锁定"
><n-switch
v-model:value="policyForm.loginRateLimitEnabled"
:checked-value="1"
:unchecked-value="0"
/></n-form-item>
<template v-if="policyForm.loginRateLimitEnabled">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="最大失败次数">
<n-input-number
v-model:value="policyForm.loginRateLimitMaxAttempts"
:min="1"
:max="20"
:precision="0"
/> </n-form-item-gi
><n-form-item-gi label="锁定时长(分钟)">
<n-input-number
v-model:value="policyForm.loginRateLimitLockMinutes"
:min="1"
:max="1440"
:precision="0"
/> </n-form-item-gi
><n-form-item-gi label="计数窗口(分钟)">
<n-input-number
v-model:value="policyForm.loginRateLimitWindowMinutes"
:min="1"
:max="240"
:precision="0"
/>
</n-form-item-gi>
</n-grid>
<n-form-item label=" ">
<n-text depth="3">
同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。
</n-text>
</n-form-item>
</template>
<n-form-item label="实名策略JSON"
><n-input
v-model:value="policyForm.realnamePolicy"
@@ -332,6 +340,7 @@
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
GetLoginLockoutConfig,
IPRuleDelete,
IPRuleEdit,
IPRuleList,
@@ -339,6 +348,7 @@
Publish,
RestoreDefault,
Save,
SaveLoginLockoutConfig,
View,
} from '@/api/imSecurityPolicy';
import { columns, ipColumns, loadOptions, schemas } from './model';
@@ -359,6 +369,7 @@
policyFormRef = ref(),
ipFormRef = ref();
const activeTab = ref('policy'),
policyActiveTab = ref('friend'),
policyVisible = ref(false),
ipVisible = ref(false),
submitting = ref(false);
@@ -443,10 +454,6 @@
tenantId: tenantId(),
policyName: '安全策略',
passwordPolicy: '{}',
loginRateLimitEnabled: 0,
loginRateLimitMaxAttempts: 5,
loginRateLimitLockMinutes: 15,
loginRateLimitWindowMinutes: 30,
sensitiveWordPolicy: '{}',
moderationPolicy: defaultModerationPolicy(),
fileReviewPolicy: '{}',
@@ -474,6 +481,46 @@
() => policyForm.value.id > 0 && policyForm.value.publishStatus !== 'draft'
);
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
const emptyLockout = () => ({
enabled: false,
passwordMaxAttempts: 5,
lockMinutes: 1440,
windowMinutes: 60,
policyId: 0,
});
const lockoutForm = ref<any>(emptyLockout());
const lockoutSaving = ref(false);
async function loadLockoutConfig(tenant = 0) {
try {
const data = await GetLoginLockoutConfig({ tenantId: tenant });
lockoutForm.value = {
enabled: !!data.enabled,
passwordMaxAttempts: data.passwordMaxAttempts || 5,
lockMinutes: data.lockMinutes || 1440,
windowMinutes: data.windowMinutes || 60,
policyId: data.policyId || 0,
};
} catch {
lockoutForm.value = emptyLockout();
}
}
async function saveLockoutConfig() {
lockoutSaving.value = true;
try {
await SaveLoginLockoutConfig({
tenantId: policyForm.value.tenantId || tenantId(),
enabled: lockoutForm.value.enabled,
passwordMaxAttempts: lockoutForm.value.passwordMaxAttempts,
lockMinutes: lockoutForm.value.lockMinutes,
windowMinutes: lockoutForm.value.windowMinutes,
});
message.success('已保存,配置即时生效');
} catch (e: any) {
message.error(e.message || '保存失败');
} finally {
lockoutSaving.value = false;
}
}
const emptyIP = () => ({
id: 0,
tenantId: tenantId(),
@@ -546,13 +593,14 @@
const policyScrollX = computed(() => adaTableScrollX(columns, actionColumn.width)),
ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
async function openPolicy(row?: any) {
policyActiveTab.value = 'friend';
if (!row) {
policyForm.value = emptyPolicy();
policyVisible.value = true;
loadLockoutConfig(tenantId());
return;
}
const data = await View({ id: row.id });
const loginRateLimit = parseJSON(data.loginRateLimit, {});
policyForm.value = {
...data,
friendPolicy: {
@@ -560,13 +608,6 @@
...parseJSON(data.friendDisabledPolicy, {}),
},
passwordPolicy: data.passwordPolicy || '{}',
loginRateLimitEnabled: loginRateLimit.enabled ? 1 : 0,
loginRateLimitMaxAttempts: loginRateLimit.password_max_attempts || 5,
loginRateLimitLockMinutes: Math.max(1, Math.round((loginRateLimit.lock_seconds || 900) / 60)),
loginRateLimitWindowMinutes: Math.max(
1,
Math.round((loginRateLimit.window_seconds || 1800) / 60)
),
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
moderationPolicy: {
...defaultModerationPolicy(),
@@ -592,7 +633,10 @@
whitelistUserText: parseJSON(v.whitelistUserIds, []).join(','),
})),
};
// 登录失败锁定配置已迁移至独立tab管理,避免随草稿保存回传覆盖
delete policyForm.value.loginRateLimit;
policyVisible.value = true;
loadLockoutConfig(policyForm.value.tenantId);
}
function addCurfew() {
policyForm.value.curfewRules.push(newCurfew(policyForm.value.curfewRules.length + 1));
@@ -626,14 +670,6 @@
await Save({
...f,
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
loginRateLimit: f.loginRateLimitEnabled
? {
enabled: true,
password_max_attempts: f.loginRateLimitMaxAttempts,
lock_seconds: f.loginRateLimitLockMinutes * 60,
window_seconds: f.loginRateLimitWindowMinutes * 60,
}
: { enabled: false },
sensitiveWordPolicy: f.moderationPolicy,
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),