From e2be9a632a22375115d908b1b1b8c14453c1323c Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 15:51:16 +0800 Subject: [PATCH 01/10] Add external authentication provider interface and GitHub implementation --- .../external/ExternalAuthProvider.java | 14 +++ .../identity/external/ExternalAuthResult.java | 13 +++ .../identity/external/GithubAuthProvider.java | 103 ++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/external/ExternalAuthProvider.java create mode 100644 identity/src/main/java/io/theurl/identity/external/ExternalAuthResult.java create mode 100644 identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java diff --git a/identity/src/main/java/io/theurl/identity/external/ExternalAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/ExternalAuthProvider.java new file mode 100644 index 0000000..742df87 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/ExternalAuthProvider.java @@ -0,0 +1,14 @@ +package io.theurl.identity.external; + +/** + * Defines the contract for external authentication providers. + * Implementations of this interface should handle the authentication process with external services. + */ +public interface ExternalAuthProvider { + /** + * Authenticates a user using the provided authentication code. + * @param authCode The authentication code received from the external service. + * @return The result of the authentication process encapsulated in an ExternalAuthResult object. + */ + ExternalAuthResult authenticate(String authCode); +} diff --git a/identity/src/main/java/io/theurl/identity/external/ExternalAuthResult.java b/identity/src/main/java/io/theurl/identity/external/ExternalAuthResult.java new file mode 100644 index 0000000..69cfee6 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/ExternalAuthResult.java @@ -0,0 +1,13 @@ +package io.theurl.identity.external; + +import lombok.Data; + +@Data +public class ExternalAuthResult { + private String id; + private String username; + private String nickname; + private String email; + private String phone; + private String avatarUrl; +} diff --git a/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java new file mode 100644 index 0000000..67d225f --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java @@ -0,0 +1,103 @@ +package io.theurl.identity.external; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +@Component +public class GithubAuthProvider implements ExternalAuthProvider { + + @Value("${external-auth.github.client-id}") + private String clientId; + @Value("${external-auth.github.client-secret}") + private String secret; + + @Override + public ExternalAuthResult authenticate(String authCode) { + var token = getToken(authCode); + var user = getUserInfo(token); + return new ExternalAuthResult() { + @Override + public String getId() { + return user.findValue("id").asText(); + } + + @Override + public String getUsername() { + return user.findValue("login").asText(); + } + + @Override + public String getNickname() { + return user.findValue("name").asText(); + } + + @Override + public String getEmail() { + return user.findValue("email").asText(); + } + + @Override + public String getAvatarUrl() { + return user.findValue("avatar_url").asText(); + } + }; + } + + private JsonNode getUserInfo(String token) { + var request = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com/user/")) + .header("Accept", "application/json") + .header("User-Agent", "Linkyou") + .header("Authorization", "Bearer " + token) + .GET() + .build(); + + var client = HttpClient.newBuilder() + .build(); + var response = client.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) + .thenApply(java.net.http.HttpResponse::body) + .join(); + + return readJson(response); + } + + private String getToken(String authCode) { + var request = HttpRequest.newBuilder() + .uri(URI.create("https://github.com/login/oauth/access_token")) + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("client_id=" + clientId + "&client_secret=" + secret + "&code=" + authCode)) + .build(); + + HttpClient client = HttpClient.newHttpClient(); + var response = client.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) + .thenApply(java.net.http.HttpResponse::body) + .join(); + try { + var jsonNode = readJson(response); + var accessToken = jsonNode.findValue("access_token").asText(); + + if (accessToken == null) { + throw new RuntimeException("Failed to retrieve access token from GitHub"); + } + + return accessToken; + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private JsonNode readJson(String json) { + try { + var objectMapper = new ObjectMapper(); + return objectMapper.readTree(json); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} From 646e5990187415e29b231818f2abfccd9904a90d Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 16:24:04 +0800 Subject: [PATCH 02/10] Add external authentication configuration and Spring Web dependency --- identity/pom.xml | 5 +++++ identity/src/main/resources/application.yaml | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/identity/pom.xml b/identity/pom.xml index 714c9e9..b942f2c 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -89,6 +89,11 @@ spring-boot-starter-webflux-test test + + org.springframework + spring-web + 6.2.15 + diff --git a/identity/src/main/resources/application.yaml b/identity/src/main/resources/application.yaml index 6af40f4..4a2de16 100644 --- a/identity/src/main/resources/application.yaml +++ b/identity/src/main/resources/application.yaml @@ -25,3 +25,17 @@ spring: mongodb: uri: ${MONGO_URI:mongodb://localhost:27017/linkyou} +external-auth: + redirect-uri: "https://theurl.io/auth/callback" + google: + client-id: ${GOOGLE_CLIENT_ID:your-google-client-id} + client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret} + github: + client-id: ${GITHUB_CLIENT_ID:your-github-client-id} + client-secret: ${GITHUB_CLIENT_SECRET:your-github-client-secret} + facebook: + client-id: ${FACEBOOK_CLIENT_ID:your-facebook-client-id} + client-secret: ${FACEBOOK_CLIENT_SECRET:your-facebook-client-secret} + microsoft: + client-id: ${MICROSOFT_CLIENT_ID:your-microsoft-client-id} + client-secret: ${MICROSOFT_CLIENT_SECRET:your-microsoft-client-secret} From 5ac2de322140de8d3059a06823d7746d3c1d6e43 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 16:24:16 +0800 Subject: [PATCH 03/10] Add SpringUtil class for application context management --- .../java/io/theurl/identity/SpringUtil.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/SpringUtil.java diff --git a/identity/src/main/java/io/theurl/identity/SpringUtil.java b/identity/src/main/java/io/theurl/identity/SpringUtil.java new file mode 100644 index 0000000..d0147c2 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/SpringUtil.java @@ -0,0 +1,34 @@ +package io.theurl.identity; + +import lombok.Getter; +import org.jspecify.annotations.NonNull; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@SuppressWarnings("unused") +@Component +public class SpringUtil implements ApplicationContextAware { + @Getter + private static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(@NonNull ApplicationContext context) throws BeansException { + if (applicationContext == null) { + applicationContext = context; + } + } + + public static T getBean(Class clazz) { + return applicationContext.getBean(clazz); + } + + public static T getBean(String beanName, Class clazz) { + return applicationContext.getBean(beanName, clazz); + } + + public static Object getBean(String beanName) { + return applicationContext.getBean(beanName); + } +} From 257ded932b550d5098ea8377c47a58bde91969dc Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 16:24:50 +0800 Subject: [PATCH 04/10] Add MicrosoftAuthProvider for external authentication with Microsoft --- .../external/MicrosoftAuthProvider.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java diff --git a/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java new file mode 100644 index 0000000..26ad591 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java @@ -0,0 +1,116 @@ +package io.theurl.identity.external; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Component +public class MicrosoftAuthProvider implements ExternalAuthProvider { + @Value("${external-auth.microsoft.client-id}") + private String clientId; + @Value("${external-auth.microsoft.client-secret}") + private String clientSecret; + @Value("${external-auth.redirect-uri}") + private String redirectUri; + + @Override + public ExternalAuthResult authenticate(String authCode) { + var token = getToken(authCode); + var user = getUserInfo(token); + return new ExternalAuthResult() { + @Override + public String getId() { + return user.findValue("id").asText(); + } + + @Override + public String getUsername() { + return user.findValue("userPrincipalName").asText(); + } + + @Override + public String getNickname() { + return user.findValue("displayName").asText(); + } + + @Override + public String getEmail() { + return user.findValue("email").asText(); + } + + @Override + public String getPhone() { + return user.findValue("mobilePhone").asText(); + } + }; + } + + private JsonNode getUserInfo(String token) { + var request = HttpRequest.newBuilder() + .header("User-Agent", "Linkyou") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + token) + .uri(URI.create("https://graph.microsoft.com/v1.0/me")) + .GET() + .build(); + + var response = HttpClient.newHttpClient() + .sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) + .join(); + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to get user info from Microsoft: " + response.body()); + } + + return readJson(response.body()); + } + + private String getToken(String authCode) { + Map formData = Map.of( + "client_id", clientId, + "client_secret", clientSecret, + "code", authCode, + "redirect_uri", redirectUri, + "grant_type", "authorization_code", + "scope", "User.Read Mail.Read" + ); + + var formString = formData.entrySet().stream() + .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .reduce((a, b) -> a + "&" + b) + .orElse(""); + + var request = HttpRequest.newBuilder() + .uri(URI.create("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formString)) + .build(); + + var response = HttpClient.newHttpClient() + .sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) + .join(); + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to get token from Microsoft: " + response.body()); + } + + var jsonObject = readJson(response.body()); + return jsonObject.get("access_token").asText(); + } + + private JsonNode readJson(String json) { + try { + var objectMapper = new ObjectMapper(); + return objectMapper.readTree(json); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} From 2332bfb59030e61cbb19e5067dfb7fcdef866662 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 16:25:05 +0800 Subject: [PATCH 05/10] Remove TestController.java --- .../identity/controller/TestController.java | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 identity/src/main/java/io/theurl/identity/controller/TestController.java diff --git a/identity/src/main/java/io/theurl/identity/controller/TestController.java b/identity/src/main/java/io/theurl/identity/controller/TestController.java deleted file mode 100644 index 3d4d0fc..0000000 --- a/identity/src/main/java/io/theurl/identity/controller/TestController.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.theurl.identity.controller; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class TestController { - - @Value("${spring.data.redis.url}") - private String redisUrl; - - @GetMapping("/test") - public String test() { - return "Redis URL: " + redisUrl; - } -} From 1b5bee961bd87e9105c7f030ef5a3d32045ccaa0 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 16:25:18 +0800 Subject: [PATCH 06/10] Add ExternalAuthConfiguration for managing external authentication providers --- .../configure/ExternalAuthConfiguration.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java diff --git a/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java new file mode 100644 index 0000000..91cf12c --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java @@ -0,0 +1,18 @@ +package io.theurl.identity.configure; + +import io.theurl.identity.external.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ExternalAuthConfiguration { + @Bean("microsoft") + public ExternalAuthProvider microsoft() { + return new MicrosoftAuthProvider(); + } + + @Bean("github") + public ExternalAuthProvider github() { + return new GithubAuthProvider(); + } +} From f07a4aeae16aba6726d458f94c5af58aae8ef4cc Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 18:08:01 +0800 Subject: [PATCH 07/10] Add BaseAuthProvider and specific implementations for Facebook, Google, and Microsoft authentication --- .../configure/ExternalAuthConfiguration.java | 18 ---- .../identity/external/BaseAuthProvider.java | 93 +++++++++++++++++++ .../external/FacebookAuthProvider.java | 46 +++++++++ .../identity/external/GithubAuthProvider.java | 70 ++------------ .../identity/external/GoogleAuthProvider.java | 58 ++++++++++++ .../external/MicrosoftAuthProvider.java | 85 +++-------------- 6 files changed, 219 insertions(+), 151 deletions(-) delete mode 100644 identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java create mode 100644 identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java create mode 100644 identity/src/main/java/io/theurl/identity/external/FacebookAuthProvider.java create mode 100644 identity/src/main/java/io/theurl/identity/external/GoogleAuthProvider.java diff --git a/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java deleted file mode 100644 index 91cf12c..0000000 --- a/identity/src/main/java/io/theurl/identity/configure/ExternalAuthConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.theurl.identity.configure; - -import io.theurl.identity.external.*; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ExternalAuthConfiguration { - @Bean("microsoft") - public ExternalAuthProvider microsoft() { - return new MicrosoftAuthProvider(); - } - - @Bean("github") - public ExternalAuthProvider github() { - return new GithubAuthProvider(); - } -} diff --git a/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java new file mode 100644 index 0000000..4f8dbc0 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java @@ -0,0 +1,93 @@ +package io.theurl.identity.external; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Provides common methods for external authentication providers, such as fetching user info and access tokens. + * Concrete providers (e.g., Google, Microsoft, GitHub) can extend this class to implement their specific authentication logic while reusing these common methods. + */ +public abstract class BaseAuthProvider implements ExternalAuthProvider { + + private final Logger logger = LoggerFactory.getLogger(BaseAuthProvider.class); + + protected JsonNode getUserInfo(String token, String url) { + var request = HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .header("Accept", "application/json") + .header("User-Agent", "Linkyou") + .header("Authorization", "Bearer " + token) + .GET() + .build(); + + try (HttpClient client = HttpClient.newHttpClient()) { + HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .join(); + if (response.statusCode() == 200) { + return readJson(response.body()); + } + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + return null; + } + + protected String getToken(String url, Map params, String paramsType) { + var form = params.entrySet().stream() + .map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + + var builder = HttpRequest.newBuilder() + .header("Accept", "application/json") + .header("User-Agent", "Linkyou"); + + switch (paramsType) { + case "form": + builder.uri(java.net.URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)); + break; + case "query": + builder.uri(URI.create(url + "?" + form)) + .POST(HttpRequest.BodyPublishers.noBody()); + break; + default: + throw new IllegalArgumentException("Unsupported params type: " + paramsType); + } + + var request = builder.build(); + + + try (HttpClient client = HttpClient.newHttpClient()) { + HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .join(); + if (response.statusCode() == 200) { + var json = readJson(response.body()); + return json.findValue("access_token").asText(); + } + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + return null; + } + + protected JsonNode readJson(String json) { + try { + var objectMapper = new ObjectMapper(); + return objectMapper.readTree(json); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/identity/src/main/java/io/theurl/identity/external/FacebookAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/FacebookAuthProvider.java new file mode 100644 index 0000000..1f79a91 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/FacebookAuthProvider.java @@ -0,0 +1,46 @@ +package io.theurl.identity.external; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component("external-auth-provider-facebook") +public class FacebookAuthProvider extends BaseAuthProvider { + + @Value("${external-auth.facebook.client-id}") + private String clientId; + @Value("${external-auth.facebook.client-secret}") + private String clientSecret; + @Value("${external-auth.redirect-uri}") + private String redirectUri; + + @Override + public ExternalAuthResult authenticate(String authCode) { + var token = getToken("https://graph.facebook.com//v22.0/oauth/access_token", + Map.of( + "client_id", clientId, + "client_secret", clientSecret, + "code", authCode, + "redirect_uri", redirectUri + ), "query"); + + var userInfo = getUserInfo(token, "https://graph.facebook.com/v12.0/me?fields=id,name,email,picture"); + return new ExternalAuthResult() { + @Override + public String getId() { + return userInfo.get("id").asText(); + } + + @Override + public String getEmail() { + return userInfo.get("email").asText(); + } + + @Override + public String getNickname() { + return userInfo.get("name").asText(); + } + }; + } +} diff --git a/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java index 67d225f..a8e62a9 100644 --- a/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java +++ b/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java @@ -1,16 +1,12 @@ package io.theurl.identity.external; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; +import java.util.Map; -@Component -public class GithubAuthProvider implements ExternalAuthProvider { +@Component("external-auth-provider-github") +public class GithubAuthProvider extends BaseAuthProvider { @Value("${external-auth.github.client-id}") private String clientId; @@ -19,8 +15,12 @@ public class GithubAuthProvider implements ExternalAuthProvider { @Override public ExternalAuthResult authenticate(String authCode) { - var token = getToken(authCode); - var user = getUserInfo(token); + var token = getToken("https://github.com/login/oauth/access_token", Map.of( + "client_id", clientId, + "client_secret", secret, + "code", authCode + ), "query"); + var user = getUserInfo(token, "https://api.github.com/user"); return new ExternalAuthResult() { @Override public String getId() { @@ -48,56 +48,4 @@ public String getAvatarUrl() { } }; } - - private JsonNode getUserInfo(String token) { - var request = HttpRequest.newBuilder() - .uri(URI.create("https://api.github.com/user/")) - .header("Accept", "application/json") - .header("User-Agent", "Linkyou") - .header("Authorization", "Bearer " + token) - .GET() - .build(); - - var client = HttpClient.newBuilder() - .build(); - var response = client.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) - .thenApply(java.net.http.HttpResponse::body) - .join(); - - return readJson(response); - } - - private String getToken(String authCode) { - var request = HttpRequest.newBuilder() - .uri(URI.create("https://github.com/login/oauth/access_token")) - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString("client_id=" + clientId + "&client_secret=" + secret + "&code=" + authCode)) - .build(); - - HttpClient client = HttpClient.newHttpClient(); - var response = client.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) - .thenApply(java.net.http.HttpResponse::body) - .join(); - try { - var jsonNode = readJson(response); - var accessToken = jsonNode.findValue("access_token").asText(); - - if (accessToken == null) { - throw new RuntimeException("Failed to retrieve access token from GitHub"); - } - - return accessToken; - } catch (Exception exception) { - throw new RuntimeException(exception); - } - } - - private JsonNode readJson(String json) { - try { - var objectMapper = new ObjectMapper(); - return objectMapper.readTree(json); - } catch (Exception exception) { - throw new RuntimeException(exception); - } - } } diff --git a/identity/src/main/java/io/theurl/identity/external/GoogleAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/GoogleAuthProvider.java new file mode 100644 index 0000000..917de7b --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/GoogleAuthProvider.java @@ -0,0 +1,58 @@ +package io.theurl.identity.external; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component("external-auth-provider-google") +public class GoogleAuthProvider extends BaseAuthProvider { + + @Value("${external-auth.google.client-id}") + private String clientId; + @Value("${external-auth.google.client-secret}") + private String clientSecret; + @Value("${external-auth.redirect-uri}") + private String redirectUri; + + @Override + public ExternalAuthResult authenticate(String authCode) { + var tokenParams = Map.of( + "client_id", clientId, + "client_secret", clientSecret, + "redirect_uri", redirectUri, + "code", authCode, + "grant_type", "authorization_code"); + + var token = getToken("https://oauth2.googleapis.com/token", tokenParams, "form"); + + var user = getUserInfo(token, "https://www.googleapis.com/oauth2/v3/userinfo"); + + return new ExternalAuthResult() { + @Override + public String getId() { + return user.findValue("sub").asText(); + } + + @Override + public String getUsername() { + return user.findValue("email").asText(); + } + + @Override + public String getNickname() { + return user.findValue("name").asText(); + } + + @Override + public String getEmail() { + return user.findValue("email").asText(); + } + + @Override + public String getAvatarUrl() { + return user.findValue("picture").asText(); + } + }; + } +} diff --git a/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java index 26ad591..b5ce883 100644 --- a/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java +++ b/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java @@ -1,19 +1,12 @@ package io.theurl.identity.external; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.nio.charset.StandardCharsets; import java.util.Map; -@Component -public class MicrosoftAuthProvider implements ExternalAuthProvider { +@Component("external-auth-provider-microsoft") +public class MicrosoftAuthProvider extends BaseAuthProvider { @Value("${external-auth.microsoft.client-id}") private String clientId; @Value("${external-auth.microsoft.client-secret}") @@ -23,8 +16,17 @@ public class MicrosoftAuthProvider implements ExternalAuthProvider { @Override public ExternalAuthResult authenticate(String authCode) { - var token = getToken(authCode); - var user = getUserInfo(token); + Map getTokenParams = Map.of( + "client_id", clientId, + "client_secret", clientSecret, + "code", authCode, + "redirect_uri", redirectUri, + "grant_type", "authorization_code", + "scope", "User.Read Mail.Read" + ); + + var token = getToken("https://login.microsoftonline.com/consumers/oauth2/v2.0/token", getTokenParams, "form"); + var user = getUserInfo(token, "https://graph.microsoft.com/v1.0/me"); return new ExternalAuthResult() { @Override public String getId() { @@ -52,65 +54,4 @@ public String getPhone() { } }; } - - private JsonNode getUserInfo(String token) { - var request = HttpRequest.newBuilder() - .header("User-Agent", "Linkyou") - .header("Accept", "application/json") - .header("Authorization", "Bearer " + token) - .uri(URI.create("https://graph.microsoft.com/v1.0/me")) - .GET() - .build(); - - var response = HttpClient.newHttpClient() - .sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) - .join(); - if (response.statusCode() != 200) { - throw new RuntimeException("Failed to get user info from Microsoft: " + response.body()); - } - - return readJson(response.body()); - } - - private String getToken(String authCode) { - Map formData = Map.of( - "client_id", clientId, - "client_secret", clientSecret, - "code", authCode, - "redirect_uri", redirectUri, - "grant_type", "authorization_code", - "scope", "User.Read Mail.Read" - ); - - var formString = formData.entrySet().stream() - .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) - .reduce((a, b) -> a + "&" + b) - .orElse(""); - - var request = HttpRequest.newBuilder() - .uri(URI.create("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(formString)) - .build(); - - var response = HttpClient.newHttpClient() - .sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString()) - .join(); - if (response.statusCode() != 200) { - throw new RuntimeException("Failed to get token from Microsoft: " + response.body()); - } - - var jsonObject = readJson(response.body()); - return jsonObject.get("access_token").asText(); - } - - private JsonNode readJson(String json) { - try { - var objectMapper = new ObjectMapper(); - return objectMapper.readTree(json); - } catch (Exception exception) { - throw new RuntimeException(exception); - } - } } From f47565eb4d546e425bf48bb52f9d1d916f2b89bd Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 23:11:17 +0800 Subject: [PATCH 08/10] Add custom exception classes for account and credential errors --- .../framework/security/AccountException.java | 38 +++++++++++++++++++ .../security/AccountLockedException.java | 20 ++++++++++ .../security/CredentialException.java | 37 ++++++++++++++++++ .../security/UnauthorizedAccessException.java | 17 +++++++++ 4 files changed, 112 insertions(+) create mode 100644 framework/src/main/java/io/theurl/framework/security/AccountException.java create mode 100644 framework/src/main/java/io/theurl/framework/security/AccountLockedException.java create mode 100644 framework/src/main/java/io/theurl/framework/security/CredentialException.java create mode 100644 framework/src/main/java/io/theurl/framework/security/UnauthorizedAccessException.java diff --git a/framework/src/main/java/io/theurl/framework/security/AccountException.java b/framework/src/main/java/io/theurl/framework/security/AccountException.java new file mode 100644 index 0000000..33433b7 --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/security/AccountException.java @@ -0,0 +1,38 @@ +package io.theurl.framework.security; + +import java.util.Collections; +import java.util.Map; + +/** + * Exception thrown for account-related errors during authentication or authorization processes, such as account not found, account locked, etc. + * This exception can be extended to provide more specific error types and include additional details as needed. + */ +@SuppressWarnings("unused") +public class AccountException extends RuntimeException { + private final String identity; + + private final Map details = Collections.emptyMap(); + + public AccountException(String identity) { + this.identity = identity; + } + + public AccountException(String identity, String message) { + super(message); + this.identity = identity; + } + + public AccountException(String identity, String message, Throwable cause) { + super(message, cause); + this.identity = identity; + } + + public String getIdentity() { + return identity; + } + + + public Map getDetails() { + return details; + } +} diff --git a/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java b/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java new file mode 100644 index 0000000..004ec3c --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java @@ -0,0 +1,20 @@ +package io.theurl.framework.security; + +/** + * Exception thrown when an account is locked ant cannot be used for authentication or authorization processes. This typically occurs after multiple failed login attempts or due to administrative actions. + * The exception includes the identity of the locked account and can be extended to include additional details as needed. + */ +@SuppressWarnings("unused") +public class AccountLockedException extends AccountException { + public AccountLockedException(String identity) { + super(identity); + } + + public AccountLockedException(String identity, String message) { + super(identity, message); + } + + public AccountLockedException(String identity, String message, Throwable cause) { + super(identity, message, cause); + } +} diff --git a/framework/src/main/java/io/theurl/framework/security/CredentialException.java b/framework/src/main/java/io/theurl/framework/security/CredentialException.java new file mode 100644 index 0000000..442983b --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/security/CredentialException.java @@ -0,0 +1,37 @@ +package io.theurl.framework.security; + +import java.util.Collections; +import java.util.Map; + +/** + * Base exception for credential-related errors, such as invalid credentials, expired credentials, etc. + * This exception can be extended to provide more specific error types and include additional details as needed. + */ +@SuppressWarnings("unused") +public class CredentialException extends RuntimeException { + private final String credential; + private final Map details = Collections.emptyMap(); + + + public CredentialException(String credential) { + this.credential = credential; + } + + public CredentialException(String credential, String message) { + super(message); + this.credential = credential; + } + + public CredentialException(String credential, String message, Throwable cause) { + super(message, cause); + this.credential = credential; + } + + public String getCredential() { + return credential; + } + + public Map getDetails() { + return details; + } +} diff --git a/framework/src/main/java/io/theurl/framework/security/UnauthorizedAccessException.java b/framework/src/main/java/io/theurl/framework/security/UnauthorizedAccessException.java new file mode 100644 index 0000000..a5bf3ae --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/security/UnauthorizedAccessException.java @@ -0,0 +1,17 @@ +package io.theurl.framework.security; + +/** + * The UnauthorizedAccessException is thrown when an attempt is made to access a resource or perform an action without proper authentication or authorization. + * This exception indicates that the user does not have the necessary credentials or permissions to access the requested resource. + * It is typically used in scenarios where authentication is required but has not been provided, or when the provided credentials are invalid. + */ +@SuppressWarnings("unused") +public class UnauthorizedAccessException extends RuntimeException { + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException(String message, Throwable cause) { + super(message, cause); + } +} From 9e25bc3765b1d15a0ff85164bb9f0da713b3f4e4 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 20 May 2026 23:11:56 +0800 Subject: [PATCH 09/10] Refactor BaseAuthProvider to use Lombok's @Slf4j for logging --- .../io/theurl/identity/external/BaseAuthProvider.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java b/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java index 4f8dbc0..22811e6 100644 --- a/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java +++ b/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java @@ -2,8 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.net.URI; import java.net.URLEncoder; @@ -18,10 +17,9 @@ * Provides common methods for external authentication providers, such as fetching user info and access tokens. * Concrete providers (e.g., Google, Microsoft, GitHub) can extend this class to implement their specific authentication logic while reusing these common methods. */ +@Slf4j public abstract class BaseAuthProvider implements ExternalAuthProvider { - private final Logger logger = LoggerFactory.getLogger(BaseAuthProvider.class); - protected JsonNode getUserInfo(String token, String url) { var request = HttpRequest.newBuilder() .uri(java.net.URI.create(url)) @@ -38,7 +36,7 @@ protected JsonNode getUserInfo(String token, String url) { return readJson(response.body()); } } catch (Exception e) { - logger.error(e.getMessage(), e); + log.error(e.getMessage(), e); } return null; } @@ -77,7 +75,7 @@ protected String getToken(String url, Map params, String paramsT return json.findValue("access_token").asText(); } } catch (Exception e) { - logger.error(e.getMessage(), e); + log.error(e.getMessage(), e); } return null; } From cbba1492284fcd948851eff0412ea490fb602a3c Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 21 May 2026 00:14:08 +0800 Subject: [PATCH 10/10] Add PriorityValueFinder utility class and corresponding tests --- .../framework/core/PriorityValueFinder.java | 41 +++++++++++++++++++ .../core/PriorityValueFinderTests.java | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java create mode 100644 framework/src/test/java/io/theurl/framework/core/PriorityValueFinderTests.java diff --git a/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java new file mode 100644 index 0000000..e1268a3 --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java @@ -0,0 +1,41 @@ +package io.theurl.framework.core; + +import java.util.PriorityQueue; +import java.util.function.Predicate; + +/** + * Utility class for finding a value in a priority queue based on a filter predicate. + * This class provides a method to search through a priority queue and return the first value that matches the given filter. + * If no matching value is found, it returns a specified default value. + */ +public class PriorityValueFinder { + + /** + * Finds the first value in the priority queue that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param values the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(PriorityQueue values, Predicate filter, T defaultValue) { + + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("Values queue cannot be null or empty"); + } + + if (filter == null) { + throw new IllegalArgumentException("Filter queue cannot be null"); + } + + while (!values.isEmpty()) { + T value = values.poll(); + if (filter.test(value)) { + return value; + } + } + return defaultValue; + } +} diff --git a/framework/src/test/java/io/theurl/framework/core/PriorityValueFinderTests.java b/framework/src/test/java/io/theurl/framework/core/PriorityValueFinderTests.java new file mode 100644 index 0000000..c5c8244 --- /dev/null +++ b/framework/src/test/java/io/theurl/framework/core/PriorityValueFinderTests.java @@ -0,0 +1,21 @@ +package io.theurl.framework.core; + +import org.junit.jupiter.api.Test; + +import java.util.PriorityQueue; + +class PriorityValueFinderTests { + @Test + void testFind() { + // Add test cases for PriorityValueFinder.find method + PriorityQueue values = new PriorityQueue<>(); + values.offer(1); + values.offer(2); + values.offer(3); + values.offer(4); + values.offer(5); + + Integer result = PriorityValueFinder.find(values, value -> value % 2 == 0, -1); + assert result == 2 : "Expected 2, but got " + result; + } +}