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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.involutionhell.backend.usercenter.model;

/**
* 待邮箱验证的注册意图(OTP 流程)。第三方登录首次、无法自动关联时创建,
* 存进 RegistrationService 的进程内缓存(Caffeine,短 TTL),验证码通过后才
* 据此建号/关联。不落库——用户放弃就随 TTL 自然消失。
*/
public record PendingRegistration(
String provider,
String providerUserId,
String displayName,
String avatarUrl,
String providerEmail, // provider 给的邮箱(预填用),用户可在验证步改成别的
Long githubId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package com.involutionhell.backend.usercenter.service;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import com.involutionhell.backend.common.email.ResendEmailService;
import com.involutionhell.backend.usercenter.model.PendingRegistration;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

/**
* 注册时的邮箱 OTP 引擎(ADR-001 的"规范邮箱"子系统)。职责聚焦:待注册态 +
* 验证码生成/发信/校验,全进程内不落库。账号建号/关联落地由调用方(wiring)在
* OTP 通过后做。只在"第三方登录首次、且未被已验证邮箱自动关联"时进入,不是每次登录。
*
* 安全模型(review 后重做):
* - 一个 pendingId 对应一个可变会话 RegSession,从 begin 起固定 15min 生命周期
* (不被重发刷新),pending 与 OTP 同生共死,避免两缓存 TTL 错配。
* - verifyAttempts 累积计在会话上、**不被重发重置**:整个会话至多 5 次校验尝试,
* 6 位码的爆破面恒为 5/1e6,堵住"重发归零 attempts 无限爆破"。
* - 发信限流:会话内至多 5 封、两封间隔 ≥60s,防邮件轰炸。
* - 校验+消费在会话锁内原子完成,双击/并发只成功一次,杜绝重复建号。
* - 校验通过返回**本次实际验证通过的 email**(不是 provider 预填的未验证地址)。
*
* ponytail: 进程内 Caffeine,靠单实例 / sticky session 成立;上多实例或 GraalVM
* native 时会话态要迁 Redis(同 Sa-Token session、JustAuth state,见 ADR-001)。
* 针对特定 pendingId 的 attempts 耗尽型 DoS 由"pendingId 是随机 UUID + 15min 短命 +
* 控制层按 IP 限流"共同兜底,引擎层不额外处理。
*/
@Service
public class RegistrationService {

private static final Logger log = LoggerFactory.getLogger(RegistrationService.class);
private static final SecureRandom RANDOM = new SecureRandom();
private static final int MAX_VERIFY_ATTEMPTS = 5;
private static final int MAX_SENDS = 5;
private static final long RESEND_COOLDOWN_NANOS = Duration.ofSeconds(60).toNanos();
private static final long OTP_TTL_NANOS = Duration.ofMinutes(10).toNanos();
private static final Duration SESSION_TTL = Duration.ofMinutes(15);
// 粗校验邮箱格式,挡住明显非法/注入型输入;真实有效性由"能收到码"证明。
private static final Pattern EMAIL_RE = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");

/** sendOtp 结果。区分"会话过期(不可恢复,需重走注册)"与"发信失败(可重试)"等语义。 */
public enum SendResult { SENT, SESSION_EXPIRED, RATE_LIMITED, INVALID_EMAIL, SEND_FAILED }

/** 校验通过的注册意图 + 本次已验证的规范邮箱。 */
public record VerifiedRegistration(PendingRegistration pending, String verifiedEmail) {}

private final ResendEmailService emailService;
private final Ticker ticker;
private final Cache<String, RegSession> sessions;

@org.springframework.beans.factory.annotation.Autowired
public RegistrationService(ResendEmailService emailService) {
this(emailService, Ticker.systemTicker());
}

/** 供测试注入 FakeTicker,控制 OTP 过期与会话 TTL。 */
RegistrationService(ResendEmailService emailService, Ticker ticker) {
this.emailService = emailService;
this.ticker = ticker;
this.sessions = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(SESSION_TTL)
.maximumSize(10_000)
.build();
}

/** 一个注册会话的可变状态。所有读改在会话锁内完成。 */
private static final class RegSession {
final PendingRegistration reg;
String otpEmail;
String otpCode;
long otpExpiresAtNanos;
int verifyAttempts; // 累积,不被重发重置
int sends;
long lastSendAtNanos = Long.MIN_VALUE;

RegSession(PendingRegistration reg) {
this.reg = reg;
}
}

/** 建一个待注册会话,返回 pendingId。会话 15min 后自然过期(不被重发刷新)。 */
public String begin(PendingRegistration reg) {
String pendingId = UUID.randomUUID().toString();
sessions.put(pendingId, new RegSession(reg));
return pendingId;
}

/** 供 wiring 预填验证页(返回 provider 预填邮箱,非已验证)。 */
public Optional<PendingRegistration> getPending(String pendingId) {
RegSession s = sessions.getIfPresent(pendingId);
return s == null ? Optional.empty() : Optional.of(s.reg);
}

/**
* 给指定邮箱发 6 位验证码。限流:会话至多 {@value #MAX_SENDS} 封、间隔 ≥60s。
* verifyAttempts 不在此重置。
*/
public SendResult sendOtp(String pendingId, String email) {
RegSession s = sessions.getIfPresent(pendingId);
if (s == null) {
return SendResult.SESSION_EXPIRED;
}
String normalized = email == null ? "" : email.trim();
if (!EMAIL_RE.matcher(normalized).matches()) {
return SendResult.INVALID_EMAIL;
}
String code;
String to;
synchronized (s) {
long now = ticker.read();
if (s.sends >= MAX_SENDS) {
return SendResult.RATE_LIMITED;
}
if (s.lastSendAtNanos != Long.MIN_VALUE && now - s.lastSendAtNanos < RESEND_COOLDOWN_NANOS) {
return SendResult.RATE_LIMITED;
}
code = String.format(Locale.ROOT, "%06d", RANDOM.nextInt(1_000_000));
s.otpEmail = normalized;
s.otpCode = code;
s.otpExpiresAtNanos = now + OTP_TTL_NANOS;
s.sends++;
s.lastSendAtNanos = now;
to = normalized;
}
String html = "<p>你的 InvolutionHell 注册验证码是:</p>"
+ "<p style=\"font-size:24px;font-weight:bold;letter-spacing:4px\">" + code + "</p>"
+ "<p>10 分钟内有效。如果不是你本人操作,忽略即可。</p>";
boolean sent = emailService.sendHtml(to, "InvolutionHell 注册验证码", html);
if (!sent) {
log.warn("OTP 发信失败 pendingId={}", pendingId); // 不落收件邮箱(PII)
return SendResult.SEND_FAILED;
}
return SendResult.SENT;
}

/**
* 校验验证码。成功则**原子**消费整个会话,返回注册意图 + 本次已验证的规范邮箱。
* 失败返回空(会话过期 / 未发码 / 码过期 / 超次 / 邮箱或码不符)。邮箱必须与发码
* 对象一致,防换邮箱绕过。attempts 累积计数,超 {@value #MAX_VERIFY_ATTEMPTS} 即锁死会话。
*/
public Optional<VerifiedRegistration> verifyAndConsume(String pendingId, String email, String code) {
RegSession s = sessions.getIfPresent(pendingId);
if (s == null) {
return Optional.empty();
}
synchronized (s) {
// 会话锁内再确认未被并发消费(原子一次性)
if (sessions.getIfPresent(pendingId) == null) {
return Optional.empty();
}
if (s.otpCode == null) {
return Optional.empty(); // 还没 sendOtp
}
if (ticker.read() > s.otpExpiresAtNanos) {
return Optional.empty(); // OTP 过期
}
if (s.verifyAttempts >= MAX_VERIFY_ATTEMPTS) {
return Optional.empty(); // 已锁死
}
String normalized = email == null ? "" : email.trim();
boolean ok = s.otpEmail.equalsIgnoreCase(normalized) && s.otpCode.equals(code);
if (!ok) {
s.verifyAttempts++;
return Optional.empty();
}
sessions.invalidate(pendingId); // 一次性消费,锁内完成
return Optional.of(new VerifiedRegistration(s.reg, s.otpEmail));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package com.involutionhell.backend.usercenter.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.github.benmanes.caffeine.cache.Ticker;
import com.involutionhell.backend.common.email.ResendEmailService;
import com.involutionhell.backend.usercenter.model.PendingRegistration;
import com.involutionhell.backend.usercenter.service.RegistrationService.SendResult;
import com.involutionhell.backend.usercenter.service.RegistrationService.VerifiedRegistration;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

/**
* OTP 引擎行为契约(review 后重做):一次性/累积限次/重发限流/冷却/过期/邮箱绑定/
* 返回已验证邮箱/并发消费。用 mock 的 ResendEmailService 抓邮件正文里的码,用
* FakeTicker 控制时间。
*/
class RegistrationServiceTests {

/** 可控时钟,驱动 OTP 过期与会话 TTL / 冷却。 */
private static final class FakeTicker implements Ticker {
private long nanos = 0;
@Override public long read() { return nanos; }
void advance(Duration d) { nanos += d.toNanos(); }
}

private final ResendEmailService email = mock(ResendEmailService.class);
private final FakeTicker ticker = new FakeTicker();
private final RegistrationService svc = new RegistrationService(email, ticker);

private PendingRegistration reg(String providerEmail) {
return new PendingRegistration("discord", "snow-1", "Nick", null, providerEmail, null);
}

/** 发一次码并抓出邮件正文里最新的 6 位验证码(sendHtml 已 mock 成 true)。 */
private String sendAndCaptureCode(String pendingId, String toEmail) {
when(email.sendHtml(eq(toEmail), anyString(), anyString())).thenReturn(true);
assertThat(svc.sendOtp(pendingId, toEmail)).isEqualTo(SendResult.SENT);
ArgumentCaptor<String> html = ArgumentCaptor.forClass(String.class);
org.mockito.Mockito.verify(email, org.mockito.Mockito.atLeastOnce())
.sendHtml(eq(toEmail), anyString(), html.capture());
Matcher m = Pattern.compile("(\\d{6})").matcher(html.getAllValues().get(html.getAllValues().size() - 1));
assertThat(m.find()).isTrue();
return m.group(1);
}

@Test
void correctCodeConsumesAndReturnsVerifiedEmail() {
String pid = svc.begin(reg("provider-prefill@example.com"));
// 用户在验证步改成自己另一个邮箱
String code = sendAndCaptureCode(pid, "real@example.com");

Optional<VerifiedRegistration> ok = svc.verifyAndConsume(pid, "real@example.com", code);
assertThat(ok).isPresent();
assertThat(ok.get().pending().provider()).isEqualTo("discord");
// 关键:返回的是本次验证通过的邮箱,不是 provider 预填的未验证地址
assertThat(ok.get().verifiedEmail()).isEqualTo("real@example.com");

// 一次性:再验第二次失败(已消费)
assertThat(svc.verifyAndConsume(pid, "real@example.com", code)).isEmpty();
}

@Test
void wrongCodeFails() {
String pid = svc.begin(reg("a@e.com"));
sendAndCaptureCode(pid, "a@e.com");
assertThat(svc.verifyAndConsume(pid, "a@e.com", "000000")).isEmpty();
}

@Test
void emailMustMatchTheOneCodeWasSentTo() {
String pid = svc.begin(reg("a@e.com"));
String code = sendAndCaptureCode(pid, "a@e.com");
assertThat(svc.verifyAndConsume(pid, "attacker@e.com", code)).isEmpty();
}

@Test
void resendDoesNotResetAttempts_bruteForceCapped() {
String pid = svc.begin(reg("a@e.com"));
sendAndCaptureCode(pid, "a@e.com");
// 打满 5 次错码
for (int i = 0; i < 5; i++) {
assertThat(svc.verifyAndConsume(pid, "a@e.com", "111111")).isEmpty();
}
// 过冷却后重发(新码),但 attempts 不重置
ticker.advance(Duration.ofSeconds(61));
String newCode = sendAndCaptureCode(pid, "a@e.com");
// 即便拿到正确的新码,也因 attempts 已耗尽而失败——这就是堵住"重发无限爆破"
assertThat(svc.verifyAndConsume(pid, "a@e.com", newCode)).isEmpty();
}

@Test
void resendCooldownEnforced() {
String pid = svc.begin(reg("a@e.com"));
when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true);
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
// 60s 内再发 → 限流
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.RATE_LIMITED);
ticker.advance(Duration.ofSeconds(61));
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
}

@Test
void resendCapEnforced() {
String pid = svc.begin(reg("a@e.com"));
when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true);
for (int i = 0; i < 5; i++) {
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT);
ticker.advance(Duration.ofSeconds(61)); // 过冷却
}
// 第 6 封超上限
assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.RATE_LIMITED);
}

