diff --git a/README.md b/README.md
index e625162f9..e47fa7990 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,9 @@ OpenIsle 基于 Spring Boot 构建,提供社区后台常见的注册、登录
- `GITHUB_CLIENT_ID`:GitHub OAuth 客户端 ID
- `GITHUB_CLIENT_SECRET`:GitHub OAuth 客户端密钥
- `VUE_APP_GITHUB_CLIENT_ID`:前端 GitHub 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)
@@ -73,6 +76,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/twitter`:Twitter 登录并获取 Token
- `GET /api/config`:查看验证码开关配置
- 需要认证的接口示例:`GET /api/hello`(需 `Authorization` 头)
- 管理员接口示例:`GET /api/admin/hello`
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 fce0e5d9d..d85b7e6f4 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 TWITTER_CLIENT_ID = 'YOUR_TWITTER_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..5d3221432 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 TwitterCallbackPageView from '../views/TwitterCallbackPageView.vue'
const routes = [
{
@@ -74,6 +75,11 @@ const routes = [
name: 'github-callback',
component: GithubCallbackPageView
},
+ {
+ 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 364a3bdca..6b0e5aca5 100644
--- a/open-isle-cli/src/views/LoginPageView.vue
+++ b/open-isle-cli/src/views/LoginPageView.vue
@@ -38,6 +38,10 @@
GitHub 登录
+
+

+
Twitter 登录
+
@@ -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 { twitterAuthorize } from '../utils/twitter'
import BaseInput from '../components/BaseInput.vue'
export default {
name: 'LoginPageView',
@@ -101,6 +106,9 @@ export default {
},
loginWithGithub() {
githubAuthorize()
+ },
+ loginWithTwitter() {
+ twitterAuthorize()
}
}
}
diff --git a/open-isle-cli/src/views/SignupPageView.vue b/open-isle-cli/src/views/SignupPageView.vue
index 35ae1f9bd..21ca9c5a4 100644
--- a/open-isle-cli/src/views/SignupPageView.vue
+++ b/open-isle-cli/src/views/SignupPageView.vue
@@ -80,6 +80,10 @@
GitHub 注册
+
+

+
Twitter 注册
+
@@ -88,6 +92,7 @@
import { API_BASE_URL, toast } from '../main'
import { googleSignIn } from '../utils/google'
import { githubAuthorize } from '../utils/github'
+import { twitterAuthorize } from '../utils/twitter'
import BaseInput from '../components/BaseInput.vue'
export default {
name: 'SignupPageView',
@@ -208,6 +213,9 @@ export default {
},
signupWithGithub() {
githubAuthorize()
+ },
+ 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 bf8d1bddd..86415ef0b 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.TwitterAuthService;
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 TwitterAuthService twitterAuthService;
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
@@ -197,6 +199,36 @@ public class AuthController {
));
}
+ @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() {
return ResponseEntity.ok(Map.of("valid", true));
@@ -228,6 +260,12 @@ public class AuthController {
private String redirectUri;
}
+ @Data
+ private static class TwitterLoginRequest {
+ private String code;
+ private String redirectUri;
+ }
+
@Data
private static class VerifyRequest {
private String username;
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