From 8777e9a022d9770d5b501bf979f705e7ffcb55b4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 26 Oct 2026 17:16:10 +0800 Subject: [PATCH] fix(auth): allow two email codes per address per minute --- .../services/miniapp-market-service/README.md | 5 +- .../miniapp-market-service/src/email_auth.rs | 49 +++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 19e363a2a8..b723f1046b 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -151,8 +151,9 @@ URL。Relay 的旧命名 `/api/auth/github/start` 同样仅在 `methods=all` 时 `POST /auth/login/start` 创建浏览器授权票据;`POST /auth/login/github` 选择 GitHub; `POST /auth/email/send` 发码,`POST /auth/email/verify` 验码。邮箱地址按 ASCII 小写 规范化,不删除加号标签或点。固定八位随机数字验证码十分钟有效,单次最多五次尝试,只存 -基于 session secret 的 HMAC 摘要。每个邮箱一分钟一次、一天二十次;服务器整体 -一分钟三百封、不设每日总量上限,发送失败也计入配额。发送记录保留一天,配额跨重启有效。 +基于 session secret 的 HMAC 摘要。每个邮箱滚动六十秒最多两次、滚动二十四小时最多二十次, +跨设备共享;服务器整体一分钟三百封、不设每日总量上限,发送失败也计入配额。 +发送记录保留一天,配额跨重启有效。 只接受八位数字,不兼容旧六位验证码;部署后旧验证码需重新获取。 SMTP 错误不得包含地址、凭据或验证码。认证接口保留 body、并发、期限和全局限流。 diff --git a/src/crates/services/miniapp-market-service/src/email_auth.rs b/src/crates/services/miniapp-market-service/src/email_auth.rs index f4ed37137f..717e960110 100644 --- a/src/crates/services/miniapp-market-service/src/email_auth.rs +++ b/src/crates/services/miniapp-market-service/src/email_auth.rs @@ -327,7 +327,7 @@ impl AuthService { .await .map_err(MarketError::internal)?; // INSERT is the first statement: serialize competing requests before quota evaluation. - let inserted = sqlx::query("INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM email_challenges WHERE email = ? AND created_at > ?) AND (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 20 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 300") + let inserted = sqlx::query("INSERT INTO email_challenges(id, ticket_hash, email, code_hash, created_at, expires_at) SELECT ?, ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 2 AND (SELECT COUNT(*) FROM email_challenges WHERE email = ? AND created_at > ?) < 20 AND (SELECT COUNT(*) FROM email_challenges WHERE created_at > ?) < 300") .bind(&id).bind(token_hash(&request.ticket)).bind(&email).bind(code_digest(&self.config.session_secret, &id, &code)).bind(now).bind(now + 600) .bind(&email).bind(now - 60).bind(&email).bind(now - 86400).bind(now - 60).execute(&mut *tx).await.map_err(MarketError::internal)?; if inserted.rows_affected() != 1 { @@ -765,21 +765,24 @@ mod tests { let (_dir, service) = setup().await; let a = service.start_web_login("/miniapp/").await.unwrap(); let b = service.start_web_login("/skin/").await.unwrap(); + let c = service.start_web_login("/miniapp/").await.unwrap(); let send = |ticket: &str| EmailSendRequest { ticket: ticket.into(), email: "ALICE@example.com".into(), locale: None, }; - let (first, second) = tokio::join!( + let (first, second, third) = tokio::join!( service.prepare_email_code(send(&a)), service.prepare_email_code(send(&b)), + service.prepare_email_code(send(&c)), ); - assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + let results = [first, second, third]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 2); assert_eq!( - first.err().or(second.err()).unwrap().code, + results.into_iter().find_map(Result::err).unwrap().code, "email_rate_limit" ); - for _ in 1..20 { + for _ in 2..20 { sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") .execute(service.db.pool()) .await @@ -807,6 +810,35 @@ mod tests { assert_eq!(count, 20); } + #[tokio::test] + async fn email_send_quota_reopens_one_slot_when_oldest_send_expires() { + let (_dir, service) = setup().await; + let a = service.start_web_login("/miniapp/").await.unwrap(); + let b = service.start_web_login("/skin/").await.unwrap(); + let (oldest, _) = code(&service, &a, "alice@example.com").await; + code(&service, &b, "ALICE@example.com").await; + sqlx::query("UPDATE email_challenges SET created_at = created_at - 61 WHERE id = ?") + .bind(oldest) + .execute(service.db.pool()) + .await + .unwrap(); + let c = service.start_web_login("/miniapp/").await.unwrap(); + code(&service, &c, "alice@example.com").await; + let d = service.start_web_login("/miniapp/").await.unwrap(); + assert_eq!( + service + .prepare_email_code(EmailSendRequest { + ticket: d, + email: "Alice@example.com".into(), + locale: None, + }) + .await + .unwrap_err() + .code, + "email_rate_limit" + ); + } + #[tokio::test] async fn global_send_quota_allows_300_per_minute_without_a_daily_cap() { let (_dir, service) = setup().await; @@ -862,6 +894,8 @@ mod tests { Database::open(&dir.path().join("db")).await.unwrap(), ) .unwrap(); + let b = reopened.start_web_login("/skin/").await.unwrap(); + self::code(&reopened, &b, "ALICE@example.com").await; assert_eq!( reopened .prepare_email_code(EmailSendRequest { @@ -894,10 +928,7 @@ mod tests { service.verify_email_code(verify(&ticket, &id, &code)) ); assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); - sqlx::query("UPDATE email_challenges SET created_at = created_at - 61") - .execute(service.db.pool()) - .await - .unwrap(); + // A second device can sign in immediately after the first consumes its code. let ticket = service.start_web_login("/miniapp/").await.unwrap(); let (id, code) = self::code(&service, &ticket, "Alice@example.com").await; service