Authenticating Services in a Microservices Environment

Mohit Kanwar | Jul 17, 2024 min read

It is easier to edit a draft than to create a new. I understand that this blog is not upto the mark that I want it to be, however, I am still publishing it, so that I can keep on improving it.

The code for this blog is checked-in at https://github.com/mohitkanwar/spring-microservices-framework

While developing a microservices setup, one of the prime aspect is taking care of the security of the APIs.

Today, we are going to talk about setting up our application infrastructure to authenticate the APIs.

Architecture

This system involves

  1. Gateway
  2. Discovery
  3. Authentication Service
  4. Secured Downstream Services e.g. Accounts Service
  5. Public Downstream Services e.g. Interest Calculator Service
block-beta
columns 5
  user(("User")):5
  space:5
  block:InfraBlock:5
    srv_gateway["Gateway"]
  end
  space:5
  block:InfraSupportBlock:5
    srv_discovery["Discovery"]:1
    srv_auth["Authentication Service"]:1
    space
    space
    space
  end
  space:5
  block:ServicesBlock:5
    space
    space
    srv_public["Public Service"]
    srv_secured["Secured Service"]
  end
  user --"Access"--> srv_gateway
  srv_gateway --"Find available
services"--> srv_discovery srv_discovery --> srv_gateway srv_gateway --"Authenticate"--> srv_auth srv_gateway --"Publically accessible"--> srv_public srv_gateway --"Validate Access
before invoking"--> srv_secured

Gateway

The Gateway service is the entry point of the application. It exposes all the APIs from downstream services.

Since this is the entry point of the application, it takes care of the following

  1. APIs Exposure
  2. Load Balancing
  3. Circuit Breaking
  4. Token Validation

Discovery

The Discovery service is the service responsible to discover the instances of running microservices and inform gateway about the same.

Authentication Service

This service is responsible for authenticating an incoming request by either generating an authentication token, or by validating an existing token. In today’s exercise, we are going to use Keycloak as our authentication service.

Secured Services

The secured downstream services (or APIs) are the endpoints that need security. Such endpoints are user specific, and a user accessing these APIs must authenticate and authorize himself before accessing these functionalities.

Public Services

The public services or APIs are the endpoints that are developed for general consumption. No authentication is necessary for such APIs.

Tech-Stack

For this solution, we are going to use Spring based technologies. There may be additional techs added as part of services development

S.NOTechnologyVersion
1Java21
2SpringBoot3.3.1
3Gradle
4springCloudVersion2023.0.3

Code Structure

Create a software system as follows

Discovery

Create a SpringBoot application with the following dependency

       <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>

Add the following annotation on your main class

@EnableEurekaServer

Add the following properties

spring:
  application:
    name: discovery
server:
  port: 8761

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
  server:
    wait-time-in-ms-when-sync-empty: 0

Start the service and check if it is running on http://localhost:8761/.

Gateway

Setup the Gateway service by adding following dependencies

          <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-jwt</artifactId>
            <version>1.1.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

Enable the eureka client

@EnableDiscoveryClient

Add the following configurations in application.yml

server:
  port: 8080

