From 79947a2c6f2371bf101d27aa9a549e18c7d480cf Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 2 Sep 2026 11:25:25 +0800 Subject: [PATCH] Improve youth mode action token flow --- docs/IM-前后端对接文档.md | 20 +- docs/IM-青少年模式前后端对接文档.md | 344 ++++++++++++++++++++++++++++ server/im_http.go | 18 +- server/im_http_test.go | 14 +- server/user_verification_test.go | 4 + 5 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 docs/IM-青少年模式前后端对接文档.md diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index a9f6819..21efd9f 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -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"}` | diff --git a/docs/IM-青少年模式前后端对接文档.md b/docs/IM-青少年模式前后端对接文档.md new file mode 100644 index 0000000..943f00a --- /dev/null +++ b/docs/IM-青少年模式前后端对接文档.md @@ -0,0 +1,344 @@ +# IM 青少年模式前后端对接文档 + +## 1. 基本约定 + +青少年模式相关接口均为登录后接口。客户端需要携带用户登录 token、普通 API Key 和协议版本头。 + +公共 Header: + +```http +Authorization: Bearer +X-MeetingIM-Auth: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Request-ID: +X-Tenant-Code: default +``` + +所有写接口还需要携带: + +```http +Idempotency-Key: +``` + +测试环境验证码固定为 `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 ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + --data '{"scene":"set_independent_password","channel":"sms"}' +``` + +```bash +curl -sS 'https://api.118im.com/v1/im/security/challenges//verify' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + --data '{"code":"123456"}' +``` + +```bash +curl -sS 'https://api.118im.com/v1/im/settings/independent-password' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + --data '{"password":"6439","purpose":"youth_mode","action_token":""}' +``` + +```bash +curl -sS 'https://api.118im.com/v1/im/settings/youth-mode/enable' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + --data '{"policy_version":1,"action_token":""}' +``` + +关闭流程: + +```bash +curl -sS 'https://api.118im.com/v1/im/settings/independent-password/verify' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + --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 ' \ + -H 'X-MeetingIM-Auth: Bearer ' \ + -H 'X-MeetingIM-APIKey: ' \ + -H 'X-IM-Schema: 1' \ + -H 'X-Tenant-Code: default' \ + -H 'X-Request-ID: ' \ + -H 'Idempotency-Key: ' \ + -H 'X-Action-Token: ' \ + --data '{}' +``` diff --git a/server/im_http.go b/server/im_http.go index b2d5fbb..8443ea9 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -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 diff --git a/server/im_http_test.go b/server/im_http_test.go index 622397b..d6894fc 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -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) diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 5495011..04477ef 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -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 }