1

I am while implementation of AWS Cognito security mechanism in my Spring Boot application. I met a problem with already existing integration test for external API after enabling security. As a test result I am receiving an error:

2020-11-15 18:18:20.033 ERROR 12072 --- [ main] .c.s.f.AwsCognitoJwtAuthenticationFilter : Invalid Action, no token found MockHttpServletResponse: Status = 401 Error message = null Headers = [Access-Control-Allow-Origin:"*", Access-Control-Allow-Methods:"POST, GET, OPTIONS, PUT, DELETE", Access-Control-Max-Age:"3600", Access-Control-Allow-Credentials:"true", Access-Control-Allow-Headers:"content-type,Authorization", Content-Type:"application/json"] Content type = application/json Body = {"data":null,"exception":{"message":"JWT Handle exception","httpStatusCode":"INTERNAL_SERVER_ERROR","detail":null}}

My WebSecurityConfiguration looks like:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableTransactionManagement
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

  private CustomAuthenticationProvider authProvider;
  private AwsCognitoJwtAuthenticationFilter awsCognitoJwtAuthenticationFilter;
  private AccountControllerExceptionHandler exceptionHandler;
  private static final String LOGIN_URL = "/auth/login";
  private static final String LOGOUT_URL = "/auth/signOut";

  @Autowired
  public WebSecurityConfiguration(
      CustomAuthenticationProvider authProvider,
      AwsCognitoJwtAuthenticationFilter awsCognitoJwtAuthenticationFilter,
      AccountControllerExceptionHandler exceptionHandler) {
    this.authProvider = authProvider;
    this.awsCognitoJwtAuthenticationFilter = awsCognitoJwtAuthenticationFilter;
    this.exceptionHandler = exceptionHandler;
  }

  public WebSecurityConfiguration() {
    super(true);
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(authProvider).eraseCredentials(false);
  }

  @Bean
  @Override
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }

  @Override
  public void configure(WebSecurity web) {
    // TokenAuthenticationFilter will ignore the below paths
    web.ignoring().antMatchers("/auth");
    web.ignoring().antMatchers("/auth/**");
    web.ignoring().antMatchers("/v2/api-docs");
    web.ignoring().antMatchers(GET, "/nutrition/api/**");
    web.ignoring().antMatchers(GET, "/**");
    web.ignoring().antMatchers(HttpMethod.OPTIONS, "/**");
  }

  @Override
  protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .addFilterAfter(corsFilter(), ExceptionTranslationFilter.class)
        .exceptionHandling()
        .authenticationEntryPoint(new SecurityAuthenticationEntryPoint())
        .accessDeniedHandler(new RestAccessDeniedHandler())
        .and()
        .anonymous()
        .and()
        .sessionManagement()
        .sessionCreationPolicy(STATELESS)
        .and()
        .authorizeRequests()
        .antMatchers("/auth")
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .addFilterBefore(
            awsCognitoJwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        .formLogin(formLogin -> formLogin.loginProcessingUrl(LOGIN_URL).failureHandler(exceptionHandler))
        .logout(logout -> logout.permitAll().logoutUrl(LOGOUT_URL))
        .csrf(AbstractHttpConfigurer::disable);
  }

  private CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOrigin("*");
    config.addAllowedHeader(ORIGIN);
    config.addAllowedHeader(CONTENT_TYPE);
    config.addAllowedHeader(ACCEPT);
    config.addAllowedHeader(AUTHORIZATION);
    config.addAllowedMethod(GET);
    config.addAllowedMethod(PUT);
    config.addAllowedMethod(POST);
    config.addAllowedMethod(OPTIONS);
    config.addAllowedMethod(DELETE);
    config.addAllowedMethod(PATCH);
    config.setMaxAge(3600L);

    source.registerCorsConfiguration("/v2/api-docs", config);
    source.registerCorsConfiguration("/**", config);

    return new CorsFilter();
  }
}

AwsCognitoJwtAuthenticationFilter

@Slf4j
public class AwsCognitoJwtAuthenticationFilter extends OncePerRequestFilter {

  private static final String ERROR_OCCURRED_WHILE_PROCESSING_THE_TOKEN =
      "Error occured while processing the token";
  private static final String INVALID_TOKEN_MESSAGE = "Invalid Token";

  private final AwsCognitoIdTokenProcessor awsCognitoIdTokenProcessor;

  @Autowired private ApplicationContext appContext;

  public AwsCognitoJwtAuthenticationFilter(AwsCognitoIdTokenProcessor awsCognitoIdTokenProcessor) {
    this.awsCognitoIdTokenProcessor = awsCognitoIdTokenProcessor;
  }

