Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/crates/services/miniapp-market-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、并发、期限和全局限流。

Expand Down
49 changes: 40 additions & 9 deletions src/crates/services/miniapp-market-service/src/email_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading