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:
@@ -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');
|
||||
Reference in New Issue
Block a user