From eb29634fa2b239c23a3ba249df68303408dcf0d9 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Sun, 26 Jul 2026 06:27:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=A4=84=E7=90=86=20#54=20review?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=81=B0=E5=BA=A6=E9=97=B8=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E3=80=81OTP=20dev=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E3=80=81INV-008?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xhigh review(#54)对已上线的 Discord 灰度(#53)查出的后端问题,逐条修: 灰度闸 - 白名单启动时解析成 Set 并**播报当前模式**:缺 env 与故意 GA 行为完全一致, 没有这条日志,一次丢掉 AUTH_DISCORD_ALLOWLIST 的部署会静默把 Discord 对全网 打开,唯一信号是"没有拒绝日志"。 - 配了值却解析不出任何 id(清列表时手滑留个逗号;Java 的 ",".split(",") 返回零长 数组)不再塌缩成全开放:保持拒绝并 ERROR 告警——错误方向要选"没人能登"。 - 剥引号:docker-compose env_file 不剥,ALLOWLIST="123" 会带引号比对失败, 把本该放行的人也锁在外面。 - 空 uuid 不放行。 - **已有账号的回访登录豁免**:灰度要拦的是"建新号"(OTP wiring 未完成会分叉账号), 不是把已有账号的人锁在自己账号外面、还只提示"敬请期待"。 - 拒绝时撤销刚换到的 access token(AuthDiscordRequest#revokeToken):既然不让登, 就别把用不上的 token 和授权留在人家 Discord 账号里。撤销失败不阻断拒绝流程。 OTP dev 兜底 - 改由显式开关 registration.otp.dev-console 驱动,不再只看"Resend 没配"——后者同时 也是生产掉 key 的样子,那样 prod 丢 key 会假装发送成功,用户永远卡在输码页 (改前是 SEND_FAILED,可重试)。关掉开关即恢复该行为。 - 日志不再打印收件邮箱(PII),只留 pendingId + code,与本文件下方及 ResendEmailService 的既有规则一致。 测试与文档 - 新增 OAuthControllerAllowlistTests(10 条):畸形取值解析、未配即全开、配了却 解析不出 id 时拒绝所有人、空 uuid、已有 identity 放行、identity 查询失败不放行。 - RegistrationServiceTests:dev 兜底改测"会话状态确实写入"(限流已计数),挡住把 isConfigured() 检查上提导致本地流程静默坏掉的重构;补"没开开关时仍返 SEND_FAILED"。 - application-test.properties pin 掉两个新属性:本机 .env 会渗进测试 JVM,否则同一 个测试在本机和 CI 结论不同(与 justauth.type.discord.* 既有 pin 同因)。 - .env.example 补 AUTH_DISCORD_ALLOWLIST / REGISTRATION_OTP_DEV_CONSOLE:照文档配出来 的环境此前 = 白名单为空 = 全开放。 - SECURITY.md 补 INV-008(闸只放行白名单与已有账号,且不得静默失效)。 全量 300 测试通过(289 → +11)。 --- .env.example | 5 + SECURITY.md | 26 +++++ .../controller/OAuthController.java | 97 ++++++++++++++--- .../usercenter/oauth/AuthDiscordRequest.java | 20 ++++ .../usercenter/service/AuthService.java | 16 +++ .../service/RegistrationService.java | 25 +++-- src/main/resources/application.properties | 12 ++- .../OAuthControllerAllowlistTests.java | 102 ++++++++++++++++++ .../service/RegistrationServiceTests.java | 35 +++++- .../resources/application-test.properties | 5 + 10 files changed, 315 insertions(+), 28 deletions(-) create mode 100644 src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java diff --git a/.env.example b/.env.example index f50d171..b80d5c4 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,11 @@ AUTH_SECRET= # 把 Client ID / Secret 填这里。留空则站点不显示 Discord 登录、后端也不报错。 AUTH_DISCORD_ID= AUTH_DISCORD_SECRET= +# Discord 登录灰度白名单(逗号分隔的 Discord user id)。留空 = Discord 对所有人开放; +# 填了值 = 只有这些 id(和已有账号的回访用户)能用 Discord 登录。 +AUTH_DISCORD_ALLOWLIST= +# 本地开发:为 true 时注册验证码打印到控制台而不真发信(免配 Resend)。生产保持 false。 +REGISTRATION_OTP_DEV_CONSOLE=false # --- AI 模型(用 OpenAI 兼容协议调用 GLM-4.6V-Flash 作为默认 fallback) --- # diff --git a/SECURITY.md b/SECURITY.md index 9d55add..47f3c09 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -143,3 +143,29 @@ cookie。绑定目标账号(M2)同理只能来自服务端校验过的当前会话,绝不取自 state。 - **历史**:2026-07-19 随多 provider 身份体系 M1 引入(RFC #42 / ADR-001)。 编号说明:INV-006 已被"付费 LLM 端点限流"占用,按流水规则用 INV-007。 + +## INV-008 · Discord 灰度闸只放行白名单与已有账号,且不得静默失效 + +- **保护点**:`OAuthController#discordAllowed` / `#configureDiscordAllowlist` + (`/api/auth/callback/discord` 在换完 token 之后、建号登录之前)。 + `auth.discord.allowlist` 配了非空值即闸启用,只放行两类人:名单内的 Discord id、 + 以及 `user_identities` 里已有该身份的回访用户;其余人一律弹回 + `/login?error=discord_canary`,不建号、不发 token,并撤销刚换到的 access token。 +- **测试**:`OAuthControllerAllowlistTests` + (解析畸形取值 / 未配即全开 / 配了却解析不出 id 时拒绝所有人 / + 空 uuid 拒绝 / 已有 identity 放行 / identity 查询失败不放行) +- **为什么**:灰度要拦的是**建新号**——新用户的"验证邮箱→建号"(OTP wiring)尚未 + 完成,此时放任新用户从 Discord 进来会把已有 GitHub 用户分叉成第二个账号。 + 两个方向都必须防住: + 1. **不得静默 fail-open**。闸由一个 env 驱动,缺失/拼错时值为空,行为与"故意 GA" + 完全一致。因此启动时必须播报当前模式(闸启用 N 个 id / 闸关闭全开放), + 否则一次丢掉 `AUTH_DISCORD_ALLOWLIST` 的部署会静默地把 Discord 对全网打开, + 唯一信号是"没有拒绝日志",而没人在 tail 它。 + 2. **配了值却解析不出 id 时必须拒绝所有人**,而不是塌缩成全开放——错误方向要选 + "没人能登"(立刻可见、无损失),不能选"所有人能登"(正是灰度要防的事)。 + 另外闸**不能**卡住已有账号的回访登录,否则会把用户锁在自己的账号外面,而提示 + 只说"灰度中",既不告知账号存在也无恢复路径。 +- **GA 流程**:OTP wiring 上线后清空 `AUTH_DISCORD_ALLOWLIST` 即全量开放; + 在那之前清空它等于提前放开分叉风险。 +- **历史**:2026-07-24 随 Discord 灰度(#53 / involutionhell#389)引入, + 2026-07-25 按 xhigh review(#54)补齐本不变量与测试。 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 fcff3d1..8cd57e5 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Set; /** * 第三方 OAuth 登录入口。provider 无关(github 内置 / discord 自定义 source); @@ -43,10 +44,16 @@ public class OAuthController { @Value("${justauth.type.discord.redirect-uri:}") private String discordRedirectUri; - // Discord 登录灰度白名单:逗号分隔的 Discord user id。非空=只放行名单内 id, - // 其他人在回调处被弹回 /login?error=discord_canary;空=对所有人开放(GA 时清空即可)。 + // Discord 登录灰度白名单:逗号分隔的 Discord user id。配了值=只放行名单内 id + // (以及已有账号的回访登录),其余人在回调处被弹回 /login?error=discord_canary; + // 完全不配=闸关闭,对所有人开放(GA 就是清空它)。 @Value("${auth.discord.allowlist:}") - private String discordAllowlist; + private String discordAllowlistRaw; + + // 闸是否生效。由"是否配了非空值"决定,与解析出的 id 个数无关——配了值却解析不出 + // 任何 id(只剩逗号/引号)时必须保持关闭状态而不是悄悄放开,见 initDiscordAllowlist。 + private boolean discordGateActive; + private Set discordAllowlist = Set.of(); @Value("${AUTH_URL:http://localhost:3000}") private String frontEndUrl; @@ -87,17 +94,80 @@ private void requireConfigured(String provider, String clientId, String clientSe } } - // 灰度白名单判定:空名单=全开放;否则精确匹配某个 Discord user id。 - private boolean discordAllowed(String discordUserId) { - if (discordAllowlist == null || discordAllowlist.isBlank()) { + /** + * 启动时解析白名单并**明确播报当前处于哪种模式**。缺 env 与故意 GA 在行为上 + * 无法区分,只能靠这条日志区分——没有它,一次丢掉 AUTH_DISCORD_ALLOWLIST 的 + * 部署会静默地把 Discord 对全网打开,而唯一信号是"没有拒绝日志"。 + */ + @jakarta.annotation.PostConstruct + void initDiscordAllowlist() { + configureDiscordAllowlist(discordAllowlistRaw); + } + + // 与 @PostConstruct 分开,便于单测直接喂各种畸形取值。 + void configureDiscordAllowlist(String raw) { + this.discordGateActive = raw != null && !raw.isBlank(); + this.discordAllowlist = parseAllowlist(raw); + if (!discordGateActive) { + log.warn("[Discord 灰度] 未配置 auth.discord.allowlist → 闸关闭,Discord 登录对所有人开放"); + } else if (discordAllowlist.isEmpty()) { + // 配了值却一个 id 都解析不出(典型:清列表时手滑留了个逗号)。保持闸关闭状态 + // 拒绝所有人——错误方向选"没人能登"而不是"所有人能登",并且必须吼出来。 + log.error("[Discord 灰度] auth.discord.allowlist 配了值但解析不出任何 id(只剩逗号/引号?)" + + " → 所有 Discord 登录都会被拒绝,包括本应放行的人"); + } else { + log.info("[Discord 灰度] 闸已启用,{} 个 id 在白名单内;其余新用户会被拒", discordAllowlist.size()); + } + } + + /** + * 逗号分隔 → id 集合。丢掉空项("a,,b" / 尾逗号)并剥掉引号——docker-compose 的 + * env_file 不剥引号,AUTH_DISCORD_ALLOWLIST="123" 会把引号一起带进来, + * trim() 处理不了,会导致白名单本人也匹配不上。 + */ + static Set parseAllowlist(String raw) { + if (raw == null || raw.isBlank()) { + return Set.of(); + } + return java.util.Arrays.stream(raw.split(",")) + .map(String::trim) + .map(s -> s.replaceAll("^[\"']+|[\"']+$", "").trim()) + .filter(s -> !s.isEmpty()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + /** + * 灰度放行判定。闸未启用=全放行(GA)。启用时放行两类人:白名单内的 id,以及 + * **已经有账号的回访用户**——灰度要挡的是"建新号"(OTP wiring 未完成,新用户 + * 可能被分叉出第二个账号),不是把已有账号的人锁在自己账号外面。 + */ + boolean discordAllowed(String discordUserId) { + if (!discordGateActive) { return true; } - for (String id : discordAllowlist.split(",")) { - if (id.trim().equals(discordUserId)) { - return true; - } + if (discordUserId == null || discordUserId.isBlank()) { + return false; + } + if (discordAllowlist.contains(discordUserId)) { + return true; + } + return authService.hasIdentity("discord", discordUserId); + } + + /** + * 拒绝后撤销刚换到的 access token。用户已经在 Discord 上点过授权、我们已经拿到 + * 他的邮箱和 token,既然不让他登录,就别把用不上的授权和 token 留着。 + * 失败不阻断——撤销只是清理,不能反过来把拒绝流程搞崩。 + */ + private void revokeDiscordToken(AuthRequest authRequest, AuthUser authUser) { + if (!(authRequest instanceof AuthDiscordRequest discord) || authUser == null) { + return; + } + try { + discord.revokeToken(authUser.getToken()); + } catch (Exception e) { + log.warn("[Discord 灰度] 撤销 token 失败(不影响拒绝流程): {}", e.getClass().getSimpleName()); } - return false; } // 仅用于排查日志:redirect_uri 是公开信息,不含密钥。 @@ -197,10 +267,11 @@ public void login(@PathVariable String provider, if (authResponse.ok()) { AuthUser authUser = (AuthUser) authResponse.getData(); - // Discord 灰度:非白名单 id 在此弹回(换 token 已发生,但不建号/不登入)。 + // Discord 灰度:不放行的 id 在此弹回(换 token 已发生,但不建号/不登入)。 // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 if ("discord".equals(provider) && !discordAllowed(authUser.getUuid())) { - log.info("[OAuth] discord 灰度:uuid={} 不在白名单,拒绝登录", authUser.getUuid()); + log.info("[OAuth] discord 灰度:uuid={} 不在放行范围,拒绝登录", authUser.getUuid()); + revokeDiscordToken(authRequest, authUser); response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); return; } diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthDiscordRequest.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthDiscordRequest.java index 777b593..8e2db5f 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthDiscordRequest.java +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthDiscordRequest.java @@ -32,6 +32,8 @@ public class AuthDiscordRequest extends AuthDefaultRequest { .connectTimeout(Duration.ofSeconds(10)) .build(); + private static final String REVOKE_ENDPOINT = "https://discord.com/api/oauth2/token/revoke"; + public AuthDiscordRequest(AuthConfig config) { super(config, DiscordAuthSource.DISCORD); } @@ -73,6 +75,24 @@ protected AuthToken getAccessToken(AuthCallback authCallback) { .build(); } + /** + * 撤销 access token。用于"已换完 token 才决定拒绝该用户"的场景(灰度闸): + * 既然不让他登录,就别把用不上的 token 和授权留在他的 Discord 账号里。 + * 端点不在 AuthSource 接口里,按 Discord 文档单列。 + */ + public void revokeToken(AuthToken authToken) { + if (authToken == null || authToken.getAccessToken() == null) { + return; + } + Map form = new LinkedHashMap<>(); + form.put("client_id", config.getClientId()); + form.put("client_secret", config.getClientSecret()); + form.put("token", authToken.getAccessToken()); + form.put("token_type_hint", "access_token"); + // 成功时 Discord 返 200 空 body,解析结果用不上,只要没抛异常即视为已撤销 + postForm(REVOKE_ENDPOINT, form); + } + @Override protected AuthUser getUserInfo(AuthToken authToken) { JSONObject u = getBearer(source.userInfo(), authToken.getAccessToken()); diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index 57bd527..b3f21e4 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -199,6 +199,22 @@ private boolean isProviderEmailVerified(String provider, AuthUser authUser) { }; } + /** + * 该第三方身份是否已经绑过账号。灰度闸用它区分"回访登录"与"建新号"—— + * 只有后者才是灰度要拦的对象。查询失败保守当作不存在(宁可多拦一次, + * 也不能让一次 DB 抖动把闸变成放行)。 + */ + public boolean hasIdentity(String provider, String providerUserId) { + try { + return userIdentityRepository + .findByProviderAndProviderUserId(provider, providerUserId) + .isPresent(); + } catch (Exception e) { + log.warn("查询 identity 失败(provider={}),保守视为不存在", provider, e); + return false; + } + } + /** * 维护 user_identities 双写:缺行则插入(惰性自愈),有则刷新 last_login_at。 * 写失败不阻断登录——与 INV-003 lazy upgrade 同策略,记日志后继续, 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 e584f78..45283f3 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java @@ -55,17 +55,22 @@ public record VerifiedRegistration(PendingRegistration pending, String verifiedE private final ResendEmailService emailService; private final Ticker ticker; + private final boolean devConsoleOtp; private final Cache sessions; @org.springframework.beans.factory.annotation.Autowired - public RegistrationService(ResendEmailService emailService) { - this(emailService, Ticker.systemTicker()); + public RegistrationService( + ResendEmailService emailService, + @org.springframework.beans.factory.annotation.Value("${registration.otp.dev-console:false}") + boolean devConsoleOtp) { + this(emailService, Ticker.systemTicker(), devConsoleOtp); } /** 供测试注入 FakeTicker,控制 OTP 过期与会话 TTL。 */ - RegistrationService(ResendEmailService emailService, Ticker ticker) { + RegistrationService(ResendEmailService emailService, Ticker ticker, boolean devConsoleOtp) { this.emailService = emailService; this.ticker = ticker; + this.devConsoleOtp = devConsoleOtp; this.sessions = Caffeine.newBuilder() .ticker(ticker) .expireAfterWrite(SESSION_TTL) @@ -132,11 +137,15 @@ 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); + // 开发兜底:验证码直接打到控制台,让贡献者不配 Resend 也能跑通完整注册流 + // (Django/Rails 的 console email backend 同款)。 + // 必须由**显式开关**驱动,不能只看"Resend 没配"——后者同时也是生产掉 key 的 + // 样子,那样 prod 一旦丢 key 就会假装发送成功,用户永远卡在输码页。 + // 日志只落 pendingId + code,不落收件邮箱(PII,与本文件下方及 + // ResendEmailService 的规则一致);本地开发者自己知道填的哪个邮箱。 + if (devConsoleOtp && !emailService.isConfigured()) { + log.warn("[DEV-OTP] 控制台兜底(registration.otp.dev-console=true),未真实发信: pendingId={} code={}", + pendingId, code); return SendResult.SENT; } String html = "

