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: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
#
Expand Down
26 changes: 26 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)补齐本不变量与测试。
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String> discordAllowlist = Set.of();

@Value("${AUTH_URL:http://localhost:3000}")
private String frontEndUrl;
Expand Down Expand Up @@ -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<String> 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 是公开信息,不含密钥。
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<String, String> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 同策略,记日志后继续,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, RegSession> 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)
Expand Down Expand Up @@ -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 = "<p>你的 InvolutionHell 注册验证码是:</p>"
Expand Down
12 changes: 10 additions & 2 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Loading