From f8a286cec23e079d0008798212c2f3cb329d1da2 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 1 Sep 2026 22:59:29 +0800 Subject: [PATCH] fix: reuse active security challenge before rate limit --- docs/IM-前后端对接文档.md | 5 +++- server/im_http.go | 24 +++++++++---------- server/im_http_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 77f00f7..abc6f36 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -761,8 +761,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 敏感操作先调用 `POST /v1/im/security/challenges`,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 只能用于指定动作且单次消费。 -- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 +- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 ### 1.8 实名认证.png @@ -2491,6 +2493,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `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/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` 限流 | 同上 | 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"}` | | `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}` | diff --git a/server/im_http.go b/server/im_http.go index 85dc388..e1cd8db 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -6243,18 +6243,6 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { now := time.Now().UTC().Round(time.Millisecond) phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") - if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { - logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err) - } else if !allowed { - retryAfter := int(ttl.Seconds()) - if retryAfter < 1 { - retryAfter = int(imVerificationCreateRateWindow.Seconds()) - } - writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ - "retry_after_seconds": retryAfter, - }) - return - } active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -6267,6 +6255,18 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi }) return } + if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { + logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err) + } else if !allowed { + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = int(imVerificationCreateRateWindow.Seconds()) + } + writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ + "retry_after_seconds": retryAfter, + }) + return + } challengeID, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) diff --git a/server/im_http_test.go b/server/im_http_test.go index 45258ae..1f56254 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -2182,6 +2182,56 @@ func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) { } } +func TestServeIMSecurityChallengeCreateReturnsActiveChallengeBeforeRateLimit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldUsers := store.Users + oldVerifications := store.Verifications + now := time.Now().UTC() + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: types.Uid(42).String()}, + TenantID: 7, + Phone: "+8613800138000", + }, nil) + verifications := &testVerificationStore{active: &types.VerificationCode{ + TenantID: 7, + ChallengeID: "challenge-active", + Scene: "sensitive_action", + Action: "close_youth_mode", + PhoneHash: hashIMSecret(7, "sensitive_action", "+8613800138000", "phone"), + TargetUser: 42, + State: types.VerificationStatePending, + ExpiresAt: now.Add(5 * time.Minute), + ResendAfter: now.Add(45 * time.Second), + }} + store.Users = users + store.Verifications = verifications + defer func() { + store.Users = oldUsers + store.Verifications = oldVerifications + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges", + bytes.NewBufferString(`{"scene":"close_youth_mode","channel":"sms"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMSecurityChallenges(rec, req) + + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Details.(map[string]any) + if data["challenge_id"] != "challenge-active" { + t.Fatalf("expected active challenge id, got %#v", data) + } + if verifications.created != nil { + t.Fatalf("should not create a new challenge while resend is cooling down: %#v", verifications.created) + } +} + func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()