  private void createExceptionResponse(
      ServletRequest request, ServletResponse response, CognitoException exception)
      throws IOException {
    HttpServletRequest req = (HttpServletRequest) request;
    ExceptionController exceptionController;
    ObjectMapper objMapper = new ObjectMapper();

    exceptionController = appContext.getBean(ExceptionController.class);
    ResponseData<Object> responseData = exceptionController.handleJwtException(req, exception);

    HttpServletResponse httpResponse = CorsHelper.addResponseHeaders(response);

    final HttpServletResponseWrapper wrapper = new HttpServletResponseWrapper(httpResponse);
    wrapper.setStatus(HttpStatus.UNAUTHORIZED.value());
    wrapper.setContentType(APPLICATION_JSON_VALUE);
    wrapper.getWriter().println(objMapper.writeValueAsString(responseData));
    wrapper.getWriter().flush();
  }

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    Authentication authentication;
    try {
      authentication = awsCognitoIdTokenProcessor.getAuthentication(request);

      SecurityContextHolder.getContext().setAuthentication(authentication);

    } catch (BadJOSEException e) {
      SecurityContextHolder.clearContext();
      log.error(e.getMessage());
      createExceptionResponse(
          request,
          response,
          new CognitoException(
              INVALID_TOKEN_MESSAGE,
              CognitoException.INVALID_TOKEN_EXCEPTION_CODE,
              e.getMessage()));
      return;
    } catch (CognitoException e) {
      SecurityContextHolder.clearContext();
      log.error(e.getMessage());
      createExceptionResponse(
          request,
          response,
          new CognitoException(
              e.getErrorMessage(),
              CognitoException.INVALID_TOKEN_EXCEPTION_CODE,
              e.getDetailErrorMessage()));
      return;
    } catch (Exception e) {
      SecurityContextHolder.clearContext();
      log.error(e.getMessage());
      createExceptionResponse(
          request,
          response,
          new CognitoException(
              ERROR_OCCURRED_WHILE_PROCESSING_THE_TOKEN,
              CognitoException.INVALID_TOKEN_EXCEPTION_CODE,
              e.getMessage()));
      return;
    }

    filterChain.doFilter(request, response);
  }
}

AwsCognitoIdTokenProcessor

@AllArgsConstructor
@NoArgsConstructor
public class AwsCognitoIdTokenProcessor {

  private static final String INVALID_TOKEN = "Invalid Token";
  private static final String NO_TOKEN_FOUND = "Invalid Action, no token found";

  private static final String ROLE_PREFIX = "ROLE_";
  private static final String EMPTY_STRING = "";

  private ConfigurableJWTProcessor<SecurityContext> configurableJWTProcessor;

  private AWSConfig jwtConfiguration;

  private String extractAndDecodeJwt(String token) {
    String tokenResult = token;

    if (token != null && token.startsWith("Bearer ")) {
      tokenResult = token.substring("Bearer ".length());
    }
    return tokenResult;
  }

  @SuppressWarnings("unchecked")
  public Authentication getAuthentication(HttpServletRequest request)
      throws ParseException, BadJOSEException, JOSEException {
    String idToken = request.getHeader(HTTP_HEADER);
    if (idToken == null) {
      throw new CognitoException(
          NO_TOKEN_FOUND,
          NO_TOKEN_PROVIDED_EXCEPTION,
          "No token found in Http Authorization Header");
    } else {

      idToken = extractAndDecodeJwt(idToken);
      JWTClaimsSet claimsSet;

      claimsSet = configurableJWTProcessor.process(idToken, null);

      if (!isIssuedCorrectly(claimsSet)) {
        throw new CognitoException(
            INVALID_TOKEN,
            INVALID_TOKEN_EXCEPTION_CODE,
            String.format(
                "Issuer %s in JWT token doesn't match cognito idp %s",
                claimsSet.getIssuer(), jwtConfiguration.getCognitoIdentityPoolUrl()));
      }

      if (!isIdToken(claimsSet)) {
        throw new CognitoException(
            INVALID_TOKEN, NOT_A_TOKEN_EXCEPTION, "JWT Token doesn't seem to be an ID Token");
      }

      String username = claimsSet.getClaims().get(USER_NAME_FIELD).toString();

      List<String> groups = (List<String>) claimsSet.getClaims().get(COGNITO_GROUPS);
      List<GrantedAuthority> grantedAuthorities =
          convertList(
              groups, group -> new SimpleGrantedAuthority(ROLE_PREFIX + group.toUpperCase()));
      User user = new User(username, EMPTY_STRING, grantedAuthorities);
      return new CognitoJwtAuthentication(user, claimsSet, grantedAuthorities);
    }
  }