spring:
  application:
    name: gateway
  cloud:
    gateway:
      routes:
        - id: eureka-client
          uri: lb://EUREKA-CLIENT
          predicates:
            - Path=/eureka-client/**

Services

For demonstration, I have created a microservice called calculators-service.

It is a springboot application. It exposes two end points. One is supposed to be public, and another one is supposed to be protected.

package com.mk.calculatorsservice.controller;


import com.mk.calculatorsservice.domain.request.FDRequest;
import com.mk.calculatorsservice.domain.response.FDResponse;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/calculator")
public class FDController {

    @PostMapping("/fd/interest")
    public FDResponse calculateFD(@RequestBody FDRequest fdRequest) {
        // Example interest rates for different schemes
        double interestRate = switch (fdRequest.getSchemeCode()) {
            case "SCHEME_A" -> 5.0;
            case "SCHEME_B" -> 6.0;
            case "SCHEME_C" -> 7.0;
            default -> throw new IllegalArgumentException("Invalid scheme code");
        };

        double interest = (fdRequest.getAmount() * interestRate * fdRequest.getDuration()) / 100;
        double maturityAmount = fdRequest.getAmount() + interest;

        FDResponse fdResponse = new FDResponse();
        fdResponse.setInterestRate(interestRate);
        fdResponse.setMaturityAmount(maturityAmount);

        return fdResponse;
    }
    // should be public
    @PostMapping("/fd/interest/p")
    public FDResponse calculatePublicFD(@RequestBody FDRequest fdRequest) {
        // Example interest rates for different schemes
        double interestRate = switch (fdRequest.getSchemeCode()) {
            case "SCHEME_A" -> 5.0;
            case "SCHEME_B" -> 6.0;
            case "SCHEME_C" -> 7.0;
            default -> throw new IllegalArgumentException("Invalid scheme code");
        };

        double interest = (fdRequest.getAmount() * interestRate * fdRequest.getDuration()) / 100;
        double maturityAmount = fdRequest.getAmount() + interest;

        FDResponse fdResponse = new FDResponse();
        fdResponse.setInterestRate(interestRate);
        fdResponse.setMaturityAmount(maturityAmount);

        return fdResponse;
    }
}

Authentication Service

For simplicity, we are going to use Keycloak as our authentication service. Practically, I would like to create a wrapper surrounding Keycloak and then use it as authentication service.

For now, let’s use Keycloak itself.

We can make use of docker-compose.yml to spin up a Keycloak instance.

version: '3.8'

services:
  keycloak:
    image: quay.io/keycloak/keycloak:25.0.2
    container_name: mkbank-keycloak-container
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_LOG_LEVEL: INFO
    ports:
      - "8082:8080"  # Map host port 8082 to container port 8080
    command: start-dev  # Use the start-dev command for development mode
    restart: unless-stopped  # Restart the container unless it's explicitly stopped
    tty: true  # Allocate a pseudo-TTY for better log display in console
    volumes:
      - ./keycloak_data:/opt/keycloak/data

Then we need to setup a new Realm : Customers Create a client and User within the client for authentication.

Gateway - revisited

Let’s go back to Gateway to configure the routes and security steps.

Update the configuration to add Keycloak configurations

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8082/realms/customers
      client:
        registration:
          keycloak:
            client-id: gateway-client
            client-secret: HPSzCoP0Q96xqm77vEhaigkMhmn4vZ1B
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            authorization-uri: http://localhost:8082/realms/customers/protocol/openid-connect/auth
            token-uri: http://localhost:8082/realms/customers/protocol/openid-connect/token
            user-info-uri: http://localhost:8082/realms/customers/protocol/openid-connect/userinfo
            jwk-set-uri: http://localhost:8082/realms/customers/protocol/openid-connect/certs
            user-name-attribute: preferred_username

Create a new configuration to specify public and protected routes :

application:
  routes:
    publicRoutes:
      - name: eureka_route
        path: /eureka/**
        uri: http://localhost:8761/eureka/
      - name: fd_interest_calculator_route
        path: /calculator/fd/interest/p
        uri: lb://CALCULATORS-SERVICE

    authenticatedRoutes:
      - name: fd_interest_calculator_route
        path: /calculator/fd/interest
        uri: lb://CALCULATORS-SERVICE

The complete yml now looks like :

server:
  port: 8080

spring:
  application:
    name: gateway
  cloud:
    gateway:
      routes:
        - id: eureka-client
          uri: lb://EUREKA-CLIENT
          predicates:
            - Path=/eureka-client/**
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8082/realms/customers
      client:
        registration:
          keycloak:
            client-id: gateway-client
            client-secret: HPSzCoP0Q96xqm77vEhaigkMhmn4vZ1B
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            authorization-uri: http://localhost:8082/realms/customers/protocol/openid-connect/auth
            token-uri: http://localhost:8082/realms/customers/protocol/openid-connect/token
            user-info-uri: http://localhost:8082/realms/customers/protocol/openid-connect/userinfo
            jwk-set-uri: http://localhost:8082/realms/customers/protocol/openid-connect/certs
            user-name-attribute: preferred_username

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
application:
  routes:
    publicRoutes:
      - name: eureka_route
        path: /eureka/**
        uri: http://localhost:8761/eureka/
      - name: fd_interest_calculator_route
        path: /calculator/fd/interest/p
        uri: lb://CALCULATORS-SERVICE

    authenticatedRoutes:
      - name: fd_interest_calculator_route
        path: /calculator/fd/interest
        uri: lb://CALCULATORS-SERVICE
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

Create a domain model to read this configuration :

package com.mk.gateway.domains;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class AppRoute {
        private String name;
        private String path;
        private String uri;
}

Create a configuration reading class

package com.mk.gateway.configs;

import com.mk.gateway.domains.AppRoute;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Setter
@Configuration
@ConfigurationProperties(prefix = "application.routes")
public class RouteConfigProperties {
    private List<AppRoute> publicAppRoutes;
    private List<AppRoute> authenticatedAppRoutes;

    public List<AppRoute> getPublicAppRoutes() {
        if (publicAppRoutes == null) {
            publicAppRoutes = new ArrayList<>();
        }
        return publicAppRoutes;
    }

    public List<AppRoute> getAuthenticatedAppRoutes() {
        if (authenticatedAppRoutes == null) {
            authenticatedAppRoutes = new ArrayList<>();
        }
        return authenticatedAppRoutes;
    }

}

Define the App routes

package com.mk.gateway.routes;

import com.mk.gateway.configs.RouteConfigProperties;
import com.mk.gateway.domains.AppRoute;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CustomRouteConfig {
    @Autowired
    private RouteConfigProperties routeConfigProperties;
    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        RouteLocatorBuilder.Builder routesBuilder = builder.routes();

        for (AppRoute appRoute : routeConfigProperties.getPublicAppRoutes()) {
            routesBuilder.route(appRoute.getName(), r -> r.path(appRoute.getPath())
                    .uri(appRoute.getUri()));
        }
        for (AppRoute appRoute : routeConfigProperties.getAuthenticatedAppRoutes()) {
            routesBuilder.route(appRoute.getName(), r -> r.path(appRoute.getPath())
                    .uri(appRoute.getUri()));
        }

        return routesBuilder.build();
    }
}

Create the security config

package com.mk.gateway.configs;

import com.mk.gateway.domains.AppRoute;
import lombok.extern.java.Log;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebFluxSecurity
@Log
public class SecurityConfig {

    @Autowired
    private RouteConfigProperties routeConfigProperties;

    @Order(Ordered.HIGHEST_PRECEDENCE)
    @Bean
    public SecurityWebFilterChain apiHttpSecurity(ServerHttpSecurity http) {
        http.csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(exchanges -> {
                    for (AppRoute appRoute : routeConfigProperties.getPublicAppRoutes()) {
                        exchanges.pathMatchers(appRoute.getPath()).permitAll();
                    }
                    for (AppRoute appRoute : routeConfigProperties.getAuthenticatedAppRoutes()) {
                        exchanges.pathMatchers(appRoute.getPath()).authenticated();
                    }
                    exchanges.anyExchange().denyAll();
                })
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));

        return http.build();
    }
}

Start all the services.

Hit the public API via gateway.

curl --location 'http://localhost:8080/calculator/fd/interest/p' \
--header 'Content-Type: application/json' \
--data '{
    "schemeCode": "SCHEME_A",
    "amount" : 20,
    "duration" : 12
}'

Hit the protected API via gateway

curl --location 'http://localhost:8080/calculator/fd/interest' \
--header 'Content-Type: application/json' \
--data '{
    "schemeCode": "SCHEME_A",
    "amount" : 20,
    "duration" : 12
}'

You ger unauthenticated exception. Generate the auth token : Get an authentication token from Keycloak


curl --location 'http://localhost:8082/realms/customers/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=gateway-client' \
--data-urlencode 'username=m.m.kanwar@gmail.com' \
--data-urlencode 'password=password' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_secret=HPSzCoP0Q96xqm77vEhaigkMhmn4vZ1B'

Then hit the API with generated token :


curl --location 'http://localhost:8080/calculator/fd/interest' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ1eUxDU2tTMmxKNVZFbGZYWV9QYmpUVm5FOXZQMkJPdXNXODhTTGVKai00In0.eyJleHAiOjE3MjE4MTUyODEsImlhdCI6MTcyMTgxNDk4MSwianRpIjoiOTkyZjY4MjItYTkyOS00ZTI5LThjM2YtMmQ1YTQxNWJkMDc0IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgyL3JlYWxtcy9jdXN0b21lcnMiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNzI5ZDYyYmQtMTk2NC00YzY3LWFmZTQtNTAxMTBmMmI2NTEzIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZ2F0ZXdheS1jbGllbnQiLCJzaWQiOiJjNTkwZThhYS1iMmU1LTQwMjUtOGJiMC0wOTgxMzY2OGE4YjkiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIi8qIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLWN1c3RvbWVycyIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Ik1vaGl0IEthbndhciIsInByZWZlcnJlZF91c2VybmFtZSI6Im0ubS5rYW53YXJAZ21haWwuY29tIiwiZ2l2ZW5fbmFtZSI6Ik1vaGl0IiwiZmFtaWx5X25hbWUiOiJLYW53YXIiLCJlbWFpbCI6Im0ubS5rYW53YXJAZ21haWwuY29tIn0.g7bicpI5LS3OryxgvBpcNkt3OWH-KpwWDaJo8C3f1vS6qXlA5sfEHNPSSaD8O33qgSenOK4i1LaeQsFKooBskXWo4GbyfWg4YXX5m4FlgJmJ5mFCX0TLdnTRV7RpeJ3bRHK43g64sQMmfRfqx8wxmFEBN0sez0vww4h7CobJs9ig3svsc0APItpTk4Aq1CP6LY-zyYslSw_Zs2HbTIKSeQlTVoMVAnUc7rYANRgyPapSdVEhYKVo4IXExHpxpN8jAaDUWApHKUX91Rjt8JNY-5FjO7lGIvw8Utd4dzhH5ekobefO8P4xMC8mbxQqcRee0ZYJpKu2_S7Kwn7eqTf5Yg' \
--data '{
    "schemeCode": "SCHEME_A",
    "amount" : 20,
    "duration" : 12
}'

Authentication should be successful.

comments powered by Disqus