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/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); + } +} 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; + } +} 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/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); + } +} 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; - } -} 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..22811e6 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/BaseAuthProvider.java @@ -0,0 +1,91 @@ +package io.theurl.identity.external; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +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. + */ +@Slf4j +public abstract class BaseAuthProvider implements ExternalAuthProvider { + + 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) { + log.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) { + log.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/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/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 new file mode 100644 index 0000000..a8e62a9 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/GithubAuthProvider.java @@ -0,0 +1,51 @@ +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-github") +public class GithubAuthProvider extends BaseAuthProvider { + + @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("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() { + 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(); + } + }; + } +} 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 new file mode 100644 index 0000000..b5ce883 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/external/MicrosoftAuthProvider.java @@ -0,0 +1,57 @@ +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-microsoft") +public class MicrosoftAuthProvider extends BaseAuthProvider { + @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) { + 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() { + 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(); + } + }; + } +} 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}