Skip to content

Latest commit

 

History

History
699 lines (549 loc) · 23.6 KB

File metadata and controls

699 lines (549 loc) · 23.6 KB

Java Microservices with Spring Boot and Spring Cloud

In this demo, you’ll see how to build a secure Java microservices architecture with Spring Boot and Spring Cloud. You’ll use Spring Data REST to expose a JPA repository as a RESTful API. You’ll also use Spring Security and its OAuth support to add authentication and authorization. Finally, you’ll use Spring Cloud Gateway to route requests to your microservices.

Features:

💡 Service Discovery with Netflix Eureka
🚦 Routing with Spring Cloud Gateway MVC
🔐 Security with OAuth 2.0 and OpenID Connect
🌟 Refresh Tokens for better security
🔑 Okta Spring Boot Starter and Keycloak

Prerequisites:

Fast Track: Clone the repo and follow the instructions in spring-boot-gateway-mvc/README.md to configure everything.

Build Java Microservices with Spring Boot & Spring Cloud

  1. Create a directory to hold all your projects:

    take spring-boot-microservices
    Note
    If take doesn’t work, use mkdir spring-boot-microservices && cd spring-boot-microservices
  2. Create three projects using start.spring.io's REST API and HTTPie:

    • discovery-service: a Netflix Eureka server used for service discovery.

    • car-service: a simple Car Service that uses Spring Data REST to serve up a REST API of cars.

    • api-gateway: an API gateway with a /cool-cars endpoint that talks to the car service and filters out cars that aren’t cool (in my opinion, of course).

      https start.spring.io/starter.tgz bootVersion==3.2.0 \
        artifactId==discovery-service name==discovery-service \
        dependencies==cloud-eureka-server baseDir==discovery-service | tar -xzvf -
      
      https start.spring.io/starter.tgz bootVersion==3.2.0 \
        artifactId==car-service name==car-service baseDir==car-service \
        dependencies==actuator,cloud-eureka,data-jpa,data-rest,postgresql,web,validation,devtools,docker-compose | tar -xzvf -
      
      https start.spring.io/starter.tgz bootVersion==3.2.0 \
        artifactId==api-gateway name==api-gateway baseDir==api-gateway \
        dependencies==cloud-eureka,cloud-gateway,cloud-resilience4j | tar -xzvf -
  3. Open the spring-boot-microservices directory in IntelliJ IDEA:

    idea .

Add Service Discovery with Netflix Eureka

  1. In the discovery-service project, configure the application.properties file to use port 8761 and turn off registration with Eureka.

    server.port=8761
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
  2. Add the @EnableEurekaServer annotation to the EurekaServiceApplication class.

    discovery-service/src/main/java/com/example/discoveryservice/EurekaServiceApplication.java
    import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
    
    @EnableEurekaServer
    @SpringBootApplication
    public class EurekaServiceApplication { ... }
  3. Start the discovery service application:

    ./gradlew bootRun

