From 18d25e303a041c2ada23810104749a75c5e0b98f Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:03:08 +0800 Subject: [PATCH] Add Discord OAuth login --- README.md | 5 + open-isle-cli/src/assets/icons/discord.svg | 1 + open-isle-cli/src/main.js | 1 + open-isle-cli/src/router/index.js | 6 + open-isle-cli/src/utils/discord.js | 59 ++++++++++ .../src/views/DiscordCallbackPageView.vue | 47 ++++++++ open-isle-cli/src/views/LoginPageView.vue | 8 ++ open-isle-cli/src/views/SignupPageView.vue | 8 ++ .../openisle/controller/AuthController.java | 38 +++++++ .../openisle/service/DiscordAuthService.java | 107 ++++++++++++++++++ src/main/resources/application.properties | 3 + 11 files changed, 283 insertions(+) create mode 100644 open-isle-cli/src/assets/icons/discord.svg create mode 100644 open-isle-cli/src/utils/discord.js create mode 100644 open-isle-cli/src/views/DiscordCallbackPageView.vue create mode 100644 src/main/java/com/openisle/service/DiscordAuthService.java diff --git a/README.md b/README.md index e625162f9..c069fc928 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ OpenIsle 基于 Spring Boot 构建,提供社区后台常见的注册、登录 * **JWT 认证**:登录后获得 Token,接口通过 `Authorization: Bearer` 认证 * **Google 登录**:支持使用 Google OAuth 登录 * **GitHub 登录**:支持使用 GitHub OAuth 登录 +* **Discord 登录**:支持使用 Discord OAuth 登录 * **邮件通知**:抽象 `EmailSender`,默认实现基于 Resend * **角色权限**:内置 `ADMIN` 与 `USER`,管理员接口以 `/api/admin/**` 提供 * **文章与评论**:支持分类、评论及多级回复 @@ -50,6 +51,9 @@ OpenIsle 基于 Spring Boot 构建,提供社区后台常见的注册、登录 - `GITHUB_CLIENT_ID`:GitHub OAuth 客户端 ID - `GITHUB_CLIENT_SECRET`:GitHub OAuth 客户端密钥 - `VUE_APP_GITHUB_CLIENT_ID`:前端 GitHub OAuth 客户端 ID + - `DISCORD_CLIENT_ID`:Discord OAuth 客户端 ID + - `DISCORD_CLIENT_SECRET`:Discord OAuth 客户端密钥 + - `VUE_APP_DISCORD_CLIENT_ID`:前端 Discord OAuth 客户端 ID - `JWT_SECRET`:JWT 签名密钥 - `JWT_EXPIRATION`:JWT 过期时间(毫秒) - `PASSWORD_STRENGTH`:密码强度(LOW、MEDIUM、HIGH) @@ -73,6 +77,7 @@ mvn spring-boot:run - `POST /api/auth/login`:登录并获取 Token - `POST /api/auth/google`:Google 登录并获取 Token - `POST /api/auth/github`:GitHub 登录并获取 Token +- `POST /api/auth/discord`:Discord 登录并获取 Token - `GET /api/config`:查看验证码开关配置 - 需要认证的接口示例:`GET /api/hello`(需 `Authorization` 头) - 管理员接口示例:`GET /api/admin/hello` diff --git a/open-isle-cli/src/assets/icons/discord.svg b/open-isle-cli/src/assets/icons/discord.svg new file mode 100644 index 000000000..9d7796b8a --- /dev/null +++ b/open-isle-cli/src/assets/icons/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js index fce0e5d9d..4851cef9b 100644 --- a/open-isle-cli/src/main.js +++ b/open-isle-cli/src/main.js @@ -19,6 +19,7 @@ export const API_PORT = 8081 export const API_BASE_URL = ""; export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com' export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ' +export const DISCORD_CLIENT_ID = '' export const toast = useToast() initTheme() diff --git a/open-isle-cli/src/router/index.js b/open-isle-cli/src/router/index.js index cfeef79ed..8710e4814 100644 --- a/open-isle-cli/src/router/index.js +++ b/open-isle-cli/src/router/index.js @@ -12,6 +12,7 @@ import SettingsPageView from '../views/SettingsPageView.vue' import ProfileView from '../views/ProfileView.vue' import NotFoundPageView from '../views/NotFoundPageView.vue' import GithubCallbackPageView from '../views/GithubCallbackPageView.vue' +import DiscordCallbackPageView from '../views/DiscordCallbackPageView.vue' const routes = [ { @@ -74,6 +75,11 @@ const routes = [ name: 'github-callback', component: GithubCallbackPageView }, + { + path: '/discord-callback', + name: 'discord-callback', + component: DiscordCallbackPageView + }, { path: '/404', name: 'not-found', diff --git a/open-isle-cli/src/utils/discord.js b/open-isle-cli/src/utils/discord.js new file mode 100644 index 000000000..97f6ebfdb --- /dev/null +++ b/open-isle-cli/src/utils/discord.js @@ -0,0 +1,59 @@ +import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main' +import { setToken, loadCurrentUser } from './auth' + +export function discordAuthorize(state = '') { + if (!DISCORD_CLIENT_ID) { + toast.error('Discord 登录不可用') + return + } + const redirectUri = `${window.location.origin}/discord-callback` + const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}` + window.location.href = url +} + +export async function discordExchange(code, state, reason) { + try { + const res = await fetch(`${API_BASE_URL}/api/auth/discord`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, redirectUri: `${window.location.origin}/discord-callback`, reason, state }) + }) + const data = await res.json() + if (res.ok && data.token) { + setToken(data.token) + await loadCurrentUser() + toast.success('登录成功') + return { + success: true, + needReason: false + } + } else if (data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + return { + success: false, + needReason: true, + token: data.token + } + } else if (data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + return { + success: true, + needReason: false + } + } else { + toast.error(data.error || '登录失败') + return { + success: false, + needReason: false, + error: data.error || '登录失败' + } + } + } catch (e) { + toast.error('登录失败') + return { + success: false, + needReason: false, + error: '登录失败' + } + } +} diff --git a/open-isle-cli/src/views/DiscordCallbackPageView.vue b/open-isle-cli/src/views/DiscordCallbackPageView.vue new file mode 100644 index 000000000..6489b8e7b --- /dev/null +++ b/open-isle-cli/src/views/DiscordCallbackPageView.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/open-isle-cli/src/views/LoginPageView.vue b/open-isle-cli/src/views/LoginPageView.vue index 364a3bdca..41fd29a75 100644 --- a/open-isle-cli/src/views/LoginPageView.vue +++ b/open-isle-cli/src/views/LoginPageView.vue @@ -38,6 +38,10 @@ GitHub Logo
GitHub 登录
+
+ Discord Logo +
Discord 登录
+
@@ -47,6 +51,7 @@ import { API_BASE_URL, toast } from '../main' import { setToken, loadCurrentUser } from '../utils/auth' import { googleSignIn } from '../utils/google' import { githubAuthorize } from '../utils/github' +import { discordAuthorize } from '../utils/discord' import BaseInput from '../components/BaseInput.vue' export default { name: 'LoginPageView', @@ -101,6 +106,9 @@ export default { }, loginWithGithub() { githubAuthorize() + }, + loginWithDiscord() { + discordAuthorize() } } } diff --git a/open-isle-cli/src/views/SignupPageView.vue b/open-isle-cli/src/views/SignupPageView.vue index 35ae1f9bd..bd1ccf2b3 100644 --- a/open-isle-cli/src/views/SignupPageView.vue +++ b/open-isle-cli/src/views/SignupPageView.vue @@ -80,6 +80,10 @@ GitHub Logo
GitHub 注册
+
+ Discord Logo +
Discord 注册
+
@@ -88,6 +92,7 @@ import { API_BASE_URL, toast } from '../main' import { googleSignIn } from '../utils/google' import { githubAuthorize } from '../utils/github' +import { discordAuthorize } from '../utils/discord' import BaseInput from '../components/BaseInput.vue' export default { name: 'SignupPageView', @@ -208,6 +213,9 @@ export default { }, signupWithGithub() { githubAuthorize() + }, + signupWithDiscord() { + discordAuthorize() } } } diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java index bf8d1bddd..d78b3bd01 100644 --- a/src/main/java/com/openisle/controller/AuthController.java +++ b/src/main/java/com/openisle/controller/AuthController.java @@ -7,6 +7,7 @@ import com.openisle.service.UserService; import com.openisle.service.CaptchaService; import com.openisle.service.GoogleAuthService; import com.openisle.service.GithubAuthService; +import com.openisle.service.DiscordAuthService; import com.openisle.service.RegisterModeService; import com.openisle.service.NotificationService; import com.openisle.model.RegisterMode; @@ -30,6 +31,7 @@ public class AuthController { private final CaptchaService captchaService; private final GoogleAuthService googleAuthService; private final GithubAuthService githubAuthService; + private final DiscordAuthService discordAuthService; private final RegisterModeService registerModeService; private final NotificationService notificationService; private final UserRepository userRepository; @@ -197,6 +199,36 @@ public class AuthController { )); } + @PostMapping("/discord") + public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { + Optional user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); + if (user.isPresent()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + } + if (!user.get().isApproved()) { + if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "error", "Account awaiting approval", + "reason_code", "IS_APPROVING", + "token", jwtService.generateReasonToken(user.get().getUsername()) + )); + } + return ResponseEntity.badRequest().body(Map.of( + "error", "Account awaiting approval", + "reason_code", "NOT_APPROVED", + "token", jwtService.generateReasonToken(user.get().getUsername()) + )); + } + + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + } + return ResponseEntity.badRequest().body(Map.of( + "error", "Invalid discord code", + "reason_code", "INVALID_CREDENTIALS" + )); + } + @GetMapping("/check") public ResponseEntity checkToken() { return ResponseEntity.ok(Map.of("valid", true)); @@ -228,6 +260,12 @@ public class AuthController { private String redirectUri; } + @Data + private static class DiscordLoginRequest { + private String code; + private String redirectUri; + } + @Data private static class VerifyRequest { private String username; diff --git a/src/main/java/com/openisle/service/DiscordAuthService.java b/src/main/java/com/openisle/service/DiscordAuthService.java new file mode 100644 index 000000000..670c248dc --- /dev/null +++ b/src/main/java/com/openisle/service/DiscordAuthService.java @@ -0,0 +1,107 @@ +package com.openisle.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class DiscordAuthService { + private final UserRepository userRepository; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${discord.client-id:}") + private String clientId; + + @Value("${discord.client-secret:}") + private String clientSecret; + + public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { + try { + String tokenUrl = "https://discord.com/api/oauth2/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("grant_type", "authorization_code"); + body.add("code", code); + if (redirectUri != null) { + body.add("redirect_uri", redirectUri); + } + body.add("scope", "identify email"); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class); + if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) { + return Optional.empty(); + } + String accessToken = tokenRes.getBody().get("access_token").asText(); + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(authHeaders); + ResponseEntity userRes = restTemplate.exchange( + "https://discord.com/api/users/@me", HttpMethod.GET, entity, JsonNode.class); + if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { + return Optional.empty(); + } + JsonNode userNode = userRes.getBody(); + String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null; + String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null; + String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null; + String avatar = null; + if (userNode.hasNonNull("avatar") && id != null) { + avatar = "https://cdn.discordapp.com/avatars/" + id + "/" + userNode.get("avatar").asText() + ".png"; + } + if (email == null) { + email = (username != null ? username : id) + "@users.noreply.discord.com"; + } + return Optional.of(processUser(email, username, avatar, mode)); + } catch (Exception e) { + return Optional.empty(); + } + } + + private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { + user.setVerified(true); + user.setVerificationCode(null); + userRepository.save(user); + } + return user; + } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); + } + return userRepository.save(user); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4d5918e46..7af6dbd95 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -53,6 +53,9 @@ google.client-id=${GOOGLE_CLIENT_ID:} # GitHub OAuth configuration github.client-id=${GITHUB_CLIENT_ID:} github.client-secret=${GITHUB_CLIENT_SECRET:} +# Discord OAuth configuration +discord.client-id=${DISCORD_CLIENT_ID:} +discord.client-secret=${DISCORD_CLIENT_SECRET:} # OpenAI configuration openai.api-key=${OPENAI_API_KEY:} openai.model=${OPENAI_MODEL:gpt-4o}