Spring Boot Admin is a popular tool for monitoring and managing Spring Boot-based applications. In this blog post you’ll learn how to secure Spring Boot Admin itself and protect the actuator endpoints of monitored applications with Keycloak.
Overview
In our demo environment we’re going to have three components:
- Keycloak Auth Server
- Spring Boot Admin
- Spring Boot App with actuator endpoints
The Keycloak Auth server is available via http://localhost:8080/auth
The Spring Boot Admin app is available via http://localhost:30001/admin
The monitored Spring Boot App is available via http://localhost:30002
Keycloak configuration
In Keycloak we’ll define a dedicated realm with the name bootadmin
.

Then we’ll create two clients: app-admin
, which represents the Spring Boot Admin application, and app-todo
, which denotes the Spring Boot app respectively.
Well start with the definition of the monitored Spring Boot app that exposes actuator endpoints
.
Client for Spring Boot app with actuator endpoints in Keycloak
Our example application is a simple to do management app with the client ID app-todo
.
The app-todo
client is configured as follows:
Client-Protocol: OpenID Connect
Access-Type: confidential
Standard-Flow Enabled: on
Direct-Access grants: off
Root URL: http://localhost:30002
Valid redirect URIs: /*
Base URL: /
Admin URL: /
Web Origins: +

In the credentials tab, you need to write down the Secret
, as we’ll need this later for our Spring Boot app configuration.

Roles
We need to define the following roles for our app-todo
client:
user
– denotes the normal to do app users.actuator
– this role is used to access the actuator endpoints.

Scope
For the sake of simplicity, we set Fill Scope Allowed: on
, however I’d recommend to be explicit about what roles a client might see to keep the tokens small. This also helps avoid exposing unnecessary information to a client application.

Client for Spring Boot Admin in Keycloak
The app-admin
client is configured as follows:
Client-Protocol: OpenID Connect
Access-Type: confidential
Standard-Flow Enabled: on
Direct-Access grants: off
Service-Accounts Enabled: on
Root URL: http://localhost:30001
Valid redirect URIs: /*
Base URL: /admin
Admin URL: /
Web Origins: +

As before, in the credentials tab write down the Secret
as we’ll need this later for our Spring Boot configuration.
Roles
We need to define the following roles for our app-admin
client:
admin
– denotes the users who can access Spring Boot Adminactuator
– internal Role for the service account user. This role is used to access the actuator endpoints of monitored applications.

Note that this actuator
role is a composite role which includes the actuator
roles of the monitored client apps.

Scope
As before we set Fill Scope Allowed: on
.

Service accounts
We grant the actuator
role of the app-admin
to the service account user. Since the app-admin:actuator
composite role includes the app-todo:actuator
role, we also have access to its actuator endpoints. One can easily apply this pattern to securely monitor new applications.

With that set, the only thing that’s left to do on the Keycloak side is to create a user who can access the Spring Boot Admin UI.


For this we create a user with the username tester
and password test
. We also assign the admin
role for the app-admin
client.

The complete example, with more details about the Keycloak configuration, can be found in the spring-boot-admin-keycloak-example repository on Github.
After our Keycloak environment is configured, we can move on to the Spring Boot apps.
We begin with the Todo-Service app that we modelled as app-todo
client.
Todo-Service
We’ll start with the Maven configuration for the to do service module, which looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>todo-service</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>todo-service</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<keycloak.version>4.8.3.Final</keycloak.version>
<spring-boot-admin.version>2.1.2</spring-boot-admin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${spring-boot-admin.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.bom</groupId>
<artifactId>keycloak-adapter-bom</artifactId>
<version>${keycloak.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> |
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>todo-service</artifactId> <version>0.1.0-SNAPSHOT</version> <packaging>jar</packaging> <name>todo-service</name> <description>Demo project for Spring Boot</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.2.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> <keycloak.version>4.8.3.Final</keycloak.version> <spring-boot-admin.version>2.1.2</spring-boot-admin.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>de.codecentric</groupId> <artifactId>spring-boot-admin-starter-client</artifactId> <version>${spring-boot-admin.version}</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.keycloak.bom</groupId> <artifactId>keycloak-adapter-bom</artifactId> <version>${keycloak.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>pl.project13.maven</groupId> <artifactId>git-commit-id-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>build-info</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
The Todo-Service is pretty simplistic and only shows the Spring Boot Admin Client configuration as well as the required actuator and Keycloak setup.
Our main class is the TodoServiceApplication
which contains an embedded TodoController
for the sake of brevity – Josh Long style FWT.
package demo.todo;
import java.util.Arrays;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@EnableScheduling
@SpringBootApplication
public class TodoServiceApplication {
public static void main(String[] args) {
SpringApplication.run(TodoServiceApplication.class, args);
}
@Scheduled(fixedRate = 5_000)
public void doSomework() {
// useful to demonstrate log dynamic level configuration
log.info("work info");
log.debug("work debug");
log.trace("work trace");
log.error("work error");
}
}
@RestController
class TodoController {
@GetMapping("/")
Object getTodos() {
return Arrays.asList("Prepare talk...");
}
} |
package demo.todo; import java.util.Arrays; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import lombok.extern.slf4j.Slf4j; @Slf4j @EnableScheduling @SpringBootApplication public class TodoServiceApplication { public static void main(String[] args) { SpringApplication.run(TodoServiceApplication.class, args); } @Scheduled(fixedRate = 5_000) public void doSomework() { // useful to demonstrate log dynamic level configuration log.info("work info"); log.debug("work debug"); log.trace("work trace"); log.error("work error"); } } @RestController class TodoController { @GetMapping("/") Object getTodos() { return Arrays.asList("Prepare talk..."); } }
The Keycloak configuration for the Todo-Service is denoted by the class KeycloakConfig
:
package demo.todo.keycloak;
import java.security.Principal;
import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootProperties;
import org.keycloak.adapters.springsecurity.KeycloakConfiguration;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@KeycloakConfiguration
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http //
.csrf().disable() //
.authorizeRequests() //
.requestMatchers(EndpointRequest.to( //
InfoEndpoint.class, //
HealthEndpoint.class //
)).permitAll() //
.requestMatchers(EndpointRequest.toAnyEndpoint()) //
.hasRole("ACTUATOR") //
.anyRequest().permitAll() //
;
}
/**
* Load Keycloak configuration from application.properties or application.yml
*
* @return
*/
@Bean
public KeycloakConfigResolver keycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
/**
* Use {@link KeycloakAuthenticationProvider}
*
* @param auth
* @throws Exception
*/
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
grantedAuthorityMapper.setPrefix("ROLE_");
grantedAuthorityMapper.setConvertToUpperCase(true);
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
auth.authenticationProvider(keycloakAuthenticationProvider);
}
@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(buildSessionRegistry());
}
@Bean
protected SessionRegistry buildSessionRegistry() {
return new SessionRegistryImpl();
}
/**
* Allows to inject requests scoped wrapper for {@link KeycloakSecurityContext}.
*
* Returns the {@link KeycloakSecurityContext} from the Spring
* {@link ServletRequestAttributes}'s {@link Principal}.
* <p>
* The principal must support retrieval of the KeycloakSecurityContext, so at
* this point, only {@link KeycloakPrincipal} values and
* {@link KeycloakAuthenticationToken} are supported.
*
* @return the current <code>KeycloakSecurityContext</code>
*/
@Bean
@Scope(scopeName = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public KeycloakSecurityContext provideKeycloakSecurityContext() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Principal principal = attributes.getRequest().getUserPrincipal();
if (principal == null) {
return null;
}
if (principal instanceof KeycloakAuthenticationToken) {
principal = Principal.class.cast(KeycloakAuthenticationToken.class.cast(principal).getPrincipal());
}
if (principal instanceof KeycloakPrincipal) {
return KeycloakPrincipal.class.cast(principal).getKeycloakSecurityContext();
}
return null;
}
} |
package demo.todo.keycloak; import java.security.Principal; import org.keycloak.KeycloakPrincipal; import org.keycloak.KeycloakSecurityContext; import org.keycloak.adapters.KeycloakConfigResolver; import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver; import org.keycloak.adapters.springboot.KeycloakSpringBootProperties; import org.keycloak.adapters.springsecurity.KeycloakConfiguration; import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.info.InfoEndpoint; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @KeycloakConfiguration @EnableConfigurationProperties(KeycloakSpringBootProperties.class) class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { super.configure(http); http // .csrf().disable() // .authorizeRequests() // .requestMatchers(EndpointRequest.to( // InfoEndpoint.class, // HealthEndpoint.class // )).permitAll() // .requestMatchers(EndpointRequest.toAnyEndpoint()) // .hasRole("ACTUATOR") // .anyRequest().permitAll() // ; } /** * Load Keycloak configuration from application.properties or application.yml * * @return */ @Bean public KeycloakConfigResolver keycloakConfigResolver() { return new KeycloakSpringBootConfigResolver(); } /** * Use {@link KeycloakAuthenticationProvider} * * @param auth * @throws Exception */ @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper(); grantedAuthorityMapper.setPrefix("ROLE_"); grantedAuthorityMapper.setConvertToUpperCase(true); KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider(); keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper); auth.authenticationProvider(keycloakAuthenticationProvider); } @Bean @Override protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { return new RegisterSessionAuthenticationStrategy(buildSessionRegistry()); } @Bean protected SessionRegistry buildSessionRegistry() { return new SessionRegistryImpl(); } /** * Allows to inject requests scoped wrapper for {@link KeycloakSecurityContext}. * * Returns the {@link KeycloakSecurityContext} from the Spring * {@link ServletRequestAttributes}'s {@link Principal}. * <p> * The principal must support retrieval of the KeycloakSecurityContext, so at * this point, only {@link KeycloakPrincipal} values and * {@link KeycloakAuthenticationToken} are supported. * * @return the current <code>KeycloakSecurityContext</code> */ @Bean @Scope(scopeName = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public KeycloakSecurityContext provideKeycloakSecurityContext() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); Principal principal = attributes.getRequest().getUserPrincipal(); if (principal == null) { return null; } if (principal instanceof KeycloakAuthenticationToken) { principal = Principal.class.cast(KeycloakAuthenticationToken.class.cast(principal).getPrincipal()); } if (principal instanceof KeycloakPrincipal) { return KeycloakPrincipal.class.cast(principal).getKeycloakSecurityContext(); } return null; } }
The application configuration for the Todo-Service is contained in application.yml
spring:
main:
allow-bean-definition-overriding: true
server:
port: 30002
keycloak:
realm: bootadmin
auth-server-url: http://localhost:8080/auth
resource: app-todo
credentials:
secret: 2cc653a3-24cc-4241-896d-813a726f9b33
ssl-required: external
principal-attribute: preferred_username
autodetect-bearer-only: true
use-resource-role-mappings: true
token-minimum-time-to-live: 30
management:
endpoints:
web:
exposure:
include: '*'
Our Todo-Service application is now ready for service. We’ll now move on to the last path, the Admin-Service.
Admin-Service
The Admin-Service is denoted by the app-admin
Keycloak client and hosts the Spring Boot Admin infrastructure. It uses a Keycloak service account to access the actuator endpoints of monitored applications. The app also exposes the Spring Boot Admin UI which is protected by Keycloak as well.
Only users with the role admin
for the app-admin
client will be able to login to the admin UI.
The Maven module configuration of Admin-Service looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>admin-service</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>admin-service</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<keycloak.version>4.8.3.Final</keycloak.version>
<spring-boot-admin.version>2.1.2</spring-boot-admin.version>
<resteasy.version>3.6.1.Final</resteasy.version>
<spring-cloud.version>Finchley.SR2</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
<version>${spring-boot-admin.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.bom</groupId>
<artifactId>keycloak-adapter-bom</artifactId>
<version>${keycloak.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project> |
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>admin-service</artifactId> <version>0.1.0-SNAPSHOT</version> <packaging>jar</packaging> <name>admin-service</name> <description>Demo project for Spring Boot</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.2.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> <keycloak.version>4.8.3.Final</keycloak.version> <spring-boot-admin.version>2.1.2</spring-boot-admin.version> <resteasy.version>3.6.1.Final</resteasy.version> <spring-cloud.version>Finchley.SR2</spring-cloud.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>de.codecentric</groupId> <artifactId>spring-boot-admin-starter-server</artifactId> <version>${spring-boot-admin.version}</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter</artifactId> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-client</artifactId> <version>${resteasy.version}</version> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-jackson2-provider</artifactId> <version>${resteasy.version}</version> </dependency> <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-admin-client</artifactId> <version>${keycloak.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.keycloak.bom</groupId> <artifactId>keycloak-adapter-bom</artifactId> <version>${keycloak.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
The main class of the Admin-Service is straightforward:
package demo.admin;
import de.codecentric.boot.admin.server.config.EnableAdminServer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@EnableAdminServer
@SpringBootApplication
public class AdminServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AdminServiceApplication.class, args);
}
} |
package demo.admin; import de.codecentric.boot.admin.server.config.EnableAdminServer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @EnableAdminServer @SpringBootApplication public class AdminServiceApplication { public static void main(String[] args) { SpringApplication.run(AdminServiceApplication.class, args); } }
The Keycloak configuration is more advanced though:
package demo.admin.keycloak;
import java.security.Principal;
import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootProperties;
import org.keycloak.adapters.springsecurity.KeycloakConfiguration;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import de.codecentric.boot.admin.server.web.client.HttpHeadersProvider;
@KeycloakConfiguration
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter {
/**
* {@link HttpHeadersProvider} used to populate the {@link HttpHeaders} for
* accessing the state of the disovered clients.
*
* @param keycloak
* @return
*/
@Bean
public HttpHeadersProvider keycloakBearerAuthHeaderProvider(Keycloak keycloak) {
return (app) -> {
String accessToken = keycloak.tokenManager().getAccessTokenString();
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
return headers;
};
}
/**
* The Keycloak Admin client that provides the service-account Access-Token
*
* @param props
* @return
*/
@Bean
public Keycloak keycloak(KeycloakSpringBootProperties props) {
return KeycloakBuilder.builder() //
.serverUrl(props.getAuthServerUrl()) //
.realm(props.getRealm()) //
.grantType(OAuth2Constants.CLIENT_CREDENTIALS) //
.clientId(props.getResource()) //
.clientSecret((String) props.getCredentials().get("secret")) //
.build();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http //
.csrf().disable() // for the sake of brevity...
.authorizeRequests() //
.antMatchers("/**/*.css", "/admin/img/**", "/admin/third-party/**").permitAll() //
.antMatchers("/admin").hasRole("ADMIN") //
.anyRequest().permitAll() //
;
}
/**
* Load Keycloak configuration from application.properties or application.yml
*
* @return
*/
@Bean
public KeycloakConfigResolver keycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
/**
* Use {@link KeycloakAuthenticationProvider}
*
* @param auth
* @throws Exception
*/
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
grantedAuthorityMapper.setPrefix("ROLE_");
grantedAuthorityMapper.setConvertToUpperCase(true);
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
auth.authenticationProvider(keycloakAuthenticationProvider);
}
@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(buildSessionRegistry());
}
@Bean
protected SessionRegistry buildSessionRegistry() {
return new SessionRegistryImpl();
}
/**
* Allows to inject requests scoped wrapper for {@link KeycloakSecurityContext}.
* <p>
* Returns the {@link KeycloakSecurityContext} from the Spring
* {@link ServletRequestAttributes}'s {@link Principal}.
* <p>
* The principal must support retrieval of the KeycloakSecurityContext, so at
* this point, only {@link KeycloakPrincipal} values and
* {@link KeycloakAuthenticationToken} are supported.
*
* @return the current <code>KeycloakSecurityContext</code>
*/
@Bean
@Scope(scopeName = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public KeycloakSecurityContext provideKeycloakSecurityContext() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Principal principal = attributes.getRequest().getUserPrincipal();
if (principal == null) {
return null;
}
if (principal instanceof KeycloakAuthenticationToken) {
principal = Principal.class.cast(KeycloakAuthenticationToken.class.cast(principal).getPrincipal());
}
if (principal instanceof KeycloakPrincipal) {
return KeycloakPrincipal.class.cast(principal).getKeycloakSecurityContext();
}
return null;
}
} |
package demo.admin.keycloak; import java.security.Principal; import org.keycloak.KeycloakPrincipal; import org.keycloak.KeycloakSecurityContext; import org.keycloak.OAuth2Constants; import org.keycloak.adapters.KeycloakConfigResolver; import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver; import org.keycloak.adapters.springboot.KeycloakSpringBootProperties; import org.keycloak.adapters.springsecurity.KeycloakConfiguration; import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.http.HttpHeaders; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import de.codecentric.boot.admin.server.web.client.HttpHeadersProvider; @KeycloakConfiguration @EnableConfigurationProperties(KeycloakSpringBootProperties.class) class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter { /** * {@link HttpHeadersProvider} used to populate the {@link HttpHeaders} for * accessing the state of the disovered clients. * * @param keycloak * @return */ @Bean public HttpHeadersProvider keycloakBearerAuthHeaderProvider(Keycloak keycloak) { return (app) -> { String accessToken = keycloak.tokenManager().getAccessTokenString(); HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); return headers; }; } /** * The Keycloak Admin client that provides the service-account Access-Token * * @param props * @return */ @Bean public Keycloak keycloak(KeycloakSpringBootProperties props) { return KeycloakBuilder.builder() // .serverUrl(props.getAuthServerUrl()) // .realm(props.getRealm()) // .grantType(OAuth2Constants.CLIENT_CREDENTIALS) // .clientId(props.getResource()) // .clientSecret((String) props.getCredentials().get("secret")) // .build(); } @Override protected void configure(HttpSecurity http) throws Exception { super.configure(http); http // .csrf().disable() // for the sake of brevity... .authorizeRequests() // .antMatchers("/**/*.css", "/admin/img/**", "/admin/third-party/**").permitAll() // .antMatchers("/admin").hasRole("ADMIN") // .anyRequest().permitAll() // ; } /** * Load Keycloak configuration from application.properties or application.yml * * @return */ @Bean public KeycloakConfigResolver keycloakConfigResolver() { return new KeycloakSpringBootConfigResolver(); } /** * Use {@link KeycloakAuthenticationProvider} * * @param auth * @throws Exception */ @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper(); grantedAuthorityMapper.setPrefix("ROLE_"); grantedAuthorityMapper.setConvertToUpperCase(true); KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider(); keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper); auth.authenticationProvider(keycloakAuthenticationProvider); } @Bean @Override protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { return new RegisterSessionAuthenticationStrategy(buildSessionRegistry()); } @Bean protected SessionRegistry buildSessionRegistry() { return new SessionRegistryImpl(); } /** * Allows to inject requests scoped wrapper for {@link KeycloakSecurityContext}. * <p> * Returns the {@link KeycloakSecurityContext} from the Spring * {@link ServletRequestAttributes}'s {@link Principal}. * <p> * The principal must support retrieval of the KeycloakSecurityContext, so at * this point, only {@link KeycloakPrincipal} values and * {@link KeycloakAuthenticationToken} are supported. * * @return the current <code>KeycloakSecurityContext</code> */ @Bean @Scope(scopeName = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public KeycloakSecurityContext provideKeycloakSecurityContext() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); Principal principal = attributes.getRequest().getUserPrincipal(); if (principal == null) { return null; } if (principal instanceof KeycloakAuthenticationToken) { principal = Principal.class.cast(KeycloakAuthenticationToken.class.cast(principal).getPrincipal()); } if (principal instanceof KeycloakPrincipal) { return KeycloakPrincipal.class.cast(principal).getKeycloakSecurityContext(); } return null; } }
Note that we defined a dedicated Keycloak
bean, which is used by the HttpHeadersProvider keycloakBearerAuthHeaderProvider
bean to transparently retrieve (and renew) an OAuth2 Access-Token for the app-admin
service account. All requests towards actuator endpoints of monitored applications will use this token.
In order to support a proper logout functionality, we’ll have to set up a dedicated /admin/logout
endpoint.
package demo.admin.keycloak;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
class KeycloakController {
/**
* Propagates the logout to the Keycloak infrastructure
* @param request
* @return
* @throws Exception
*/
@PostMapping("/admin/logout")
public String logout(HttpServletRequest request) throws Exception {
request.logout();
return "redirect:/admin";
}
} |
package demo.admin.keycloak; import javax.servlet.http.HttpServletRequest; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; @Controller class KeycloakController { /** * Propagates the logout to the Keycloak infrastructure * @param request * @return * @throws Exception */ @PostMapping("/admin/logout") public String logout(HttpServletRequest request) throws Exception { request.logout(); return "redirect:/admin"; } }
The spring configuration file application.yml
for the Admin-Service looks like this:
server:
port: 30001
spring:
main:
allow-bean-definition-overriding: true
boot:
admin:
context-path: /admin
cloud:
discovery:
client:
simple:
instances:
app-todo:
- uri: http://localhost:30002
keycloak:
realm: bootadmin
auth-server-url: http://localhost:8080/auth
resource: app-admin
credentials:
secret: 97edad04-49ca-4770-8e4a-3bc97c1714ce
ssl-required: external
principal-attribute: preferred_username
use-resource-role-mappings: true
token-minimum-time-to-live: 30
Et voilà, we now have a setup that is fully secured via Keycloak 🙂
