Hello!
In this article, I will demonstrate the basic components for creating Reactive RESTful mixservices using Spring WebFlux, Spring Security, Spring Cloud Netflix Eureka (Service Discovery), Hystrix (Circuit Breaker), Ribbon (Client Side Balancer), External Configuration (via git repository) , Spring Cloud Sleuth, Spring Cloud Gateway, Spring Boot Reactive MongoDB. As well as Spring Boot Admin and Zipkin for monitoring.
This review was made after studying the books of Spring Microservices in Action and Hands-On Spring 5 Security for Reactive Applications.
In this article, we will create an elementary application with three queries: get a list of games, get a list of players, create a game from player id, a request to check for rollback (Hystrix fallback) in case of a long wait for a response. And the implementation of authentication via JWT token based on the book Hands-On Spring 5 Security for Reactive Applications.
I will not describe how each application is created in the IDE, since this article is intended for an experienced user.
The project consists of two modules. spring-servers
module can be easily copied from project to project. There are almost no code and configurations. The tictactoe-services
module contains modules and microservices of our application. Immediately, I note that by adding auth-module
and domain-module
modules to services, I’m breaking one of the principles of the microservice architecture about the autonomy of microservices. But at the stage of development of these modules, I believe that this is the most optimal solution.
I like it when the entire Gradle configuration is in one file, so I configured the entire project in one build.gradle
.
buildscript { ext { springBootVersion = '2.1.1.RELEASE' gradleDockerVersion = '0.20.1' } repositories { mavenCentral() maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") classpath("gradle.plugin.com.palantir.gradle.docker:gradle-docker:${gradleDockerVersion}") } } allprojects { group = 'com.tictactoe' apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' apply plugin: 'com.palantir.docker' apply plugin: 'com.palantir.docker-run' apply plugin: 'com.palantir.docker-compose' } docker.name = 'com.tictactoe' bootJar.enabled = false sourceCompatibility = 11 repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" } } subprojects { ext['springCloudVersion'] = 'Greenwich.M3' sourceSets.configureEach { sourceSet -> tasks.named(sourceSet.compileJavaTaskName, { options.annotationProcessorGeneratedSourcesDirectory = file("$buildDir/generated/sources/annotationProcessor/java/${sourceSet.name}") }) } repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" } } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } dependencies { compile fileTree(include: ['*.jar'], dir: 'libs') compileOnly('org.projectlombok:lombok') annotationProcessor('org.projectlombok:lombok') } } project(':spring-servers') { bootJar.enabled = false task cleanAll { dependsOn subprojects*.tasks*.findByName('clean') } task buildAll { dependsOn subprojects*.tasks*.findByName('build') } dockerCompose { template 'docker-compose.spring-servers.template.yml' dockerComposeFile 'docker-compose.spring-servers.yml' } } project(':tictactoe-services') { bootJar.enabled = false task cleanAll { dependsOn subprojects*.tasks*.findByName('clean') } task buildAll { dependsOn subprojects*.tasks*.findByName('build') } } // Tictactoe Modules project(':tictactoe-services:domain-module') { bootJar.enabled = false jar { enabled = true group 'com.tictactoe' baseName = 'domain-module' version = '1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-validation') implementation('com.fasterxml.jackson.core:jackson-annotations:2.9.3') implementation 'com.intellij:annotations:+@jar' compileOnly('org.projectlombok:lombok') testCompile group: 'junit', name: 'junit', version: '4.12' } } project(':tictactoe-services:auth-module') { bootJar.enabled = false jar { enabled = true baseName = 'auth-module' version = '1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.security:spring-security-oauth2-core') implementation('org.springframework.security:spring-security-oauth2-jose') implementation 'com.intellij:annotations:+@jar' testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('io.projectreactor:reactor-test') testImplementation('org.springframework.security:spring-security-test') } } project(':tictactoe-services:user-service') { bootJar { launchScript() baseName = 'user-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') } } project(':tictactoe-services:game-service') { bootJar { launchScript() baseName = 'game-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') } } project(':tictactoe-services:webapi-service') { bootJar { launchScript() baseName = 'webapi-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') } } // Spring Servers project(':spring-servers:discovery-server') { bootJar { launchScript() baseName = 'discovery-server' version = '0.1.0' } dependencies { implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server') implementation('org.springframework.boot:spring-boot-starter-security') compile('javax.xml.bind:jaxb-api:2.3.0') compile('javax.activation:activation:1.1') compile('org.glassfish.jaxb:jaxb-runtime:2.3.0') testImplementation('org.springframework.boot:spring-boot-starter-test') } } project(':spring-servers:config-server') { bootJar { launchScript() baseName = 'config-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.cloud:spring-cloud-config-server') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') } } project(':spring-servers:gateway-server') { bootJar { launchScript() baseName = 'gateway-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-actuator') implementation('org.springframework.cloud:spring-cloud-starter-gateway') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') } } project(':spring-servers:admin-server') { ext['springBootAdminVersion'] = '2.1.1' bootJar { launchScript() baseName = 'admin-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-web') implementation('org.springframework.boot:spring-boot-starter-security') implementation('de.codecentric:spring-boot-admin-starter-server') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('org.springframework.security:spring-security-test') } dependencyManagement { imports { mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}" } } } subprojects { subproject -> if (file("${subproject.projectDir}/docker/Dockerfile").exists()) { docker { // workingbit - replace with your dockerhub's username name "workingbit/${subproject.group}.${subproject.bootJar.baseName}" tags 'latest' dockerfile file("${subproject.projectDir}/docker/Dockerfile") files tasks.bootJar.archivePath, 'docker/run.sh' buildArgs "JAR_FILE": "${subproject.bootJar.baseName}-${subproject.bootJar.version}.jar", "RUN_SH": "run.sh" } } else { docker.name = 'noop' } if (subproject.name.endsWith('service')) { dependencies { implementation('org.springframework.boot:spring-boot-starter-actuator') implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.security:spring-security-oauth2-core') implementation('org.springframework.security:spring-security-oauth2-jose') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') implementation('org.springframework.cloud:spring-cloud-starter-netflix-hystrix') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.cloud:spring-cloud-starter-sleuth') implementation('org.springframework.cloud:spring-cloud-starter-zipkin') implementation('org.springframework.security:spring-security-rsa') implementation('com.intellij:annotations:+@jar') implementation('org.apache.commons:commons-lang3:3.8.1') runtimeOnly('org.springframework.boot:spring-boot-devtools') testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('de.flapdoodle.embed:de.flapdoodle.embed.mongo') testImplementation('io.projectreactor:reactor-test') } } }
The use of a common configuration file allows us to place dependencies common to microservices, in this case, services with the name ending in “service” in one place. BUT, this again violates the principle of autonomy of microservices. In addition to general dependencies, you can add tasks to subprojects. I added the tasks of the gradle.plugin.com.palantir.gradle.docker:gradle-docker
plugin gradle.plugin.com.palantir.gradle.docker:gradle-docker
to work with Docker
.
Now, consider the JWT authentication module. A description of the auth
package of this module can be found in the book on reactive authentication, which I have indicated above.
And, config
dwell on the config
package.
@Data @Component @ConfigurationProperties("appclients") public class ApplicationClientsProperties { private List<ApplicationClient> clients = new ArrayList<>(); @Data public static class ApplicationClient { private String username; private String password; private String[] roles; } }
This class contains “complex” properties for the inMemory database configuration.
@Data @Configuration @PropertySource("classpath:moduleConfig.yml") public class AuthModuleConfig { @Value("${tokenExpirationMinutes:60}") private Integer tokenExpirationMinutes; @Value("${tokenIssuer:workingbit-example.com}") private String tokenIssuer; @Value("${tokenSecret:secret}") // length minimum 256 bites private String tokenSecret; }
In the resource file, you must specify these variables. In my configuration, the token fades after 10 hours.
public class MicroserviceServiceJwtAuthWebFilter extends JwtAuthWebFilter { private final String[] matchersStrings; public MicroserviceServiceJwtAuthWebFilter(JwtService jwtService, String[] matchersStrings) { super(jwtService); this.matchersStrings = matchersStrings; } @Override protected ServerWebExchangeMatcher getAuthMatcher() { List<ServerWebExchangeMatcher> matchers = Arrays.stream(this.matchersStrings) .map(PathPatternParserServerWebExchangeMatcher::new) .collect(Collectors.toList()); return ServerWebExchangeMatchers.matchers(new OrServerWebExchangeMatcher(matchers)); } }
When this filter is constructed, the service for working with JWT and the list of paths that this filter will process are transferred.
@ConditionalOnProperty(value = "microservice", havingValue = "true") @EnableReactiveMethodSecurity @PropertySource(value = "classpath:/application.properties") public class MicroserviceSpringSecurityWebFluxConfig { @Value("${whiteListedAuthUrls}") private String[] whiteListedAuthUrls; @Value("${jwtTokenMatchUrls}") private String[] jwtTokenMatchUrls; /** * Bean which configures whiteListed and JWT filter urls * Also it configures authentication for Actuator. Actuator takes configured AuthenticationManager automatically * which uses MapReactiveUserDetailsService to configure inMemory users */ @Bean public SecurityWebFilterChain springSecurityFilterChain( ServerHttpSecurity http, JwtService jwtService ) { MicroserviceServiceJwtAuthWebFilter userServiceJwtAuthWebFilter = new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls); http.csrf().disable(); http .authorizeExchange() .pathMatchers(whiteListedAuthUrls) .permitAll() .and() .authorizeExchange() .pathMatchers("/actuator/**").hasRole("SYSTEM") .and() .httpBasic() .and() .addFilterAt(userServiceJwtAuthWebFilter, SecurityWebFiltersOrder.AUTHENTICATION); return http.build(); } }
There are three interesting annotations here.
@ConditionalOnProperty(value = "microservice", havingValue = "true")
Annotation, which connects this module depending on the microservice variable in the configuration file, which is specified in the annotation. This is necessary in order to disable the general token check in some modules. In this application, this is the webapi-service
, which has its own implementation of the SecurityWebFilterChain
bean.
@PropertySource(value = "classpath:/application.properties")
This annotation also allows you to take properties from the main service to which this module is imported. In other words, variables
@Value("${whiteListedAuthUrls}") private String[] whiteListedAuthUrls; @Value("${jwtTokenMatchUrls}") private String[] jwtTokenMatchUrls;
Take your values ​​from the configuration of the child microservice.
And, the summary, which allows you to hang security annotations like @PreAuthorize(“hasRole('MY_ROLE')”)
@EnableReactiveMethodSecurity
And in this module, a SecurityWebFilterChain
bin is created, which configures the access to the actuator, which are allowed by the url and url on which the JWT token is checked. It should be noted that access to the JWT token filter must be open.
In this configuration, MapReactiveUserDetailsService
are created to configure the actuator and other system users in memory.
@Bean @Primary public MapReactiveUserDetailsService userDetailsRepositoryInMemory() { List<UserDetails> users = applicationClients.getClients() .stream() .map(applicationClient -> User.builder() .username(applicationClient.getUsername()) .password(passwordEncoder().encode(applicationClient.getPassword())) .roles(applicationClient.getRoles()).build()) .collect(toList()); return new MapReactiveUserDetailsService(users); }
The ReactiveUserDetailsService
that is needed to stitch our user's repository with Spring Security
.
@Bean public ReactiveUserDetailsService userDetailsRepository(UserRepository users) { return (email) -> users.findByEmail(email).cast(UserDetails.class); }
Bean create WebClient
- client to perform reactive requests.
@Bean public WebClient loadBalancedWebClientBuilder(JwtService jwtService) { return WebClient.builder() .filter(lbFunction) .filter(authorizationFilter(jwtService)) .build(); } private ExchangeFilterFunction authorizationFilter(JwtService jwtService) { return ExchangeFilterFunction .ofRequestProcessor(clientRequest -> ReactiveSecurityContextHolder.getContext() .map(securityContext -> ClientRequest.from(clientRequest) .header(HttpHeaders.AUTHORIZATION, jwtService.getHttpAuthHeaderValue(securityContext.getAuthentication())) .build())); }
Two filters are added during creation. LoadBalancer
and the filter that the Authentication
instance takes from the ReactiveSecurityContext
context and creates a token from it so that it can be authenticated by the target server's filter and authorized accordingly.
And for the convenience of working with the MongoDB ObjectId
type and dates, I added an objectMapper creation bin:
@Bean @Primary ObjectMapper objectMapper() { Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); builder.serializerByType(ObjectId.class, new ToStringSerializer()); builder.deserializerByType(ObjectId.class, new JsonDeserializer() { @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { Map oid = p.readValueAs(Map.class); return new ObjectId( (Integer) oid.get("timestamp"), (Integer) oid.get("machineIdentifier"), ((Integer) oid.get("processIdentifier")).shortValue(), (Integer) oid.get("counter")); } }); builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return builder.build(); }
Microservice game-service has the following structure:
As you can see there is only one ApplicationConfig configuration file.
@Data @Configuration @EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository") @Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class}) public class ApplicationConfig { @Value("${userserviceUrl}") private String userServiceUrl; }
It contains a variable with the user-service
address and there are two interesting annotations:
@EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository")
This annotation is necessary in order to specify the MongoDB repository to the configurator.
@Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class})
This annotation imports configurations from the auth-module
.
In this service there is only the following interesting code:
@HystrixCommand public Flux<Game> getAllGames() { return gameRepository.findAll(); } @HystrixCommand(fallbackMethod = "buildFallbackAllGames", threadPoolKey = "licenseByOrgThreadPool", threadPoolProperties = {@HystrixProperty(name = "coreSize", value = "30"), @HystrixProperty(name = "maxQueueSize", value = "10")}, commandProperties = { @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"), @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "75"), @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "7000"), @HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "15000"), @HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "5")} ) public Flux<Game> getAllGamesLong() { // logger.debug("LicenseService.getLicensesByOrg Correlation id: {}", UserContextHolder.getContext().getCorrelationId()); randomlyRunLong(); return gameRepository.findAll(); }
This method randomly throws an exception, and Hystrix, in accordance with the annotation, returns the result of the following method:
private Flux<Game> buildFallbackAllGames() { User fakeUserBlack = new User("fakeUserBlack", "password", Collections.emptyList()); User fakeUserWhite = new User("fakeUserBlack", "password", Collections.emptyList()); Game game = new Game(fakeUserBlack, fakeUserWhite); List<Game> games = List.of(game); return Flux.fromIterable(games); }
As mentioned in the above-mentioned book, if something broke, then let's better show the cached or alternative data than nothing.
This is a kind of middleware between Gateway and internal microservices that are not visible from the outside. The purpose of this service is to get a sample from other services and form the answer to the user on its basis.
We will begin consideration with a configuration.
@Configuration @EnableReactiveMethodSecurity public class SpringSecurityWebFluxConfig { private static final String AUTH_TOKEN_PATH = "/auth/token"; @Value("${whiteListedAuthUrls}") private String[] whiteListedAuthUrls; @Value("${jwtTokenMatchUrls}") private String[] jwtTokenMatchUrls; @Bean @Primary public SecurityWebFilterChain systemSecurityFilterChain( ServerHttpSecurity http, JwtService jwtService, @Qualifier("userDetailsRepository") ReactiveUserDetailsService userDetailsService ) {
Here we create an authentication manager with the userDetailsService
services, which we defined earlier in the auth-module
.
UserDetailsRepositoryReactiveAuthenticationManager authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
And we create a filter with this manager, and also add the Authentication instance converter in order to get user data encoded in x-www-form-urlencoded
.
AuthenticationWebFilter tokenWebFilter = new AuthenticationWebFilter(authenticationManager); tokenWebFilter.setServerAuthenticationConverter(exchange -> Mono.justOrEmpty(exchange) .filter(ex -> AUTH_TOKEN_PATH.equalsIgnoreCase(ex.getRequest().getPath().value())) .flatMap(ServerWebExchange::getFormData) .filter(formData -> !formData.isEmpty()) .map((formData) -> { String email = formData.getFirst("email"); String password = formData.getFirst("password"); return new UsernamePasswordAuthenticationToken(email, password); }) );
Add a successful authorization handler whose essence is to put the JWT token in the request header generated from Authentication
so that you can authenticate only by valid guest token.
tokenWebFilter.setAuthenticationSuccessHandler(new JwtAuthSuccessHandler(jwtService)); MicroserviceServiceJwtAuthWebFilter webApiJwtServiceWebFilter = new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls); http.csrf().disable(); http .authorizeExchange()
We allow addresses from the white list. As I wrote earlier, the addresses that will be processed by the JWT filter should also be opened
.pathMatchers(whiteListedAuthUrls) .permitAll() .and() .authorizeExchange()
We protect the actuator and some addresses with basic authentication
.pathMatchers("/actuator/**").hasRole("SYSTEM") .pathMatchers(HttpMethod.GET, "/url-protected/**").hasRole("GUEST") .pathMatchers(HttpMethod.POST, "/url-protected/**").hasRole("USER") .and() .httpBasic() .and() .authorizeExchange()
Mandatory authentication for access to the token
.pathMatchers(AUTH_TOKEN_PATH).authenticated() .and()
Add filters. To authenticate and verify the JWT token.
.addFilterAt(webApiJwtServiceWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .addFilterAt(tokenWebFilter, SecurityWebFiltersOrder.AUTHENTICATION); return http.build(); }
And as I wrote above, this service disables the common for other services JWT token check, specifying the value of the variable micoservice=false
in the file application.properites
.
I will not describe this controller, since it is very specific.
This service is called in the WebApiMethodProtectedController.jav
a controller and has an interesting annotation:
@PreAuthorize("hasRole('GUEST')") public Flux<User> getAllUsers() { }
This annotation only allows access to authorized users with the guest role.
Create an environment:
Get a token
Update the TOKEN variable in the environment with the received token.
Register a new user
After registration, you will receive a user token. It expires in 10 hours. When it expires you need to get a new one. To do this, request the guest token again, update the environment and make the request
Next, you can get a list of users, games, or create a new game. And also test Hystrix, look at the configs of services and encrypt variables for the git repository.
Source: https://habr.com/ru/post/434810/
All Articles