Build a Java Microservice with Spring Data REST

  1. In the car-service project, configure the application.properties file to use port 8090, to have an application name, and to create the database automatically.

    server.port=8090
    spring.application.name=car-service
    spring.jpa.hibernate.ddl-auto=update
  2. Create a Car entity in the data package with id and name properties.

    package com.example.carservice.data;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.Id;
    import jakarta.validation.constraints.NotNull;
    
    import java.util.Objects;
    
    @Entity
    public class Car {
    
        public Car() {
        }
    
        public Car(String name) {
            this.name = name;
        }
    
        @Id
        @GeneratedValue
        private Long id;
    
        @NotNull
        private String name;
    
        // generate getters and setters with your IDE
        // create equals(), hashCode(), and toString() with your IDE
    }
  3. Create a CarRepository interface in the same package:

    package com.example.carservice.data;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface CarRepository extends JpaRepository<Car, Long> {
    }
  4. Modify CarServiceApplication to enable service discovery and to create a default set of cars when the application loads.

    car-service/src/main/java/com/example/carservice/CarServiceApplication.java
    package com.example.carservice;
    
    import com.example.carservice.data.Car;
    import com.example.carservice.data.CarRepository;
    import org.springframework.boot.ApplicationRunner;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
    import org.springframework.context.annotation.Bean;
    
    import java.util.stream.Stream;
    
    @EnableDiscoveryClient
    @SpringBootApplication
    public class CarServiceApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(CarServiceApplication.class, args);
        }
    
        @Bean
        ApplicationRunner init(CarRepository repository) {
            repository.deleteAll();
            return args -> {
                Stream.of("Ferrari", "Jaguar", "Porsche", "Lamborghini", "Bugatti",
                    "AMC Gremlin", "Triumph Stag", "Ford Pinto", "Yugo GV").forEach(name -> {
                    repository.save(new Car(name));
                });
                repository.findAll().forEach(System.out::println);
            };
        }
    }
  5. Create a CarController class in the web package to expose a /cars endpoint.

    package com.example.carservice.web;
    
    import com.example.carservice.data.Car;
    import com.example.carservice.data.CarRepository;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.List;
    
    @RestController
    class CarController {
    
        private final CarRepository repository;
    
        public CarController(CarRepository repository) {
            this.repository = repository;
        }
    
        @GetMapping("/cars")
        public List<Car> getCars() {
            return repository.findAll();
        }
    }
  6. There’s a compose.yaml file in the root directory to start a PostgreSQL instance.

    services:
      postgres:
        image: 'postgres:latest'
        environment:
          - 'POSTGRES_DB=mydatabase'
          - 'POSTGRES_PASSWORD=secret'
          - 'POSTGRES_USER=myuser'
        ports:
          - '5432'
  7. Start the car service application:

    ./gradlew bootRun
  8. Confirm you can access the /cars endpoint with HTTPie:

    http :8090/cars

Add Routing with Spring Cloud Gateway

  1. In the api-gateway project, configure the application.properties file to have an application name.

    spring.application.name=api-gateway
  2. Update ApiGatewayApplication.java to enable service discovery and add an OpenFeign client to talk to the car service.

    api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java
    package com.example.apigateway;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
    import org.springframework.cloud.openfeign.EnableFeignClients;
    import org.springframework.cloud.openfeign.FeignClient;
    import org.springframework.hateoas.CollectionModel;
    import org.springframework.stereotype.Component;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.Collection;
    import java.util.stream.Collectors;
    
    @EnableFeignClients
    @EnableDiscoveryClient
    @SpringBootApplication
    public class ApiGatewayApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(ApiGatewayApplication.class, args);
        }
    }
    
    record Car(String name) {
    }
    
    @FeignClient(name = "car-service", fallback = Fallback.class)
    interface CarClient {
    
        @GetMapping("/cars")
        CollectionModel<Car> readCars();
    
    }
    
    @Component
    class Fallback implements CarClient {
    
        @Override
        public CollectionModel<Car> readCars() {
            return CollectionModel.empty();
        }
    }
    
    @RestController
    class CoolCarController {
    
        private final CarClient carClient;
    
        public CoolCarController(CarClient carClient) {
            this.carClient = carClient;
        }
    
        @GetMapping("/cool-cars")
        public Collection<Car> coolCars() {
            return carClient.readCars()
                .getContent()
                .stream()
                .filter(this::isCool)
                .collect(Collectors.toList());
        }
    
        private boolean isCool(Car car) {
            return !car.name().equals("AMC Gremlin") &&
                !car.name().equals("Triumph Stag") &&
                !car.name().equals("Ford Pinto") &&
                !car.name().equals("Yugo GV");
        }
    }
  3. Spring Cloud Gateway MVC 2023.0.0 doesn’t allow you to configure a TokenRelay filter in YAML, so a RouterFunction bean to ApiGatewayApplication.

    package com.example.apigateway;
    
    // other imports omitted for brevity
    
    import org.springframework.web.servlet.function.RouterFunction;
    import org.springframework.web.servlet.function.ServerResponse;
    
    import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb;
    import static org.springframework.cloud.gateway.server.mvc.filter.TokenRelayFilterFunctions.tokenRelay;
    import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
    import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
    import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path;
    
    @EnableFeignClients
    @EnableDiscoveryClient
    @SpringBootApplication
    public class ApiGatewayApplication {
    
        @Bean
        public RouterFunction<ServerResponse> gatewayRouterFunctionsLoadBalancer() {
            return route("car-service")
                .route(path("/home/**"), http())
                .filter(lb("car-service"))
                .filter(tokenRelay())
                .build();
        }
    
        public static void main(String[] args) {
            SpringApplication.run(ApiGatewayApplication.class, args);
        }
    }
  4. Create an application.yml file in the resources directory to enable service discovery.

    spring:
      cloud:
        gateway:
          discovery:
            locator:
              enabled: true
  5. Start the API gateway application:

    ./gradlew bootRun
  6. Confirm you can access the /cool-cars endpoint with HTTPie:

    http :8080/cool-cars

