Skip to content

Commit 05cc00c

Browse files
authored
feat(auth): OTP 注册引擎(待注册态 + 验证码,review 后重做) (#52)
* feat(auth): OTP 注册引擎——待注册态 + 验证码生成/校验/发信 ADR-001 规范邮箱子系统的引擎部分(自包含可测): - PendingRegistration:待邮箱验证的注册意图,进程内 Caffeine(15min TTL,不落库) - RegistrationService.begin/sendOtp/verifyAndConsume:6 位码,10min TTL, 限 5 次防爆破,邮箱与发码对象绑定(防换邮箱绕过),一次性消费 - 发信复用 ResendEmailService;发信失败返 false 供调用方作可重试用户错误 账号建号/关联的落地留给 wiring PR(改认证主流程,单独隔离)。 6 引擎测试;全量 278 通过。 * fix(auth): 处理 #51 review——OTP 引擎安全重做 review 发现的漏洞集中修: - 【爆破封堵】attempts 累积在会话上、不被重发重置 → 6 位码爆破面恒为 5 次 - 【邮件轰炸】发信限流:会话至多 5 封、间隔 ≥60s;邮箱格式校验 - 【原子一次性】校验+消费在会话锁内完成,并发/双击只成功一次,不再重复建号 - 【邮箱验证不被架空】返回本次实际验证通过的 email(VerifiedRegistration), 不再返回 provider 预填的未验证地址 - 【TTL 错配】单缓存可变会话,pending 与 OTP 同生命周期,会话 15min 不被重发刷新 - OTP 过期显式判定;String.format 指定 Locale.ROOT;SendResult 区分失败语义 - 可注入 Ticker,新增测试:重发不重置 attempts / 重发上限 / 冷却 / OTP 过期 / 并发只消费一次 / 返回已验证邮箱 单实例内存态限制(多实例/native 需迁 Redis)与 pendingId 耗尽型 DoS(靠随机 UUID+短命+控制层 IP 限流)在类注释里写明。全量 283 测试通过。
1 parent 7e460ec commit 05cc00c

3 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.involutionhell.backend.usercenter.model;
2+
3+
/**
4+
* 待邮箱验证的注册意图(OTP 流程)。第三方登录首次、无法自动关联时创建,
5+
* 存进 RegistrationService 的进程内缓存(Caffeine,短 TTL),验证码通过后才
6+
* 据此建号/关联。不落库——用户放弃就随 TTL 自然消失。
7+
*/
8+
public record PendingRegistration(
9+
String provider,
10+
String providerUserId,
11+
String displayName,
12+
String avatarUrl,
13+
String providerEmail, // provider 给的邮箱(预填用),用户可在验证步改成别的
14+
Long githubId
15+
) {
16+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package com.involutionhell.backend.usercenter.service;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import com.github.benmanes.caffeine.cache.Ticker;
6+
import com.involutionhell.backend.common.email.ResendEmailService;
7+
import com.involutionhell.backend.usercenter.model.PendingRegistration;
8+
import java.security.SecureRandom;
9+
import java.time.Duration;
10+
import java.util.Locale;
11+
import java.util.Optional;
12+
import java.util.UUID;
13+
import java.util.regex.Pattern;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
import org.springframework.stereotype.Service;
17+
18+
/**
19+
* 注册时的邮箱 OTP 引擎(ADR-001 的"规范邮箱"子系统)。职责聚焦:待注册态 +
20+
* 验证码生成/发信/校验,全进程内不落库。账号建号/关联落地由调用方(wiring)在
21+
* OTP 通过后做。只在"第三方登录首次、且未被已验证邮箱自动关联"时进入,不是每次登录。
22+
*
23+
* 安全模型(review 后重做):
24+
* - 一个 pendingId 对应一个可变会话 RegSession,从 begin 起固定 15min 生命周期
25+
* (不被重发刷新),pending 与 OTP 同生共死,避免两缓存 TTL 错配。
26+
* - verifyAttempts 累积计在会话上、**不被重发重置**:整个会话至多 5 次校验尝试,
27+
* 6 位码的爆破面恒为 5/1e6,堵住"重发归零 attempts 无限爆破"。
28+
* - 发信限流:会话内至多 5 封、两封间隔 ≥60s,防邮件轰炸。
29+
* - 校验+消费在会话锁内原子完成,双击/并发只成功一次,杜绝重复建号。
30+
* - 校验通过返回**本次实际验证通过的 email**(不是 provider 预填的未验证地址)。
31+
*
32+
* ponytail: 进程内 Caffeine,靠单实例 / sticky session 成立;上多实例或 GraalVM
33+
* native 时会话态要迁 Redis(同 Sa-Token session、JustAuth state,见 ADR-001)。
34+
* 针对特定 pendingId 的 attempts 耗尽型 DoS 由"pendingId 是随机 UUID + 15min 短命 +
35+
* 控制层按 IP 限流"共同兜底,引擎层不额外处理。
36+
*/
37+
@Service
38+
public class RegistrationService {
39+
40+
private static final Logger log = LoggerFactory.getLogger(RegistrationService.class);
41+
private static final SecureRandom RANDOM = new SecureRandom();
42+
private static final int MAX_VERIFY_ATTEMPTS = 5;
43+
private static final int MAX_SENDS = 5;
44+
private static final long RESEND_COOLDOWN_NANOS = Duration.ofSeconds(60).toNanos();
45+
private static final long OTP_TTL_NANOS = Duration.ofMinutes(10).toNanos();
46+
private static final Duration SESSION_TTL = Duration.ofMinutes(15);
47+
// 粗校验邮箱格式,挡住明显非法/注入型输入;真实有效性由"能收到码"证明。
48+
private static final Pattern EMAIL_RE = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
49+
50+
/** sendOtp 结果。区分"会话过期(不可恢复,需重走注册)"与"发信失败(可重试)"等语义。 */
51+
public enum SendResult { SENT, SESSION_EXPIRED, RATE_LIMITED, INVALID_EMAIL, SEND_FAILED }
52+
53+
/** 校验通过的注册意图 + 本次已验证的规范邮箱。 */
54+
public record VerifiedRegistration(PendingRegistration pending, String verifiedEmail) {}
55+
56+
private final ResendEmailService emailService;
57+
private final Ticker ticker;
58+
private final Cache<String, RegSession> sessions;
59+
60+
@org.springframework.beans.factory.annotation.Autowired
61+
public RegistrationService(ResendEmailService emailService) {
62+
this(emailService, Ticker.systemTicker());
63+
}
64+
65+
/** 供测试注入 FakeTicker,控制 OTP 过期与会话 TTL。 */
66+
RegistrationService(ResendEmailService emailService, Ticker ticker) {
67+
this.emailService = emailService;
68+
this.ticker = ticker;
69+
this.sessions = Caffeine.newBuilder()
70+
.ticker(ticker)
71+
.expireAfterWrite(SESSION_TTL)
72+
.maximumSize(10_000)
73+
.build();
74+
}
75+
76+
/** 一个注册会话的可变状态。所有读改在会话锁内完成。 */
77+
private static final class RegSession {
78+
final PendingRegistration reg;
79+
String otpEmail;
80+
String otpCode;
81+
long otpExpiresAtNanos;
82+
int verifyAttempts; // 累积,不被重发重置
83+
int sends;
84+
long lastSendAtNanos = Long.MIN_VALUE;
85+
86+
RegSession(PendingRegistration reg) {
87+
this.reg = reg;
88+
}
89+
}
90+
91+
/** 建一个待注册会话,返回 pendingId。会话 15min 后自然过期(不被重发刷新)。 */
92+
public String begin(PendingRegistration reg) {
93+
String pendingId = UUID.randomUUID().toString();
94+
sessions.put(pendingId, new RegSession(reg));
95+
return pendingId;
96+
}
97+
98+
/** 供 wiring 预填验证页(返回 provider 预填邮箱,非已验证)。 */
99+
public Optional<PendingRegistration> getPending(String pendingId) {
100+
RegSession s = sessions.getIfPresent(pendingId);
101+
return s == null ? Optional.empty() : Optional.of(s.reg);
102+
}
103+
104+
/**
105+
* 给指定邮箱发 6 位验证码。限流:会话至多 {@value #MAX_SENDS} 封、间隔 ≥60s。
106+
* verifyAttempts 不在此重置。
107+
*/
108+
public SendResult sendOtp(String pendingId, String email) {
109+
RegSession s = sessions.getIfPresent(pendingId);
110+
if (s == null) {
111+
return SendResult.SESSION_EXPIRED;
112+
}
113+
String normalized = email == null ? "" : email.trim();
114+
if (!EMAIL_RE.matcher(normalized).matches()) {
115+
return SendResult.INVALID_EMAIL;
116+
}
117+
String code;
118+
String to;
119+
synchronized (s) {
120+
long now = ticker.read();
121+
if (s.sends >= MAX_SENDS) {
122+
return SendResult.RATE_LIMITED;
123+
}
124+
if (s.lastSendAtNanos != Long.MIN_VALUE && now - s.lastSendAtNanos < RESEND_COOLDOWN_NANOS) {
125+
return SendResult.RATE_LIMITED;
126+
}
127+
code = String.format(Locale.ROOT, "%06d", RANDOM.nextInt(1_000_000));
128+
s.otpEmail = normalized;
129+
s.otpCode = code;
130+
s.otpExpiresAtNanos = now + OTP_TTL_NANOS;
131+
s.sends++;
132+
s.lastSendAtNanos = now;
133+
to = normalized;
134+
}
135+
String html = "<p>你的 InvolutionHell 注册验证码是:</p>"
136+
+ "<p style=\"font-size:24px;font-weight:bold;letter-spacing:4px\">" + code + "</p>"
137+
+ "<p>10 分钟内有效。如果不是你本人操作,忽略即可。</p>";
138+
boolean sent = emailService.sendHtml(to, "InvolutionHell 注册验证码", html);
139+
if (!sent) {
140+
log.warn("OTP 发信失败 pendingId={}", pendingId); // 不落收件邮箱(PII)
141+
return SendResult.SEND_FAILED;
142+
}
143+
return SendResult.SENT;
144+
}
145+
146+
/**
147+
* 校验验证码。成功则**原子**消费整个会话,返回注册意图 + 本次已验证的规范邮箱。
148+
* 失败返回空(会话过期 / 未发码 / 码过期 / 超次 / 邮箱或码不符)。邮箱必须与发码
149+
* 对象一致,防换邮箱绕过。attempts 累积计数,超 {@value #MAX_VERIFY_ATTEMPTS} 即锁死会话。
150+
*/
151+
public Optional<VerifiedRegistration> verifyAndConsume(String pendingId, String email, String code) {
152+
RegSession s = sessions.getIfPresent(pendingId);
153+
if (s == null) {
154+
return Optional.empty();
155+
}
156+
synchronized (s) {
157+
// 会话锁内再确认未被并发消费(原子一次性)
158+
if (sessions.getIfPresent(pendingId) == null) {
159+
return Optional.empty();
160+
}
161+
if (s.otpCode == null) {
162+
return Optional.empty(); // 还没 sendOtp
163+
}
164+
if (ticker.read() > s.otpExpiresAtNanos) {
165+
return Optional.empty(); // OTP 过期
166+
}
167+
if (s.verifyAttempts >= MAX_VERIFY_ATTEMPTS) {
168+
return Optional.empty(); // 已锁死
169+
}
170+
String normalized = email == null ? "" : email.trim();
171+
boolean ok = s.otpEmail.equalsIgnoreCase(normalized) && s.otpCode.equals(code);
172+
if (!ok) {
173+
s.verifyAttempts++;
174+
return Optional.empty();
175+
}
176+
sessions.invalidate(pendingId); // 一次性消费,锁内完成
177+
return Optional.of(new VerifiedRegistration(s.reg, s.otpEmail));
178+
}
179+
}
180+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.involutionhell.backend.usercenter.service;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.anyString;
5+
import static org.mockito.ArgumentMatchers.eq;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.when;
8+
9+
import com.github.benmanes.caffeine.cache.Ticker;
10+
import com.involutionhell.backend.common.email.ResendEmailService;
11+
import com.involutionhell.backend.usercenter.model.PendingRegistration;
12+
import com.involutionhell.backend.usercenter.service.RegistrationService.SendResult;
13+
import com.involutionhell.backend.usercenter.service.RegistrationService.VerifiedRegistration;
14+
import java.time.Duration;
15+
import java.util.Optional;
16+
import java.util.concurrent.CountDownLatch;
17+
import java.util.concurrent.ExecutorService;
18+
import java.util.concurrent.Executors;
19+
import java.util.concurrent.Future;
20+
import java.util.concurrent.atomic.AtomicInteger;
21+
import java.util.regex.Matcher;
22+
import java.util.regex.Pattern;
23+
import org.junit.jupiter.api.Test;
24+
import org.mockito.ArgumentCaptor;
25+
26+
/**
27+
* OTP 引擎行为契约(review 后重做):一次性/累积限次/重发限流/冷却/过期/邮箱绑定/
28+
* 返回已验证邮箱/并发消费。用 mock 的 ResendEmailService 抓邮件正文里的码,用
29+
* FakeTicker 控制时间。
30+
*/
31+
class RegistrationServiceTests {
32+
33+
/** 可控时钟,驱动 OTP 过期与会话 TTL / 冷却。 */
34+
private static final class FakeTicker implements Ticker {
35+
private long nanos = 0;
36+
@Override public long read() { return nanos; }
37+
void advance(Duration d) { nanos += d.toNanos(); }
38+
}
39+
40+
private final ResendEmailService email = mock(ResendEmailService.class);
41+
private final FakeTicker ticker = new FakeTicker();
42+
private final RegistrationService svc = new RegistrationService(email, ticker);
43+
44+
private PendingRegistration reg(String providerEmail) {
45+
return new PendingRegistration("discord", "snow-1", "Nick", null, providerEmail, null);
46+
}
47+
48+
/** 发一次码并抓出邮件正文里最新的 6 位验证码(sendHtml 已 mock 成 true)。 */
49+
private String sendAndCaptureCode(String pendingId, String toEmail) {
50+
when(email.sendHtml(eq(toEmail), anyString(), anyString())).thenReturn(true);
51+
assertThat(svc.sendOtp(pendingId, toEmail)).isEqualTo(SendResult.SENT);
52+
ArgumentCaptor<String> html = ArgumentCaptor.forClass(String.class);
53+
org.mockito.Mockito.verify(email, org.mockito.Mockito.atLeastOnce())
54+
.sendHtml(eq(toEmail), anyString(), html.capture());
55+
Matcher m = Pattern.compile("(\\d{6})").matcher(html.getAllValues().get(html.getAllValues().size() - 1));
56+
assertThat(m.find()).isTrue();
57+
return m.group(1);
58+
}
59+
60+
@Test
61+
void correctCodeConsumesAndReturnsVerifiedEmail() {
62+
String pid = svc.begin(reg("provider-prefill@example.com"));
63+
// 用户在验证步改成自己另一个邮箱
64+
String code = sendAndCaptureCode(pid, "real@example.com");
65+
66+
Optional<VerifiedRegistration> ok = svc.verifyAndConsume(pid, "real@example.com", code);
67+
assertThat(ok).isPresent();
68+
assertThat(ok.get().pending().provider()).isEqualTo("discord");
69+
// 关键:返回的是本次验证通过的邮箱,不是 provider 预填的未验证地址
70+
assertThat(ok.get().verifiedEmail()).isEqualTo("real@example.com");
71+
72+
// 一次性:再验第二次失败(已消费)
73+
assertThat(svc.verifyAndConsume(pid, "real@example.com", code)).isEmpty();
74+
}
75+
76+
@Test
77+
void wrongCodeFails() {
78+
String pid = svc.begin(reg("a@e.com"));
79+
sendAndCaptureCode(pid, "a@e.com");
80+
assertThat(svc.verifyAndConsume(pid, "a@e.com", "000000")).isEmpty();
81+
}
82+
83+
@Test
84+
void emailMustMatchTheOneCodeWasSentTo() {
85+
String pid = svc.begin(reg("a@e.com"));
86+
String code = sendAndCaptureCode(pid, "a@e.com");
87+
assertThat(svc.verifyAndConsume(pid, "attacker@e.com", code)).isEmpty();
88+
}
89+
90+
@Test
91+
void resendDoesNotResetAttempts_bruteForceCapped() {
92+
String pid = svc.begin(reg("a@e.com"));
93+
sendAndCaptureCode(pid, "a@e.com");
94+
// 打满 5 次错码
95+
for (int i = 0; i < 5; i++) {
96+
assertThat(svc.verifyAndConsume(pid, "a@e.com", "111111")).isEmpty();
97+
}
98+
// 过冷却后重发(新码),但 attempts 不重置
99+
ticker.advance(Duration.ofSeconds(61));
100+
String newCode = sendAndCaptureCode(pid, "a@e.com");
101+
// 即便拿到正确的新码,也因 attempts 已耗尽而失败——这就是堵住"重发无限爆破"
102+
assertThat(svc.verifyAndConsume(pid, "a@e.com", newCode)).isEmpty();
103+
}
104+
105+
@Test
106+
void resendCooldownEnforced() {
107+
String pid = svc.begin(reg("a@e.com"));
108+
when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true);
109+
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
110+
// 60s 内再发 → 限流
111+
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.RATE_LIMITED);
112+
ticker.advance(Duration.ofSeconds(61));
113+
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
114+
}
115+
116+
@Test
117+
void resendCapEnforced() {
118+
String pid = svc.begin(reg("a@e.com"));
119+
when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true);
120+
for (int i = 0; i < 5; i++) {
121+
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
122+
ticker.advance(Duration.ofSeconds(61)); // 过冷却
123+
}
124+
// 第 6 封超上限
125+
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.RATE_LIMITED);
126+
}
127+
128+
@Test
129+
void otpExpiresAfterTtl() {
130+
String pid = svc.begin(reg("a@e.com"));
131+
String code = sendAndCaptureCode(pid, "a@e.com");
132+
ticker.advance(Duration.ofMinutes(11)); // 超 10min OTP TTL
133+
assertThat(svc.verifyAndConsume(pid, "a@e.com", code)).isEmpty();
134+
}
135+
136+
@Test
137+
void invalidEmailRejected() {
138+
String pid = svc.begin(reg("a@e.com"));
139+
assertThat(svc.sendOtp(pid, "not-an-email")).isEqualTo(SendResult.INVALID_EMAIL);
140+
}
141+
142+
@Test
143+
void unknownPendingIsSessionExpired() {
144+
assertThat(svc.sendOtp("nonexistent", "a@e.com")).isEqualTo(SendResult.SESSION_EXPIRED);
145+
assertThat(svc.verifyAndConsume("nonexistent", "a@e.com", "123456")).isEmpty();
146+
}
147+
148+
@Test
149+
void verifyFailsWhenNothingSent() {
150+
String pid = svc.begin(reg("a@e.com"));
151+
assertThat(svc.verifyAndConsume(pid, "a@e.com", "123456")).isEmpty();
152+
}
153+
154+
@Test
155+
void concurrentConsumeSucceedsExactlyOnce() throws Exception {
156+
String pid = svc.begin(reg("a@e.com"));
157+
String code = sendAndCaptureCode(pid, "a@e.com");
158+
159+
int threads = 8;
160+
ExecutorService pool = Executors.newFixedThreadPool(threads);
161+
CountDownLatch start = new CountDownLatch(1);
162+
AtomicInteger successes = new AtomicInteger();
163+
Future<?>[] futures = new Future<?>[threads];
164+
for (int i = 0; i < threads; i++) {
165+
futures[i] = pool.submit(() -> {
166+
try {
167+
start.await();
168+
if (svc.verifyAndConsume(pid, "a@e.com", code).isPresent()) {
169+
successes.incrementAndGet();
170+
}
171+
} catch (InterruptedException ignored) {
172+
Thread.currentThread().interrupt();
173+
}
174+
});
175+
}
176+
start.countDown();
177+
for (Future<?> f : futures) f.get();
178+
pool.shutdown();
179+
180+
// 并发多线程用同一正确码,只能成功一次(原子一次性消费)
181+
assertThat(successes.get()).isEqualTo(1);
182+
}
183+
}

0 commit comments

Comments
 (0)