  private boolean isIssuedCorrectly(JWTClaimsSet claimsSet) {
    return claimsSet.getIssuer().equals(jwtConfiguration.getCognitoIdentityPoolUrl());
  }

  private boolean isIdToken(JWTClaimsSet claimsSet) {
    return claimsSet.getClaim("token_use").equals("id");
  }

  private static <T, U> List<U> convertList(List<T> from, Function<T, U> func) {
    return from.stream().map(func).collect(Collectors.toList());
  }
}

CognitoJwtAutoConfiguration

@Configuration
@Import(AWSConfig.class)
@ConditionalOnClass({AwsCognitoJwtAuthenticationFilter.class, AwsCognitoIdTokenProcessor.class})
public class CognitoJwtAutoConfiguration {

  private final AWSConfig jwtConfiguration;

  public CognitoJwtAutoConfiguration(AWSConfig jwtConfiguration) {
    this.jwtConfiguration = jwtConfiguration;
  }

  @Bean
  @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
  public CognitoJwtIdTokenCredentialsHolder awsCognitoCredentialsHolder() {
    return new CognitoJwtIdTokenCredentialsHolder();
  }

  @Bean
  public AwsCognitoIdTokenProcessor awsCognitoIdTokenProcessor() {
    return new AwsCognitoIdTokenProcessor();
  }

  @Bean
  public CognitoJwtAuthenticationProvider jwtAuthenticationProvider() {
    return new CognitoJwtAuthenticationProvider();
  }

  @Bean
  public AwsCognitoJwtAuthenticationFilter awsCognitoJwtAuthenticationFilter() {
    return new AwsCognitoJwtAuthenticationFilter(awsCognitoIdTokenProcessor());
  }

  @SuppressWarnings({"rawtypes", "unchecked"})
  @Bean
  public ConfigurableJWTProcessor configurableJWTProcessor() throws MalformedURLException {
    ResourceRetriever resourceRetriever =
        new DefaultResourceRetriever(CONNECTION_TIMEOUT, READ_TIMEOUT);
    // https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json.
    URL jwkSetURL = new URL(jwtConfiguration.getJwkUrl());
    // Creates the JSON Web Key (JWK)
    JWKSource keySource = new RemoteJWKSet(jwkSetURL, resourceRetriever);
    ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor();
    JWSKeySelector keySelector = new JWSVerificationKeySelector(RS256, keySource);
    jwtProcessor.setJWSKeySelector(keySelector);
    return jwtProcessor;
  }

  @Bean
  public AWSCognitoIdentityProvider awsCognitoIdentityProvider() {
    return AWSCognitoIdentityProviderClientBuilder.standard()
        .withRegion(Regions.EU_CENTRAL_1)
        .withCredentials(getCredentialsProvider())
        .build();
  }

  @Bean
  public AWSCredentialsProvider getCredentialsProvider() {
    return new ClasspathPropertiesFileCredentialsProvider();
  }
}

I want to exclude my controller URL from being considered as an endpoint which requires authorization.

Based on sight tested controller looks like:

@RestController
@RequestMapping("/nutrition/api/")
class NutritionixApiController {

  private ProductFacadeImpl productFacadeImpl;

  public NutritionixApiController(
      ProductFacadeImpl productFacadeImpl) {
    this.productFacadeImpl = productFacadeImpl;
  }

  @GetMapping("/productDetails")
  public ResponseEntity<Set<RecipeIngredient>> productsDetails(@RequestParam String query) {
  //logic here
  }
}

I have tried to whitelist URL "/nutrition/api/**" in method configure(WebSecurity web) aby adding:

web.ignoring().antMatchers(GET, "/nutrition/api/**");

or

web.ignoring().antMatchers(GET, "/**");

but without desirable effect. I am a little bit confused about why ignoring.antMatchers() not working so I will be grateful for suggestions on how to fix the above problem.

EDIT

I came back to the topic but with the same result. In WebSecurityConfiguration I commented out @EnableGlobalMethodSecurity(prePostEnabled = true) to try configuration without prePostEnabled = true but without desirable effect. I have the same problem with endpoint /auth which is ignored in the configuration. I patterned after tutorial which is working and available here click but I refactored my code a little to get rid of field injection with @Autowired but without doing radical changes and logic under the hood.

Moreover class CustomAuthenticationProvider looks like:

@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

  private final CognitoAuthenticationService cognitoService;

  @SuppressWarnings("unchecked")
  @Override
  public Authentication authenticate(Authentication authentication) {
    AuthenticationRequest authenticationRequest;

    if (authentication != null) {
      authenticationRequest = new AuthenticationRequest();
      Map<String, String> credentials = (Map<String, String>) authentication.getCredentials();
      authenticationRequest.setNewPassword(credentials.get(NEW_PASS_WORD_KEY));
      authenticationRequest.setPassword(credentials.get(PASS_WORD_KEY));
      authenticationRequest.setUsername(authentication.getName());

      SpringSecurityUser userAuthenticated = cognitoService.authenticate(authenticationRequest);
      if (userAuthenticated != null) {

        Map<String, String> authenticatedCredentials = new HashMap<>();
        authenticatedCredentials.put(ACCESS_TOKEN_KEY, userAuthenticated.getAccessToken());
        authenticatedCredentials.put(EXPIRES_IN_KEY, userAuthenticated.getExpiresIn().toString());
        authenticatedCredentials.put(ID_TOKEN_KEY, userAuthenticated.getIdToken());
        authenticatedCredentials.put(PASS_WORD_KEY, userAuthenticated.getPassword());
        authenticatedCredentials.put(REFRESH_TOKEN_KEY, userAuthenticated.getRefreshToken());
        authenticatedCredentials.put(TOKEN_TYPE_KEY, userAuthenticated.getTokenType());
        return new UsernamePasswordAuthenticationToken(
            userAuthenticated.getUsername(),
            authenticatedCredentials,
            userAuthenticated.getAuthorities());
      } else {
        return null;
      }
    } else {
      throw new UsernameNotFoundException("No application user for given username");
    }
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return authentication.equals(UsernamePasswordAuthenticationToken.class);
  }
}

To be honest I don't know what can be done more to solve this problem with not working filter. Will be grateful for help.

Martin
  • 1,139
  • 4
  • 23
  • 49

1 Answers1

1

Although you indicated the right ignoring pattern and Spring Security is actually ignoring the filter, I think it is being still executed because probably Spring is registering again the filter outside of the security chain because you exposed the filter with @Bean in CognitoJwtAutoConfiguration.

To avoid the problem, perform the following modifications in your code (basically, be sure that only one instance of your filter is in place). First, in WebSecurityConfiguration:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableTransactionManagement
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

  private CustomAuthenticationProvider authProvider;
  private AccountControllerExceptionHandler exceptionHandler;
  private static final String LOGIN_URL = "/auth/login";
  private static final String LOGOUT_URL = "/auth/signOut";

  @Autowired
  public WebSecurityConfiguration(
      CustomAuthenticationProvider authProvider,
      AccountControllerExceptionHandler exceptionHandler) {
    // Do not provide AwsCognitoJwtAuthenticationFilter() as instance filed any more
    this.authProvider = authProvider;
    this.exceptionHandler = exceptionHandler;
  }

  public WebSecurityConfiguration() {
    super(true);
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(authProvider).eraseCredentials(false);
  }

  @Bean
  @Override
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }

  @Override
  public void configure(WebSecurity web) {
    // TokenAuthenticationFilter will ignore the below paths
    web.ignoring().antMatchers("/auth");
    web.ignoring().antMatchers("/auth/**");
    web.ignoring().antMatchers("/v2/api-docs");
    web.ignoring().antMatchers(GET, "/nutrition/api/**");
    web.ignoring().antMatchers(GET, "/**");
    web.ignoring().antMatchers(HttpMethod.OPTIONS, "/**");
  }

  @Override
  protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .addFilterAfter(corsFilter(), ExceptionTranslationFilter.class)
        .exceptionHandling()
        .authenticationEntryPoint(new SecurityAuthenticationEntryPoint())
        .accessDeniedHandler(new RestAccessDeniedHandler())
        .and()
        .anonymous()
        .and()
        .sessionManagement()
        .sessionCreationPolicy(STATELESS)
        .and()
        .authorizeRequests()
        .antMatchers("/auth")
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        // Instantiate a new instance of the filter
        .addFilterBefore(
            awsCognitoJwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .formLogin(formLogin -> formLogin.loginProcessingUrl(LOGIN_URL).failureHandler(exceptionHandler))
        .logout(logout -> logout.permitAll().logoutUrl(LOGOUT_URL))
        .csrf(AbstractHttpConfigurer::disable);
  }

  private CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOrigin("*");
    config.addAllowedHeader(ORIGIN);
    config.addAllowedHeader(CONTENT_TYPE);
    config.addAllowedHeader(ACCEPT);
    config.addAllowedHeader(AUTHORIZATION);
    config.addAllowedMethod(GET);
    config.addAllowedMethod(PUT);
    config.addAllowedMethod(POST);
    config.addAllowedMethod(OPTIONS);
    config.addAllowedMethod(DELETE);
    config.addAllowedMethod(PATCH);
    config.setMaxAge(3600L);

    source.registerCorsConfiguration("/v2/api-docs", config);
    source.registerCorsConfiguration("/**", config);

    return new CorsFilter();
  }

  // It will also be possible to inject AwsCognitoIdTokenProcessor
  private AwsCognitoJwtAuthenticationFilter awsCognitoJwtAuthenticationFilter() {
    return new AwsCognitoJwtAuthenticationFilter(new AwsCognitoIdTokenProcessor());
  }
}

