0

I have a springboot webflux application protected by spring security oauth2. I have both restricted and unrestricted endpoints in the application. The application is throwing 401 when pass Authorization header to unrestricted endpoint. It works fine when I don't pass Authorization header for unrestricted endpoint. I can see that AuthenticationManageris getting executed for both restricted and unrestricted endpoints when Authorization header is passed.

SecurityWebFilterChain bean configuration is given below.

public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity) {
        return serverHttpSecurity
                .requestCache()
                .requestCache(NoOpServerRequestCache.getInstance())
                .and()
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .exceptionHandling()
                .authenticationEntryPoint((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)))
                .accessDeniedHandler((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
                .and().csrf().disable()
                .authorizeExchange()
                .pathMatchers("/api/unrestricted").permitAll()
                .and()
                .authorizeExchange().anyExchange().authenticated()
                .and()
                .oauth2ResourceServer()
                .jwt(jwtSpec -> jwtSpec.authenticationManager(authenticationManager()))
                .authenticationEntryPoint((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)))
                .accessDeniedHandler((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
                .and().build();

    }

AuthenticationManager code is like below.

private ReactiveAuthenticationManager authenticationManager() {
        return authentication -> {
            log.info("executing authentication manager");
            return Mono.justOrEmpty(authentication)
                    .filter(auth -> auth instanceof BearerTokenAuthenticationToken)
                    .cast(BearerTokenAuthenticationToken.class)
                    .filter(token -> RSAHelper.verifySigning(token.getToken()))
                    .switchIfEmpty(Mono.error(new BadCredentialsException("Invalid token")))
                    .map(token -> (Authentication) new UsernamePasswordAuthenticationToken(
                            token.getToken(),
                            token.getToken(),
                            Collections.emptyList()
                    ));
        };
    }

We found this issue when one of our API consumers sent dummy Authorization header for unrestricted endpoint.

I can find Spring MVC solution for the similar issue in SpringMVC Oauth2.

I have a working example in the github project demo-security. I have written couple of Integration Tests to explain this issue.

@AutoConfigureWebTestClient
@SpringBootTest
public class DemoIT {

    @Autowired
    private WebTestClient webTestClient;


    @Test
    void testUnrestrictedEndpointWithAuthorizationHeader() {
        webTestClient.get()
                .uri("/api/unrestricted")
                .header(HttpHeaders.AUTHORIZATION, "Bearer token") // fails when passing token
                .exchange()
                .expectStatus().isOk();
    }

    @Test
    void testUnrestrictedEndpoint() {
        webTestClient.get()
                .uri("/api/unrestricted")
                .exchange()
                .expectStatus().isOk();
    }

    @SneakyThrows
    @Test
    void testRestrictedEndpoint() {
        webTestClient.get()
                .uri("/api/restricted")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + RSAHelper.getJWSToken())
                .exchange()
                .expectStatus().isOk();
    }
}

I'm not sure what might be the problem. Is my Security Config misconfigured ? Any help would be really appreciated.

user3610007
  • 101
  • 2
  • 7
  • You pass an invalid token so yes it will fail. The fact that something is unrestricted doesn't mean the security header doesn't get parsed... – M. Deinum Feb 25 '21 at 09:57
  • you have `.anyExchange()` i suspect that is overriding your previous declaration – Toerktumlare Feb 25 '21 at 11:25
  • @Toerktumlare I tried with different settings but that didn't help – user3610007 Mar 10 '21 at 11:29
  • @M.Deinum I agree. The point I'm trying to make here is why authenticationManager is getting executed for unrestricted endpoints – user3610007 Mar 10 '21 at 11:29
  • `.authorizeExchange().anyExchange().authenticated()` you are calling `authorizeExchange` twice, which means you are overriding the previous one. – Toerktumlare Mar 10 '21 at 11:40
  • Because, as I said earlier, the fact that something isn't protected doesn't mean that authentication is skipped when a header is present. – M. Deinum Mar 10 '21 at 11:40

2 Answers2

2

I have finally managed to solve it with different approach. I moved away from using oauth2ResourceServer() method.

The updated SecurityWebFilterChain bean configuration is given below.

@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity,
                                                         BearerTokenConverter bearerTokenConverter) {
        return serverHttpSecurity
                .requestCache()
                .requestCache(NoOpServerRequestCache.getInstance()) // disable cache
                .and()
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .exceptionHandling()
                .authenticationEntryPoint((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)))
                .accessDeniedHandler((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)))
                .and()
                .csrf().disable().authorizeExchange()
                .pathMatchers("/api/unrestricted")
                .permitAll()
                .anyExchange().access((mono, authorizationContext) -> mono.map(authentication -> new AuthorizationDecision(authentication.isAuthenticated())))
                .and()
                .addFilterAt(authenticationWebFilter(bearerTokenConverter), SecurityWebFiltersOrder.AUTHENTICATION)
                .build();
    }

Instead of using oauth2ResourceServer()I have added custom AuthenticationWebFilter in the chain.

AuthenticationWebFilter code is given below.

private AuthenticationWebFilter authenticationWebFilter(BearerTokenConverter bearerTokenConverter) {
        AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
        authenticationWebFilter.setServerAuthenticationConverter(bearerTokenConverter);
        authenticationWebFilter.setRequiresAuthenticationMatcher(new NegatedServerWebExchangeMatcher(pathMatchers("/api/unrestricted")));
        authenticationWebFilter.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler((swe, e) -> Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))));
        return authenticationWebFilter;
    }

AuthenticationWebFilter will be executed only for restricted endpoints with the help of authenticationWebFilter.setRequiresAuthenticationMatcher().

Now it works even when we pass Authorization header for unrestricted endpoints. The question that would arise is why should one pass. But we didn't want our API to break with unexpected headers. So we took this approach.

This implementation helped us to solve the issue. But the issue is still there with the previous approach.

I have updated the github project demo-security with the working code.

Dharman
  • 30,962
  • 25
  • 85
  • 135
user3610007
  • 101
  • 2
  • 7
0

First some helpful info:

Authentication and Authorization are done in separate filters in Spring Security: AuthenticationWebFilter and AuthorizationWebFilter.

First authentication checks for any passed in credentials and puts them into the security context. Later Authorization filter checks for whether access is allowed or not based on your ServerHttpSecurity setup.

In your case, I am not 100% sure, but I think the issue might be that your AuthenticationManager is returning an error if there is not a valid authentication

.switchIfEmpty(Mono.error(new BadCredentialsException("Invalid token")))

What worked for me was returning Mono.empty() if authentication failed:

public Mono<Authentication> authenticate(Authentication authentication) {
        String jwt = authentication.getCredentials().toString(); 

        if (StringUtils.hasText(jwt) && this.tokenProvider.validateToken(jwt)) {
            return Mono.just(this.tokenProvider.getAuthentication(jwt));
        }
        else {
            return Mono.empty();
        }
    }
Michael
  • 556
  • 3
  • 12
  • Yes it is expected to throw exception for invalid token. But I wouldn't expect authenticationManager to get executed for unrestricted endpoints. That's the problem here. – user3610007 Mar 10 '21 at 11:35
  • It does get executed. What you need to do is return Mono.empty() if there is no authentication. I will update the answer above with my code that works – Michael Mar 11 '21 at 16:00