@Test
void otpExpiresAfterTtl() {
String pid = svc.begin(reg("a@e.com"));
String code = sendAndCaptureCode(pid, "a@e.com");
ticker.advance(Duration.ofMinutes(11)); // 超 10min OTP TTL
assertThat(svc.verifyAndConsume(pid, "a@e.com", code)).isEmpty();
}

@Test
void invalidEmailRejected() {
String pid = svc.begin(reg("a@e.com"));
assertThat(svc.sendOtp(pid, "not-an-email")).isEqualTo(SendResult.INVALID_EMAIL);
}

@Test
void unknownPendingIsSessionExpired() {
assertThat(svc.sendOtp("nonexistent", "a@e.com")).isEqualTo(SendResult.SESSION_EXPIRED);
assertThat(svc.verifyAndConsume("nonexistent", "a@e.com", "123456")).isEmpty();
}

@Test
void verifyFailsWhenNothingSent() {
String pid = svc.begin(reg("a@e.com"));
assertThat(svc.verifyAndConsume(pid, "a@e.com", "123456")).isEmpty();
}

@Test
void concurrentConsumeSucceedsExactlyOnce() throws Exception {
String pid = svc.begin(reg("a@e.com"));
String code = sendAndCaptureCode(pid, "a@e.com");

int threads = 8;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger successes = new AtomicInteger();
Future<?>[] futures = new Future<?>[threads];
for (int i = 0; i < threads; i++) {
futures[i] = pool.submit(() -> {
try {
start.await();
if (svc.verifyAndConsume(pid, "a@e.com", code).isPresent()) {
successes.incrementAndGet();
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
});
}
start.countDown();
for (Future<?> f : futures) f.get();
pool.shutdown();

// 并发多线程用同一正确码,只能成功一次(原子一次性消费)
assertThat(successes.get()).isEqualTo(1);
}
}