You also need to remove the unnecessary stuff from CognitoJwtAutoConfiguration:

@Configuration
@Import(AWSConfig.class)
@ConditionalOnClass({AwsCognitoJwtAuthenticationFilter.class, AwsCognitoIdTokenProcessor.class})
public class CognitoJwtAutoConfiguration {

  private final AWSConfig jwtConfiguration;

  public CognitoJwtAutoConfiguration(AWSConfig jwtConfiguration) {
    this.jwtConfiguration = jwtConfiguration;
  }

  @Bean
  @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
  public CognitoJwtIdTokenCredentialsHolder awsCognitoCredentialsHolder() {
    return new CognitoJwtIdTokenCredentialsHolder();
  }

  /* No longer needed
  @Bean
  public AwsCognitoIdTokenProcessor awsCognitoIdTokenProcessor() {
    return new AwsCognitoIdTokenProcessor();
  }*/

  @Bean
  public CognitoJwtAuthenticationProvider jwtAuthenticationProvider() {
    return new CognitoJwtAuthenticationProvider();
  }

  /* No longer needed
  @Bean
  public AwsCognitoJwtAuthenticationFilter awsCognitoJwtAuthenticationFilter() {
    return new AwsCognitoJwtAuthenticationFilter(awsCognitoIdTokenProcessor());
  }*/

  @SuppressWarnings({"rawtypes", "unchecked"})
  @Bean
  public ConfigurableJWTProcessor configurableJWTProcessor() throws MalformedURLException {
    ResourceRetriever resourceRetriever =
        new DefaultResourceRetriever(CONNECTION_TIMEOUT, READ_TIMEOUT);
    // https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json.
    URL jwkSetURL = new URL(jwtConfiguration.getJwkUrl());
    // Creates the JSON Web Key (JWK)
    JWKSource keySource = new RemoteJWKSet(jwkSetURL, resourceRetriever);
    ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor();
    JWSKeySelector keySelector = new JWSVerificationKeySelector(RS256, keySource);
    jwtProcessor.setJWSKeySelector(keySelector);
    return jwtProcessor;
  }

  @Bean
  public AWSCognitoIdentityProvider awsCognitoIdentityProvider() {
    return AWSCognitoIdentityProviderClientBuilder.standard()
        .withRegion(Regions.EU_CENTRAL_1)
        .withCredentials(getCredentialsProvider())
        .build();
  }

  @Bean
  public AWSCredentialsProvider getCredentialsProvider() {
    return new ClasspathPropertiesFileCredentialsProvider();
  }
}

I think this SO question also could be of help.

jccampanero
  • 50,989
  • 3
  • 20
  • 49
  • @Martin Were you able to test the proposed solution? – jccampanero Jan 14 '21 at 19:25
  • just now I managed to take a look at my private project. It seems that it's working and the token is not mandatory for each request any longer. Thanks a million for your support, I forgot that filter needs to be created by new, not with the constructor. Much thanks! – Martin Jan 17 '21 at 12:17
  • You are welcome @Martin. That is great, I am happy to now that everything is working fine now. Please, do not hesitate to contact me if you need further help. Also, please, if you do not mind and you consider it appropriate, so that other people who have the same problem know that the answer is the right one, can you mark the answer as correct? Thank you very much. – jccampanero Jan 17 '21 at 13:09
  • Sure, I awarded a bounty but forgot about the magic green tick. In case of problems, I will contact with you. Thanks! – Martin Jan 17 '21 at 13:12
  • Thank you very much @Martin. Yes, please, do not hesitate to contact me if you need further help. – jccampanero Jan 17 '21 at 15:26