diff --git a/README.md b/README.md index e47fa7990..3ce9f7189 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 - `TWITTER_CLIENT_ID`:Twitter OAuth 客户端 ID - `TWITTER_CLIENT_SECRET`:Twitter OAuth 客户端密钥 - `VUE_APP_TWITTER_CLIENT_ID`:前端 Twitter OAuth 客户端 ID @@ -76,6 +80,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 - `POST /api/auth/twitter`:Twitter 登录并获取 Token - `GET /api/config`:查看验证码开关配置 - 需要认证的接口示例:`GET /api/hello`(需 `Authorization` 头) 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 108324864..15b8285fe 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 TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ' export const toast = useToast() diff --git a/open-isle-cli/src/router/index.js b/open-isle-cli/src/router/index.js index 5d3221432..877f6da9d 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' import TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue' const routes = [ @@ -75,6 +76,11 @@ const routes = [ name: 'github-callback', component: GithubCallbackPageView }, + { + path: '/discord-callback', + name: 'discord-callback', + component: DiscordCallbackPageView + }, { path: '/twitter-callback', name: 'twitter-callback', 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 6b0e5aca5..194ba8e38 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 登录
+
Twitter Logo
Twitter 登录
@@ -51,6 +55,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 { twitterAuthorize } from '../utils/twitter' import BaseInput from '../components/BaseInput.vue' export default { @@ -107,6 +112,9 @@ export default { loginWithGithub() { githubAuthorize() }, + loginWithDiscord() { + discordAuthorize() + }, loginWithTwitter() { twitterAuthorize() } diff --git a/open-isle-cli/src/views/SignupPageView.vue b/open-isle-cli/src/views/SignupPageView.vue index 21ca9c5a4..0471bfeff 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 注册
+
Twitter Logo
Twitter 注册
@@ -92,6 +96,7 @@ import { API_BASE_URL, toast } from '../main' import { googleSignIn } from '../utils/google' import { githubAuthorize } from '../utils/github' +import { discordAuthorize } from '../utils/discord' import { twitterAuthorize } from '../utils/twitter' import BaseInput from '../components/BaseInput.vue' export default { @@ -214,6 +219,9 @@ export default { signupWithGithub() { githubAuthorize() }, + signupWithDiscord() { + discordAuthorize() + } signupWithTwitter() { twitterAuthorize() } diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java index 86415ef0b..afe0735c8 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.TwitterAuthService; import com.openisle.service.RegisterModeService; import com.openisle.service.NotificationService; @@ -31,6 +32,7 @@ public class AuthController { private final CaptchaService captchaService; private final GoogleAuthService googleAuthService; private final GithubAuthService githubAuthService; + private final DiscordAuthService discordAuthService; private final TwitterAuthService twitterAuthService; private final RegisterModeService registerModeService; private final NotificationService notificationService; @@ -199,6 +201,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" + )); + } + @PostMapping("/twitter") public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { Optional user = twitterAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); @@ -260,6 +292,12 @@ public class AuthController { private String redirectUri; } + @Data + private static class DiscordLoginRequest { + private String code; + private String redirectUri; + } + @Data private static class TwitterLoginRequest { private String code; 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 c101524cf..686c0ec3a 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:} # Twitter OAuth configuration twitter.client-id=${TWITTER_CLIENT_ID:} twitter.client-secret=${TWITTER_CLIENT_SECRET:}