fix: reuse active security challenge before rate limit
This commit is contained in:
+4
-1
@@ -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}` |
|
||||
|
||||
+12
-12
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user