Improve youth mode action token flow

This commit is contained in:
richjohnrambo
2026-09-02 11:25:25 +08:00
parent 7f6995d8fe
commit 79947a2c6f
5 changed files with 387 additions and 13 deletions
+12 -8
View File
@@ -759,11 +759,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
### 1.8 实名认证.png
@@ -803,8 +803,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。
- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。`action_token` 为一次性 token,会在设置密码时消费;设置成功后响应会返回新的 `enable_action_token`,用于下一步开启青少年模式,前端不要复用设置密码时提交的 token。
- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。
- 青少年模式完整开启和关闭流程见 `docs/IM-青少年模式前后端对接文档.md`。
- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。
- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。
@@ -2402,7 +2403,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
### 10.15 青少年模式提示(安卓).png
@@ -2491,13 +2493,15 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/security/challenges` | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 限流 | 同上 | HTTP `429`:`{"code":30020,"reason":"VERIFICATION_RATE_LIMITED","message":"verification rate limited","details":{"challenge_id":"sec_chg_1","retry_after_seconds":45}}`;小时限流时 `retry_after_seconds` 可能为数百/数千秒且可不返回 `challenge_id` |
| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/security/challenges/{id}/verify` 开启青少年模式 | `{"code":"123456"}` | `{"action_token":"set_password_token","action":"set_independent_password","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` |
@@ -0,0 +1,344 @@
# IM 青少年模式前后端对接文档
## 1. 基本约定
青少年模式相关接口均为登录后接口。客户端需要携带用户登录 token、普通 API Key 和协议版本头。
公共 Header:
```http
Authorization: Bearer <access_token>
X-MeetingIM-Auth: Bearer <access_token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Request-ID: <uuid>
X-Tenant-Code: default
```
所有写接口还需要携带:
```http
Idempotency-Key: <uuid>
```
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
## 2. 开启青少年模式
### 2.1 查询当前状态
```http
GET /v1/im/settings/youth-mode
```
成功响应:
```json
{
"code": 0,
"data": {
"enabled": false,
"policy_version": 1,
"restrictions": ["night_quiet", "content_filter", "payment_guard"],
"password_set": false,
"can_enable": false,
"updated_at": "2026-08-20T09:00:00Z"
}
}
```
前端使用 `policy_version` 和 `restrictions` 渲染开启确认页。最终开启时需要提交用户确认过的 `policy_version`。
### 2.2 发起短信身份验证
```http
POST /v1/im/security/challenges
```
请求体:
```json
{
"scene": "set_independent_password",
"channel": "sms"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"challenge_id": "sec_chg_1",
"phone_mask": "+861****000",
"expires_in_seconds": 300,
"resend_after_seconds": 60
}
}
```
手机号由服务端从当前登录用户读取,客户端不提交目标手机号。
### 2.3 校验短信验证码
```http
POST /v1/im/security/challenges/{challenge_id}/verify
```
请求体:
```json
{
"code": "123456"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"action": "set_independent_password",
"action_token": "set_password_token",
"expires_at": "2026-08-20T09:05:00Z"
}
}
```
这里拿到的 `action_token` 只用于设置独立密码,不能直接用于开启青少年模式。
### 2.4 设置独立密码
```http
POST /v1/im/settings/independent-password
```
请求体:
```json
{
"password": "6439",
"purpose": "youth_mode",
"action_token": "set_password_token"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"status": "ok",
"password_set": true,
"purpose": "youth_mode",
"enable_action_token": "enable_token",
"enable_action_expires_at": "2026-08-20T09:05:00Z",
"next_action": "enable_youth_mode"
}
}
```
服务端会消费 `set_password_token`,并返回新的 `enable_action_token`。前端下一步必须使用 `enable_action_token`,不要复用 `set_password_token`。
独立密码规则:
- 必须是 4 位数字。
- 不能是明显连续或重复组合。
- 服务端只保存强哈希,不返回明文密码。
- 前端离开页面后应清空输入内容,不落普通存储。
### 2.5 开启青少年模式
```http
POST /v1/im/settings/youth-mode/enable
```
请求体:
```json
{
"policy_version": 1,
"action_token": "enable_token"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"enabled": true,
"policy_version": 1,
"password_set": true,
"restrictions": ["night_quiet", "content_filter", "payment_guard"],
"updated_at": "2026-08-20T09:00:00Z"
}
}
```
开启成功后,客户端应刷新本地设置页和 `bootstrap` 缓存。服务端会持久化状态,前端不能只依赖本地标志位。
## 3. 关闭青少年模式
### 3.1 校验独立密码
```http
POST /v1/im/settings/independent-password/verify
```
请求体:
```json
{
"password": "6439",
"purpose": "youth_mode",
"action": "close_youth_mode"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"action": "close_youth_mode",
"action_token": "close_token",
"expires_at": "2026-08-20T09:05:00Z"
}
}
```
### 3.2 关闭模式
```http
POST /v1/im/settings/youth-mode/disable
X-Action-Token: close_token
```
请求体:
```json
{}
```
成功响应:
```json
{
"code": 0,
"data": {
"enabled": false,
"policy_version": 1,
"password_set": true,
"updated_at": "2026-08-20T09:00:00Z"
}
}
```
关闭接口的 `action_token` 通过 Header `X-Action-Token` 传递,不放在 Body 中。
## 4. Token 使用规则
| 来源 | action | 用途 | 传递方式 | 是否单次消费 |
| --- | --- | --- | --- | --- |
| `security/challenges/{id}/verify` | `set_independent_password` | 设置独立密码 | Body `action_token` | 是 |
| `independent-password` | `enable_youth_mode` | 开启青少年模式 | Body `action_token` | 是 |
| `independent-password/verify` | `close_youth_mode` | 关闭青少年模式 | Header `X-Action-Token` | 是 |
常见错误:
- `ACTION_TOKEN_INVALID`:token 已消费、过期、action 不匹配、target 不匹配或用户不匹配。
- `CODE_INVALID`:短信验证码错误。
- `CHALLENGE_EXPIRED`:短信 challenge 已过期。
- `TOO_MANY_ATTEMPTS`:独立密码或验证码错误次数过多。
- `VERIFICATION_RATE_LIMITED`:发码过于频繁,按 `retry_after_seconds` 倒计时。
## 5. curl 示例
开启流程:
```bash
curl -sS 'https://api.118im.com/v1/im/security/challenges' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"scene":"set_independent_password","channel":"sms"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/security/challenges/<challenge_id>/verify' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"code":"123456"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/independent-password' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"password":"6439","purpose":"youth_mode","action_token":"<set_password_token>"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/youth-mode/enable' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"policy_version":1,"action_token":"<enable_action_token>"}'
```
关闭流程:
```bash
curl -sS 'https://api.118im.com/v1/im/settings/independent-password/verify' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"password":"6439","purpose":"youth_mode","action":"close_youth_mode"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/youth-mode/disable' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
-H 'X-Action-Token: <close_token>' \
--data '{}'
```
+15 -3
View File
@@ -3021,7 +3021,7 @@ func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "enable_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
@@ -6081,7 +6081,19 @@ func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose})
enableToken, enableExpiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, "enable_youth_mode", purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"status": "ok",
"password_set": true,
"purpose": purpose,
"enable_action_token": enableToken,
"enable_action_expires_at": enableExpiresAt.Format(time.RFC3339Nano),
"next_action": "enable_youth_mode",
})
}
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
@@ -6338,7 +6350,7 @@ func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target s
func defaultIMActionTarget(action, fallback string) string {
switch action {
case "set_independent_password", "close_youth_mode", "reset_independent_password":
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
return "youth_mode"
default:
return fallback
+12 -2
View File
@@ -2132,6 +2132,13 @@ func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil {
t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["password_set"] != true || data["next_action"] != "enable_youth_mode" || data["enable_action_token"] == "" {
t.Fatalf("unexpected set response: %#v", data)
}
if security.actionToken == nil || security.actionToken.Action != "enable_youth_mode" || security.actionToken.Target != "youth_mode" {
t.Fatalf("unexpected enable token after set: %#v", security.actionToken)
}
expectIMBearer(t, ctrl, "token-verify", 7, 42)
req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify",
@@ -2144,7 +2151,7 @@ func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["action"] != "close_youth_mode" || data["action_token"] == "" {
t.Fatalf("unexpected verify response: %#v", data)
}
@@ -3156,7 +3163,7 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) {
defer ctrl.Finish()
oldSecurity := store.Security
security := &testSecurityStore{
actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"},
actionToken: &types.ActionToken{Action: "enable_youth_mode", Target: "youth_mode"},
independentPassword: &types.IndependentPassword{PasswordHash: []byte("hash")},
}
store.Security = security
@@ -3172,6 +3179,9 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("enable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.consumedAction != "enable_youth_mode" || security.consumedTarget != "youth_mode" {
t.Fatalf("unexpected enable token consumption: action=%q target=%q", security.consumedAction, security.consumedTarget)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["enabled"] != true || data["policy_version"].(float64) != 2 {
t.Fatalf("unexpected youth enable payload: %#v", data)
+4
View File
@@ -223,6 +223,8 @@ type testSecurityStore struct {
realName *types.RealNameVerification
realNameAppeal *types.RealNameAppeal
actionToken *types.ActionToken
consumedAction string
consumedTarget string
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
attempts int
@@ -294,6 +296,8 @@ func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.Act
}
func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) {
s.consumedAction = action
s.consumedTarget = target
if s.err != nil {
return nil, s.err
}