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 @@
+
\ 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 @@
+
+
+
+
Magic is happening...
+
+
+
+
+
+
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 登录
+
+

+
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 注册
+
+

+
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}