diff --git a/.gitignore b/.gitignore index 1062418..dbf292f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ *.iml +authid.txt diff --git a/analytics-view/.claude/settings.local.json b/analytics-view/.claude/settings.local.json new file mode 100644 index 0000000..dc5e694 --- /dev/null +++ b/analytics-view/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(find /c/Users/balex/IdeaProjects/post-hub-platform -type f \\\\\\(-name *.tsx -o -name *.ts -o -name *.jsx -o -name *.js -o -name *.vue \\\\\\) ! -path */node_modules/* ! -path */.next/*)" + ] + } +} diff --git a/gateway-service/pom.xml b/gateway-service/pom.xml index a22e1b1..1344ffb 100644 --- a/gateway-service/pom.xml +++ b/gateway-service/pom.xml @@ -106,6 +106,13 @@ ${lombok.version} true + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java b/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java index 1856014..06b3b16 100644 --- a/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java +++ b/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java @@ -1,5 +1,7 @@ package com.posthub.gateway.config; +import com.posthub.gateway.security.OAuth2AuthenticationSuccessHandler; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -13,8 +15,11 @@ import reactor.core.publisher.Mono; @Configuration @EnableWebFluxSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { return http @@ -27,7 +32,9 @@ public class SecurityConfig { .pathMatchers( "/api/auth/login", "/api/auth/register", - "/api/auth/refresh/token" + "/api/auth/refresh/token", + "/oauth2/authorization/**", + "/login/oauth2/code/**" ).permitAll() .pathMatchers( "/actuator/**", @@ -36,6 +43,9 @@ public class SecurityConfig { ).permitAll() .anyExchange().permitAll() ) + .oauth2Login(oauth2 -> oauth2 + .authenticationSuccessHandler(oAuth2AuthenticationSuccessHandler) + ) .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint((exchange, ex) -> { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java b/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java index 0b99db9..a43b47f 100644 --- a/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java +++ b/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java @@ -1,5 +1,6 @@ package com.posthub.gateway.model.entity; +import com.posthub.gateway.model.enums.AuthProvider; import com.posthub.gateway.model.enums.RegistrationStatus; import com.posthub.gateway.model.enums.UserRole; import lombok.Getter; @@ -40,4 +41,10 @@ public class User { @Column("role") private UserRole role; + + @Column("auth_provider") + private AuthProvider authProvider; + + @Column("provider_id") + private String providerId; } \ No newline at end of file diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/enums/AuthProvider.java b/gateway-service/src/main/java/com/posthub/gateway/model/enums/AuthProvider.java new file mode 100644 index 0000000..05a81b1 --- /dev/null +++ b/gateway-service/src/main/java/com/posthub/gateway/model/enums/AuthProvider.java @@ -0,0 +1,8 @@ +package com.posthub.gateway.model.enums; + +public enum AuthProvider { + LOCAL, + GOOGLE, + FACEBOOK, + GITHUB +} \ No newline at end of file diff --git a/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java b/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java index 871eda4..23b7b39 100644 --- a/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java +++ b/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.posthub.gateway.repository; import com.posthub.gateway.model.entity.User; +import com.posthub.gateway.model.enums.AuthProvider; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import reactor.core.publisher.Mono; @@ -11,4 +12,6 @@ public interface UserRepository extends ReactiveCrudRepository { Mono findByUsername(String username); Mono existsByEmail(String email); + + Mono findByAuthProviderAndProviderId(AuthProvider authProvider, String providerId); } \ No newline at end of file diff --git a/gateway-service/src/main/java/com/posthub/gateway/security/OAuth2AuthenticationSuccessHandler.java b/gateway-service/src/main/java/com/posthub/gateway/security/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..1736fb9 --- /dev/null +++ b/gateway-service/src/main/java/com/posthub/gateway/security/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,135 @@ +package com.posthub.gateway.security; + +import com.posthub.gateway.model.entity.RefreshToken; +import com.posthub.gateway.model.entity.User; +import com.posthub.gateway.model.enums.AuthProvider; +import com.posthub.gateway.model.enums.RegistrationStatus; +import com.posthub.gateway.model.enums.UserRole; +import com.posthub.gateway.repository.RefreshTokenRepository; +import com.posthub.gateway.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${oauth2.redirect-uri:https://balexvic.com/login}") + private String redirectUri; + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { + OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; + OAuth2User oAuth2User = authToken.getPrincipal(); + String registrationId = authToken.getAuthorizedClientRegistrationId(); + + AuthProvider provider = AuthProvider.valueOf(registrationId.toUpperCase()); + String providerId = extractProviderId(oAuth2User, registrationId); + String email = extractEmail(oAuth2User, registrationId); + String name = extractName(oAuth2User, registrationId); + + return userRepository.findByAuthProviderAndProviderId(provider, providerId) + .switchIfEmpty( + email != null + ? userRepository.findByEmail(email) + .flatMap(existingUser -> { + existingUser.setAuthProvider(provider); + existingUser.setProviderId(providerId); + existingUser.setUpdated(LocalDateTime.now()); + return userRepository.save(existingUser); + }) + .switchIfEmpty(Mono.defer(() -> createNewUser(provider, providerId, email, name))) + : Mono.defer(() -> createNewUser(provider, providerId, email, name)) + ) + .flatMap(user -> { + user.setLastLogin(LocalDateTime.now()); + user.setUpdated(LocalDateTime.now()); + return userRepository.save(user); + }) + .flatMap(this::generateTokensAndRedirect) + .flatMap(redirectUrl -> { + webFilterExchange.getExchange().getResponse().setStatusCode(HttpStatus.FOUND); + webFilterExchange.getExchange().getResponse().getHeaders().setLocation(URI.create(redirectUrl)); + return webFilterExchange.getExchange().getResponse().setComplete(); + }); + } + + private Mono createNewUser(AuthProvider provider, String providerId, String email, String name) { + User user = new User(); + user.setAuthProvider(provider); + user.setProviderId(providerId); + user.setEmail(email); + user.setUsername(name != null ? name : "user_" + providerId.substring(0, 8)); + user.setRegistrationStatus(RegistrationStatus.ACTIVE); + user.setRole(UserRole.USER); + user.setCreated(LocalDateTime.now()); + user.setUpdated(LocalDateTime.now()); + user.setDeleted(false); + log.info("Creating new OAuth2 user: provider={}, email={}", provider, email); + return userRepository.save(user); + } + + private Mono generateTokensAndRedirect(User user) { + return refreshTokenRepository.findByUserId(user.getId()) + .flatMap(existing -> { + existing.setCreated(LocalDateTime.now()); + existing.setToken(generateUuid()); + existing.setSessionId(generateUuid()); + return refreshTokenRepository.save(existing); + }) + .switchIfEmpty(Mono.defer(() -> { + RefreshToken newToken = new RefreshToken(); + newToken.setUserId(user.getId()); + newToken.setCreated(LocalDateTime.now()); + newToken.setToken(generateUuid()); + newToken.setSessionId(generateUuid()); + return refreshTokenRepository.save(newToken); + })) + .map(refreshToken -> { + String accessToken = jwtTokenProvider.generateToken(user, refreshToken.getSessionId()); + return redirectUri + "?token=" + accessToken + "&refreshToken=" + refreshToken.getToken(); + }); + } + + private String extractProviderId(OAuth2User oAuth2User, String registrationId) { + return switch (registrationId) { + case "google" -> oAuth2User.getAttribute("sub"); + case "facebook" -> oAuth2User.getAttribute("id"); + case "github" -> String.valueOf(oAuth2User.getAttributes().get("id")); + default -> oAuth2User.getName(); + }; + } + + private String extractEmail(OAuth2User oAuth2User, String registrationId) { + return oAuth2User.getAttribute("email"); + } + + private String extractName(OAuth2User oAuth2User, String registrationId) { + return switch (registrationId) { + case "github" -> oAuth2User.getAttribute("login"); + default -> oAuth2User.getAttribute("name"); + }; + } + + private String generateUuid() { + return UUID.randomUUID().toString().replace("-", ""); + } +} \ No newline at end of file diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index c609ba4..9c28462 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -6,6 +6,23 @@ spring: application: name: gateway-service + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + scope: email,profile + github: + client-id: ${GITHUB_CLIENT_ID:} + client-secret: ${GITHUB_CLIENT_SECRET:} + scope: user:email,read:user + facebook: + client-id: ${FACEBOOK_CLIENT_ID:} + client-secret: ${FACEBOOK_CLIENT_SECRET:} + scope: email,public_profile + # ---- R2DBC (reactive DB) ---- r2dbc: url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb} @@ -75,12 +92,18 @@ jwt: secret: ${JWT_SECRET:} expiration: ${JWT_EXPIRATION:103600000} +# ---- OAuth2 redirect after success ---- +oauth2: + redirect-uri: ${OAUTH2_REDIRECT_URI:https://balexvic.com/login} + # ---- Auth path config ---- auth: public-paths: - /api/auth/login - /api/auth/register - /api/auth/refresh/token + - /oauth2/authorization/** + - /login/oauth2/code/** - /actuator/** - /api/*/v3/api-docs/** - /api/*/swagger-ui/** @@ -102,7 +125,7 @@ management: health: show-details: always gateway: - enabled: true + access: unrestricted # ---- Logging ---- logging: