Custom Annotation-Based Authorization and Headers Propagation in Spring Boot microservices

Written by parohaabhay | Published 2024/01/03
Tech Story Tags: microservices | spring-boot | aspectj | java | cloud | authentication | authorization | annotation-based-authorization

TLDRAspectJ custom annotation-based authorization and headers propagation using request scoped bean in Spring Boot microservicesvia the TL;DR App

Microservices (or microservices architecture) is an architectural style for developing applications. When a microservices-based application responds to a user workflow or request, it may call on multiple external microservices (exposed over the internet), which may call on too few internal microservices (not exposed over the internet). Each microservice has a specific concern, which allows an extensive application to be separated into smaller pieces.

Authentication and Authorization in Microservices

When moving to microservices, securing the microservices needs to be undertaken differently than a monolithic application. A monolith application has a single-user session context shared by all internal components. An architecture based on microservices does not share user context across them, so sharing it requires explicit communication between microservices.

A combination of Authentication (AuthN) and Authorization (AutZ) is commonly used to secure applications.

AuthN – verifies that you are who you claim to be.

AuthZ – whether the user can access information or execute the operation.

Recently, I was developing an authorization workflow for a set of microservices, and the challenge was: -

1. How to enable role-based authorization per API?

2. How to propagate roles received from external microservice to internal microservice?

In this article, we will see how I solved these two problems by developing: -

1. Custom annotation using AspectJ

2. Custom request filter and client interceptor using Spring framework

Let’s get started!

What is our Problem?

I created a data flow diagram (Figure 1) to explain the problem. There is one UI application, making calls to external microservices and these calls may also be served by other internal microservices. The session manager’s service responsibility is to log in to the user, store the session, and call the AuthZ service. AuthZ service calls the user subscription service to get logged-in user roles. AuthZ service returns the role to the session manager service, and these roles are passed (in the header as JSON array) to all backend external microservices per API call.

Session manager service attaches one request HTTP header “X-Application-Roles” to all API requests.

Example : X-Application-Roles : ["GLOBAL_ADMIN", “TENANT_ADMIN”, “APPLICATION_USER”]

When an API request has this header with given values, it says that the logged-in user ID has these roles assigned per application subscription.

What is our solution?

Coming next to original problem statements:-

How do we enable role-based authorization per API? I tried the following approaches to solve this problem: -

  • Create a separate microservice to check whether roles coming in the X-Application-Roles header have the role needed to make this API call and use this microservice call in each API request.
  • Create a custom roles annotation and use this annotation in all backend APIs

I decided to proceed with the second approach as I needed to resolve roles for 30+ backend APIs and the first approach was introducing another hop with each API request.

How do we propagate roles received from external microservice to internal microservice? The solution to the first problem is well suited for external microservices as they were directly receiving the X-Application-Roles header from requests coming from UI, but the challenge was to propagate this header further to internal microservices and use the same custom role annotation. I tried the following approaches to solve this problem: -

I decided to proceed with the second approach as I don’t want to introduce another library dependency and extra configuration.

Custom roles annotation to annotate backend APIs

Step 1: Create and enumeration to hold roles and header name: -

public enum ApplicationRoles { 
  GLOBAL_ADMIN,
  TENANT_ADMIN,
  APPLICATION_USER;
  
  public static final String X_APPLICATION_ROLES_HEADER = "X-Application-Roles";
}

Step 2: Create an interface that we’ll use as a method-level annotation on Spring boot’s controller’s APIs: -

@Target(ElementType.METHOD) 
@Retention(RetentionPolicy.RUNTIME) 
public @interface RolesAllowed { 
  ApplicationRoles[] value();
}

Step 3: Create a custom aspect using the AspectJ library to check whether roles coming in the X-Application-Roles header have the role needed to make this API call. This annotation will serve the API request when roles present in the request header have role applied over API (will show in next step) else it will throw a FORBIDDEN exception.

@Aspect
public class RolesCheckAspect {
    
    // User your package name where RolesAllowed interface exists
    @Before("@annotation(com.adp.security.auth.RolesAllowed)")
    public void before(JoinPoint joinPoint) throws JsonProcessingException {
        
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        // Get expected Roles from annotation
        Set<ApplicationRoles> expectedRoles = Arrays.stream(methodSignature.getMethod().getAnnotation(RolesAllowed.class).value()).collect(Collectors.toSet());
        // Get all annotations defined in method signature
        Annotation[][] parameterAnnotations = methodSignature.getMethod().getParameterAnnotations();
        // Get actual arguments from the method
        Object[] methodArguments = joinPoint.getArgs();
        // Get actual roles from request
        Set<ApplicationRoles> actualRoles = getActualRoles(parameterAnnotations, methodArguments);
        // Check whether actual roles have expected API role
        if (expectedRoles.stream().noneMatch(actualRoles::contains))
            throw new ForbiddenException(String.format("Required roles are missing in '%s' header", ApplicationRoles.X_APPLICATION_ROLES_HEADER));
    }

