October 17, 2024

Securing And Rate Limiting Endpoints With Spring Cloud Gateway, Spring Security And JWT

API gateway is an infrastructure layer that places in front of application to route requests from the client side to the appropriate service. Spring Cloud Gateway is an API gateway on top of Spring WebFlux (non-blocking and asynchronous), also implements other cross cutting concerns such as security, rate limiting, resiliency and etc.

Although applying security checks on api-gateway is a necessity, it is not enough to prevent attacks such as DDoS. So, considering our resources, we need a mechanism to monitor, control, and limit the number of requests we recieve/process. This is what the Spring Cloud Gateway does for us. In this post, I am going to explain how to implement routing, securing and rate limiting tasks in microservices architecture using Spring Cloud Gateway and spring security in a sample project. You can find final project from the Github repository.

The project consists of two back-end services and one api-gateway as follows :

  1. Product service : It serves requests related to product domain. It has endpoints accessible to everyone, only users, and only admins.
  2. Authentication service : It serves the authentication request, verifies the credentials, generates JSON Web Token (JWT) and send it to client.
  3. Api-Gateway : It routes requests from clients to the right back-end service ( here we have only one back-end services).

Microservices using spring security and rate limitation on api-gateway

First, we need to add dependencies related to JWT and spring security to maven pom.xml file of all services including API-gateway, because we work with them in all the services.


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.1</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.1</version>
    <scope>runtime</scope>
</dependency>

API Gateway

Add Spring cloud gateway and Redis dependencies to the ApiGateway project. We add Redis dependency because for rate limiting, the SCG uses redis to store request information, also we need a Redis service in our project.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

Defining routing rules in application.yml is more preferred way comparing to programmatic way, because it’s more readable and extendable.

jwt:
  secret: WillBeSetByDockerCompose
server:
  port: 8010
