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 @@ + + Twitter icon + + 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 Logo
Discord 登录
+
+ Twitter Logo +
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 Logo
Discord 注册
+
+ Twitter Logo +
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> 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://api.twitter.com/2/users/me", HttpMethod.GET, entity, JsonNode.class); + if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { + return Optional.empty(); + } + JsonNode userNode = userRes.getBody(); + String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null; + String email = null; + if (userNode.hasNonNull("email")) { + email = userNode.get("email").asText(); + } + if (email == null) { + email = username + "@twitter.com"; + } + return Optional.of(processUser(email, username, mode)); + } catch (Exception e) { + return Optional.empty(); + } + } + + private User processUser(String email, String username, 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); + user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); + return userRepository.save(user); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7af6dbd95..686c0ec3a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,6 +56,9 @@ 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:} # OpenAI configuration openai.api-key=${OPENAI_API_KEY:} openai.model=${OPENAI_MODEL:gpt-4o}