Skip to content

Commit 55a7aa8

Browse files
authored
feat(auth): Discord 登录后端 — 自定义 AuthSource + OAuth 端点泛化 (#48)
* feat(auth): Discord 登录后端——自定义 AuthSource + OAuth 端点泛化为 {provider} RFC #42 / ADR-001 M3(后端部分)。这是"接入 Discord 登录"里唯一的新技术点: JustAuth 1.16.6 内置 github/google/microsoft 但无 discord,按其自定义 source 约定补 DiscordAuthSource + AuthDiscordRequest。 - DiscordAuthSource / AuthDiscordRequest:token 交换与拉用户信息用标准 HttpClient 自己发(Discord 严格要求 form-encoded body + Bearer 头),fastjson 解析; scope 空格预编码成 %20(JustAuth UrlBuilder 不编码,裸空格进 Location 头不安全) - OAuthController 泛化:/oauth/render/{provider} + /api/auth/callback/{provider}, provider 工厂(github 内置 / discord 自定义 / 未知或未配置 → error=oauth_provider, 不 500);callback 改调 loginByProvider(M1 已就绪) - SaToken 白名单 /oauth/render/* + /api/auth/callback/*(原来按 github 具体路径) - Discord 配置可选(AUTH_DISCORD_ID/SECRET),未配不影响启动;README + .env.example 加 Discord App 注册步骤 登录路径复用 M1 的 loginByProvider(identity 双写 + state cookie 硬化 INV-007)。 全量 269 测试通过(+6:Discord 用户解析/authorize URL/未知/未配置 provider)。 注意(launch 前):尚无绑定流程(M2b),现阶段已有 github 账号的用户用 Discord 登录会新建独立账号(分叉)。ADR 要求"同邮箱提示绑定"随 M2b 前端一起上再开放。 Discord source 的 token 交换需线上真 App 冒烟一次(本地无 Discord 凭据只能单测解析层)。 * fix(auth): 处理 #48 review——requireConfigured 连 secret 一起校验 + 恢复 render 日志 - requireConfigured 同时检查 client-id 与 secret:只配一半时提前挡在 oauth_provider(配置问题),不再让 token 交换阶段以 oauth_failed 失败误导 - 恢复 render 的调试日志(provider + redirect_uri,均公开信息不含密钥) - OAuthControllerIntegrationTests 加 @TestPropertySource 强制 discord 未配置, 修掉"本机 .env 有真实 AUTH_DISCORD_ID 时该断言失败"的环境脆弱依赖 全量 269 测试通过(本地含 Discord 凭据的环境)。
1 parent e607ce9 commit 55a7aa8

10 files changed

Lines changed: 405 additions & 51 deletions

File tree

‎.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ AUTH_GITHUB_ID=
6161
AUTH_GITHUB_SECRET=
6262
AUTH_SECRET=
6363

64+
# Discord 登录(可选)。要开就到 https://discord.com/developers/applications 建个 App,
65+
# OAuth2 → Redirects 加 ${AUTH_URL}/api/auth/callback/discord,勾 identify + email scope,
66+
# 把 Client ID / Secret 填这里。留空则站点不显示 Discord 登录、后端也不报错。
67+
AUTH_DISCORD_ID=
68+
AUTH_DISCORD_SECRET=
69+
6470
# --- AI 模型(用 OpenAI 兼容协议调用 GLM-4.6V-Flash 作为默认 fallback) ---
6571
#
6672
# 变量名沿用 OPENAI_* 是因为 Java 代码里用的是 OpenAI /chat/completions 标准协议,

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ docker compose up -d postgres
118118
> [!TIP]
119119
> 生产环境用另一个 OAuth App(仓库维护者持有,回调注册的是 `https://involutionhell.com/...`),跟本地这套互不干扰。本地 `.env` 只放你自己的 dev key。
120120
121+
#### (可选)配置 Discord 登录
122+
123+
同 GitHub,每个开发者用自己的 Discord App:
124+
125+
1. [Discord Developer Portal](https://discord.com/developers/applications) → **New Application**
126+
2. **OAuth2 → Redirects** 加 `http://localhost:3010/api/auth/callback/discord`(与 `${AUTH_URL}/api/auth/callback/discord` 对齐)
127+
3. OAuth2 → 复制 **Client ID** 和 **Client Secret**,填到 `.env`:
128+
```
129+
AUTH_DISCORD_ID=...
130+
AUTH_DISCORD_SECRET=...
131+
```
132+
133+
不填就不显示 Discord 登录、后端也不报错(`/oauth/render/discord` 会重定向到 `?error=oauth_provider`)。scope 由后端固定为 `identify email`,无需在 Portal 额外设置。
134+
121135
### 4. 启动后端服务
122136
按以下任一方式启动:
123137

‎docs/wiki/adr/001-multi-provider-identity.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,6 @@ INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规
113113
| M1 | `loginByProvider` 统一流程(username 主查 + identity 双写) + state/cookie 硬化 + INV-007;`github_id` 列双写沿用 | ✅ 本 PR |
114114
| M2a | 解绑 + 列表后端(解绑锁死防护 + github 解绑清 github_id 列) | ✅ 本 PR |
115115
| M2b | 绑定流程(intent store + callback 分支)后端 + 设置页前端 UI | 待做 |
116-
| M3 | Discord 上线;`/u/`、follows 查询改走 identities | 待做 |
116+
| M3 后端 | Discord provider(自定义 AuthSource)+ OAuth 端点泛化为 {provider} | ✅ 本 PR |
117+
| M3 前端 | 登录页 Discord 按钮 + 设置页身份 UI(另 repo,另 PR) | 待做 |
117118
| M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 |

‎src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ public void addInterceptors(InterceptorRegistry registry) {
2020
.match("/**") // 拦截所有路由
2121
.notMatch("/auth/login") // 账号密码登录
2222
.notMatch("/auth/register") // 注册
23-
.notMatch("/oauth/render/github") // GitHub OAuth 授权发起
24-
.notMatch("/api/auth/callback/github") // GitHub OAuth 回调(路径与 OAuth App 注册保持一致)
23+
.notMatch("/oauth/render/*") // OAuth 授权发起(github / discord / ...,未登录才需要)
24+
.notMatch("/api/auth/callback/*") // OAuth 回调(github 路径与 OAuth App 注册保持一致)
2525
.notMatch("/analytics/top-docs") // 文档热榜公开接口
2626
.notMatch("/analytics/events/summary") // 事件聚合摘要,公开只读接口
2727
.notMatch("/analytics/events") // 浏览器埋点写入,匿名也放行(登录用户通过 satoken header 识别)
Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.involutionhell.backend.usercenter.controller;
22

33
import com.involutionhell.backend.usercenter.dto.LoginResponse;
4+
import com.involutionhell.backend.usercenter.oauth.AuthDiscordRequest;
45
import com.involutionhell.backend.usercenter.service.AuthService;
56
import me.zhyd.oauth.config.AuthConfig;
67
import me.zhyd.oauth.model.AuthCallback;
@@ -12,44 +13,82 @@
1213
import org.slf4j.LoggerFactory;
1314
import org.springframework.beans.factory.annotation.Value;
1415
import org.springframework.web.bind.annotation.GetMapping;
16+
import org.springframework.web.bind.annotation.PathVariable;
1517
import org.springframework.web.bind.annotation.RequestParam;
1618
import org.springframework.web.bind.annotation.RestController;
1719
import jakarta.servlet.http.HttpServletResponse;
1820
import 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
2128
public 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

Comments
 (0)