Secure Spring Boot Microservices with OAuth 2.0 and OIDC

To secure your microservices, you’ll use OAuth 2.0 and OpenID Connect (OIDC) with Auth0. Auth0 is a popular identity provider that supports many different authentication and authorization protocols. It’s easy to use and has a generous free tier.

  1. Open a terminal and run auth0 login to configure the Auth0 CLI to get an API key for your tenant. Then, run auth0 apps create to register an OIDC app with the appropriate URLs:

    auth0 apps create \
      --name "Kick-Ass Cars" \
      --description "Microservices for Cool Cars" \
      --type regular \
      --callbacks http://localhost:8080/login/oauth2/code/okta \
      --logout-urls http://localhost:8080 \
      --reveal-secrets
  2. Modify the build.gradle files in both the gateway and car service projects to use the Okta Spring Boot starter and spring-dotenv:

    implementation 'com.okta.spring:okta-spring-boot-starter:3.0.6'
    implementation 'me.paulschwarz:spring-dotenv:4.0.0'
  3. Create an api-gateway/.env file and edit it to contain the values from the command above.

    OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
    OKTA_OAUTH2_CLIENT_ID=
    OKTA_OAUTH2_CLIENT_SECRET=
    OKTA_OAUTH2_AUDIENCE=https://<your-auth0-domain>/api/v2/
  4. Update the gateway’s application.properties to configure the Okta Spring Boot starter with these values:

    api-gateway/src/main/resources/application.properties
    okta.oauth2.issuer=${OKTA_OAUTH2_ISSUER}
    okta.oauth2.client-id=${OKTA_OAUTH2_CLIENT_ID}
    okta.oauth2.client-secret=${OKTA_OAUTH2_CLIENT_SECRET}
    okta.oauth2.audience=${OKTA_OAUTH2_AUDIENCE}
  5. Add the following properties to configure OpenFeign to work with OAuth 2.0:

    api-gateway/src/main/resources/application.properties
    spring.cloud.openfeign.oauth2.enabled=true
    spring.cloud.openfeign.oauth2.clientRegistrationId=okta
  6. Create car-service/.env and update its values.

    OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
    OKTA_OAUTH2_AUDIENCE=https://<your-auth0-domain>/api/v2/
    Note
    The car service doesn’t need the client ID and secret because it’s acting as a resource server and simply validates the access token, without communicating with Auth0.
  7. Update the car service’s application.properties:

    car-service/src/main/resources/application.properties
    okta.oauth2.issuer=${OKTA_OAUTH2_ISSUER}
    okta.oauth2.audience=${OKTA_OAUTH2_AUDIENCE}
  8. Add a HomeController class to the car service project that displays the access token’s claims.

    car-service/src/main/java/com/example/carservice/web/HomeController.java
    package com.example.carservice.web;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.security.Principal;
    
    @RestController
    public class HomeController {
    
        private final static Logger log = LoggerFactory.getLogger(HomeController.class);
    
        @GetMapping("/home")
        public String home(Principal principal) {
            var username = principal.getName();
            if (principal instanceof JwtAuthenticationToken token) {
                log.info("claims: " + token.getTokenAttributes());
            }
            return "Hello, " + username;
        }
    }
  9. Add a HomeController class to the API gateway project that displays your user’s name and access token.

    api-gateway/src/main/java/com/example/apigateway/web/HomeController.java
    package com.example.apigateway.web;
    
    import org.springframework.security.core.annotation.AuthenticationPrincipal;
    import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
    import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
    import org.springframework.security.oauth2.core.oidc.user.OidcUser;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    class HomeController {
    
        @GetMapping("/")
        public String howdy(@AuthenticationPrincipal OidcUser user) {
            return "Hello, " + user.getFullName();
        }
    
        @GetMapping("/print-token")
        public String printAccessToken(@RegisteredOAuth2AuthorizedClient("okta")
                                       OAuth2AuthorizedClient authorizedClient) {
    
            var accessToken = authorizedClient.getAccessToken();
    
            System.out.println("Access Token Value: " + accessToken.getTokenValue());
            System.out.println("Token Type: " + accessToken.getTokenType().getValue());
            System.out.println("Expires At: " + accessToken.getExpiresAt());
    
            return "Access token printed";
        }
    }
  10. Restart both the car service and API gateway applications using Ctrl+C and ./gradlew bootRun.

  11. Open http://localhost:8080 in your favorite browser. You’ll be redirected to Auth0 to log in. After authenticating, you’ll see your name in lights! ✨

  12. If you go to http://localhost:8080/cool-cars, you won’t see any data and there will be an error in your gateway app’s console.

    [503] during [GET] to [http://car-service/cars]
  13. Go to http://localhost:8080/print-token and view the access token printed to the console.

  14. Check if it’s a valid access token by copying/pasting it into jwt.io. You’ll see it’s invalid. This is because Auth0 returns an opaque token when you don’t pass in an audience parameter.

Fetch an Access Token as a JWT

  1. Create a SecurityConfiguration class in the API gateway project to configure Spring Security to send an audience parameter to Auth0.

    api-gateway/src/main/java/com/example/apigateway/config/SecurityConfiguration.java
    package com.example.apigateway.config;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
    import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
    import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
    import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
    import org.springframework.security.web.SecurityFilterChain;
    
    import java.util.function.Consumer;
    
    @Configuration
    public class SecurityConfiguration {
    
        @Value("${okta.oauth2.audience:}")
        private String audience;
    
        private final ClientRegistrationRepository clientRegistrationRepository;
    
        public SecurityConfiguration(ClientRegistrationRepository clientRegistrationRepository) {
            this.clientRegistrationRepository = clientRegistrationRepository;
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .authorizeHttpRequests(authorize -> authorize
                    .anyRequest().authenticated()
                )
                .oauth2Login(oauth2 -> oauth2
                    .authorizationEndpoint(authorization -> authorization
                        .authorizationRequestResolver(
                            authorizationRequestResolver(this.clientRegistrationRepository)
                        )
                    )
                );
            return http.build();
        }
    
        private OAuth2AuthorizationRequestResolver authorizationRequestResolver(
            ClientRegistrationRepository clientRegistrationRepository) {
    
            DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
                new DefaultOAuth2AuthorizationRequestResolver(
                    clientRegistrationRepository, "/oauth2/authorization");
            authorizationRequestResolver.setAuthorizationRequestCustomizer(
                authorizationRequestCustomizer());
    
            return authorizationRequestResolver;
        }
    
        private Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer() {
            return customizer -> customizer
                .additionalParameters(params -> params.put("audience", audience));
        }
    }
  2. Restart the API gateway and now http://localhost:8080/print-token will print a valid JWT. Prove the other URLs work:

  3. Copy the JWT from the console and access the car service directly.

    TOKEN=<access-token>
    http :8090/cars Authorization:"Bearer $TOKEN"

Spring Boot Microservices and Refresh Tokens

  1. Change the default scopes in the gateway project to request a refresh token using the offline_access scope. Also, change the audience to be one that quickly expires its access tokens.

    .env
    OKTA_OAUTH2_AUDIENCE=https://fast-expiring-api
    OKTA_OAUTH2_SCOPES=openid,profile,email,offline_access
  2. Add a property to application.properties to read the updated scopes and add logging for WebClient.

    api-gateway/src/main/resources/application.properties
    okta.oauth2.scopes=${OKTA_OAUTH2_SCOPES}
    
    logging.level.org.springframework.web.reactive.function.client=DEBUG
  3. Create a new API in Auth0 and configure it to have a 30-second access token lifetime.

    auth0 apis create --name fast-expiring --identifier https://fast-expiring-api \
      --token-lifetime 30 --offline-access --no-input
  4. Restart the API gateway and go to http://localhost:8080/print-token to see your access token.

  5. Copy the expired time to timestamp-converter.com (under ISO 8601) to see when it expires in your local timezone.

  6. Wait 30 seconds and refresh the page. You’ll see a request for a new token and an updated Expires At timestamp in your terminal.

The Okta Spring Boot starter and Keycloak

If you find yourself in a situation where you don’t have an internet connection, it can be handy to run Keycloak locally in a Docker container. Since the Okta Spring Boot starter is a thin wrapper around Spring Security, it works with Keycloak, too.

Note
The Okta Spring Boot starter does validate the issuer to ensure it’s an Okta URL, so you must use Spring Security’s properties instead of the okta.oauth2.* properties when using Keycloak.
  1. An easy way to get a pre-configured Keycloak instance is to use JHipster's jhipster-sample-app-oauth2 application. It gets updated with every JHipster release. Clone it with the following command:

    git clone https://github.com/jhipster/jhipster-sample-app-oauth2.git --depth=1
    cd jhipster-sample-app-oauth2
  2. Start Keycloak with Docker Compose:

    docker compose -f src/main/docker/keycloak.yml up -d
  3. Configure the gateway to use Keycloak by removing the okta.oauth2.* properties and using Spring Security’s in application.properties:

    api-gateway/src/main/resources/application.properties
    spring.security.oauth2.client.provider.okta.issuer-uri=http://localhost:9080/realms/jhipster
    spring.security.oauth2.client.registration.okta.client-id=web_app
    spring.security.oauth2.client.registration.okta.client-secret=web_app
    spring.security.oauth2.client.registration.okta.scope=openid,profile,email,offline_access
  4. Update the car service to use Keycloak by removing the okta.oauth2.* properties and using Spring Security’s in application.properties:

    car-service/src/main/resources/application.properties
    spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9080/realms/jhipster
    spring.security.oauth2.resourceserver.jwt.audiences=account
  5. Restart both apps, open http://localhost:8080, and you’ll be able to log in with Keycloak.

  6. Use admin/admin for credentials, and you can access http://localhost:8080/cool-cars as you did before.

Have fun with Spring Boot and Spring Cloud!

I hope you enjoyed this demo, and it helped you learn how to use Spring Boot with microservices in a secure way. Using OpenID Connect is a recommended practice for authenticating with microservices, OAuth 2.0 is great for securing communication between them. And, Auth0 makes it easy to do both.

Using short-lived access tokens is recommended for enhanced security and refresh tokens make them easier on your users. Finally, isn’t it neat how the Okta Spring Boot starter works with Keycloak too?!

🍃 Find the source code on GitHub: @oktadev/auth0-java-microservices-examples