diff --git a/README.md b/README.md
index c069fc928..3ce9f7189 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,9 @@ OpenIsle 基于 Spring Boot 构建,提供社区后台常见的注册、登录
- `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
- `JWT_SECRET`:JWT 签名密钥
- `JWT_EXPIRATION`:JWT 过期时间(毫秒)
- `PASSWORD_STRENGTH`:密码强度(LOW、MEDIUM、HIGH)
@@ -78,6 +81,7 @@ mvn spring-boot:run
- `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` 头)
- 管理员接口示例:`GET /api/admin/hello`
diff --git a/open-isle-cli/src/App.vue b/open-isle-cli/src/App.vue
index 85e25ecda..245fe3b1d 100644
--- a/open-isle-cli/src/App.vue
+++ b/open-isle-cli/src/App.vue
@@ -26,7 +26,7 @@ export default {
},
computed: {
hideMenu() {
- return ['/login', '/signup', '/404', '/signup-reason', '/github-callback'].includes(this.$route.path)
+ return ['/login', '/signup', '/404', '/signup-reason', '/github-callback', '/twitter-callback'].includes(this.$route.path)
}
}
}
diff --git a/open-isle-cli/src/assets/icons/twitter.svg b/open-isle-cli/src/assets/icons/twitter.svg
new file mode 100644
index 000000000..42f23d845
--- /dev/null
+++ b/open-isle-cli/src/assets/icons/twitter.svg
@@ -0,0 +1,4 @@
+
diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js
index 4851cef9b..15b8285fe 100644
--- a/open-isle-cli/src/main.js
+++ b/open-isle-cli/src/main.js
@@ -20,6 +20,7 @@ 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()
initTheme()
diff --git a/open-isle-cli/src/router/index.js b/open-isle-cli/src/router/index.js
index 8710e4814..877f6da9d 100644
--- a/open-isle-cli/src/router/index.js
+++ b/open-isle-cli/src/router/index.js
@@ -13,6 +13,7 @@ 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 = [
{
@@ -80,6 +81,11 @@ const routes = [
name: 'discord-callback',
component: DiscordCallbackPageView
},
+ {
+ path: '/twitter-callback',
+ name: 'twitter-callback',
+ component: TwitterCallbackPageView
+ },
{
path: '/404',
name: 'not-found',
diff --git a/open-isle-cli/src/utils/twitter.js b/open-isle-cli/src/utils/twitter.js
new file mode 100644
index 000000000..e699898a4
--- /dev/null
+++ b/open-isle-cli/src/utils/twitter.js
@@ -0,0 +1,41 @@
+import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
+import { setToken, loadCurrentUser } from './auth'
+
+export function twitterAuthorize(state = '') {
+ if (!TWITTER_CLIENT_ID) {
+ toast.error('Twitter 登录不可用')
+ return
+ }
+ const redirectUri = `${window.location.origin}/twitter-callback`
+ const url = `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read&state=${state}`
+ window.location.href = url
+}
+
+export async function twitterExchange(code, state, reason) {
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code, redirectUri: `${window.location.origin}/twitter-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/LoginPageView.vue b/open-isle-cli/src/views/LoginPageView.vue
index 41fd29a75..194ba8e38 100644
--- a/open-isle-cli/src/views/LoginPageView.vue
+++ b/open-isle-cli/src/views/LoginPageView.vue
@@ -42,6 +42,10 @@
Discord 登录
+
+

+
Twitter 登录
+
@@ -52,6 +56,7 @@ 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 {
name: 'LoginPageView',
@@ -109,6 +114,9 @@ export default {
},
loginWithDiscord() {
discordAuthorize()
+ },
+ loginWithTwitter() {
+ twitterAuthorize()
}
}
}
diff --git a/open-isle-cli/src/views/SignupPageView.vue b/open-isle-cli/src/views/SignupPageView.vue
index bd1ccf2b3..0471bfeff 100644
--- a/open-isle-cli/src/views/SignupPageView.vue
+++ b/open-isle-cli/src/views/SignupPageView.vue
@@ -84,6 +84,10 @@
Discord 注册
+
+

+
Twitter 注册
+
@@ -93,6 +97,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 {
name: 'SignupPageView',
@@ -217,6 +222,9 @@ export default {
signupWithDiscord() {
discordAuthorize()
}
+ signupWithTwitter() {
+ twitterAuthorize()
+ }
}
}
diff --git a/open-isle-cli/src/views/TwitterCallbackPageView.vue b/open-isle-cli/src/views/TwitterCallbackPageView.vue
new file mode 100644
index 000000000..18aaa1263
--- /dev/null
+++ b/open-isle-cli/src/views/TwitterCallbackPageView.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java
index d78b3bd01..afe0735c8 100644
--- a/src/main/java/com/openisle/controller/AuthController.java
+++ b/src/main/java/com/openisle/controller/AuthController.java
@@ -8,6 +8,7 @@ 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;
import com.openisle.model.RegisterMode;
@@ -32,6 +33,7 @@ public class AuthController {
private final GoogleAuthService googleAuthService;
private final GithubAuthService githubAuthService;
private final DiscordAuthService discordAuthService;
+ private final TwitterAuthService twitterAuthService;
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
@@ -228,6 +230,36 @@ public class AuthController {
"reason_code", "INVALID_CREDENTIALS"
));
}
+
+ @PostMapping("/twitter")
+ public ResponseEntity> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
+ Optional user = twitterAuthService.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 twitter code",
+ "reason_code", "INVALID_CREDENTIALS"
+ ));
+ }
@GetMapping("/check")
public ResponseEntity> checkToken() {
@@ -265,6 +297,12 @@ public class AuthController {
private String code;
private String redirectUri;
}
+
+ @Data
+ private static class TwitterLoginRequest {
+ private String code;
+ private String redirectUri;
+ }
@Data
private static class VerifyRequest {
diff --git a/src/main/java/com/openisle/service/TwitterAuthService.java b/src/main/java/com/openisle/service/TwitterAuthService.java
new file mode 100644
index 000000000..9c3ee3d39
--- /dev/null
+++ b/src/main/java/com/openisle/service/TwitterAuthService.java
@@ -0,0 +1,99 @@
+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.web.client.RestTemplate;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class TwitterAuthService {
+ private final UserRepository userRepository;
+ private final RestTemplate restTemplate = new RestTemplate();
+
+ @Value("${twitter.client-id:}")
+ private String clientId;
+
+ @Value("${twitter.client-secret:}")
+ private String clientSecret;
+
+ public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
+ try {
+ String tokenUrl = "https://api.twitter.com/2/oauth2/token";
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ Map body = new HashMap<>();
+ body.put("client_id", clientId);
+ body.put("client_secret", clientSecret);
+ body.put("code", code);
+ body.put("grant_type", "authorization_code");
+ if (redirectUri != null) {
+ body.put("redirect_uri", redirectUri);
+ }
+
+ HttpEntity