11package com .involutionhell .backend .usercenter .controller ;
22
33import com .involutionhell .backend .usercenter .dto .LoginResponse ;
4+ import com .involutionhell .backend .usercenter .oauth .AuthDiscordRequest ;
45import com .involutionhell .backend .usercenter .service .AuthService ;
56import me .zhyd .oauth .config .AuthConfig ;
67import me .zhyd .oauth .model .AuthCallback ;
1213import org .slf4j .LoggerFactory ;
1314import org .springframework .beans .factory .annotation .Value ;
1415import org .springframework .web .bind .annotation .GetMapping ;
16+ import org .springframework .web .bind .annotation .PathVariable ;
1517import org .springframework .web .bind .annotation .RequestParam ;
1618import org .springframework .web .bind .annotation .RestController ;
1719import jakarta .servlet .http .HttpServletResponse ;
1820import java .io .IOException ;
1921
22+ /**
23+ * 第三方 OAuth 登录入口。provider 无关(github 内置 / discord 自定义 source);
24+ * github 的特殊性下沉到 AuthService 的业务层。端点路径带 {provider},github 的
25+ * /api/auth/callback/github 与 OAuth App 注册的回调 URL 保持一致。
26+ */
2027@ RestController
2128public class OAuthController {
2229
2330 private static final Logger log = LoggerFactory .getLogger (OAuthController .class );
2431
25- @ Value ("${justauth.type.github.client-id}" )
32+ @ Value ("${justauth.type.github.client-id: }" )
2633 private String githubClientId ;
27-
28- @ Value ("${justauth.type.github.client-secret}" )
34+ @ Value ("${justauth.type.github.client-secret:}" )
2935 private String githubClientSecret ;
30-
31- @ Value ("${justauth.type.github.redirect-uri}" )
36+ @ Value ("${justauth.type.github.redirect-uri:}" )
3237 private String githubRedirectUri ;
3338
39+ @ Value ("${justauth.type.discord.client-id:}" )
40+ private String discordClientId ;
41+ @ Value ("${justauth.type.discord.client-secret:}" )
42+ private String discordClientSecret ;
43+ @ Value ("${justauth.type.discord.redirect-uri:}" )
44+ private String discordRedirectUri ;
45+
3446 @ Value ("${AUTH_URL:http://localhost:3000}" )
3547 private String frontEndUrl ;
3648
37- // 注入认证服务,用于查询/注册用户并执行 Sa-Token 登录
3849 private final AuthService authService ;
3950
4051 public OAuthController (AuthService authService ) {
4152 this .authService = authService ;
4253 }
4354
4455 /**
45- * 获取 GitHub 授权请求对象
56+ * 按 provider 造 AuthRequest。未知或未配置 → IllegalArgumentException,
57+ * 由调用方兜底重定向到错误页(不 500)。
4658 */
47- private AuthRequest getAuthRequest () {
48- return new AuthGithubRequest (AuthConfig .builder ()
49- .clientId (githubClientId )
50- .clientSecret (githubClientSecret )
51- .redirectUri (githubRedirectUri )
52- .build ());
59+ private AuthRequest authRequestFor (String provider ) {
60+ return switch (provider ) {
61+ case "github" -> {
62+ requireConfigured ("github" , githubClientId , githubClientSecret );
63+ yield new AuthGithubRequest (AuthConfig .builder ()
64+ .clientId (githubClientId ).clientSecret (githubClientSecret )
65+ .redirectUri (githubRedirectUri ).build ());
66+ }
67+ case "discord" -> {
68+ requireConfigured ("discord" , discordClientId , discordClientSecret );
69+ yield new AuthDiscordRequest (AuthConfig .builder ()
70+ .clientId (discordClientId ).clientSecret (discordClientSecret )
71+ .redirectUri (discordRedirectUri ).build ());
72+ }
73+ default -> throw new IllegalArgumentException ("不支持的 OAuth provider: " + provider );
74+ };
75+ }
76+
77+ // client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题),
78+ // 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。
79+ private void requireConfigured (String provider , String clientId , String clientSecret ) {
80+ if (clientId == null || clientId .isBlank () || clientSecret == null || clientSecret .isBlank ()) {
81+ throw new IllegalArgumentException (provider + " OAuth 未配置(缺 client-id 或 secret)" );
82+ }
83+ }
84+
85+ // 仅用于排查日志:redirect_uri 是公开信息,不含密钥。
86+ private String redirectUriOf (String provider ) {
87+ return switch (provider ) {
88+ case "github" -> githubRedirectUri ;
89+ case "discord" -> discordRedirectUri ;
90+ default -> "(n/a)" ;
91+ };
5392 }
5493
5594 // OAuth state 双提交 cookie 名。INV-007:callback 校验 URL state 必须等于此 cookie,
@@ -58,18 +97,23 @@ private AuthRequest getAuthRequest() {
5897 private static final int STATE_COOKIE_MAX_AGE_SECONDS = 300 ;
5998
6099 /**
61- * 构建授权链接并重定向到 GitHub
62- * 前端直接跳转到后端此地址(NEXT_PUBLIC_BACKEND_URL + /oauth/render/github)发起登录
100+ * 构建授权链接并重定向到 provider。前端跳到 /oauth/render/{provider} 发起登录。
63101 */
64- @ GetMapping ("/oauth/render/github" )
65- public void renderAuth (HttpServletResponse response ) throws IOException {
66- // 打印当前使用的 GitHub Client ID 和 redirect_uri,便于排查 token 配置问题
67- log .info ("[OAuth] GitHub Client ID = {}, redirect_uri = {}" , githubClientId , githubRedirectUri );
102+ @ GetMapping ("/oauth/render/{provider}" )
103+ public void renderAuth (@ PathVariable String provider , HttpServletResponse response ) throws IOException {
104+ AuthRequest authRequest ;
105+ try {
106+ authRequest = authRequestFor (provider );
107+ } catch (IllegalArgumentException e ) {
108+ log .warn ("[OAuth] render 未知/未配置 provider={}: {}" , provider , e .getMessage ());
109+ response .sendRedirect (frontEndUrl + "/login?error=oauth_provider" );
110+ return ;
111+ }
112+ log .info ("[OAuth] render provider={}, redirect_uri={}" , provider , redirectUriOf (provider ));
68113 String state = me .zhyd .oauth .utils .AuthStateUtils .createState ();
69- // 把 state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 github.com
70- // 发起的跨站顶级导航 ,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。
114+ // state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 provider 发起的
115+ // 跨站顶级导航 ,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。
71116 response .addHeader ("Set-Cookie" , buildStateCookie (state , STATE_COOKIE_MAX_AGE_SECONDS ));
72- AuthRequest authRequest = getAuthRequest ();
73117 response .sendRedirect (authRequest .authorize (state ));
74118 }
75119
@@ -93,55 +137,54 @@ private String readStateCookie(jakarta.servlet.http.HttpServletRequest request)
93137 }
94138
95139 /**
96- * GitHub OAuth 回调地址,路径与 GitHub OAuth App 注册保持一致( /api/auth/callback/github)
97- * GitHub → localhost:3000 /api/auth/callback/github → Next.js rewrite → localhost:8080/api/auth/callback/github
140+ * OAuth 回调。github 的路径 /api/auth/callback/github 与 OAuth App 注册一致;
141+ * discord 走 /api/auth/callback/discord。
98142 */
99- @ GetMapping ("/api/auth/callback/github" )
100- public void login (@ RequestParam (required = false ) String code ,
143+ @ GetMapping ("/api/auth/callback/{provider}" )
144+ public void login (@ PathVariable String provider ,
145+ @ RequestParam (required = false ) String code ,
101146 @ RequestParam (required = false ) String state ,
102147 jakarta .servlet .http .HttpServletRequest request ,
103148 HttpServletResponse response ) throws IOException {
104- // 参数缺失时直接走失败分支:若 @RequestParam 保持 required=true,Spring 在进入方法前
105- // 就抛 MissingServletRequestParameterException → 默认 500 白屏;
106- // 手动 null check 能把 "用户直接访问 / GitHub 异常回调" 统一兜底到前端错误页。
149+ // 参数缺失(用户直接访问 / provider 异常回调)统一兜底到前端错误页。
107150 if (code == null || state == null ) {
108- log .warn ("[OAuth] GitHub callback missing code/state (direct access?), redirecting to error page" );
151+ log .warn ("[OAuth] {} callback missing code/state (direct access?), redirecting" , provider );
109152 response .sendRedirect (frontEndUrl + "/login?error=oauth_failed" );
110153 return ;
111154 }
112155
113156 // INV-007:state 必须等于本次 render 种下的 cookie(双提交校验)。缺失/不匹配
114- // 即拒绝,且在换 token 之前——不给伪造 state 触发登录的机会,也不白打 GitHub 。
157+ // 即拒绝,且在换 token 之前——不给伪造 state 触发登录的机会,也不白打 provider 。
115158 String cookieState = readStateCookie (request );
116- // 用完即清(无论后续成败),避免 cookie 泄漏 / 复用。
117- response .addHeader ("Set-Cookie" , buildStateCookie ("" , 0 ));
159+ response .addHeader ("Set-Cookie" , buildStateCookie ("" , 0 )); // 用完即清
118160 if (cookieState == null || !cookieState .equals (state )) {
119- log .warn ("[OAuth] state 与 cookie 不匹配(可能的 CSRF 或 cookie 丢失),拒绝登录" );
161+ log .warn ("[OAuth] {} state 与 cookie 不匹配(CSRF 或 cookie 丢失),拒绝" , provider );
120162 response .sendRedirect (frontEndUrl + "/login?error=oauth_state" );
121163 return ;
122164 }
123165
166+ AuthRequest authRequest ;
167+ try {
168+ authRequest = authRequestFor (provider );
169+ } catch (IllegalArgumentException e ) {
170+ log .warn ("[OAuth] callback 未知/未配置 provider={}: {}" , provider , e .getMessage ());
171+ response .sendRedirect (frontEndUrl + "/login?error=oauth_provider" );
172+ return ;
173+ }
174+
124175 AuthCallback callback = new AuthCallback ();
125176 callback .setCode (code );
126177 callback .setState (state );
127- AuthRequest authRequest = getAuthRequest ();
128178 AuthResponse <?> authResponse = authRequest .login (callback );
129-
179+
130180 if (authResponse .ok ()) {
131181 AuthUser authUser = (AuthUser ) authResponse .getData ();
132-
133- // 调用 AuthService.loginByGithub():查询或自动注册用户,然后执行 Sa-Token 登录
134- // 返回的 LoginResponse 包含 tokenName、tokenValue 和用户视图
135- LoginResponse loginResponse = authService .loginByGithub (authUser );
136-
137- // 登录成功后重定向到前端,将 token 放在 URL fragment(#token=)中传给前端
138- // fragment 不会出现在服务器日志和 Referer 头中,避免 token 泄露
139- // 前端读取 #token= 参数后存入 localStorage,并清除 URL 中的 fragment
140- String redirectUrl = frontEndUrl + "/#token=" + loginResponse .tokenValue ();
141- response .sendRedirect (redirectUrl );
182+ LoginResponse loginResponse = authService .loginByProvider (provider , authUser );
183+ // token 放 URL fragment(#token=),不进服务器日志/Referer;前端读入 localStorage
184+ response .sendRedirect (frontEndUrl + "/#token=" + loginResponse .tokenValue ());
142185 } else {
143- // 登录失败,重定向回前端并带上错误信息
186+ log . warn ( "[OAuth] {} 登录失败: {}" , provider , authResponse . getMsg ());
144187 response .sendRedirect (frontEndUrl + "/login?error=oauth_failed" );
145188 }
146189 }
147- }
190+ }
0 commit comments