584 lines
24 KiB
Markdown
584 lines
24 KiB
Markdown
# Login Region Risk Architecture
|
||
|
||
本文档定义登录地区、语言和时区限制的当前推荐方案。方案按当前产品取舍设计:不引入 `pending_risk` 会话,不让各玩法服务分别判断风险状态;用户在未知 IP 首次登录时可以进入 App,后台异步 IP 风控命中不支持国家后撤销 session 并踢下线,后续同一 IP 再登录会在入口直接拒绝。
|
||
|
||
## Scope
|
||
|
||
| Capability | Owner | Rule |
|
||
| --- | --- | --- |
|
||
| 真实入口 IP 提取 | `gateway-service` | 从可信上游 header 或 `RemoteAddr` 提取,不信任前端 JSON 里的 IP |
|
||
| 语言和时区快速判断 | `gateway-service` | 只做入口级弱信号拦截,命中明显不支持配置时直接拒绝登录 |
|
||
| IP 风控缓存读取 | `gateway-service` | 登录前读取 Redis IP 决策缓存,`blocked` 直接拒绝,`allowed/unknown/miss` 放行并触发复核 |
|
||
| 登录、session 和 token | `user-service` | 仍是 session owner;登录成功后创建 `auth_sessions` 并签发 token |
|
||
| 异步 IP 风控任务 | `cron-service` 调度,`user-service` 执行 | 按优先级顺序查询 IP Geo 源;当前一个不可用时才降级到下一个,写持久审计和 Redis 缓存 |
|
||
| session 撤销 | `user-service` | 命中不支持国家后撤销 session,写撤销原因和审计 |
|
||
| 登录态接口统一拦截 | `gateway-service` | JWT 校验后统一检查 session 是否已撤销,避免玩法服务散落判断 |
|
||
| 房间/IM 踢下线 | `gateway-service` 编排,`room-service`/腾讯云 IM 执行 | 只能走 Room Cell 命令或腾讯云 IM 服务端接口,不能直接改 room Redis |
|
||
| 后台登录日志 | `hyapp-admin-server` | 只在用户管理一级菜单下新增二级菜单“登录日志”,不单独做风控策略后台 |
|
||
|
||
## Non-Goals
|
||
|
||
| Non-goal | Reason |
|
||
| --- | --- |
|
||
| 不做 `pending_risk` session | 当前阶段要降低接入成本,避免设计白名单接口和受限权限集 |
|
||
| 不在玩法服务判断地区风险 | 房间、礼物、钱包、VIP、任务都不应该各自写 `risk_status` 判断,容易漏 |
|
||
| 不把 IP Geo 查询接口放进同步登录路径 | 外部接口慢、超时或不稳定会拖慢登录 |
|
||
| 不信任前端传过来的 IP | 客户端字段可伪造,只能作为调试或兼容字段记录 |
|
||
| 不靠“清客户端 token”完成踢下线 | JWT 已签发后客户端仍可能继续使用,必须服务端撤销 session 并由 gateway 拦截 |
|
||
| 不把 Redis 当唯一事实来源 | Redis 只做快查缓存;审计、任务、明文 IP 和撤销原因必须能在 MySQL 追溯 |
|
||
| 不做独立风控后台 | 当前后台只提供登录日志查看,不提供策略编辑、provider 管理或人工改判 |
|
||
|
||
## Core Decision
|
||
|
||
采用“入口快拦 + 登录后异步复核 + session 统一撤销”的模型:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
C["Client"] --> G["gateway-service"]
|
||
G --> LangTZ["language/timezone fast guard"]
|
||
G --> Cache["Redis IP decision cache"]
|
||
Cache -->|blocked| Deny["reject login"]
|
||
Cache -->|allowed/miss/unknown| U["user-service Auth"]
|
||
U --> Session[("auth_sessions")]
|
||
U --> Resp["token response"]
|
||
U --> Job[("login_ip_risk_jobs / outbox")]
|
||
Job --> RiskBatch["cron triggered IP risk batch"]
|
||
RiskBatch --> IP1["IP Geo A"]
|
||
IP1 -. "unavailable/timeout" .-> IP2["IP Geo B"]
|
||
IP2 -. "unavailable/timeout" .-> IP3["IP Geo C"]
|
||
IP3 -. "unavailable/timeout" .-> IP4["IP Geo D"]
|
||
RiskBatch --> Decision[("risk decision + Redis cache")]
|
||
RiskBatch -->|blocked| Revoke["revoke session"]
|
||
Revoke --> Kick["IM/Room kick"]
|
||
```
|
||
|
||
这个方案接受一个明确边界:未知 IP 第一次可能短暂进入 App。通过 Redis 缓存和 session 撤销,同一个 IP 后续不能反复完整登录。
|
||
|
||
## Login Flow
|
||
|
||
### Password Login
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant App
|
||
participant Gateway
|
||
participant User as user-service
|
||
participant Redis
|
||
participant Risk as cron-service/user-service batch
|
||
|
||
App->>Gateway: POST /api/v1/auth/account/login
|
||
Gateway->>Gateway: extract trusted client_ip
|
||
Gateway->>Gateway: check language/timezone config
|
||
Gateway->>Redis: GET ip decision
|
||
alt ip blocked
|
||
Gateway-->>App: AUTH_LOGIN_BLOCKED
|
||
else allowed/miss/unknown
|
||
Gateway->>User: LoginPassword(meta.client_ip, language, timezone)
|
||
User->>User: verify password and create session
|
||
User->>User: enqueue login_ip_risk_job
|
||
User-->>Gateway: token(session_id)
|
||
Gateway-->>App: token
|
||
Risk->>Risk: async resolve IP by provider fallback chain
|
||
alt unsupported country
|
||
Risk->>User: RevokeSession(session_id, reason)
|
||
Risk->>Redis: SET ip decision = blocked
|
||
Risk->>Gateway: trigger kick orchestration
|
||
else allowed country
|
||
Risk->>Redis: SET ip decision = allowed
|
||
end
|
||
end
|
||
```
|
||
|
||
### Third Party Login
|
||
|
||
三方登录和密码登录使用同一套入口风控,只是 `user-service` 在创建或复用用户 session 后写风险任务。三方首次注册时,客户端提交的 `country` 仍然只是用户选择国家;IP 国家由服务端 IP Geo 结果决定,不能用 `country` 替代风控国家。
|
||
|
||
## Gateway Fast Guard
|
||
|
||
gateway 只做三件事:
|
||
|
||
1. 提取可信入口 IP。
|
||
2. 对 `language` 和 `timezone` 做简单配置判断。
|
||
3. 查询 Redis IP 决策缓存,命中 `blocked` 时拒绝登录。
|
||
|
||
### Trusted IP
|
||
|
||
可信 IP 来源顺序:
|
||
|
||
```text
|
||
trusted edge header -> X-Forwarded-For first public IP -> RemoteAddr
|
||
```
|
||
|
||
前端提交的 `ip` 字段不参与判断。旧客户端如果还传 IP,只能写入审计扩展字段,不能作为风控依据。
|
||
|
||
### Language And Timezone
|
||
|
||
语言和时区是弱信号,因为客户端可以修改。它们只能做入口快速拦截:
|
||
|
||
```yaml
|
||
login_risk:
|
||
blocked_languages:
|
||
- zh-CN
|
||
blocked_language_primary_tags:
|
||
- zh
|
||
blocked_timezones:
|
||
- Asia/Shanghai
|
||
blocked_timezone_prefixes:
|
||
- Asia/
|
||
```
|
||
|
||
规则:
|
||
|
||
- `language` 按 BCP 47 轻量格式校验,例如 `en-US`、`zh-CN`。
|
||
- `timezone` 必须是 IANA 名称,例如 `America/Los_Angeles`。
|
||
- 命中阻断配置时,gateway 返回 `AUTH_LOGIN_BLOCKED`。
|
||
- 未传、格式异常或不可信时,不直接推断国家;只记录审计。
|
||
|
||
### IP Decision Cache
|
||
|
||
Redis key 建议不要保存明文 IP:
|
||
|
||
```text
|
||
login_risk:ip:{app_code}:{sha256(ip)}
|
||
```
|
||
|
||
Value:
|
||
|
||
```json
|
||
{
|
||
"decision": "blocked",
|
||
"country": "CN",
|
||
"source": "geo_provider_chain",
|
||
"checked_at_ms": 1710000000000,
|
||
"expires_at_ms": 1710604800000
|
||
}
|
||
```
|
||
|
||
决策值:
|
||
|
||
| Decision | Gateway Action |
|
||
| --- | --- |
|
||
| `blocked` | 登录前直接拒绝 |
|
||
| `allowed` | 放行登录,可低频复核 |
|
||
| `unknown` | 放行登录,必须异步复核 |
|
||
| miss | 放行登录,必须异步复核 |
|
||
|
||
TTL 建议:
|
||
|
||
| Decision | TTL |
|
||
| --- | --- |
|
||
| `blocked` | 7 到 30 天 |
|
||
| `allowed` | 1 到 7 天 |
|
||
| `unknown` | 10 到 60 分钟 |
|
||
|
||
可用性策略:Redis 不可用时放行登录,只记录错误和告警;不能因为 Redis 故障阻断登录。
|
||
|
||
## Async IP Risk Check
|
||
|
||
异步 IP 风控 batch 按优先级顺序调用 IP Geo 源,不阻塞登录响应。一次任务只使用一个可用 provider 的结果;只有当前 provider 超时、不可用或返回不可解析结果时,才请求下一个 provider。
|
||
|
||
### Job
|
||
|
||
风险任务需要包含:
|
||
|
||
| Field | Rule |
|
||
| --- | --- |
|
||
| `job_id` | 幂等任务 ID |
|
||
| `app_code` | 多 App 隔离 |
|
||
| `session_id` | 命中后撤销的 session |
|
||
| `user_id` | 审计和踢下线目标 |
|
||
| `client_ip` | 明文入口 IP,后台登录日志直接展示 |
|
||
| `client_ip_hash` | 缓存 key、索引和日志关联字段,避免普通日志输出明文 IP |
|
||
| `channel` | 登录渠道:`account/google/facebook/twitter/tiktok` |
|
||
| `platform` | 客户端平台:`android/ios` |
|
||
| `language` | 登录时客户端语言 |
|
||
| `timezone` | 登录时客户端时区 |
|
||
| `request_id` | 串联登录日志 |
|
||
| `status` | `pending/running/completed/failed/skipped` |
|
||
| `created_at_ms` | 任务创建时间 |
|
||
| `updated_at_ms` | 任务更新时间 |
|
||
|
||
任务可以落 MySQL 表 `login_ip_risk_jobs`,也可以由 user outbox 派生。首版如果没有统一 MQ,优先用 MySQL 任务表 + cron-service 调度的 batch claim,避免 Redis list 丢失导致永远不复核。MySQL 写任务失败时不影响登录结果,但必须记录错误和告警。
|
||
|
||
### Provider Fallback Chain
|
||
|
||
batch 行为:
|
||
|
||
1. 先查 Redis 是否已有新鲜决策;已有 `blocked/allowed` 可以跳过外部查询。
|
||
2. miss 时按配置顺序调用第一个 provider。
|
||
3. 如果 provider 在 timeout 内返回有效国家码,立即停止查询后续 provider。
|
||
4. 如果 provider 超时、不可用、限流、返回空国家或返回不可解析结果,记录失败原因并降级到下一个 provider。
|
||
5. 所有 provider 都失败时,输出 `unknown`,写短 TTL 缓存并等待后续任务重试;如果缓存也不可用,只记录日志和告警。
|
||
6. 有效国家码命中不支持国家时输出 `blocked`;有效国家码未命中不支持国家时输出 `allowed`。
|
||
7. 写 MySQL 决策记录和 Redis 缓存。
|
||
8. 如果命中不支持国家,撤销当前 session 并触发踢下线。
|
||
|
||
该模型没有“结果冲突”问题,因为同一个任务不会同时采信多个 provider。provider 顺序就是信任优先级:
|
||
|
||
| Case | Decision |
|
||
| --- | --- |
|
||
| 第一个可用 provider 返回不支持国家 | `blocked` |
|
||
| 第一个可用 provider 返回支持国家 | `allowed` |
|
||
| provider 超时、不可用或返回无效结果 | 请求下一个 provider |
|
||
| 所有 provider 都失败 | `unknown`,短 TTL,后续重试 |
|
||
|
||
不支持国家列表必须来自配置,不应硬编码在风控逻辑里;当前后台不提供策略编辑能力。
|
||
|
||
## Session Revocation
|
||
|
||
命中不支持国家时,撤销 session 是核心动作。
|
||
|
||
```text
|
||
auth_sessions.revoked_at_ms = now
|
||
auth_sessions.revoked_reason = GEO_POLICY_BLOCKED
|
||
```
|
||
|
||
当前表只有 `revoked_at_ms`,实现时建议补充:
|
||
|
||
| Field | Reason |
|
||
| --- | --- |
|
||
| `revoked_reason` | 区分用户 logout、refresh 轮换、后台封禁、IP 风控撤销 |
|
||
| `revoked_request_id` | 串联风险任务和审计 |
|
||
| `revoked_by` | `risk_worker`、`user_logout`、`admin` |
|
||
|
||
撤销成功后写 Redis denylist:
|
||
|
||
```text
|
||
auth:revoked_session:{app_code}:{session_id} = GEO_POLICY_BLOCKED
|
||
TTL = access token remaining ttl + safety window
|
||
```
|
||
|
||
## Gateway Session Guard
|
||
|
||
如果 access token 是 JWT,只校验签名不够。用户已经拿到 access token 后,即使 `auth_sessions` 被撤销,JWT 在过期前仍然能访问接口。
|
||
|
||
因此 gateway 必须增加统一 session guard:
|
||
|
||
```text
|
||
Authorization JWT valid
|
||
+ sid exists
|
||
+ auth:revoked_session:{app_code}:{sid} not exists
|
||
```
|
||
|
||
首版可以先只查 Redis denylist,不对每个请求同步查 MySQL:
|
||
|
||
- 登录成功时不需要写 active cache。
|
||
- 风控撤销 session 时写 denylist。
|
||
- logout、refresh 轮换、后台封禁同样写 denylist。
|
||
- access token TTL 到期后 denylist 自然失效。
|
||
|
||
后续如果需要更强一致性,再增加 `ValidateSession` RPC 或 `auth:active_session` cache。
|
||
|
||
所有 App 登录态接口都走 gateway middleware,因此玩法服务不需要感知风险状态:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Req["Business Request"] --> JWT["Verify JWT"]
|
||
JWT --> SID["Read sid claim"]
|
||
SID --> Deny["Check Redis revoked_session"]
|
||
Deny -->|revoked| Fail["SESSION_REVOKED"]
|
||
Deny -->|active| Handler["Business handler"]
|
||
```
|
||
|
||
## Kick Offline
|
||
|
||
踢下线分三层,按能做到的程度逐步接入:
|
||
|
||
| Layer | Action | Required |
|
||
| --- | --- | --- |
|
||
| API | gateway session guard 返回 `SESSION_REVOKED` | 必须 |
|
||
| IM | 调腾讯云 IM 服务端接口踢下线或发系统消息通知客户端清 token | 建议 |
|
||
| Room | 如果用户在房间,通过 room-service Room Cell 命令踢出或触发离房 | 建议 |
|
||
|
||
不能直接删除 room-service Redis presence 或麦位缓存。房间状态 owner 是 Room Cell,风险踢人必须走 room-service 的命令入口;如果首版没有踢人命令,至少依靠 session guard 阻止后续 HTTP 操作,并让房间 heartbeat/断线补偿释放状态。
|
||
|
||
## Error Codes
|
||
|
||
建议新增稳定业务码:
|
||
|
||
| Code | HTTP | gRPC | Meaning |
|
||
| --- | --- | --- | --- |
|
||
| `AUTH_LOGIN_BLOCKED` | 403 | `PermissionDenied` | 登录入口命中地区、语言、时区或 IP 风控策略 |
|
||
| `SESSION_REVOKED` | 401 | `Unauthenticated` | session 已撤销,客户端必须清登录态 |
|
||
|
||
客户端行为:
|
||
|
||
- `AUTH_LOGIN_BLOCKED`:停留登录页,展示地区不支持类提示。
|
||
- `SESSION_REVOKED`:清本地 token,退出房间/IM,回登录页。
|
||
- 普通 5xx 或网络失败不能清 token。
|
||
|
||
## Audit And Observability
|
||
|
||
必须记录:
|
||
|
||
| Event | Fields |
|
||
| --- | --- |
|
||
| `login_fast_guard_blocked` | `request_id/app_code/client_ip_hash/language/timezone/block_reason` |
|
||
| `login_ip_risk_job_created` | `request_id/app_code/session_id/user_id/client_ip/client_ip_hash/channel/platform` |
|
||
| `login_ip_geo_provider_attempt` | `job_id/provider/order/country/status/duration_ms/error_code` |
|
||
| `login_ip_risk_decision` | `job_id/session_id/user_id/decision/country/confidence` |
|
||
| `login_session_revoked_by_risk` | `job_id/session_id/user_id/reason/country` |
|
||
| `kick_offline_result` | `session_id/user_id/im_result/room_result` |
|
||
|
||
敏感规则:
|
||
|
||
- 不记录明文 access token、refresh token、三方 credential。
|
||
- 明文 IP 可以落 MySQL 业务表,供后台登录日志查看;普通服务日志仍只记录 `client_ip_hash`,避免日志系统大范围暴露明文 IP。
|
||
- provider 返回原始报文不进日志,只记录本次尝试的顺序、标准化国家码、耗时和错误码。
|
||
|
||
## Data Model
|
||
|
||
### `login_ip_risk_jobs`
|
||
|
||
```sql
|
||
CREATE TABLE login_ip_risk_jobs (
|
||
app_code VARCHAR(32) NOT NULL,
|
||
job_id VARCHAR(96) NOT NULL,
|
||
session_id VARCHAR(96) NOT NULL,
|
||
user_id BIGINT NOT NULL,
|
||
client_ip VARCHAR(64) NOT NULL,
|
||
client_ip_hash VARCHAR(128) NOT NULL,
|
||
channel VARCHAR(32) NOT NULL,
|
||
platform VARCHAR(16) NOT NULL,
|
||
language VARCHAR(32) NOT NULL DEFAULT '',
|
||
timezone VARCHAR(64) NOT NULL DEFAULT '',
|
||
request_id VARCHAR(96) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL,
|
||
decision VARCHAR(32) NOT NULL DEFAULT '',
|
||
country_code VARCHAR(3) NOT NULL DEFAULT '',
|
||
failure_reason VARCHAR(128) NOT NULL DEFAULT '',
|
||
locked_by VARCHAR(128) NULL,
|
||
locked_until_ms BIGINT NULL,
|
||
attempt_count INT NOT NULL DEFAULT 0,
|
||
created_at_ms BIGINT NOT NULL,
|
||
updated_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, job_id),
|
||
KEY idx_login_ip_risk_jobs_status (app_code, status, updated_at_ms),
|
||
KEY idx_login_ip_risk_jobs_session (app_code, session_id),
|
||
KEY idx_login_ip_risk_jobs_user (app_code, user_id, created_at_ms),
|
||
KEY idx_login_ip_risk_jobs_ip (app_code, client_ip_hash, updated_at_ms)
|
||
);
|
||
```
|
||
|
||
### `login_ip_risk_decisions`
|
||
|
||
```sql
|
||
CREATE TABLE login_ip_risk_decisions (
|
||
app_code VARCHAR(32) NOT NULL,
|
||
decision_id VARCHAR(96) NOT NULL,
|
||
client_ip VARCHAR(64) NOT NULL,
|
||
client_ip_hash VARCHAR(128) NOT NULL,
|
||
decision VARCHAR(32) NOT NULL,
|
||
country_code VARCHAR(3) NOT NULL DEFAULT '',
|
||
confidence INT NOT NULL DEFAULT 0,
|
||
provider_attempts_json JSON NOT NULL,
|
||
decided_at_ms BIGINT NOT NULL,
|
||
expires_at_ms BIGINT NOT NULL,
|
||
created_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, decision_id),
|
||
KEY idx_login_ip_risk_decisions_ip (app_code, client_ip_hash, decided_at_ms),
|
||
KEY idx_login_ip_risk_decisions_country (app_code, country_code, decided_at_ms),
|
||
KEY idx_login_ip_risk_decisions_expires (app_code, expires_at_ms)
|
||
);
|
||
```
|
||
|
||
### `login_audit` Additions
|
||
|
||
现有 `login_audit` 已记录登录类型、provider、结果和失败原因。为了支撑后台登录日志,需要把登录入口展示字段补齐:
|
||
|
||
```sql
|
||
ALTER TABLE login_audit
|
||
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT '',
|
||
ADD COLUMN platform VARCHAR(16) NOT NULL DEFAULT '',
|
||
ADD COLUMN client_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||
ADD COLUMN ip_country_code VARCHAR(3) NOT NULL DEFAULT '',
|
||
ADD COLUMN blocked TINYINT(1) NOT NULL DEFAULT 0,
|
||
ADD COLUMN block_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||
ADD KEY idx_login_audit_user_created (app_code, user_id, created_at_ms),
|
||
ADD KEY idx_login_audit_blocked (app_code, blocked, created_at_ms),
|
||
ADD KEY idx_login_audit_ip_country (app_code, ip_country_code, created_at_ms);
|
||
```
|
||
|
||
字段口径:
|
||
|
||
| Field | Rule |
|
||
| --- | --- |
|
||
| `channel` | `account/google/facebook/twitter/tiktok`,三方 provider 必须归一成这几个展示值 |
|
||
| `platform` | `android/ios`,来自客户端请求体或可信 header |
|
||
| `client_ip` | 明文入口 IP,来自 gateway 提取结果 |
|
||
| `ip_country_code` | IP Geo 解析出的国家码,未解析时为空 |
|
||
| `blocked` | 当前登录是否被语言、时区、IP cache 或异步 IP 风控阻断 |
|
||
| `block_reason` | `language/timezone/ip_cache/ip_async` 等稳定原因 |
|
||
|
||
### `auth_sessions` Additions
|
||
|
||
```sql
|
||
ALTER TABLE auth_sessions
|
||
ADD COLUMN revoked_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||
ADD COLUMN revoked_request_id VARCHAR(96) NOT NULL DEFAULT '',
|
||
ADD COLUMN revoked_by VARCHAR(64) NOT NULL DEFAULT '';
|
||
```
|
||
|
||
## Admin Login Logs
|
||
|
||
后台管理当前只做查询和展示,不做风控配置编辑。入口位置:
|
||
|
||
```text
|
||
用户管理
|
||
└── 登录日志
|
||
```
|
||
|
||
### List Page
|
||
|
||
列表按登录事件展示,默认按 `created_at_ms DESC` 分页。
|
||
|
||
展示字段:
|
||
|
||
| Column | Source |
|
||
| --- | --- |
|
||
| 用户 ID | `login_audit.user_id` |
|
||
| 短号 | join `users.current_display_user_id` |
|
||
| 昵称 | join `users.username` |
|
||
| 国家/区域 | join `users.country/region_id`,只作用户资料快照展示 |
|
||
| 登录 IP | `login_audit.client_ip` |
|
||
| IP 所在国家 | `login_audit.ip_country_code` |
|
||
| 渠道 | `login_audit.channel`,值为 `account/google/facebook/twitter/tiktok` |
|
||
| 平台 | `login_audit.platform`,值为 `android/ios` |
|
||
| 结果 | `success/failed/blocked/revoked` |
|
||
| 屏蔽原因 | `login_audit.block_reason` |
|
||
| 登录时间 | `login_audit.created_at_ms` |
|
||
|
||
交互规则:
|
||
|
||
- `blocked=true` 或 `failure_code=AUTH_LOGIN_BLOCKED` 的表格行背景显示红色。
|
||
- 如果异步 IP 风控在登录成功后撤销 session,该次登录日志也要更新为屏蔽态,行背景显示红色。
|
||
- 点击任意用户行,进入该用户历史登录日志侧栏或详情页。
|
||
- 列表只读,不提供解除屏蔽、修改策略、重试 provider 或手动踢下线按钮。
|
||
|
||
筛选条件:
|
||
|
||
| Filter | Rule |
|
||
| --- | --- |
|
||
| 用户 ID / 短号 | 精确搜索,短号需要先解析成 `user_id` |
|
||
| IP | 精确搜索明文 IP |
|
||
| IP 国家 | 按 `ip_country_code` |
|
||
| 渠道 | `account/google/facebook/twitter/tiktok` |
|
||
| 平台 | `android/ios` |
|
||
| 是否屏蔽 | `all/blocked/not_blocked` |
|
||
| 时间范围 | 按 `created_at_ms` |
|
||
|
||
### User History
|
||
|
||
点击用户行后展示该用户历史登录日志:
|
||
|
||
| Field | Rule |
|
||
| --- | --- |
|
||
| 用户基础信息 | user_id、短号、昵称、头像、当前国家、当前区域 |
|
||
| 最近登录列表 | 按时间倒序展示该用户所有登录事件 |
|
||
| 风控结果 | 展示 IP 国家、是否屏蔽、屏蔽原因、撤销 session 时间 |
|
||
| 渠道和平台 | 展示每次登录的 channel/platform |
|
||
|
||
用户历史页只读。若后续需要手动封禁、解封或策略配置,必须走独立后台能力和审计,不混在登录日志里。
|
||
|
||
## Configuration
|
||
|
||
首版配置放 gateway、user-service 和 cron-service 的 YAML。当前后台只做登录日志查看,不管理不支持国家、provider 顺序或语言/时区策略:
|
||
|
||
```yaml
|
||
login_risk:
|
||
enabled: true
|
||
fast_guard:
|
||
blocked_languages: []
|
||
blocked_language_primary_tags: []
|
||
blocked_timezones: []
|
||
blocked_timezone_prefixes: []
|
||
ip:
|
||
unsupported_countries:
|
||
- CN
|
||
blocked_ttl_sec: 2592000
|
||
allowed_ttl_sec: 604800
|
||
unknown_ttl_sec: 1800
|
||
provider_timeout_ms: 800
|
||
providers:
|
||
- primary_geo
|
||
- fallback_geo_a
|
||
- fallback_geo_b
|
||
- fallback_geo_c
|
||
|
||
# services/cron-service/configs/config*.yaml
|
||
tasks:
|
||
login_ip_risk:
|
||
enabled: true
|
||
interval: 5s
|
||
timeout: 3s
|
||
lock_ttl: 30s
|
||
batch_size: 100
|
||
```
|
||
|
||
配置原则:
|
||
|
||
- gateway 快速判断和 user-service IP 风控策略的不支持国家列表必须同源,避免 gateway 放行但后台风控用另一套规则。
|
||
- provider 顺序必须显式配置,顺序代表信任优先级;不要在代码里随机切换。
|
||
- 线上配置不要写 provider secret;provider key 走密钥系统或环境变量。
|
||
- 多 App 时按 `app_code` 覆盖策略。
|
||
|
||
## Failure Policy
|
||
|
||
| Failure | Policy |
|
||
| --- | --- |
|
||
| Redis 不可用 | 放行登录;记录错误和告警,不读写 IP decision cache;session denylist 失效属于踢下线实时性下降,不能阻断登录 |
|
||
| MySQL 写登录日志或风险任务失败 | 放行登录;记录 error 和告警,后续依赖下次登录或缓存命中补偿 |
|
||
| 所有 IP provider 都失败 | 放行登录;能写缓存则写 `unknown` 短 TTL,不能写缓存也不阻塞 |
|
||
| 前置 provider 不可用 | 自动降级到下一个 provider;如果全部不可用则放行 |
|
||
| session 已经 logout | 后台风控撤销幂等成功,不重复踢 |
|
||
| 用户已换新 session | 只撤销命中的 `session_id`;如果 IP 已 blocked,后续登录会被 gateway 拒绝 |
|
||
| 踢 IM 失败 | session 已撤销仍然生效;记录补偿任务或等待客户端下一次 API 返回 `SESSION_REVOKED` |
|
||
| 房间踢出失败 | 不直接改 Redis;记录补偿,依赖 Room Cell/heartbeat 兜底 |
|
||
|
||
## Implementation Sequence
|
||
|
||
1. 增加 `AUTH_LOGIN_BLOCKED` 错误码,并确认 gateway 对 `SESSION_REVOKED` 的客户端语义。
|
||
2. gateway 登录入口增加 language/timezone fast guard 和 IP decision cache 读取。
|
||
3. 登录请求把 `client_ip/language/timezone` 透传到 user-service;密码登录也需要补这两个字段。
|
||
4. user-service 登录成功后创建 `login_ip_risk_jobs`,任务至少包含 `session_id/user_id/client_ip/client_ip_hash/channel/platform/request_id`。
|
||
5. 扩展 `login_audit`,保存明文 IP、IP 国家、渠道、平台、是否屏蔽和屏蔽原因。
|
||
6. 实现 `UserCronService.ProcessLoginIPRiskBatch`:MySQL claim 任务,按优先级顺序调用 provider fallback chain,写决策和 Redis cache。
|
||
7. user-service 增加按 `session_id` 撤销 session 的内部能力,并写撤销原因。
|
||
8. gateway 增加统一 session denylist middleware,JWT 校验后查 `auth:revoked_session:{app_code}:{sid}`。
|
||
9. 风控撤销 session 时写 Redis denylist,并把对应登录审计更新为屏蔽态。
|
||
10. 接入腾讯云 IM 踢下线或系统消息通知。
|
||
11. 增加 room-service 踢人命令或复用现有 Room Cell 管理命令,不直接操作 room Redis。
|
||
12. admin 在用户管理下新增二级菜单“登录日志”,支持列表、筛选、红色屏蔽行和用户历史登录日志。
|
||
13. 增加日志、指标和告警:provider 超时率、unknown 比例、blocked 数、撤销数、踢下线失败数。
|
||
|
||
## Test Cases
|
||
|
||
| Case | Expected |
|
||
| --- | --- |
|
||
| language 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED`,不调用 user-service 登录 |
|
||
| timezone 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED` |
|
||
| IP cache blocked | gateway 直接返回 `AUTH_LOGIN_BLOCKED` |
|
||
| IP cache miss | 登录成功,创建风险任务 |
|
||
| 后台风控判定 allowed | 写 allowed cache,不撤销 session |
|
||
| 后台风控判定 blocked | 撤销 session,写 blocked cache,写 denylist,触发踢下线 |
|
||
| blocked 后同 IP 再登录 | gateway 登录前拒绝 |
|
||
| 已撤销 session 调业务接口 | gateway middleware 返回 `SESSION_REVOKED` |
|
||
| provider 全失败 | 放行,能写缓存则写 unknown cache,不踢下线 |
|
||
| 第一个 provider 不可用、第二个 provider 返回 blocked country | 写 blocked cache,撤销 session |
|
||
| 登录日志列表含 blocked 行 | 该行背景为红色 |
|
||
| 点击用户登录日志行 | 展示该用户历史登录日志 |
|
||
| refresh 使用已撤销 session | user-service 返回 `SESSION_REVOKED` |
|
||
|
||
## Open Decisions
|
||
|
||
| Decision | Recommendation |
|
||
| --- | --- |
|
||
| Redis/MySQL/IP 查询不可用时是否阻断登录 | 当前方案统一 fail-open:全部放行,只记录错误和告警 |
|
||
| 第一个可用 provider 命中不支持国家是否踢 | 当前方案直接踢;provider 顺序代表信任优先级 |
|
||
| 明文 IP 是否落库 | 明文 IP 落 MySQL,普通服务日志仍只打 hash |
|
||
| 是否做后台管理页面 | 只做用户管理下的“登录日志”二级菜单,不做策略编辑后台 |
|
||
|
||
## Final Rule
|
||
|
||
当前阶段最重要的工程约束是:地区风险只在 gateway 入口和 user-service session 层处理,玩法服务不感知风险状态。只要 gateway 统一检查 session denylist,后台异步命中后撤销 session 就能对房间、钱包、礼物、VIP、消息等所有 HTTP 业务统一生效。
|