diff --git a/docs/im/IM-登录失败锁定移动端对接文档.md b/docs/im/IM-登录失败锁定移动端对接文档.md new file mode 100644 index 0000000..58d69a9 --- /dev/null +++ b/docs/im/IM-登录失败锁定移动端对接文档.md @@ -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 秒)加速验证。 diff --git a/docs/im/IM-登录失败锁定菜单权限.sql b/docs/im/IM-登录失败锁定菜单权限.sql index e0e4d33..fe33f38 100644 --- a/docs/im/IM-登录失败锁定菜单权限.sql +++ b/docs/im/IM-登录失败锁定菜单权限.sql @@ -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'); diff --git a/docs/im/IM-登录失败锁定配置权限.sql b/docs/im/IM-登录失败锁定配置权限.sql new file mode 100644 index 0000000..bf9922b --- /dev/null +++ b/docs/im/IM-登录失败锁定配置权限.sql @@ -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'); diff --git a/server/api/admin/imsecuritypolicy/im_security_policy.go b/server/api/admin/imsecuritypolicy/im_security_policy.go index 5042a88..fb66336 100644 --- a/server/api/admin/imsecuritypolicy/im_security_policy.go +++ b/server/api/admin/imsecuritypolicy/im_security_policy.go @@ -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 diff --git a/server/internal/controller/admin/sys/im_security_policy.go b/server/internal/controller/admin/sys/im_security_policy.go index a1e87cb..7f30cf1 100644 --- a/server/internal/controller/admin/sys/im_security_policy.go +++ b/server/internal/controller/admin/sys/im_security_policy.go @@ -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 { diff --git a/server/internal/logic/sys/im_security_policy.go b/server/internal/logic/sys/im_security_policy.go index b54c44e..e4e9920 100644 --- a/server/internal/logic/sys/im_security_policy.go +++ b/server/internal/logic/sys/im_security_policy.go @@ -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 diff --git a/server/internal/model/input/sysin/im_security_policy.go b/server/internal/model/input/sysin/im_security_policy.go index 36645fc..e62fe78 100644 --- a/server/internal/model/input/sysin/im_security_policy.go +++ b/server/internal/model/input/sysin/im_security_policy.go @@ -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"` diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index da72af2..a1e48ad 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -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) diff --git a/web/src/api/imSecurityPolicy/index.ts b/web/src/api/imSecurityPolicy/index.ts index afb3c3d..1da2e83 100644 --- a/web/src/api/imSecurityPolicy/index.ts +++ b/web/src/api/imSecurityPolicy/index.ts @@ -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) => diff --git a/web/src/views/imSecurityPolicy/index.vue b/web/src/views/imSecurityPolicy/index.vue index d48a600..ed89b1b 100644 --- a/web/src/views/imSecurityPolicy/index.vue +++ b/web/src/views/imSecurityPolicy/index.vue @@ -64,11 +64,11 @@ ref="policyFormRef" :model="policyForm" :rules="policyRules" - :disabled="policyReadOnly" + :disabled="policyReadOnly && policyActiveTab !== 'lockout'" label-placement="left" label-width="132" > - + + + + + + + 同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。保存后立即生效,无需发布策略。 + + + + 保存 + + - - 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(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, '实名策略'),