-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathOAuthController.java
More file actions
286 lines (259 loc) · 13.8 KB
/
Copy pathOAuthController.java
File metadata and controls
286 lines (259 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
package com.involutionhell.backend.usercenter.controller;
import com.involutionhell.backend.usercenter.dto.LoginResponse;
import com.involutionhell.backend.usercenter.oauth.AuthDiscordRequest;
import com.involutionhell.backend.usercenter.service.AuthService;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthGithubRequest;
import me.zhyd.oauth.request.AuthRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
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);
* github 的特殊性下沉到 AuthService 的业务层。端点路径带 {provider},github 的
* /api/auth/callback/github 与 OAuth App 注册的回调 URL 保持一致。
*/
@RestController
public class OAuthController {
private static final Logger log = LoggerFactory.getLogger(OAuthController.class);
@Value("${justauth.type.github.client-id:}")
private String githubClientId;
@Value("${justauth.type.github.client-secret:}")
private String githubClientSecret;
@Value("${justauth.type.github.redirect-uri:}")
private String githubRedirectUri;
@Value("${justauth.type.discord.client-id:}")
private String discordClientId;
@Value("${justauth.type.discord.client-secret:}")
private String discordClientSecret;
@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 discordAllowlistRaw;
// 闸是否生效。由"是否配了非空值"决定,与解析出的 id 个数无关——配了值却解析不出
// 任何 id(只剩逗号/引号)时必须保持关闭状态而不是悄悄放开,见 initDiscordAllowlist。
private boolean discordGateActive;
private Set<String> discordAllowlist = Set.of();
@Value("${AUTH_URL:http://localhost:3000}")
private String frontEndUrl;
private final AuthService authService;
public OAuthController(AuthService authService) {
this.authService = authService;
}
/**
* 按 provider 造 AuthRequest。未知或未配置 → IllegalArgumentException,
* 由调用方兜底重定向到错误页(不 500)。
*/
private AuthRequest authRequestFor(String provider) {
return switch (provider) {
case "github" -> {
requireConfigured("github", githubClientId, githubClientSecret);
yield new AuthGithubRequest(AuthConfig.builder()
.clientId(githubClientId).clientSecret(githubClientSecret)
.redirectUri(githubRedirectUri).build());
}
case "discord" -> {
requireConfigured("discord", discordClientId, discordClientSecret);
yield new AuthDiscordRequest(AuthConfig.builder()
.clientId(discordClientId).clientSecret(discordClientSecret)
.redirectUri(discordRedirectUri).build());
}
default -> throw new IllegalArgumentException("不支持的 OAuth provider: " + provider);
};
}
// client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题),
// 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。
private void requireConfigured(String provider, String clientId, String clientSecret) {
if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
throw new IllegalArgumentException(provider + " OAuth 未配置(缺 client-id 或 secret)");
}
}
/**
* 启动时解析白名单并**明确播报当前处于哪种模式**。缺 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;
}
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());
}
}
// 仅用于排查日志:redirect_uri 是公开信息,不含密钥。
private String redirectUriOf(String provider) {
return switch (provider) {
case "github" -> githubRedirectUri;
case "discord" -> discordRedirectUri;
default -> "(n/a)";
};
}
// OAuth state 双提交 cookie 名。INV-007:callback 校验 URL state 必须等于此 cookie,
// 二者都由本次 render 生成——防登录 CSRF(攻击者无法向受害者浏览器种此 cookie)。
static final String STATE_COOKIE = "ih_oauth_state";
private static final int STATE_COOKIE_MAX_AGE_SECONDS = 300;
/**
* 构建授权链接并重定向到 provider。前端跳到 /oauth/render/{provider} 发起登录。
*/
@GetMapping("/oauth/render/{provider}")
public void renderAuth(@PathVariable String provider, HttpServletResponse response) throws IOException {
AuthRequest authRequest;
try {
authRequest = authRequestFor(provider);
} catch (IllegalArgumentException e) {
log.warn("[OAuth] render 未知/未配置 provider={}: {}", provider, e.getMessage());
response.sendRedirect(frontEndUrl + "/login?error=oauth_provider");
return;
}
log.info("[OAuth] render provider={}, redirect_uri={}", provider, redirectUriOf(provider));
String state = me.zhyd.oauth.utils.AuthStateUtils.createState();
// state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 provider 发起的
// 跨站顶级导航,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。
response.addHeader("Set-Cookie", buildStateCookie(state, STATE_COOKIE_MAX_AGE_SECONDS));
response.sendRedirect(authRequest.authorize(state));
}
private String buildStateCookie(String value, int maxAgeSeconds) {
return org.springframework.http.ResponseCookie.from(STATE_COOKIE, value)
.httpOnly(true)
.secure(frontEndUrl.startsWith("https")) // 本地 http 下不置 Secure,否则浏览器不回传
.sameSite("Lax")
.path("/")
.maxAge(maxAgeSeconds)
.build()
.toString();
}
private String readStateCookie(jakarta.servlet.http.HttpServletRequest request) {
if (request.getCookies() == null) return null;
for (jakarta.servlet.http.Cookie c : request.getCookies()) {
if (STATE_COOKIE.equals(c.getName())) return c.getValue();
}
return null;
}
/**
* OAuth 回调。github 的路径 /api/auth/callback/github 与 OAuth App 注册一致;
* discord 走 /api/auth/callback/discord。
*/
@GetMapping("/api/auth/callback/{provider}")
public void login(@PathVariable String provider,
@RequestParam(required = false) String code,
@RequestParam(required = false) String state,
jakarta.servlet.http.HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 参数缺失(用户直接访问 / provider 异常回调)统一兜底到前端错误页。
if (code == null || state == null) {
log.warn("[OAuth] {} callback missing code/state (direct access?), redirecting", provider);
response.sendRedirect(frontEndUrl + "/login?error=oauth_failed");
return;
}
// INV-007:state 必须等于本次 render 种下的 cookie(双提交校验)。缺失/不匹配
// 即拒绝,且在换 token 之前——不给伪造 state 触发登录的机会,也不白打 provider。
String cookieState = readStateCookie(request);
response.addHeader("Set-Cookie", buildStateCookie("", 0)); // 用完即清
if (cookieState == null || !cookieState.equals(state)) {
log.warn("[OAuth] {} state 与 cookie 不匹配(CSRF 或 cookie 丢失),拒绝", provider);
response.sendRedirect(frontEndUrl + "/login?error=oauth_state");
return;
}
AuthRequest authRequest;
try {
authRequest = authRequestFor(provider);
} catch (IllegalArgumentException e) {
log.warn("[OAuth] callback 未知/未配置 provider={}: {}", provider, e.getMessage());
response.sendRedirect(frontEndUrl + "/login?error=oauth_provider");
return;
}
AuthCallback callback = new AuthCallback();
callback.setCode(code);
callback.setState(state);
AuthResponse<?> authResponse = authRequest.login(callback);
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());
revokeDiscordToken(authRequest, authUser);
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());
} else {
log.warn("[OAuth] {} 登录失败: {}", provider, authResponse.getMsg());
response.sendRedirect(frontEndUrl + "/login?error=oauth_failed");
}
}
}