From 9ca0492f61c7e4a52885bab514ce3febae90e531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Kie=C5=82basa?= Date: Fri, 6 Dec 2024 16:35:19 +0100 Subject: [PATCH] fix: #202 fix oauth2 login for error --- .../configuration/SecurityConfiguration.java | 24 +--- .../OAuth2AuthorizeResponseBodyExtractor.java | 49 +++++++ .../OAuth2ReactiveAuthorizationManager.java | 37 ++--- .../oauth2/ReactiveOAuth2AuthorizeClient.java | 36 +++++ .../module/oauth2/ReactiveOAuth2User.java | 40 ++++++ .../handler/AuthenticationFailureHandler.java | 59 ++++++++ .../handler/AuthenticationSuccessHandler.java | 75 +++++++++++ .../oauth2/model/AuthorizeResponse.java | 57 ++++++++ .../FrontendRedirectGatewayFilterFactory.java | 126 ------------------ .../src/main/resources/application.yml | 6 - .../configuration/WebConfiguration.java | 31 +++-- .../module/oauth2/OAuth2AuthorizeService.java | 46 ++++--- .../module/oauth2/OAuth2Controller.java | 9 +- .../module/oauth2/engine/ProviderEngine.java | 13 +- .../oauth2/engine/ProviderExchangeFlow.java | 60 ++++++--- .../engine/github/GithubExchangeFlow.java | 44 +++--- .../rest/OAuth2ControllerDefinition.java | 3 +- .../factory/ProviderUserFactory.java | 37 +++++ .../oauth2/OAuth2AuthorizeServiceSpec.groovy | 105 +++++++++++++++ .../OAuth2GetProvidersServiceSpec.groovy | 16 +++ .../github/GithubExchangeFlowSpec.groovy | 8 +- 21 files changed, 641 insertions(+), 240 deletions(-) create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2AuthorizeResponseBodyExtractor.java create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2AuthorizeClient.java create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2User.java create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationFailureHandler.java create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationSuccessHandler.java create mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/model/AuthorizeResponse.java delete mode 100644 kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/util/FrontendRedirectGatewayFilterFactory.java create mode 100644 kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/factory/ProviderUserFactory.java create mode 100644 kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeServiceSpec.groovy create mode 100644 kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2GetProvidersServiceSpec.groovy diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/configuration/SecurityConfiguration.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/configuration/SecurityConfiguration.java index 9e77ed8e..89a382f3 100644 --- a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/configuration/SecurityConfiguration.java +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/configuration/SecurityConfiguration.java @@ -11,11 +11,11 @@ import org.springframework.security.oauth2.client.web.server.DefaultServerOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver; import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import pl.sknikod.kodemygateway.infrastructure.module.oauth2.OAuth2ReactiveAuthorizationManager; -import reactor.core.publisher.Mono; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.handler.AuthenticationFailureHandler; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.handler.AuthenticationSuccessHandler; import java.util.function.Function; @@ -38,7 +38,9 @@ public SecurityWebFilterChain springSecurityFilterChain( ServerHttpSecurity http, ServerOAuth2AuthorizationRequestResolver authorizationRequestResolver, @Value("${app.security.oauth2.endpoint.callback}") String callbackEndpoint, - OAuth2ReactiveAuthorizationManager reactiveAuthenticationManager + OAuth2ReactiveAuthorizationManager reactiveAuthenticationManager, + AuthenticationSuccessHandler authenticationSuccessHandler, + AuthenticationFailureHandler authenticationFailureHandler ) { http .authorizeExchange(auth -> auth.anyExchange().permitAll()) @@ -46,24 +48,12 @@ public SecurityWebFilterChain springSecurityFilterChain( .authorizationRequestResolver(authorizationRequestResolver) .authenticationMatcher(callbackMatcher(callbackEndpoint)) .authenticationManager(reactiveAuthenticationManager) - .authenticationSuccessHandler(authenticationSuccessHandler()) - // TODO check if need change - //.authenticationFailureHandler(authenticationFailureHandler()) + .authenticationSuccessHandler(authenticationSuccessHandler) + .authenticationFailureHandler(authenticationFailureHandler) ); return http.build(); } - private ServerAuthenticationSuccessHandler authenticationSuccessHandler() { - return (webFilterExchange, authentication) -> webFilterExchange - .getChain() - .filter(webFilterExchange.getExchange()) - .and(Mono.empty()); - } - - /*private ServerAuthenticationFailureHandler authenticationFailureHandler() { - return (webFilterExchange, exception) -> Mono.empty(); - }*/ - @Bean public ServerOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver( ReactiveClientRegistrationRepository clientRegistrationRepository, diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2AuthorizeResponseBodyExtractor.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2AuthorizeResponseBodyExtractor.java new file mode 100644 index 00000000..45afe3f0 --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2AuthorizeResponseBodyExtractor.java @@ -0,0 +1,49 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.lang.NonNull; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.web.reactive.function.BodyExtractor; +import org.springframework.web.reactive.function.BodyExtractors; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.model.AuthorizeResponse; +import reactor.core.publisher.Mono; + +import java.util.Map; + +public class OAuth2AuthorizeResponseBodyExtractor implements BodyExtractor, ReactiveHttpInputMessage> { + private static final BodyExtractor>, ReactiveHttpInputMessage> DELEGATE; + + static { + DELEGATE = BodyExtractors.toMono(new ParameterizedTypeReference<>() { + }); + } + + @Override + @NonNull + public Mono extract(@NonNull ReactiveHttpInputMessage inputMessage, @NonNull Context context) { + return DELEGATE.extract(inputMessage, context).map(this::parse).flatMap(this::validate); + } + + private AuthorizeResponse parse(Map jsonMap) { + try { + return AuthorizeResponse.parse(jsonMap); + } catch (RuntimeException ex) { + OAuth2Error oAuth2Error = new OAuth2Error( + "invalid_token_response", "An error occurred parsing the Authorize response: " + ex.getMessage(), null + ); + throw new OAuth2AuthorizationException(oAuth2Error, ex); + } + } + + private Mono validate(AuthorizeResponse response) { + if (response.hasError()) { + AuthorizeResponse.Error errorResponse = (AuthorizeResponse.Error) response; + return Mono.error(new OAuth2AuthorizationException(new OAuth2Error( + errorResponse.getError(), errorResponse.getErrorDescription(), null)) + ); + } + return Mono.just(response).cast(AuthorizeResponse.Success.class); + } +} diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2ReactiveAuthorizationManager.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2ReactiveAuthorizationManager.java index 11ace682..f6952f1e 100644 --- a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2ReactiveAuthorizationManager.java +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/OAuth2ReactiveAuthorizationManager.java @@ -1,23 +1,24 @@ package pl.sknikod.kodemygateway.infrastructure.module.oauth2; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2AuthorizationException; -import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.*; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.stereotype.Component; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.model.AuthorizeResponse; import reactor.core.publisher.Mono; +import java.time.Duration; import java.time.Instant; -import java.util.Map; @Component +@RequiredArgsConstructor public class OAuth2ReactiveAuthorizationManager implements ReactiveAuthenticationManager { + private final ReactiveOAuth2AuthorizeClient client; + @Override public Mono authenticate(Authentication authentication) { return Mono.defer(() -> { @@ -29,8 +30,13 @@ public Mono authenticate(Authentication authentication) { if (!isStateEqually(exchange)) { return Mono.error(new OAuth2AuthorizationException(new OAuth2Error("invalid_state_parameter"))); } - return Mono.just(toOAuth2LoginAuthenticationToken(token)).onErrorMap(OAuth2AuthorizationException.class, - (e) -> new OAuth2AuthenticationException(e.getError(), e.getError().toString(), e)); + return this.client.authorize(new ReactiveOAuth2AuthorizeClient.Request( + token.getClientRegistration().getRegistrationId(), exchange.getAuthorizationResponse().getCode() + )) + .cast(AuthorizeResponse.Success.class) + .map(response -> onSuccess(response, token)) + .onErrorMap(OAuth2AuthorizationException.class, + (e) -> new OAuth2AuthenticationException(e.getError(), e.getMessage(), e)); }); } @@ -39,14 +45,15 @@ private boolean isStateEqually(OAuth2AuthorizationExchange exchange) { .equals(exchange.getAuthorizationResponse().getState()); } - private Authentication toOAuth2LoginAuthenticationToken(OAuth2AuthorizationCodeAuthenticationToken token) { + private OAuth2LoginAuthenticationToken onSuccess(AuthorizeResponse.Success response, OAuth2AuthorizationCodeAuthenticationToken token) { + Instant issuedAt = Instant.now(); + Instant expiresAt = Instant.from(issuedAt).plus(Duration.ofMinutes(10)); + var accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, response.getAccessToken(), issuedAt, expiresAt); + var refreshToken = new OAuth2RefreshToken(response.getRefreshToken(), issuedAt, expiresAt); return new OAuth2LoginAuthenticationToken( - token.getClientRegistration(), - token.getAuthorizationExchange(), - new DefaultOAuth2User(token.getAuthorities(), Map.of("name", token.getName()), "name"), - token.getAuthorities(), - new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "tokenValue", Instant.now(), Instant.now()), - token.getRefreshToken() + token.getClientRegistration(), token.getAuthorizationExchange(), + new ReactiveOAuth2User(token.getName(), accessToken, refreshToken), + token.getAuthorities(), accessToken, refreshToken ); } } diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2AuthorizeClient.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2AuthorizeClient.java new file mode 100644 index 00000000..fa0fcfef --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2AuthorizeClient.java @@ -0,0 +1,36 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.model.AuthorizeResponse; +import reactor.core.publisher.Mono; + +import java.util.function.BiFunction; + +@Component +public class ReactiveOAuth2AuthorizeClient { + private final WebClient webClient = WebClient.builder().build(); + private final OAuth2AuthorizeResponseBodyExtractor BODY_EXTRACTOR = new OAuth2AuthorizeResponseBodyExtractor(); + private final BiFunction authorizeUriFunction; + + public ReactiveOAuth2AuthorizeClient( + @Value("${service.baseUrl.auth}") String authBaseUrl, + @Value("${app.security.oauth2.endpoint.authorize}") String authorizeEndpoint + ) { + this.authorizeUriFunction = (registrationId, code) -> + authBaseUrl + authorizeEndpoint + "/" + registrationId + "?code=" + code; + } + + public Mono authorize(Request request) { + return this.webClient.get() + .uri(authorizeUriFunction.apply(request.getRegistrationId(), request.getCode())) + .exchangeToMono(clientResponse -> clientResponse.body(BODY_EXTRACTOR)); + } + + @lombok.Value + public static class Request { + String registrationId; + String code; + } +} diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2User.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2User.java new file mode 100644 index 00000000..5cbe9f3f --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/ReactiveOAuth2User.java @@ -0,0 +1,40 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2; + +import lombok.Getter; +import lombok.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class ReactiveOAuth2User implements OAuth2User, Serializable { + @Serial + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + private final String name; + private final OAuth2AccessToken accessToken; + private final OAuth2RefreshToken refreshToken; + + public ReactiveOAuth2User(String name, OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + this.name = name; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } +} diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationFailureHandler.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationFailureHandler.java new file mode 100644 index 00000000..12bf9ffb --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationFailureHandler.java @@ -0,0 +1,59 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2.handler; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; + +import java.net.URI; + +@Component +public class AuthenticationFailureHandler implements ServerAuthenticationFailureHandler { + private final String frontBaseUrl; + + public AuthenticationFailureHandler(@Value("${service.baseUrl.front}") String frontBaseUrl) { + this.frontBaseUrl = frontBaseUrl; + } + + @Override + public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { + ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); + performRedirect(response, exception); + return Mono.empty(); + } + + private void performRedirect(ServerHttpResponse response, AuthenticationException exception) { + URI location = UriComponentsBuilder.fromUriString(frontBaseUrl) + .queryParams(createParams(exception)) + .build().toUri(); + + response.setStatusCode(HttpStatus.FOUND); + response.getHeaders().setLocation(location); + } + + private MultiValueMap createParams(AuthenticationException exception) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("auth", "failure"); + if (exception instanceof OAuth2AuthenticationException oauth2Exception) { + OAuth2Error error = oauth2Exception.getError(); + if (error != null) { + params.add("error", error.getErrorCode()); + params.add("details", error.getDescription()); + return params; + } + } + params.add("error", OAuth2ErrorCodes.SERVER_ERROR); + params.add("details", "Unknown authorization error"); + return params; + } +} diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationSuccessHandler.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationSuccessHandler.java new file mode 100644 index 00000000..f00b75aa --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/handler/AuthenticationSuccessHandler.java @@ -0,0 +1,75 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2.handler; + +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import pl.sknikod.kodemygateway.infrastructure.module.oauth2.ReactiveOAuth2User; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; + +@Component +public class AuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + private static final String ACCESS_TOKEN_COOKIE = "AUTH_CONTEXT"; + private static final String REFRESH_TOKEN_COOKIE = "AUTH_PERSIST"; + private final String frontBaseUrl; + + public AuthenticationSuccessHandler(@Value("${service.baseUrl.front}") String frontBaseUrl) { + this.frontBaseUrl = frontBaseUrl; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { + ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); + modifyHeaders(response, authentication); + performRedirect(response); + return Mono.empty(); + } + + private void modifyHeaders(ServerHttpResponse response, Authentication token) { + ReactiveOAuth2User user = (ReactiveOAuth2User) ((OAuth2AuthenticationToken) token).getPrincipal(); + Instant now = Instant.now(); + var accessToken = createCookie( + ACCESS_TOKEN_COOKIE, user.getAccessToken().getTokenValue(), + Duration.between(now, user.getAccessToken().getExpiresAt()) + ); + var refreshToken = createCookie( + REFRESH_TOKEN_COOKIE, user.getRefreshToken().getTokenValue(), + Duration.between(now, user.getRefreshToken().getExpiresAt()) + ); + response.getHeaders() + .addAll(HttpHeaders.SET_COOKIE, List.of(accessToken.toString(), refreshToken.toString())); + } + + private ResponseCookie createCookie(@NonNull String name, @NonNull String value, @NonNull Duration age) { + return ResponseCookie.from(name, value) + .path("/") + .httpOnly(true) + .sameSite("Lax") + .maxAge(age) + .build(); + } + + private void performRedirect(ServerHttpResponse response) { + URI location = UriComponentsBuilder.fromUriString(frontBaseUrl) + .queryParam("auth", "success") + .build().toUri(); + + response.setStatusCode(HttpStatus.FOUND); + response.getHeaders().setLocation(location); + } +} diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/model/AuthorizeResponse.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/model/AuthorizeResponse.java new file mode 100644 index 00000000..ecde8b65 --- /dev/null +++ b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/infrastructure/module/oauth2/model/AuthorizeResponse.java @@ -0,0 +1,57 @@ +package pl.sknikod.kodemygateway.infrastructure.module.oauth2.model; + +import lombok.Getter; + +import java.util.Map; + +public interface AuthorizeResponse { + boolean hasError(); + + static AuthorizeResponse parse(Map jsonMap) { + if ((Boolean) jsonMap.get("hasError")) { + return Error.parse(jsonMap); + } + return Success.parse(jsonMap); + } + + @Getter + class Success implements AuthorizeResponse { + private final String accessToken; + private final String refreshToken; + + public Success(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + @Override + public boolean hasError() { + return false; + } + + public static AuthorizeResponse parse(Map jsonMap) { + return new Success((String) jsonMap.get("accessToken"), (String) jsonMap.get("refreshToken")); + } + } + + @Getter + class Error implements AuthorizeResponse { + private final String error; + private final String errorDescription; + + public Error(String error, String errorDescription) { + this.error = error; + this.errorDescription = errorDescription; + } + + @Override + public boolean hasError() { + return true; + } + + public static AuthorizeResponse parse(Map jsonMap) { + return new Error((String) jsonMap.get("error"), (String) jsonMap.get("errorDescription")); + } + } +} + diff --git a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/util/FrontendRedirectGatewayFilterFactory.java b/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/util/FrontendRedirectGatewayFilterFactory.java deleted file mode 100644 index f8c82fee..00000000 --- a/kodemy-api-gateway/src/main/java/pl/sknikod/kodemygateway/util/FrontendRedirectGatewayFilterFactory.java +++ /dev/null @@ -1,126 +0,0 @@ -package pl.sknikod.kodemygateway.util; - -import lombok.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cloud.gateway.filter.GatewayFilter; -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter; -import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; -import org.springframework.core.Ordered; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.server.reactive.ServerHttpResponse; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import reactor.netty.Connection; -import reactor.netty.DisposableChannel; - -import java.net.URI; -import java.time.Duration; -import java.util.List; -import java.util.Optional; - -import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CLIENT_RESPONSE_CONN_ATTR; - -@Component -@Slf4j -public class FrontendRedirectGatewayFilterFactory - extends AbstractGatewayFilterFactory { - - public FrontendRedirectGatewayFilterFactory() { - super(Config.class); - } - - @Override - public GatewayFilter apply(Config config) { - return new FrontendRedirectGatewayFilter(config); - } - - @Getter - @Setter - @NoArgsConstructor - @AllArgsConstructor - public static class Config { - private String location; - } - - @RequiredArgsConstructor - private static final class FrontendRedirectGatewayFilter implements GatewayFilter, Ordered { - private final Config config; - private static final String ACCESS_TOKEN_COOKIE = "AUTH_CONTEXT"; - private static final String REFRESH_TOKEN_COOKIE = "AUTH_PERSIST"; - - @Override - public int getOrder() { - return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER; - } - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - return chain.filter(exchange) - .then(Mono.defer(() -> { - if (exchange.getAttribute(CLIENT_RESPONSE_CONN_ATTR) != null) { - modifyHeaders(exchange.getResponse()); - performRedirect(exchange.getResponse()); - disposeConnection(exchange); - } - return Mono.empty(); - })) - .doOnCancel(() -> disposeConnection(exchange)) - .doOnError(th -> disposeConnection(exchange)); - } - - private void modifyHeaders(ServerHttpResponse response) { - if (response.getStatusCode() != HttpStatus.OK) { - log.warn("Response status code is {}. Skipping modifyHeaders", response.getStatusCode()); - return; - } - - HttpHeaders headers = response.getHeaders(); - List accessTokens = headers.get(ACCESS_TOKEN_COOKIE); - List refreshTokens = headers.get(REFRESH_TOKEN_COOKIE); - - if (accessTokens == null || accessTokens.isEmpty() - || refreshTokens == null || refreshTokens.isEmpty()) { - log.warn("Authorization tokens don't exist or are empty. Skipping modifyHeaders"); - return; - } - - var accessToken = createCookie( - ACCESS_TOKEN_COOKIE, - accessTokens.get(0), - Duration.ofDays(1) - ); - - var refreshToken = createCookie( - REFRESH_TOKEN_COOKIE, - refreshTokens.get(0), - Duration.ofDays(1) - ); - - headers.addAll(HttpHeaders.SET_COOKIE, List.of(accessToken.toString(), refreshToken.toString())); - } - - private ResponseCookie createCookie(@NonNull String name, @NonNull String value, @NonNull Duration age) { - return ResponseCookie.from(name, value) - .path("/") - .httpOnly(true) - .sameSite("Lax") - .maxAge(age) - .build(); - } - - private void performRedirect(ServerHttpResponse response) { - response.setStatusCode(HttpStatus.FOUND); - response.getHeaders().setLocation(URI.create(config.location)); - } - - private void disposeConnection(ServerWebExchange exchange) { - Optional.ofNullable((Connection) exchange.getAttribute(CLIENT_RESPONSE_CONN_ATTR)) - .filter(conn -> conn.channel().isActive()) - .ifPresent(DisposableChannel::dispose); - } - } -} diff --git a/kodemy-api-gateway/src/main/resources/application.yml b/kodemy-api-gateway/src/main/resources/application.yml index 05e7c659..1e8964a3 100644 --- a/kodemy-api-gateway/src/main/resources/application.yml +++ b/kodemy-api-gateway/src/main/resources/application.yml @@ -8,12 +8,6 @@ spring: uri: ${service.baseUrl.auth} predicates: - Path=${app.security.oauth2.endpoint.callback}/{provider} - filters: - - RewritePath=${app.security.oauth2.endpoint.callback}/(?.*), ${app.security.oauth2.endpoint.authorize}/${provider} - - name: FrontendRedirect - args: - location: ${service.baseUrl.front} - - RewriteCookieToBearer - id: auth_logout uri: ${service.baseUrl.auth} diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/configuration/WebConfiguration.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/configuration/WebConfiguration.java index 8ecdedad..b1d511da 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/configuration/WebConfiguration.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/configuration/WebConfiguration.java @@ -1,7 +1,9 @@ package pl.sknikod.kodemyauth.configuration; import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; @@ -18,26 +20,37 @@ public class WebConfiguration { public static final String OAUTH2_REST_TEMPLATE = "oAuth2RestTemplate"; + @Bean("clientHttpRequestFactory") + public BufferingClientHttpRequestFactory clientHttpRequestFactory() { + final var httpClient = HttpClientBuilder.create() + .setDefaultRequestConfig(RequestConfig.custom().build()) + .evictExpiredConnections() + .build(); + return new BufferingClientHttpRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)); + } + @Bean - @LoadBalanced + //@LoadBalanced public RestTemplate restTemplate( RestTemplateBuilder restTemplateBuilder, + @Qualifier("clientHttpRequestFactory") BufferingClientHttpRequestFactory clientHttpRequestFactory, LogbookClientHttpRequestInterceptor logbookInterceptor ) { - var requestFactory = new HttpComponentsClientHttpRequestFactory(HttpClientBuilder.create().build()); - restTemplateBuilder.requestFactory(() -> new BufferingClientHttpRequestFactory(requestFactory)); - restTemplateBuilder.additionalInterceptors(Collections.singletonList(logbookInterceptor)); - return restTemplateBuilder.build(); + return restTemplateBuilder + .requestFactory(() -> clientHttpRequestFactory) + .additionalInterceptors(Collections.singletonList(logbookInterceptor)) + .build(); } @Bean(OAUTH2_REST_TEMPLATE) public RestTemplate oAuth2RestTemplate( RestTemplateBuilder restTemplateBuilder, + @Qualifier("clientHttpRequestFactory") BufferingClientHttpRequestFactory clientHttpRequestFactory, LogbookClientHttpRequestInterceptor logbookInterceptor ) { - var requestFactory = new HttpComponentsClientHttpRequestFactory(HttpClientBuilder.create().build()); - restTemplateBuilder.requestFactory(() -> new BufferingClientHttpRequestFactory(requestFactory)); - restTemplateBuilder.additionalInterceptors(Collections.singletonList(logbookInterceptor)); - return restTemplateBuilder.build(); + return restTemplateBuilder + .requestFactory(() -> clientHttpRequestFactory) + .additionalInterceptors(Collections.singletonList(logbookInterceptor)) + .build(); } } diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeService.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeService.java index efeaabf6..bfc0df4d 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeService.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeService.java @@ -2,12 +2,11 @@ import io.vavr.Tuple; import io.vavr.Tuple2; -import io.vavr.control.Option; import io.vavr.control.Try; -import lombok.RequiredArgsConstructor; -import lombok.Value; +import lombok.*; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; import org.springframework.stereotype.Service; import pl.sknikod.kodemyauth.infrastructure.database.*; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.ProviderEngine; @@ -15,7 +14,7 @@ import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration; import pl.sknikod.kodemyauth.infrastructure.store.RefreshTokenStore; import pl.sknikod.kodemyauth.infrastructure.store.UserStore; -import pl.sknikod.kodemycommons.exception.Validation400Exception; +import pl.sknikod.kodemycommons.exception.InternalError500Exception; import pl.sknikod.kodemycommons.exception.content.ExceptionUtil; import pl.sknikod.kodemycommons.security.JwtProvider; import pl.sknikod.kodemycommons.security.UserPrincipal; @@ -36,17 +35,18 @@ public class OAuth2AuthorizeService { private final RefreshTokenStore refreshTokenStore; public AuthorizeResponse authorize(Registration registrationId, Map parameters) { - return Try.of(() -> { - if (!parameters.containsKey("code")) { - throw new Validation400Exception("Bad parameters map"); - } - return Option.ofOptional(providerEngine.createProviderUser(registrationId.getId(), parameters)) - .map(this::createOrLoadUser) - .map(this::toUserPrincipal) - .map(this::generateTokens) - .map(tokens -> new AuthorizeResponse(tokens._1.value(), tokens._2.getToken().toString())) - .getOrNull(); - }).getOrElseThrow(ExceptionUtil::throwIfFailure); + if (!parameters.containsKey("code")) { + throw new InternalError500Exception(); + } + return providerEngine.createProviderUser(registrationId.getId(), parameters) + .map(this::createOrLoadUser) + .map(this::toUserPrincipal) + .map(this::generateTokens) + .map(tokens -> new AuthorizeResponse(tokens._1.value(), tokens._2.getToken().toString())) + .recoverWith(OAuth2AuthorizationException.class, e -> { + return Try.success(AuthorizeResponse.error(e.getError().getErrorCode(), e.getError().getDescription())); + }) + .getOrElseThrow(() -> new InternalError500Exception()); } private Tuple2 createOrLoadUser(ProviderUser providerUser) { @@ -106,8 +106,24 @@ private JwtProvider.Input mapToJwtInput(UserPrincipal user) { } @Value + @AllArgsConstructor public static class AuthorizeResponse { + boolean hasError; String accessToken; String refreshToken; + String error; + String errorDescription; + + public AuthorizeResponse(String accessToken, String refreshToken) { + this.hasError = false; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.error = null; + this.errorDescription = null; + } + + public static AuthorizeResponse error(String error, String errorDescription) { + return new AuthorizeResponse(true, null, null, error, errorDescription); + } } } diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2Controller.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2Controller.java index 418c48df..ba72bcb7 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2Controller.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2Controller.java @@ -15,8 +15,6 @@ public class OAuth2Controller implements OAuth2ControllerDefinition { private final OAuth2GetProvidersService oAuth2GetProvidersService; private final OAuth2AuthorizeService oAuth2AuthorizeService; - private static final String ACCESS_TOKEN_COOKIE = "AUTH_CONTEXT"; - private static final String REFRESH_TOKEN_COOKIE = "AUTH_PERSIST"; @Override public ResponseEntity> getProvidersList() { @@ -25,12 +23,9 @@ public ResponseEntity> getProvi } @Override - public ResponseEntity authorize( + public ResponseEntity authorize( Registration registration, Map parameters) { - final var tokens = oAuth2AuthorizeService.authorize(registration, parameters); return ResponseEntity.status(HttpStatus.OK) - .header(ACCESS_TOKEN_COOKIE, tokens.getAccessToken()) - .header(REFRESH_TOKEN_COOKIE, tokens.getRefreshToken()) - .build(); + .body(oAuth2AuthorizeService.authorize(registration, parameters)); } } diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderEngine.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderEngine.java index 0b797395..94518abe 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderEngine.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderEngine.java @@ -1,14 +1,17 @@ package pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine; +import io.vavr.control.Try; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.stereotype.Component; import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Optional; @Component @RequiredArgsConstructor @@ -17,19 +20,19 @@ public class ProviderEngine { private final List providerExchangeFlows; private final ClientRegistrationRepository clientRegistrationRepository; - public Optional createProviderUser(String registrationId, Map parameters) { + public Try createProviderUser(String registrationId, Map parameters) { return this.execute(registrationId, parameters.get("code"), providerExchangeFlows.iterator()); } - private Optional execute(String registrationId, String code, Iterator iterator) { + private Try execute(String registrationId, String code, Iterator iterator) { if (!iterator.hasNext()) { log.info("No processable provider for registration ID: {}", registrationId); - return Optional.empty(); + return Try.failure(new OAuth2AuthorizationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT))); } ProviderExchangeFlow providerExchangeFlow = iterator.next(); if (providerExchangeFlow.isApply(registrationId)) { log.info("Process {} provider flow", providerExchangeFlow.getClass().getSimpleName()); - return Optional.of(providerExchangeFlow.exchange(clientRegistrationRepository, code)); + return providerExchangeFlow.exchange(clientRegistrationRepository, code); } return execute(registrationId, code, iterator); // check another one } diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderExchangeFlow.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderExchangeFlow.java index 3c858537..1f6ce086 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderExchangeFlow.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/ProviderExchangeFlow.java @@ -12,9 +12,10 @@ import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.web.client.RestTemplate; -import pl.sknikod.kodemycommons.exception.InternalError500Exception; -import pl.sknikod.kodemycommons.exception.content.ExceptionUtil; import java.util.Collections; import java.util.HashMap; @@ -37,13 +38,13 @@ public abstract class ProviderExchangeFlow { public abstract boolean isApply(String registrationId); - public abstract ProviderUser exchange(ClientRegistrationRepository repository, String code); + public abstract Try exchange(ClientRegistrationRepository repository, String code); protected Map initNewAttributesMap(ClientRegistration clientRegistration) { return new HashMap<>(Map.of(DEFAULT_REGISTRATION_ID_URI_VARIABLE_NAME, clientRegistration.getRegistrationId())); } - protected AccessToken postForAccessToken(ClientRegistration clientRegistration, @NonNull String code) { + protected Try postForAccessToken(ClientRegistration clientRegistration, @NonNull String code) { log.info("Exchanging {}'s authorization code", clientRegistration.getRegistrationId()); var headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); @@ -58,28 +59,29 @@ protected AccessToken postForAccessToken(ClientRegistration clientRegistration, HttpMethod.POST, new HttpEntity<>(params, headers), PARAMETERIZED_MAP_TYPE )) .onFailure(th -> log.error("Error during the exchange of authorization code", th)) - .filter(res -> { - if (res.getBody() != null && res.getBody().containsKey("error")) { - log.error("Error during the exchange of authorization code: {}", res.getBody()); - return false; + .toTry(() -> { + var msg = "Failed to exchange authorization code"; + return new OAuth2AuthorizationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, msg, null), msg); + }) + .flatMap(res -> { + Map map = res.getBody(); + if (map != null && map.containsKey("error")) { + final var exception = new OAuth2AuthorizationException(getOAuth2Error(map)); + log.error("Failed to exchange authorization code", exception); + return Try.failure(exception); } - return true; + return Try.success(map); }) - .toTry(() -> new InternalError500Exception("Failed to exchange authorization code")) - .map(HttpEntity::getBody) .map(body -> new AccessToken( (String) body.getOrDefault("access_token", null), (String) body.getOrDefault("token_type", null) - )) - .onSuccess(accessToken -> log.info(accessToken.toString())) - .getOrElseThrow(ExceptionUtil::throwIfFailure); + )); } - protected Map getUserAttributes(ClientRegistration clientRegistration, AccessToken accessToken) { + protected Try> getUserAttributes(ClientRegistration clientRegistration, AccessToken accessToken) { log.info("Fetching {}'s user attributes", clientRegistration.getRegistrationId()); var headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - log.info("Access {}", accessToken.toString()); headers.setBearerAuth(accessToken.accessToken); return Try.of(() -> restTemplate.exchange( @@ -87,9 +89,29 @@ protected Map getUserAttributes(ClientRegistration clientRegistr HttpMethod.GET, new HttpEntity<>(headers), PARAMETERIZED_MAP_TYPE )) .onFailure(th -> log.error("Error during fetching user attributes", th)) - .toTry(() -> new InternalError500Exception("Failed to fetch user attributes")) - .map(HttpEntity::getBody) - .getOrElseThrow(ExceptionUtil::throwIfFailure); + .toTry(() -> { + var msg = "Failed to fetch user attributes"; + return new OAuth2AuthorizationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, msg, null), msg); + }) + .flatMap(res -> { + Map map = res.getBody(); + if (map == null || map.isEmpty()) { + var msg = "Failed to fetch user attributes"; + final var exception = new OAuth2AuthorizationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, msg, null), msg); + log.error(msg, exception); + return Try.failure(exception); + } + return Try.success(map); + }) + .onSuccess(map -> log.info("Successfully fetched user attributes")); + } + + private OAuth2Error getOAuth2Error(Map body) { + return new OAuth2Error( + (String) body.get("error"), + (String) body.get("errorDescription"), + (String) body.get("uri") + ); } @Value diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlow.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlow.java index 6139831b..31f3e7b4 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlow.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlow.java @@ -5,24 +5,22 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; +import org.springframework.http.*; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import pl.sknikod.kodemyauth.configuration.WebConfiguration; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.ProviderExchangeFlow; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.ProviderUser; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration; -import pl.sknikod.kodemycommons.exception.InternalError500Exception; -import pl.sknikod.kodemycommons.exception.content.ExceptionUtil; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; @Component @Slf4j @@ -42,16 +40,28 @@ public boolean isApply(String registrationId) { } @Override - public ProviderUser exchange(ClientRegistrationRepository repository, String code) { + public Try exchange(ClientRegistrationRepository repository, String code) { ClientRegistration clientRegistration = repository.findByRegistrationId(getRegistration().getId()); Map attributes = initNewAttributesMap(clientRegistration); - AccessToken accessToken = super.postForAccessToken(clientRegistration, code); - attributes.putAll(super.getUserAttributes(clientRegistration, accessToken)); - attributes.put("email", fixEmailNull(attributes, clientRegistration, accessToken)); - return new GithubUser(attributes); + Try accessTokenTry = super.postForAccessToken(clientRegistration, code); + if (accessTokenTry.isFailure()) { + return Try.failure(accessTokenTry.getCause()); + } + AccessToken accessToken = accessTokenTry.get(); + final var userAttributesTry = super.getUserAttributes(clientRegistration, accessToken); + if (userAttributesTry.isFailure()) { + return Try.failure(accessTokenTry.getCause()); + } + attributes.putAll(userAttributesTry.get()); + Try emailTry = fixEmailNull(clientRegistration, accessToken); + if (emailTry.isFailure()) { + return Try.failure(emailTry.getCause()); + } + attributes.put("email", emailTry.get()); + return Try.success(new GithubUser(attributes)); } - private String fixEmailNull(Map attributes, ClientRegistration clientRegistration, AccessToken accessToken) { + private Try fixEmailNull(ClientRegistration clientRegistration, AccessToken accessToken) { log.info("Fetching {}'s user emails", clientRegistration.getRegistrationId()); var headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); @@ -59,16 +69,18 @@ private String fixEmailNull(Map attributes, ClientRegistration c final var typeReference = new ParameterizedTypeReference>() { }; + var ex = new OAuth2AuthorizationException( + new OAuth2Error("email_fetch_error", "Failed to fetch user emails", null)); return Try.of(() -> restTemplate.exchange( clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri() + "/emails", HttpMethod.GET, new HttpEntity<>(headers), typeReference )) .onFailure(th -> log.error("Error during fetching user emails", th)) - .toTry(() -> new InternalError500Exception("Failed to fetch user emails")) + .toTry(() -> ex) .map(HttpEntity::getBody) - .map(emails -> emails.stream().filter(e -> e.primary).findFirst().orElse(null)) - .map(Email::getEmail) - .getOrElseThrow(ExceptionUtil::throwIfFailure); + .flatMap(emails -> Try.of(() -> emails.stream().filter(e -> e.primary).findFirst()) + .map(Optional::get).toTry(() -> ex)) + .map(Email::getEmail); } @Data diff --git a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/rest/OAuth2ControllerDefinition.java b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/rest/OAuth2ControllerDefinition.java index 93c10faf..bc52fc74 100644 --- a/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/rest/OAuth2ControllerDefinition.java +++ b/kodemy-auth/src/main/java/pl/sknikod/kodemyauth/infrastructure/rest/OAuth2ControllerDefinition.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.OAuth2AuthorizeService; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.OAuth2GetProvidersService; import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration; import pl.sknikod.kodemycommons.doc.SwaggerResponse; @@ -27,7 +28,7 @@ public interface OAuth2ControllerDefinition { @Operation(summary = "OAuth2 authorize", description = """ This endpoint performs the OAuth2 authorization, which is handled by kodemy-api-gateway service.\n Executing request here will throws Internal Server Error status.""") - ResponseEntity authorize( + ResponseEntity authorize( @PathVariable(value = DEFAULT_REGISTRATION_ID_URI_VARIABLE_NAME) Registration registration, @RequestParam(required = false, defaultValue = "{}") Map parameters ); diff --git a/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/factory/ProviderUserFactory.java b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/factory/ProviderUserFactory.java new file mode 100644 index 00000000..4c31de0e --- /dev/null +++ b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/factory/ProviderUserFactory.java @@ -0,0 +1,37 @@ +package pl.sknikod.kodemyauth.factory; + +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.ProviderUser; +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration; + +import java.util.Map; + +public class ProviderUserFactory { + public static ProviderUser create(Registration registrationId) { + return new ProviderUser(Map.of()) { + @Override + public String getRegistrationId() { + return registrationId.getId(); + } + + @Override + public String getPrincipalId() { + return "12345"; + } + + @Override + public String getUsername() { + return "username"; + } + + @Override + public String getEmail() { + return "email@email.com"; + } + + @Override + public String getPhoto() { + return "http://example.com"; + } + }; + } +} diff --git a/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeServiceSpec.groovy b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeServiceSpec.groovy new file mode 100644 index 00000000..b4bddac7 --- /dev/null +++ b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2AuthorizeServiceSpec.groovy @@ -0,0 +1,105 @@ +package pl.sknikod.kodemyauth.infrastructure.module.oauth2 + +import io.vavr.control.Try +import org.springframework.security.oauth2.core.OAuth2AuthorizationException +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2ErrorCodes +import pl.sknikod.kodemyauth.factory.ProviderUserFactory +import pl.sknikod.kodemyauth.factory.RefreshTokenFactory +import pl.sknikod.kodemyauth.factory.UserFactory +import pl.sknikod.kodemyauth.infrastructure.database.RoleRepository +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.ProviderEngine +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration +import pl.sknikod.kodemyauth.infrastructure.store.RefreshTokenStore +import pl.sknikod.kodemyauth.infrastructure.store.UserStore +import pl.sknikod.kodemycommons.exception.InternalError500Exception +import pl.sknikod.kodemycommons.security.JwtProvider +import spock.lang.Specification + +class OAuth2AuthorizeServiceSpec extends Specification { + + def providerEngine = Mock(ProviderEngine) + def roleRepository = Mock(RoleRepository) + def userStore = Mock(UserStore) + def jwtProvider = Mock(JwtProvider) + def refreshTokenStore = Mock(RefreshTokenStore) + + def oAuth2AuthorizeService = new OAuth2AuthorizeService( + providerEngine, roleRepository, userStore, jwtProvider, refreshTokenStore + ) + + def REGISTRATION_ID = Registration.github + def PARAMETERS = Map.of("code", "code") + def USER = UserFactory.create() + def USER_TOKEN_ID = UUID.randomUUID() + def REFRESH_TOKEN = RefreshTokenFactory.create(UUID.randomUUID(), USER_TOKEN_ID) + def GITHUB_USER = ProviderUserFactory.create(REGISTRATION_ID) + + def setup() { + roleRepository.findById(USER.getRole().getId()) >> Optional.of(USER.getRole()) + jwtProvider.generateUserToken(_ as JwtProvider.Input) >> Mock(JwtProvider.Token) { + id() >> USER_TOKEN_ID + } + refreshTokenStore.createAndGet(_ as Long, USER_TOKEN_ID) >> Try.success(REFRESH_TOKEN) + } + + def "should throw InternalError500Exception if parameters missing"() { + when: + oAuth2AuthorizeService.authorize(REGISTRATION_ID, [:]) + + then: + thrown(InternalError500Exception.class) + } + + def "should return error response when providerEngine has OAuth2AuthorizationException"() { + given: + def parameters = Map.of("code", "code") + def errorMsg = "Some error message" + + providerEngine.createProviderUser(REGISTRATION_ID.getId(), parameters) >> Try.failure( + new OAuth2AuthorizationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, errorMsg, null), errorMsg)) + + when: + def result = oAuth2AuthorizeService.authorize(REGISTRATION_ID, parameters) + + then: + verifyAll(result) { + hasError + error == OAuth2ErrorCodes.SERVER_ERROR.toString() + errorDescription == errorMsg + } + } + + def "should load user and return tokens"() { + given: + providerEngine.createProviderUser(REGISTRATION_ID.getId(), PARAMETERS) >> Try.success(GITHUB_USER) + userStore.findByProviderUser(GITHUB_USER) >> Try.success(USER) + + when: + def result = oAuth2AuthorizeService.authorize(REGISTRATION_ID, PARAMETERS) + + then: + verifyAll(result) { + !hasError + accessToken == accessToken + refreshToken == REFRESH_TOKEN.token.toString() + } + } + + def "should create new user and return tokens"() { + given: + providerEngine.createProviderUser(REGISTRATION_ID.getId(), PARAMETERS) >> Try.success(GITHUB_USER) + userStore.findByProviderUser(GITHUB_USER) >> Try.failure(new RuntimeException()) + userStore.save(GITHUB_USER) >> Optional.of(USER) + + when: + def result = oAuth2AuthorizeService.authorize(REGISTRATION_ID, PARAMETERS) + + then: + verifyAll(result) { + !hasError + accessToken == accessToken + refreshToken == REFRESH_TOKEN.token.toString() + } + } +} diff --git a/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2GetProvidersServiceSpec.groovy b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2GetProvidersServiceSpec.groovy new file mode 100644 index 00000000..f63200aa --- /dev/null +++ b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/OAuth2GetProvidersServiceSpec.groovy @@ -0,0 +1,16 @@ +package pl.sknikod.kodemyauth.infrastructure.module.oauth2 + +import pl.sknikod.kodemyauth.infrastructure.module.oauth2.engine.Registration +import spock.lang.Specification + +class OAuth2GetProvidersServiceSpec extends Specification { + def service = new OAuth2GetProvidersService("http://localhost:8080", "/api/oauth2/authorize") + + def "should return providers with authorize link"() { + when: + def result = service.getProviders() + + then: + result.size() == Registration.values().size() + } +} diff --git a/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlowSpec.groovy b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlowSpec.groovy index e18f6b95..206bfeaa 100644 --- a/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlowSpec.groovy +++ b/kodemy-auth/src/test/groovy/pl/sknikod/kodemyauth/infrastructure/module/oauth2/engine/github/GithubExchangeFlowSpec.groovy @@ -14,7 +14,7 @@ class GithubExchangeFlowSpec extends Specification { def githubExchangeFlow = new GithubExchangeFlow(restTemplate) - def "should get successfully provider user"() { + def "should return github user"() { given: def code = "code" def registrationId = githubExchangeFlow.registration.toString() @@ -49,9 +49,9 @@ class GithubExchangeFlowSpec extends Specification { def result = githubExchangeFlow.exchange(repository, code) then: - verifyAll { - result.username == "login" - result.email == "email@email.com" + result.isSuccess() + verifyAll(result.get()) { + username == "login" } } }