no

Spring Method Level Security with Amazon Cognito and JWT Token

Learn how to authenticate your user with AWS Cognito and secure your Spring REST endpoints with JWT token using Spring Security . 1. Int...

Learn how to authenticate your user with AWS Cognito and secure your Spring REST endpoints with JWT token using Spring Security.

1. Introduction

The goal of this tutorial is to authenticate and authorize a user in a Spring REST service using the JWT token.

1.1 Prerequisite

  • Create a user pool in Amazon Cognito.
  • Create an application in Google Console.
    • Add a user/email that we can authenticate later for testing.
Follow this guide  https://www.czetsuyatech.com/2021/06/nextjs-security-with-aws-cognito.html in case you are not familiar with them.

1.2 Goal - Create a Java Library with the following Features

  • Decoding an AWS Cognito JWT token.
  • Verifying the JWT token issuer.
  • Creating a custom SimpleCtAccount using the information contained in the JWT token.
  • Convert the associated Cognito groups into a custom CtRole.
  • A single interface to enable security.

2. The Spring Security Module

Spring has provided a lot of utility classes that we can use to secure our web application. One such class that we will use in our library is the WebSecurityConfigurerAdapter class to customize our HTTP and Web Security.

3. Class Diagram

3.1 Spring Security (HTTP, WEB)

With this configuration, we can authenticate a user and check whether that user is authorized to access a particular URL.

Let's take a look at our class diagram focusing on HTTP and Web security customization:



In the WebSecurityConfigurerAdapter, we add the  CtAuthenticationProcessingFilter to the filters that use the  CtJwtTokenProcessor to process the JWT token.

Let's take a look at the class:
  public class CtWebSecurityConfiguration extends WebSecurityConfigurerAdapter {

  private final CtHttpSecurityConfigurer httpSecurityConfig;
  private final CtAuthenticationProvider ctAuthenticationProvider;
  private final CtJwtTokenProcessor ctJwtTokenProcessor;

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.authenticationProvider(ctAuthenticationProvider);
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {

    httpSecurityConfig.configure(http);
    http.addFilterBefore(ctAuthenticationFilter(), AnonymousAuthenticationFilter.class);
  }

  @Override
  public void configure(WebSecurity webSecurity) throws Exception {
    webSecurity.ignoring().antMatchers("/token/**");
  }

  @Bean
  public CtAuthenticationProcessingFilter ctAuthenticationFilter()
      throws Exception {

    RequestMatcher protectedUrls = new OrRequestMatcher(new AntPathRequestMatcher("/api/**"));
    final CtAuthenticationProcessingFilter filter = new CtAuthenticationProcessingFilter(protectedUrls,
        ctJwtTokenProcessor);
    filter.setAuthenticationManager(authenticationManager());
    filter.setSessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy());

    return filter;
  }
}
  
Here we configure both HTTP and web security.

