spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.8k stars 5.9k forks source link

OAuth2 setup for reactive applications for both servlet and non-servlet contexts #12228

Closed rjbgaspar closed 1 year ago

rjbgaspar commented 1 year ago

I'm using 5.7.3 via Spring Boot security starters and the app was build using JHipster version 7.9.3

Describe the bug If a filter of type ServerOAuth2AuthorizedClientExchangeFilterFunction is used to make any request before any incoming web request (servlet) is received, this request is not process until a timeout stop it.

http://localhost:50090/api/v2/ts-orders
[[java.net.SocketTimeoutException](http://java.net.sockettimeoutexception/)](http://java.net.sockettimeoutexception/): Read timed out

The current filter is org.springframework.security.web.server.authentication.AuthenticationWebFilter and I think it's somehow related to the fact of the processor being unable to create the claims set.

NimbusReactiveJwtDecoder.java

Converter<JWT, Mono<JWTClaimsSet>> processor() {
   JWKSecurityContextJWKSet jwkSource = new JWKSecurityContextJWKSet();
   DefaultJWTProcessor<JWKSecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
   JWSKeySelector<JWKSecurityContext> jwsKeySelector = jwsKeySelector(jwkSource);
   jwtProcessor.setJWSKeySelector(jwsKeySelector);
   jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {
   });
   ReactiveRemoteJWKSource source = new ReactiveRemoteJWKSource(this.jwkSetUri);
   source.setWebClient(this.webClient);
   Function<JWSAlgorithm, Boolean> expectedJwsAlgorithms = getExpectedJwsAlgorithms(jwsKeySelector);
   Mono<ConfigurableJWTProcessor<JWKSecurityContext>> jwtProcessorMono = this.jwtProcessorCustomizer
         .apply(source, jwtProcessor)
         .cache((processor) -> FOREVER, (ex) -> Duration.ZERO, () -> Duration.ZERO);
   return (jwt) -> {
      JWKSelector selector = createSelector(expectedJwsAlgorithms, jwt.getHeader());
      return jwtProcessorMono.flatMap((processor) -> source.get(selector)
            .onErrorMap((ex) -> new IllegalStateException("Could not obtain the keys", ex))
            .map((jwkList) -> createClaimsSet(processor, jwt, new JWKSecurityContext(jwkList))));
   };
}

The selector is created, but it never execute the source.get(selector) the log shows:

2022-11-17T13:47:18.423Z DEBUG 34432 --- [ctor-http-nio-5] .w.s.u.m.NegatedServerWebExchangeMatcher : matches = true
2022-11-17T13:47:18.430Z DEBUG 34432 --- [ctor-http-nio-5] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'null /oauth2/authorization/{registrationId}'

If the app is started and there no communications between the microservices, if a web request is received it is correctly processed as shown in the log

2022-11-17T13:53:49.462Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'null /oauth2/authorization/{registrationId}'
2022-11-17T13:53:49.474Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=POST}
2022-11-17T13:53:49.475Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'POST /logout'
2022-11-17T13:53:49.475Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-11-17T13:53:49.481Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/api/authenticate', method=null}
2022-11-17T13:53:49.481Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'null /api/authenticate'
2022-11-17T13:53:49.481Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/api/auth-info', method=null}
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'null /api/auth-info'
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/api/admin/**', method=null}
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Request 'POST /api/v2/ts-orders' doesn't match 'null /api/admin/**'
2022-11-17T13:53:49.482Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-11-17T13:53:49.483Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/api/**', method=null}
2022-11-17T13:53:49.483Z DEBUG 33800 --- [ctor-http-nio-4] athPatternParserServerWebExchangeMatcher : Checking match of request : '/api/v2/ts-orders'; against '/api/**'
2022-11-17T13:53:49.484Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2022-11-17T13:53:49.484Z DEBUG 33800 --- [ctor-http-nio-4] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/api/v2/ts-orders' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@384fec71
2022-11-17T13:53:49.485Z DEBUG 33800 --- [ctor-http-nio-4] o.s.s.w.s.a.AuthorizationWebFilter       : Authorization successful
2022-11-17T13:53:49.763Z DEBUG 33800 --- [ctor-http-nio-4] r.client.log.DefaultReactiveLogger       : [ConfigApi#getConfig(String)]--->GET http://panther/api/v1/configs/slug/pdm-connect-enabled HTTP/1.1
2022-11-17T13:53:49.973Z DEBUG 33800 --- [ctor-http-nio-3] r.client.log.DefaultReactiveLogger       : [ConfigApi#getConfig(String)]<--- headers takes 210 milliseconds
2022-11-17T13:53:49.977Z DEBUG 33800 --- [ctor-http-nio-3] i.g.r.c.i.CircuitBreakerStateMachine     : CircuitBreaker 'ConfigApi#getConfig(String)' succeeded:
2022-11-17T13:53:49.979Z DEBUG 33800 --- [ctor-http-nio-3] i.g.r.c.i.CircuitBreakerStateMachine     : Event SUCCESS published: 2022-11-17T13:53:49.977616300Z[Europe/Lisbon]: CircuitBreaker 'ConfigApi#getConfig(String)' recorded a successful call. Elapsed time: 218 ms

To Reproduce This is my current configuration:

SecurityConfiguration.java

mport static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;

import com.gv.zeppelin.security.AuthoritiesConstants;
import com.gv.zeppelin.security.SecurityUtils;
import com.gv.zeppelin.security.oauth2.AudienceValidator;
import com.gv.zeppelin.security.oauth2.JwtGrantedAuthorityConverter;
import java.util.HashSet;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter;
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
import org.springframework.security.web.server.savedrequest.NoOpServerRequestCache;
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.zalando.problem.spring.webflux.advice.security.SecurityProblemSupport;
import reactor.core.publisher.Mono;
import tech.jhipster.config.JHipsterProperties;
import tech.jhipster.web.filter.reactive.CookieCsrfFilter;

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
@Import(SecurityProblemSupport.class)
public class SecurityConfiguration {

    private final JHipsterProperties jHipsterProperties;

    @Value("${spring.security.oauth2.client.provider.oidc.issuer-uri}")
    private String issuerUri;

    private final SecurityProblemSupport problemSupport;
    private final CorsWebFilter corsWebFilter;

    public SecurityConfiguration(
        JHipsterProperties jHipsterProperties,
        SecurityProblemSupport problemSupport,
        CorsWebFilter corsWebFilter
    ) {
        this.jHipsterProperties = jHipsterProperties;
        this.problemSupport = problemSupport;
        this.corsWebFilter = corsWebFilter;
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        // @formatter:off
        http
            .securityMatcher(new NegatedServerWebExchangeMatcher(new OrServerWebExchangeMatcher(
                pathMatchers("/app/**", "/_app/**", "/i18n/**", "/img/**", "/content/**", "/swagger-ui/**", "/v3/api-docs/**", "/test/**"),
                pathMatchers(HttpMethod.OPTIONS, "/**")
            )))
            .csrf()
                .disable()
            .addFilterBefore(corsWebFilter, SecurityWebFiltersOrder.REACTOR_CONTEXT)
            .exceptionHandling()
                .accessDeniedHandler(problemSupport)
                .authenticationEntryPoint(problemSupport)
        .and()
            .headers()
                .contentSecurityPolicy(jHipsterProperties.getSecurity().getContentSecurityPolicy())
            .and()
                .referrerPolicy(ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            .and()
                .permissionsPolicy().policy("camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()")
            .and()
                .frameOptions().mode(Mode.DENY)
        .and()
            .requestCache()
            .requestCache(NoOpServerRequestCache.getInstance())
        .and()
            .authorizeExchange()
            .pathMatchers("/api/authenticate").permitAll()
            .pathMatchers("/api/auth-info").permitAll()
            .pathMatchers("/api/admin/**").hasAuthority(AuthoritiesConstants.ADMIN)
            .pathMatchers("/api/**").authenticated()
            .pathMatchers("/management/health").permitAll()
            .pathMatchers("/management/health/**").permitAll()
            .pathMatchers("/management/info").permitAll()
            .pathMatchers("/management/prometheus").permitAll()

            // SPE Begin WebSocket
            .pathMatchers("/websocket.html").permitAll()
            .pathMatchers("/websocket/**").permitAll()
            // SPE End WebSocket

            .pathMatchers("/management/**").hasAuthority(AuthoritiesConstants.ADMIN);

        http
            .oauth2ResourceServer()
                .jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        http.oauth2Client();
        // @formatter:on
        return http.build();
    }

    Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new JwtGrantedAuthorityConverter());
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }

    /**
     * Map authorities from "groups" or "roles" claim in ID Token.
     *
     * @return a {@link ReactiveOAuth2UserService} that has the groups from the IdP.
     */
    @Bean
    public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService();

        return userRequest -> {
            // Delegate to the default implementation for loading a user
            return delegate
                .loadUser(userRequest)
                .map(user -> {
                    Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

                    user
                        .getAuthorities()
                        .forEach(authority -> {
                            if (authority instanceof OidcUserAuthority) {
                                OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
                                mappedAuthorities.addAll(
                                    SecurityUtils.extractAuthorityFromClaims(oidcUserAuthority.getUserInfo().getClaims())
                                );
                            }
                        });

                    return new DefaultOidcUser(mappedAuthorities, user.getIdToken(), user.getUserInfo());
                });
        };
    }

    @Bean
    ReactiveJwtDecoder jwtDecoder() {
        NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) ReactiveJwtDecoders.fromOidcIssuerLocation(issuerUri);

        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(jHipsterProperties.getSecurity().getOauth2().getAudience());
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
        OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

        jwtDecoder.setJwtValidator(withAudience);

        return jwtDecoder;
    }
}

