diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java index 93c1333..fcff3d1 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -43,6 +43,11 @@ public class OAuthController { @Value("${justauth.type.discord.redirect-uri:}") private String discordRedirectUri; + // Discord 登录灰度白名单:逗号分隔的 Discord user id。非空=只放行名单内 id, + // 其他人在回调处被弹回 /login?error=discord_canary;空=对所有人开放(GA 时清空即可)。 + @Value("${auth.discord.allowlist:}") + private String discordAllowlist; + @Value("${AUTH_URL:http://localhost:3000}") private String frontEndUrl; @@ -82,6 +87,19 @@ private void requireConfigured(String provider, String clientId, String clientSe } } + // 灰度白名单判定:空名单=全开放;否则精确匹配某个 Discord user id。 + private boolean discordAllowed(String discordUserId) { + if (discordAllowlist == null || discordAllowlist.isBlank()) { + return true; + } + for (String id : discordAllowlist.split(",")) { + if (id.trim().equals(discordUserId)) { + return true; + } + } + return false; + } + // 仅用于排查日志:redirect_uri 是公开信息,不含密钥。 private String redirectUriOf(String provider) { return switch (provider) { @@ -179,6 +197,13 @@ public void login(@PathVariable String provider, if (authResponse.ok()) { AuthUser authUser = (AuthUser) authResponse.getData(); + // Discord 灰度:非白名单 id 在此弹回(换 token 已发生,但不建号/不登入)。 + // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 + if ("discord".equals(provider) && !discordAllowed(authUser.getUuid())) { + log.info("[OAuth] discord 灰度:uuid={} 不在白名单,拒绝登录", authUser.getUuid()); + response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); + return; + } LoginResponse loginResponse = authService.loginByProvider(provider, authUser); // token 放 URL fragment(#token=),不进服务器日志/Referer;前端读入 localStorage response.sendRedirect(frontEndUrl + "/#token=" + loginResponse.tokenValue()); diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java b/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java index bad4341..e584f78 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java @@ -132,6 +132,13 @@ public SendResult sendOtp(String pendingId, String email) { s.lastSendAtNanos = now; to = normalized; } + // 本地/CI 没配 Resend key 时:验证码直接打到控制台,让贡献者不配 Resend 也能 + // 跑通完整注册流(Django/Rails 的 console email backend 同款)。生产必配 key, + // isConfigured() 为 true,走不到这里;这行只在开发环境出现。 + if (!emailService.isConfigured()) { + log.warn("[DEV-OTP] Resend 未配置,验证码只打印到控制台(生产不应出现此行): email={} code={}", to, code); + return SendResult.SENT; + } String html = "
你的 InvolutionHell 注册验证码是:
" + "" + code + "
" + "10 分钟内有效。如果不是你本人操作,忽略即可。
"; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 92a9746..ef78219 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,6 +32,9 @@ justauth.type.github.redirect-uri=${AUTH_URL:http://localhost:3000}/api/auth/cal justauth.type.discord.client-id=${AUTH_DISCORD_ID_DEV:${AUTH_DISCORD_ID:}} justauth.type.discord.client-secret=${AUTH_DISCORD_SECRET_DEV:${AUTH_DISCORD_SECRET:}} justauth.type.discord.redirect-uri=${AUTH_URL:http://localhost:3000}/api/auth/callback/discord +# Discord 登录灰度白名单(逗号分隔的 Discord user id)。非空=只这些 id 能用 Discord 登录, +# 其他人在回调处被弹回 /login?error=discord_canary。空=对所有人开放,GA 时清空即可。 +auth.discord.allowlist=${AUTH_DISCORD_ALLOWLIST:} # JWT ?? (Temporarily Commented Out for JustAuth Migration) # jwt.secret-key=${AUTH_SECRET:involutionhell-default-secret-key-32-chars-long} diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java index d3f3d7d..b1f9186 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java @@ -4,6 +4,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.github.benmanes.caffeine.cache.Ticker; @@ -20,6 +22,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -41,6 +44,12 @@ private static final class FakeTicker implements Ticker { private final FakeTicker ticker = new FakeTicker(); private final RegistrationService svc = new RegistrationService(email, ticker); + // 默认按"已配置 Resend"跑正常发信路径;dev-fallback 用例单独覆盖为 false。 + @BeforeEach + void resendConfigured() { + when(email.isConfigured()).thenReturn(true); + } + private PendingRegistration reg(String providerEmail) { return new PendingRegistration("discord", "snow-1", "Nick", null, providerEmail, null); } @@ -133,6 +142,15 @@ void otpExpiresAfterTtl() { assertThat(svc.verifyAndConsume(pid, "a@e.com", code)).isEmpty(); } + @Test + void devConsoleFallbackWhenResendUnconfigured() { + when(email.isConfigured()).thenReturn(false); // 覆盖 @BeforeEach:本地没配 Resend + String pid = svc.begin(reg("a@e.com")); + // 不真发信,但流程照走(返回 SENT),验证码只进日志——贡献者本地读控制台即可 + assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT); + verify(email, never()).sendHtml(anyString(), anyString(), anyString()); + } + @Test void invalidEmailRejected() { String pid = svc.begin(reg("a@e.com"));