And the processor, where we actually do the conversion of idToken to a user account with roles and context.
  public class CognitoJwtTokenProcessor implements CtJwtTokenProcessor {

  private final ConfigurableJWTProcessor configurableJWTProcessor;
  private final CognitoJwtConfigData cognitoJwtConfigData;

  @Override
  public Authentication getAuthentication(HttpServletRequest request) throws Exception {

    String idToken = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (StringUtils.hasText(idToken)) {

      JWTClaimsSet claimsSet;

      claimsSet = configurableJWTProcessor.process(stripBearerToken(idToken), null);

      if (!isIssuedCorrectly(claimsSet)) {
        throw new InvalidJwtIssuerException(
            String.format("Issuer %s in JWT token doesn't match Cognito IDP %s", claimsSet.getIssuer(),
                cognitoJwtConfigData.getIssuerId()));
      }

      if (!isIdToken(claimsSet)) {
        throw new InvalidIdTokenException("JWT Token is not a valid Access or Id Token");
      }

      String username = claimsSet.getClaims().get(cognitoJwtConfigData.getUserNameField()).toString();

      if (username != null) {
        Optional<Object> optGroup = Optional.ofNullable(
            claimsSet.getClaims().get(cognitoJwtConfigData.getGroupField()));
        List<String> groups = Stream.of(optGroup.orElse(new ArrayList<>())).map(Object::toString).toList();

        CtSecurityContext securityContext = new CtSecurityContext(stripBearerToken(idToken), null);
        CtPrincipal<CtSecurityContext> ctPrincipal = new CtPrincipal(username, securityContext);
        SimpleCtAccount account = new SimpleCtAccount(ctPrincipal, Set.copyOf(groups), securityContext);

        return new CtAuthenticationToken(account);
      }
    }

    log.trace("No idToken found in HTTP Header");
    return null;
  }
  
What happens here?
  1. We get the claimset from the idToken.
  2. We check if the claimset is valid.
  3. Get the username and check if it's null.
  4. Convert the group to role object.
  5. Initialize a securityContext class.
  6. Initialize a Principal object.
  7. Initialize a simpleAccount object from 5 and 6, etc.
Claimset contains pieces of information about a subject. In our case, it contains the username and groups of the currently logged user from AWS Cognito.

3.2 Method Security

What if we wanted to do a finely detailed check on a particular method? For example, is this user living in the Philippines? Or is this user a member of a particular group? 

This is where the Spring class  GlobalMethodSecurityConfiguration comes in. First, let's take a look at the class diagram.


In this diagram, most of the common authorization checks are defined in  CtMethodSecurityExpressionRoot. This class is extended by  DefaultMethodSecurityExpressionRoot which we can extend on our microservice to provide additional authorization checks (This will be covered later).

To inform Spring and pickup this configuration, we need to annotate the class CtMethodSecurityConfiguration with: @Configuration and @EnableGlobalMethodSecurity(prePostEnabled = true).

4. How to use our Library?

4.1 Service Integration

I have uploaded this library on maven central for convenience. Also, the source code is 100% open-source on GitHub.

To use on a project:
1. Add a maven dependency:
  <dependency>
	<groupId>com.czetsuyatech</groupId>
	<artifactId>ct-services-jwt-security</artifactId>
	<version>0.0.1</version>
</dependency>
  
2. Extend the class  DefaultMethodSecurityExpressionRoot, and add more authorization checks.
public class CtAppMethodSecurityExpressionExtension extends DefaultMethodSecurityExpressionRoot {

  public CtAppMethodSecurityExpressionExtension(Authentication authentication) {
    super(authentication);
  }

  public boolean isAuthorized() {
    return true;
  }

  public boolean isUnAuthorized() {
    return false;
  }
}
3. Create a Configuration class  CtAppSecurityConfiguration where we will initialize the HTTP security and produce the  CtMethodSecurityExpressionHandler bean.
@Configuration
@RequiredArgsConstructor
@EnableCtSecurity
public class CtAppSecurityConfiguration {

  @Bean
  public CtHttpSecurityConfigurer httpSecurityConfig() {
    return http ->
        http.csrf().disable()
            .cors()

            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .sessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy())

            .and()
            .httpBasic().disable()
            .formLogin().disable()

            .authorizeHttpRequests()
            .antMatchers(HttpMethod.GET, "/actuator/**").permitAll()
            .antMatchers("/api/**").authenticated()
            .anyRequest().permitAll()
        ;
  }

  @Bean
  public CtMethodSecurityExpressionHandler methodSecurityExpressionHandler() {
    return CtAppMethodSecurityExpressionExtension::new;
  }
}

Don't forget to annotate this class with @EnableCtSecurity.

4.2 Secured Endpoints

Create a new controller with the following endpoints and use the @PreAuthorize annotation.
@RestController
@Validated
@Slf4j
@RequiredArgsConstructor
public class ApiTestController {

  @GetMapping("/hello")
  @ResponseStatus(HttpStatus.OK)
  public String hello(@CurrentSecurityContext(expression = "authentication") Authentication auth) {

    log.debug("" + auth.getPrincipal());
    log.debug("" + auth.getCredentials());
    log.debug("" + auth.getDetails());

    return "Hello " + auth.getPrincipal();
  }

  @GetMapping("/api/testing/authenticated")
  @PreAuthorize("isAuthenticated()")
  @ResponseStatus(HttpStatus.OK)
  public String authenticated(@CurrentSecurityContext(expression = "authentication") Authentication auth) {

    log.debug("" + auth.getPrincipal());
    log.debug("" + auth.getCredentials());
    log.debug("" + auth.getDetails());

    return "Hello " + auth.getPrincipal();
  }

  @GetMapping("/api/testing/authorized")
  @PreAuthorize("isAuthorized()")
  @ResponseStatus(HttpStatus.OK)
  public String authorized() {
    return "authorized";
  }

  @GetMapping("/api/testing/unauthorized")
  @PreAuthorize("isUnAuthorized()")
  @ResponseStatus(HttpStatus.FORBIDDEN)
  public String unAuthorized() {
    return "unauthorized";
  }
}

Related

spring-security 904801248562870997

Post a Comment Default Comments

Outsourcing

Are you looking for Java Developers in the Philippines? Get in touch.

Donations

If you like what I do, you can support this channel by buying me a coffee. I would be grateful for your contribution!

item