OAuth2ClientConfiguration.java

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.*;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;

@Configuration
public class OAuth2ClientConfiguration {

    private static final Logger log = LogManager.getLogger(OAuth2ClientConfiguration.class);

    final ReactiveOAuth2AuthorizedClientService authorizedClientService;

    public OAuth2ClientConfiguration(ReactiveOAuth2AuthorizedClientService authorizedClientService) {
        this.authorizedClientService = authorizedClientService;
    }

    /**
     * (non-servlet) it's used OAuth2AuthorizedClientManager
     * When operating outside of a HttpServletRequest context, use:
     *  - AuthorizedClientServiceOAuth2AuthorizedClientManager (non-reactive)
     *  - AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager (reactive)
     *
     * @see <a href="https://github.com/spring-projects/spring-security/issues/8444">Docs: WebClient OAuth2 Setup for Reactive Applications might be wrong</a>
     */

    @Bean
    public ReactiveOAuth2AuthorizedClientManager nonServletAuthorizedClientManager(
        ReactiveClientRegistrationRepository clientRegistrationRepository,
        ReactiveOAuth2AuthorizedClientService authorizedClientService
    ) {
        ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder
            .builder()
            .clientCredentials()
            .refreshToken()
            .build();

        var authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
            clientRegistrationRepository,
            authorizedClientService
        );
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
        return authorizedClientManager;
    }

    /**
     * Wire
     */
    @Bean
    public ServerOAuth2AuthorizedClientExchangeFilterFunction nonServletFilterFunction(
        ReactiveOAuth2AuthorizedClientManager authorizedClientManager
    ) {
        var oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth.setDefaultClientRegistrationId("oidc");
        //        oauth.setAuthorizationFailureHandler(authorizationFailureHandler());
        return oauth;
    }

    public ReactiveOAuth2AuthorizationFailureHandler authorizationFailureHandler() {
        return new RemoveAuthorizedClientReactiveOAuth2AuthorizationFailureHandler((clientRegistrationId, principal, attributes) -> {
            log.warn("ReactiveOAuth2AuthorizationFailureHandler: {} {} {}", clientRegistrationId, principal, attributes);

            return authorizedClientService.removeAuthorizedClient(clientRegistrationId, principal.getName());
        });
    }
}

WebClientConfiguration .java

import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfiguration {

    @Bean
    @LoadBalanced
    public WebClient.Builder webClientBuilder(ServerOAuth2AuthorizedClientExchangeFilterFunction oAuth2AuthorizedClientFilter) {
        return WebClient.builder().filter(oAuth2AuthorizedClientFilter);
    }
}

Expected behavior I spent several days trying to understand why this is happening, but I was not able to, despite I think it has something to do with createClaimsSet or with cachedJWKSet (ReactiveRemoteJWKSource). I honestly believe that the request should have an answer, e.g. not ending with timeout.

sjohnr commented 1 year ago

Thanks for getting in touch, but it feels like this is a question that would be better suited to Stack Overflow. We prefer to use GitHub issues only for bugs and enhancements. Feel free to update this issue with a link to the re-posted question (so that other people can find it) or add a minimal sample that reproduces this issue if you feel this is a genuine bug.

Having said that, please note that for servlet environments, the class ServletOAuth2AuthorizedClientExchangeFilterFunction should be used. See WebClient Integration for Servlet Environments for more information. If you feel I have misunderstood your question, please let me know and we can re-open if necessary.