spring:
  redis:
    host: redis
    port: 6379
  application:
    name: gateway
  cloud:
    gateway:
      routes:
        - id: product-service
          uri: http://productService:8084
          predicates:
            - Path=/products/**
          filters:
            - StripPrefix=0
            - name : RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 1
                redis-rate-limiter.burstCapacity: 2
                redis-rate-limiter.requestedTokens: 2
        - id: auth-service
          uri: http://authService:8080 
          predicates:
            - Path=/auth/**
          filters :
            - StripPrefix=0

In the application.yml file, I defined the jwt.secret key, it will be overridden by environment variable that we define in docker compose and read from a .env file beside docker-compose.yml file, it later will be used in jwt token creation and validation, however, in real world project working with Kubernetes, it’s better to use other methods to store your critical secret keys such as Valut that is much more secure than storing secrets in files.

In the next line Api-gateway listening port is set to 8010. Next, we configure the routing rules. As we run our project on docker compose, we don’t need to use IP address of services in routing rules, instead, we can use the service name defined in docker compose file. For example, for product service definition in docker-compose file , I used productService to name the service, then I routed request targeting product service to http://productService:8084. With the StripPrefix parameter we can set the number of parts in the path to be removed from the request before sending it to the back-end service. For example, in this project we are going to place api/ prefix behind product back-end service APIs, so that, clients should call http://serverAddress:8010/api/products/productList to reach products/productList endpoint of the product service, but if we send the client request to product service without modification, we will get 404 error, because, target service does not have endpoint path starting by api/. So, we need to remove api/ from client request path by setting StripPrefix = 1. It simply remove first part of the path(/api) from the request.

In configuration related to API rate limiting, you can see three parameters :

  • redis-rate-limiter.replenishRate : Indicates the number of requests per second the server would process.
  • redis-rate-limiter.burstCapacity : Indicates the maximum number of requests clients are allowed to send in a second.
  • redis-ratelimiter.requestedTokens : One API may need to perform more than one light/heavy operation to fulfill a request. This parameter helps to recognize that API may cost more than one simple request with a simple operation.

Assume that an API is allowed to have a burst (peak) of 300 requests in a single second and your replenish rate is 100 request per second. It means that if a burst in the number of requests occurs, clients have already passed two cycles’ allowance so the server accepts those 300 requests, but all other requests will be dropped in next 3 seconds.

To let the api rate limiting configuration works, we need to declare a simple custom KeyResolver bean :

@Bean
KeyResolver userKeyResolver() {
      return exchange -> Mono.just("1");
 }

In this project Api-gateway has one other important responsibility, it should secure the application and prevent unauthorized access to endpoints. We do it by implementing WebFilter interface and applying it in SecurityWebFilterChain. As I said earlier Spring cloud gateway is based on Webflux, whereas two other services ( Authentication service and Product service) are Servlet based application, so dealing with filters is a little bit different in these applications.

@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter implements WebFilter {

    public static final String HEADER_PREFIX = "Bearer ";

    private final JwtTokenProvider tokenProvider;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String token = resolveToken(exchange.getRequest());
        if (StringUtils.hasText(token) && this.tokenProvider.validateToken(token)) {
            Authentication authentication = this.tokenProvider.getAuthentication(token);
            return chain.filter(exchange)
                    .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
        }
        return chain.filter(exchange);
    }

    private String resolveToken(ServerHttpRequest request) {
        String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

}

In the filter method declaration, first it checks existence of jwt token in requests, if the token exists then it authenticates the request based on the provided token and in case of successful authentication it retrieves the user information from the token using JwtTokenProvider.

Now, we configure the SecurityWebFilterChain to use the JwtTokenAuthenticationFilter :

@Slf4j
@Configuration
public class SecurityConfig {

    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
    @Bean
    SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http,
                                                JwtTokenProvider tokenProvider) {

        return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(it -> it
                        .pathMatchers("/auth/login","/auth/refresh","/guest").permitAll()
                        .pathMatchers("/products/**").hasAnyRole("USER","ADMIN")
                        .anyExchange().authenticated()
                )
                .addFilterAt(new JwtTokenAuthenticationFilter(tokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
                .build();
    }





}

As our application is stateless, we use NoOpServerSecurityContextRepository in HTTP security configuration. Using authorizeExchange we define application rules for accessing back-end endpoints. Then we add the JwtTokenAuthenticationFilter to the filter chain using addFilterAt. It accepts two arguments, the filter we want to add and the filter class that we want our custom filter place at same order as it is. For the first argument we pass our custom filter JwtTokenAuthenticationFilter, and for the second argument we pass SecurityWebFiltersOrder.HTTP_BASIC.

Securing Back-end Service (Product Service)

A secure back-end service must guarantee that the API it expose will be secure by making it visible only to the users and other services that are authorized to consume it. Every back-end service should be able to determine the user information including its roles, so in Api-gateway we need to forward the JWT token of the requests to back-end service to make it possible for back-end services to validate and authenticate the requests.

Now, let’s create the JwtTokenAuthenticationFilter for product service. Although its implementation is similar to JwtTokenAuthenticationFilter that we created for API-gateway, there are some differences, because it’s a servlet based web application and spring cloud gateway works on WebFlux.

@RequiredArgsConstructor
@Component
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {

    public static final String HEADER_PREFIX = "Bearer ";

    private final JwtTokenProvider tokenProvider;


    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        String token = resolveToken(request);
        if (StringUtils.hasText(token) && this.tokenProvider.validateToken(token)) {

            Authentication authentication = tokenProvider.getAuthentication(token);

            UsernamePasswordAuthenticationToken
                    usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                    authentication.getPrincipal(), null,
                    authentication.getPrincipal() == null ?
                            Arrays.asList() : authentication.getAuthorities()
            );

            usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            chain.doFilter(request, response);
            return;
        }

        chain.doFilter(request, response);
    }
}

Now, we should apply the filter we have just created to the request in the filter chain in a configuration class :

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

  private JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter;

  @Autowired
  public SecurityConfig(JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter) {
    this.jwtTokenAuthenticationFilter = jwtTokenAuthenticationFilter;
  }



  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Enable CORS and disable CSRF
    http.cors().and().csrf().disable();

    // Set session management to stateless
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    // Set unauthorized requests exception handler
    http.exceptionHandling(
        (exceptions) ->
            exceptions
                .authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
                .accessDeniedHandler((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_FORBIDDEN)));

    // Set permissions on endpoints
    http.authorizeRequests()

        .antMatchers("/products/public" )
        .permitAll()
        // Our private endpoints
        .anyRequest()
        .authenticated();
        
    
    http.addFilterBefore(jwtTokenAuthenticationFilter,
            UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }


  // Used by spring security if CORS is enabled.
  @Bean
  public CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOrigin("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    source.registerCorsConfiguration("/**", config);
    return new CorsFilter(source);
  }


}

As you can see, I added our custom filter to the filter chain and also I set restriction on wccessing endpoints.

In the controller, we define our endpoint and set the roles that are allowed to use them :

package mst.example.productservice.controllers;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/products")
public class ProductController {

    @GetMapping("public")
    public String publicProducts(){

        return "Hello all! It's a public endpoint. Every user can reach me.";
    }


    @GetMapping("user")
    @PreAuthorize("hasRole('USER')")
    public String userProducts(){

        return "Hello dear User! This endpoint is available to USERs only.";
    }


    @GetMapping("admin")
    @PreAuthorize("hasRole('USER')")
    public String adminProducts(){

        return "Hello dear Admin! This endpoint is available to ADMINs only.";
    }


    @GetMapping("productList")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public String ProductList(){

        return "Products List here!";
    }
}

As you can see, I used @PreAuthorize annotation to restrict accessing the endpoint.

Authentication Server

Finally, we need an Authentication service to check provided credential with the user database, generate jwt token and send it to client.

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

  JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter;

  @Autowired
  public SecurityConfig(JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter) {
    this.jwtTokenAuthenticationFilter = jwtTokenAuthenticationFilter;
  }

  

  @Bean
  public AuthenticationManager authenticationManager(
          HttpSecurity http) throws Exception {
    PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    return http.getSharedObject(AuthenticationManagerBuilder.class)
            .inMemoryAuthentication()
            .withUser("admin").password(encoder.encode("admin")).roles("ADMIN", "USER").and()
            .withUser("mosy").password(encoder.encode("1234")).roles("USER")
            .and().and().parentAuthenticationManager(null).build();
  }


  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Enable CORS and disable CSRF
    http.cors().and().csrf().disable();

    // Set session management to stateless
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    // Set unauthorized requests exception handler
    http.exceptionHandling(
        (exceptions) ->
            exceptions

                .authenticationEntryPoint((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
                .accessDeniedHandler((req, rsp, e) -> rsp.sendError(HttpServletResponse.SC_FORBIDDEN)));

    // Set permissions on endpoints
    http.authorizeRequests()
        
        .antMatchers("/auth/**" )
        .permitAll()
        // Our private endpoints
        .anyRequest()
        .authenticated();

    http.addFilterBefore(jwtTokenAuthenticationFilter,
            UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }


  // Used by spring security if CORS is enabled.
  @Bean
  public CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOrigin("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    source.registerCorsConfiguration("/**", config);
    return new CorsFilter(source);
  }

}

For simplicity I used in-memory authentication, and defined two users with different roles.

In the AuthController we need to define two endpoints, /login and /refresh endpoint :


@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {


    private final JwtTokenProvider tokenProvider;
    private final AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(@Valid @RequestBody AuthenticationRequest authRequest) throws AuthenticationException {


        Authentication authentication = this.authenticationManager
                .authenticate(new UsernamePasswordAuthenticationToken(
                                                                        authRequest.getUsername(),
                                                                        authRequest.getPassword()
                                                                      ));

      

        String accessToken = this.tokenProvider.createToken(authentication, "access");
        String refreshToken = this.tokenProvider.createToken(authentication, "refresh");

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
        AuthenticationResponse authenticationResponse = new AuthenticationResponse(accessToken,refreshToken);
        return new ResponseEntity<>(authenticationResponse, httpHeaders, HttpStatus.OK);


    }



    @PostMapping("/refresh")
    public ResponseEntity<AuthenticationResponse> refreshToken(@Valid @RequestBody RefreshTokenRequest refreshTokenRequest) {

            if(!tokenProvider.validateToken(refreshTokenRequest.getRefreshToken()))
                throw new AccessDeniedException("Access denied");


        String accessToken = this.tokenProvider.createToken(refreshTokenRequest.getRefreshToken(), "access");
        String refreshToken = this.tokenProvider.createToken(refreshTokenRequest.getRefreshToken(), "refresh");

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
        AuthenticationResponse authenticationResponse = new AuthenticationResponse(accessToken,refreshToken);
        return new ResponseEntity<>(authenticationResponse, httpHeaders, HttpStatus.OK);

    }

    }

The /login endpoint verifies credential, if the user exist and the password was correct it generates jwt token & refresh token and send it to client. The /referesh endpoint, accepts the refresh token, validate it, and re-generate jwt and its refresh token again and send it back to client.

Running The Project

To run three services of the project, we should create a docker-compose.yml file and define services and configure their parameters :

version: '2.1'

services:

  redis:
    image: 'bitnami/redis:latest'
    hostname: redis
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
    expose:
      - "6379"
  #service 1: Authentication service
  authService:
    image: auth-service
    container_name: auth-service
    hostname: auth-service
    build :
      context : ./AuthServer
      dockerfile: Dockerfile
    expose:
      - "8080"
    environment:
      jwt_secret : ${JWT_KEY}
    restart: always

  #service 2: Product service
  productService:
    image: product-service
    container_name: product-service
    hostname: product-service
    build:
      context: ./productService
      dockerfile: Dockerfile
    expose:
      - "8084"
    environment:
      jwt_secret : ${JWT_KEY}
    restart: always
  #service 3: API Gateway
  apiGateway:
    image: apigateway
    container_name: apigateway
    hostname: apigateway
    build :
      context : ./ApiGateWay
      dockerfile: Dockerfile
    ports:
      - '8010:8010'
    environment:
         jwt_secret : ${JWT_KEY}
    restart: always
    depends_on:
      - authService
      - productService
      - redis

In the above docker compose file, we have defined three services + redis service. And as I said earlier, I stored jwt secret key in .env file as JWT_KEY beside docker-compose.yml file, and I read it in the docker-compose.yml to set jwt_secret environment variable.

Excuting below command in project root will run our tiny microservice project:

docker-compose up

I use postman to test the project, however you can use any other tools such as curl. I call localhost:8010/auth/login with the below payload to get the jwt token :

{
    "username" : "mosy",
    "password" : "1234"
}

The api gateway routes the request to Authentication service and the response would be a json message containing the jwt token and refresh token :

{
    "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtb3N5Iiwicm9sZXMiOiJST0xFX1VTRVIiLCJpYXQiOjE2NzM2NDE3MzAsImV4cCI6MTY3MzY0NTMzMH0.Tve7jOR8vLO-riqY4g2rybb87mq2j_G8MhSg9WZFUzk",
    "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtb3N5Iiwicm9sZXMiOiJST0xFX1VTRVIiLCJpYXQiOjE2NzM2NDE3MzAsImV4cCI6MTY3MzY1OTczMH0._JuZBk48fZFKL36qqvIhuVIlJvD-D7KtRaD6whg8Ubc"
}

Now, having jwt token we can call other endpoints which need client to be authenticated. Here, I call localhost:8010/api/products/user endpoint which only users with USER role can access it, as you can see below, we get respond with 200 status code.

securing and rate limiting spring cloud gateway
Spring Cloud Gateway routed the request and sent back the backend response to the client

The interesting part of the work comes here, when you send request consecutively within a second, you will get 429 status Too Many Requests error. Because, in our configuration we set replenishRate = 1, which means only one request will be proccessed within a second, burstCapacity = 2 to accept maximum 2 request per second, and requestedTokens =2 which tells the rate limiter that calling these APIs cost 2 requests.

Api rate limiting sends 429 status Too Many Requests error to client

Obviuosly, we can put rate limiting parameters’ values in in the .env file to prevent source code modification each time we need to set new rate limiting policies.

Thanks for reading the post!

2 thoughts on “Securing And Rate Limiting Endpoints With Spring Cloud Gateway, Spring Security And JWT

  1. This was very well written. Thanks.
    In a real world scenario, I’d like to be able to run multiple Auth services for HA. Each instance would return a different token. How would the Resource server know which Auth server to validate the token it received in the request? i.e. if the client makes the request for the token from Auth Server 1, how would I ensure that the request to the Resource server would not go to Auth Server 2 to validate the token? That would fail since the token on Auth Server 2 is different from the one on Auth Server 1.

    1. If I’ve understood you correctly, you won’t face any problem if you use same secret key for all Auth services. Because, validation process consists of decrypting the signature with secret key and checking expiration time.

Leave a Reply

Your email address will not be published. Required fields are marked *