From 0822d78a341a83ab2e03d4c668013ef0308d8dc4 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:22:19 +0800 Subject: [PATCH] Implement PKCE for Twitter login --- open-isle-cli/src/utils/twitter.js | 38 +++++++++++++++++-- .../openisle/controller/AuthController.java | 7 +++- .../openisle/service/TwitterAuthService.java | 23 ++++++----- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/open-isle-cli/src/utils/twitter.js b/open-isle-cli/src/utils/twitter.js index e699898a4..4e5692b19 100644 --- a/open-isle-cli/src/utils/twitter.js +++ b/open-isle-cli/src/utils/twitter.js @@ -1,22 +1,54 @@ import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main' import { setToken, loadCurrentUser } from './auth' -export function twitterAuthorize(state = '') { +function generateCodeVerifier() { + const array = new Uint8Array(32) + window.crypto.getRandomValues(array) + return Array.from(array) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +async function generateCodeChallenge(codeVerifier) { + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + const digest = await window.crypto.subtle.digest('SHA-256', data) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +export async 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}` + const codeVerifier = generateCodeVerifier() + sessionStorage.setItem('twitter_code_verifier', codeVerifier) + const codeChallenge = await generateCodeChallenge(codeVerifier) + 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}&code_challenge=${codeChallenge}&code_challenge_method=S256` window.location.href = url } export async function twitterExchange(code, state, reason) { try { + const codeVerifier = sessionStorage.getItem('twitter_code_verifier') + sessionStorage.removeItem('twitter_code_verifier') 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 }) + body: JSON.stringify({ + code, + redirectUri: `${window.location.origin}/twitter-callback`, + reason, + state, + codeVerifier + }) }) const data = await res.json() if (res.ok && data.token) { diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java index afe0735c8..e7204e50b 100644 --- a/src/main/java/com/openisle/controller/AuthController.java +++ b/src/main/java/com/openisle/controller/AuthController.java @@ -233,7 +233,11 @@ public class AuthController { @PostMapping("/twitter") public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { - Optional user = twitterAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); + Optional user = twitterAuthService.authenticate( + req.getCode(), + req.getCodeVerifier(), + registerModeService.getRegisterMode(), + req.getRedirectUri()); if (user.isPresent()) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); @@ -302,6 +306,7 @@ public class AuthController { private static class TwitterLoginRequest { private String code; private String redirectUri; + private String codeVerifier; } @Data diff --git a/src/main/java/com/openisle/service/TwitterAuthService.java b/src/main/java/com/openisle/service/TwitterAuthService.java index 9c3ee3d39..bc24bf96a 100644 --- a/src/main/java/com/openisle/service/TwitterAuthService.java +++ b/src/main/java/com/openisle/service/TwitterAuthService.java @@ -9,6 +9,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import org.springframework.util.MultiValueMap; +import org.springframework.util.LinkedMultiValueMap; import java.util.*; @@ -21,25 +23,22 @@ public class TwitterAuthService { @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) { + public Optional authenticate(String code, String codeVerifier, 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); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - Map body = new HashMap<>(); - body.put("client_id", clientId); - body.put("client_secret", clientSecret); - body.put("code", code); - body.put("grant_type", "authorization_code"); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("client_id", clientId); + body.add("code", code); + body.add("grant_type", "authorization_code"); + body.add("code_verifier", codeVerifier); if (redirectUri != null) { - body.put("redirect_uri", redirectUri); + body.add("redirect_uri", redirectUri); } - HttpEntity> request = new HttpEntity<>(body, headers); + 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();