你的 InvolutionHell 注册验证码是:

" diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ef78219..c485631 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,8 +32,11 @@ 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 时清空即可。 +# Discord 登录灰度白名单(逗号分隔的 Discord user id)。 +# 配了非空值 = 闸启用:只有名单内 id、以及**已经有账号的回访用户**能登录, +# 其余人在回调处被弹回 /login?error=discord_canary。 +# 完全不配 = 闸关闭:Discord 对所有人开放(GA 就是清空它)。 +# 启动日志会明确播报当前处于哪种模式——缺 env 与故意 GA 行为上无法区分,只能靠日志。 auth.discord.allowlist=${AUTH_DISCORD_ALLOWLIST:} # JWT ?? (Temporarily Commented Out for JustAuth Migration) @@ -109,3 +112,8 @@ spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=600s # 仍是沙箱地址且已配 key 时,ResendEmailService 启动会告警。 resend.api-key=${RESEND_API_KEY:} resend.from=${RESEND_FROM:onboarding@resend.dev} + +# 注册 OTP 的本地控制台兜底:为 true 且 Resend 未配置时,验证码打到日志而不真发信, +# 让贡献者不配 Resend key 也能跑通注册流。**生产必须保持 false**——它一旦为 true +# 且 key 丢了,用户会看到"验证码已发送"却永远收不到。 +registration.otp.dev-console=${REGISTRATION_OTP_DEV_CONSOLE:false} diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java new file mode 100644 index 0000000..69f9917 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java @@ -0,0 +1,102 @@ +package com.involutionhell.backend.usercenter.controller; + +import static org.assertj.core.api.Assertions.assertThat; +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.involutionhell.backend.usercenter.service.AuthService; +import org.junit.jupiter.api.Test; + +/** + * INV-008 回归:Discord 灰度闸的判定逻辑。 + * + * 回调成功路径需要真实的 Discord code,MockMvc 到不了闸,所以这里直接测判定函数 + * (OAuthControllerIntegrationTests 覆盖到 INV-007 的 state 校验为止)。 + */ +class OAuthControllerAllowlistTests { + + private final AuthService authService = mock(AuthService.class); + private final OAuthController controller = new OAuthController(authService); + + private OAuthController withAllowlist(String raw) { + controller.configureDiscordAllowlist(raw); + return controller; + } + + // ---------- 解析:畸形取值不能把闸变成"谁都进不去"或"谁都能进" ---------- + + @Test + void parsesCommaSeparatedIdsIgnoringWhitespaceAndEmptyEntries() { + assertThat(OAuthController.parseAllowlist(" 111 , 222 ,, 333 ,")) + .containsExactlyInAnyOrder("111", "222", "333"); + } + + @Test + void stripsQuotesBecauseDockerEnvFileKeepsThem() { + // docker-compose 的 env_file 不剥引号,AUTH_DISCORD_ALLOWLIST="123" 会带引号进来 + assertThat(OAuthController.parseAllowlist("\"123\",'456'")) + .containsExactlyInAnyOrder("123", "456"); + } + + @Test + void blankValuesYieldEmptySet() { + assertThat(OAuthController.parseAllowlist(null)).isEmpty(); + assertThat(OAuthController.parseAllowlist(" ")).isEmpty(); + // 只剩逗号:Java 的 ",".split(",") 返回零长数组 + assertThat(OAuthController.parseAllowlist(",")).isEmpty(); + } + + // ---------- 闸的两种模式 ---------- + + @Test + void unconfiguredAllowlistOpensDiscordToEveryone() { + // 不配 = GA。这是有意的 fail-open,启动日志会播报,别在此处改成拒绝。 + assertThat(withAllowlist("").discordAllowed("anyone")).isTrue(); + assertThat(withAllowlist(null).discordAllowed("anyone")).isTrue(); + } + + @Test + void configuredButUnparseableRejectsEveryoneRatherThanOpeningUp() { + // 配了值却解析不出 id(清列表时手滑留了个逗号)→ 错误方向必须选"没人能登", + // 绝不能塌缩成"所有人能登"。 + assertThat(withAllowlist(",").discordAllowed("111")).isFalse(); + } + + @Test + void allowlistedIdPasses() { + assertThat(withAllowlist("111,222").discordAllowed("222")).isTrue(); + verify(authService, never()).hasIdentity(eq("discord"), eq("222")); + } + + @Test + void nonAllowlistedNewUserIsRejected() { + when(authService.hasIdentity("discord", "999")).thenReturn(false); + assertThat(withAllowlist("111").discordAllowed("999")).isFalse(); + } + + @Test + void blankUuidIsRejected() { + assertThat(withAllowlist("111").discordAllowed(null)).isFalse(); + assertThat(withAllowlist("111").discordAllowed(" ")).isFalse(); + } + + // ---------- 闸保护的是"建新号",不是把老用户锁在门外 ---------- + + @Test + void existingIdentityPassesEvenWhenNotAllowlisted() { + // 灰度要拦的是新用户建号(OTP wiring 未完成);已经有账号的人再登录不该被拦, + // 否则他会被锁在自己的账号外面,且提示只说"敬请期待"。 + when(authService.hasIdentity("discord", "old-user")).thenReturn(true); + assertThat(withAllowlist("111").discordAllowed("old-user")).isTrue(); + } + + @Test + void identityLookupFailureDoesNotOpenTheGate() { + // AuthService.hasIdentity 内部吞异常返 false,这里锁定"查不到就拒绝"的方向 + when(authService.hasIdentity("discord", "boom")).thenReturn(false); + assertThat(withAllowlist("111").discordAllowed("boom")).isFalse(); + } +} 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 b1f9186..2b31a90 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java @@ -42,7 +42,7 @@ private static final class FakeTicker implements Ticker { private final ResendEmailService email = mock(ResendEmailService.class); private final FakeTicker ticker = new FakeTicker(); - private final RegistrationService svc = new RegistrationService(email, ticker); + private final RegistrationService svc = new RegistrationService(email, ticker, false); // 默认按"已配置 Resend"跑正常发信路径;dev-fallback 用例单独覆盖为 false。 @BeforeEach @@ -144,11 +144,36 @@ void otpExpiresAfterTtl() { @Test void devConsoleFallbackWhenResendUnconfigured() { - when(email.isConfigured()).thenReturn(false); // 覆盖 @BeforeEach:本地没配 Resend - String pid = svc.begin(reg("a@e.com")); + ResendEmailService devEmail = mock(ResendEmailService.class); + when(devEmail.isConfigured()).thenReturn(false); // 本地没配 Resend + RegistrationService dev = new RegistrationService(devEmail, ticker, true); + + String pid = dev.begin(reg("a@e.com")); // 不真发信,但流程照走(返回 SENT),验证码只进日志——贡献者本地读控制台即可 - assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT); - verify(email, never()).sendHtml(anyString(), anyString(), anyString()); + assertThat(dev.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SENT); + verify(devEmail, never()).sendHtml(anyString(), anyString(), anyString()); + + // 关键:兜底必须发生在会话状态写入**之后**。若今后把 isConfigured() 检查上提到 + // 生成验证码之前(很自然的 fail-fast 重构),otpCode/otpEmail 就不会被写入, + // 本地拿到码也 verifyAndConsume 不过 —— 本地流程静默坏掉而上面两条断言照过。 + // 用"限流状态已计数"来锁定这个副作用。 + assertThat(dev.getPending(pid)).isPresent(); + assertThat(dev.sendOtp(pid, "a@e.com")) + .as("已发过一次,冷却期内再发应被限流——证明会话状态确实写进去了") + .isEqualTo(SendResult.RATE_LIMITED); + } + + @Test + void withoutDevSwitchUnconfiguredResendIsARetryableFailure() { + // 没开 dev 开关时,Resend 未配置必须回到"发信失败"(可重试),而不是假装已发送—— + // 否则生产掉了 key 就会让用户永远卡在输码页。 + ResendEmailService prodEmail = mock(ResendEmailService.class); + when(prodEmail.isConfigured()).thenReturn(false); + when(prodEmail.sendHtml(anyString(), anyString(), anyString())).thenReturn(false); + RegistrationService prod = new RegistrationService(prodEmail, ticker, false); + + String pid = prod.begin(reg("a@e.com")); + assertThat(prod.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SEND_FAILED); } @Test diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 62293c9..f1e7d55 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -18,3 +18,8 @@ openai.api-key=test-key openai.model=gpt-4.1 logging.level.root=WARN + +# 本机开发用 `set -a && . ./.env` 跑测试时,AUTH_DISCORD_ALLOWLIST 会渗进测试 JVM, +# 让灰度闸在本机是开的、在 CI 是关的,同一个测试两台机器结论不同。显式 pin 空。 +auth.discord.allowlist= +registration.otp.dev-console=false