    private static Set<ApplicationRoles> getActualRoles(Annotation[][] parameterAnnotations, Object[] methodArguments) throws JsonProcessingException {
        // Get roles header index in annotations
        int rolesHeaderIndex = getRolesHeaderIndex(parameterAnnotations);

        ObjectMapper objectMapper = JsonMapper.builder()
                .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
                .build();
        return objectMapper.readValue(String.valueOf(methodArguments[rolesHeaderIndex]), new TypeReference<>() {});
    }

    private static int getRolesHeaderIndex(Annotation[][] parameterAnnotations) {
        record HeaderWithIndex(int index, Optional<RequestHeader> requestHeader){}

        HeaderWithIndex rolesHeader = IntStream.range(0, parameterAnnotations.length)
                .mapToObj(argIndex -> {
                    Optional<RequestHeader> optionalRequestHeader = Arrays.stream(parameterAnnotations[argIndex])
                            .filter(RequestHeader.class::isInstance)
                            .map(RequestHeader.class::cast)
                            .filter(requestHeader -> requestHeader.value().equalsIgnoreCase(ApplicationRoles.X_APPLICATION_ROLES_HEADER))
                            .findFirst();
                    return new HeaderWithIndex(argIndex, optionalRequestHeader);
                })
                .filter(headerWithIndex -> headerWithIndex.requestHeader().isPresent())
                .findFirst()
                .orElseThrow(() -> new BadRequestException(String.format("Required header '%s' is not present", ApplicationRoles.X_APPLICATION_ROLES_HEADER)));

        return rolesHeader.index();
    }
}

Step 4: Configure RolesCheckAspect bean. This step is necessary when your roles aspect class exists in different library projects.

@Configuration
public class RestTemplateConfig {
    @Bean
    public RolesCheckAspect rolesCheckAspect() {
           return new RolesCheckAspect();
    }
}

Step 5: Use roles annotation over the API

@GetMapping("/api/v1/hierarchy")
@RolesAllowed(value={ApplicationRoles.APPLICATION_USER})
public ResponseEntity<String> getHierarchy(
                     @RequestHeader(TENANT) String tenant,
                     @RequestHeader(ApplicationRoles.X_APPLICATION_ROLES_HEADER) String roles
) { 
       return ResponseEntity.ok(hierarchyService.getHierarchy(tenant));
}

Roles header propagation

Roles header propagation from external microservice APIs to internal microservice APIs

Now, we have authorization enabled for external microservice; the remaining problem is propagating the X-Application-Roles header to the internal microservice.

Step 1: Create a class to store roles from incoming API requests. This class will be configured as request scoped bean.

public class RolesInfo { 
  private String roles;
  
  public String getRoles() {
    return roles;
  }

  public void setRoles(String roles) {
    this.roles = roles;
  }
}

Step 2: Create a custom request filter to store roles from the header to the RolesInfo object.

public class RolesFilter implements Filter {
    
    private final RolesInfo rolesInfo;
    
    public RolesFilter(RolesInfo rolesInfo) {
        this.rolesInfo = rolesInfo;
    }
    
    @Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
    String roles = httpServletRequest.getHeader(ApplicationRoles.X_APPLICATION_ROLES_HEADER);
    // Setting roles in request scoped bean object
    rolesInfo.setRoles(roles);
    filterChain.doFilter(servletRequest, servletResponse);
  }
}

Step 3: Create a client interceptor to fetch roles from the request scoped bean and propagate to internal microservice API requests.

public class RolesInterceptor implements ClientHttpRequestInterceptor { 
  
  private final RolesInfo rolesInfo;
  
  public RolesInterceptor(RolesInfo rolesInfo) {
      this.rolesInfo = rolesInfo;
  }

  @Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
    // Header propagation : Get roles from request scoped bean object and add with header
    String roles = rolesInfo.getRoles();
    request.getHeaders().add(ApplicationRoles.X_APPLICATION_ROLES_HEADER, roles);
    return execution.execute(request, body);
  }
} 

Step 4: Configure request scoped bean and add client interceptor to RestTemplate.

@Configuration 
public class RestTemplateConfig { 

  @Bean
  public RestTemplate restTemplate() {
      RestTemplate restTemplate = new RestTemplate();
      List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
      if (CollectionUtils.isEmpty(interceptors)) {
          interceptors = new ArrayList<>();
      }
      interceptors.add(new RolesInterceptor(rolesInfo()));
      restTemplate.setInterceptors(interceptors);
      return restTemplate;
  }

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

It's better to create a separate library project with roles aspect, request filter, and client interceptor and use it in different microservice code repositories to avoid repeating the same steps and code duplication.

Hope you enjoyed reading this article!


Written by parohaabhay | Abhay Dutt Paroha is an experienced Software Engineer and Technology Leader.
Published by HackerNoon on 2024/01/03