Einleitung
Dieser Artikel beschreibt die Implementierung einer Spring Boot-Anwendung, welche REST-Schnittstellen anbietet. Diese Schnittstellen sollen nur dann erwartete Responses liefern, wenn der Nutzer sich vorher vollständig gegenüber Keycloak authentifiziert hat und der Anfrage mit einem entsprechenden Authorization
- Header und dem gültigen Bearer
Json Web Token (JWT) abgesetzt hat.
Problemstellung
Mit der zunehmenden Anzahl an APIs steigt auch der Wartungsaufwand, berechtigten Usern Zugriff auf die Schnittstellen gewähren. Keycloak bietet hier eine etablierte, zentral verwaltete Lösung für die Authentifizierung von Benutzern ab.
Docker (compose...)
Im folgenden wird beschrieben wie Keycloak in eine Spring Boot-Anwendung mit REST-Schnittstellen integriert werden kann. Die einfachste Möglichkeit diese Instanzen für die Entwicklung lokal zu starten ist es, sich entsprechende Images herunterzuladen und diese als Docker Container zu starten. Abhängigkeiten zwischen den Services und einfacherer Skripte diese Container zu starten sind realisierbar durch eine docker-compose.yml
Datei.
Dieser Blogartikel beschreibt die Ausführung der Container mittels docker-compose
.
Erfahrungen mit
docker
unddocker-compose
sind für das Verständnis der folgenden Abschnitte hilfreich.
docker-compose.yml Initialisierung
Zunächst muss eine Datei mit dem Namen docker-compose.yml
erstellt werden. Diese beinhaltet aktuell nur das leere Element services
. Die benötigten Dienste werden in den kommenden Paragraphen nach und nach ergänzt.
docker-compose.yml
1services:
PostgreSQL Datenbank
Eine lokale Datenbank für Keycloak erleichtert die Entwicklung, da die Daten in Keycloak tatsächlich persistiert werden. In diesem Beispiel wird eine PostgreSQL Datenbank verwendet. Hierfür wird ein neuer Service mit dem Namen db
erstellt, welcher unter anderem die Ports des Containers an das Host-System weiterleitet und ein externen Container Speicher anlegt damit die Daten bei einem Neustart nicht verloren gehen.
docker-compose.yml
1services:
2 db: # Service name
3 image: postgres:16.0
4 container_name: postgres_db
5 ports:
6 - "5432:5432"
7 environment:
8 POSTGRES_USER: db-user
9 POSTGRES_PASSWORD: db-secret
10 POSTGRES_DB: courtyarddb # Datenbank wird erstellt
11
12 volumes:
13 - postgres_data:/var/lib/postgresql/data/
14
15volumes:
16 postgres_data:
Der Befehl docker-compose up db -d
lädt das Image herunter und startet den Container. Das optionale -d
Flag löst den Prozess vom Terminal.
Keycloak
Da nun eine lokale Datenbank zur Verfügung steht gilt es eine Keycloak Instanz zu starten. Hierfür wird die docker-compose.yml
Datei um einen neuen Service erweitert, welcher von der Datenbank abhängt. Das liegt daran, dass Keycloak auf die lokale Datenbank zeigen soll und somit die Änderungen in dieser persisitert werden sollen. Die Verbindung zur lokalen Datenbank werden durch Umgebungsvariablen definiert. Bei der Datenbank URL wird als Host der Servicename der Datenbank gesetzt. In diesem fall ist es db
. Damit auch lokaler Zugriff auf die Keycloak Instanz möglich ist, wird der Standartkeycloak Port 8080
auf den Host-Port 8081
gemappt. Der Grund hierfür ist, dass der Port 8080
bereits von der Spring Boot Anwendung genutzt werden soll, da dies auch der Standartport für Spring Boot ist.
Da dieser Blogartikel sich nicht darum handelt wir man eine produktive Keycloak Instanz startet, wird start-dev
übergeben um eine Developmentumgebung zu instanziieren.
Damit ein initialer Keycloak Client beim Start zur Verfügung steht ist es für dieses docker-compose File notwending einen imports
Folder zu erstellen welcher eine test1-client.json
Datei beinhaltet. Diese Datei wird beim Start des Keycloak services importiert. Grundsätzlich erstellt dieser ausschließlich einen Client mit dem man dann die Anfragen an Keycloak machen kann
test1-client.json
1{ 2 "realm": "master", 3 "enabled": true, 4 "clients": [ 5 { 6 "clientId": "test1_client", 7 "enabled": true, 8 "protocol": "openid-connect", 9 "publicClient": true, 10 "directAccessGrantsEnabled": true 11 } 12 ] 13}
docker-compose.yml
1services:
2 keycloak: # Servicename
3 depends_on:
4 - db
5 image: quay.io/keycloak/keycloak:26.0
6 container_name: keycloak
7 ports:
8 - "8081:8080"
9 environment:
10 KC_DB: postgres
11 KC_DB_USERNAME: db-user
12 KC_DB_PASSWORD: db-secret
13 KC_DB_URL: jdbc:postgresql://db:5432/courtyarddb
14 KC_BOOTSTRAP_ADMIN_USERNAME: admin
15 KC_BOOTSTRAP_ADMIN_PASSWORD: admin
16 KEYCLOAK_IMPORT: /opt/keycloak/data/import/test1-client.json
17 volumes:
18 - ./imports:/opt/keycloak/data/import/
19 command: [ "start-dev", "--import-realm" ]
20 db: # Servicename
21 image: postgres:16.0
22 container_name: postgres_db
23 ports:
24 - "5432:5432"
25 environment:
26 POSTGRES_USER: db-user
27 POSTGRES_PASSWORD: db-secret
28 POSTGRES_DB: courtyarddb # Datenbank wird erstellt
29
30 volumes:
31 - postgres_data:/var/lib/postgresql/data/
32
33volumes:
34 postgres_data:
Der Befehl docker-compose up keycloak -d
startet nun die Keycloak Umgebung und zustätzlich auch die Datenbank, sollte sie noch nicht laufen.
Erhalten des Access Tokens
Nun sollte der Keycloak Service laufen und unter http://localhost:8081
erreichbar sein. Mit dem User admin
und dem Password admin
muss man sich einloggen und fehlende Informationen Ergänzen bevor man ein JSON Web Token anfordern kann.
Die folgende CURL Anfrage sollte ein JSON zurückliefern mit verschiedenen keys. Darunter auch den access_token
.
1curl -X POST 'http://localhost:8081/realms/master/protocol/openid-connect/token' -H 'Content-Type: application/x-www-form-urlencoded' -d 'client_id=test1_client' -d 'username=admin' -d 'password=admin' -d 'grant_type=password'
Ziel wird es sein valide Ergebnisse von Endpoints zu erhalten, nur dann wenn man diesen mit dem Authorization
Header im Request mitliefert.
Spring Boot Initialisierung
Zunächst muss eine Spring Boot Anwendung initialisiert werden. Dies ist mit dem spring initializr
möglich. In diesem Blogartikel wird die Programmiersprache kotlin
und als dependency management gradle
verwendet.
Damit die Anwendung sauber in einem Tomcat starten kann ist folgende dependency notwendig.
build.gradle.kts
1dependencies { 2... 3implementation("org.springframework.boot:spring-boot-starter-web") 4... 5}
Jetzt sollte die Anwendung gestartet sein mit folgendem Output:
Output der erfolgreich hochgefahrenen Spring Boot Anwendung
Controller Implementierung
Auf Ebene der Main Funktion wird ein package mit dem Namen controller
angelegt. Darunter eine neue Klasse UserController
mit folgendem Inhalt:
UserController.kt
1import org.springframework.web.bind.annotation.GetMapping
2import org.springframework.web.bind.annotation.RestController
3
4@RestController
5class UserController {
6
7 @GetMapping("/user")
8 fun getUser(): String {
9 return "Accessible for users without Authorization Header"
10 }
11
12 @GetMapping("/authenticated-user")
13 fun getAuthUser(): String {
14 return "Accessible only for authenticated users"
15 }
16}
Wie dem Returnvalue zu entnehmen soll der Pfad /user
jedem Request eine valide Response zurückliefern. Wohingegen der Pfad /authenticated-user
nur Requests mit korrektem Authorization
- Header vorenthalten ist. Aktuell wurde dies noch nicht konfiguriert.
Test getriebene Implementierung mit Testcontainer
Um nach der Konvention des Test Driven Developments (TDD) vorzugehen werden zunächst Tests entwickelt, die die Implementierung auf fachliche Korrektheit testen. Da die Implementierung noch nicht existiert werden diese Tests erwartungsgemäß fehlschlagen. Entwickelt werden Integration Tests. Damit die Integration Tests nicht abhängig von einer laufenden Keycloakumgebung ist, wird ein Testcontainer für den Test hochgefahren. Dieser wird somit als Authentifizierungserver genutzt. Folgende Dependencies sind notwendig:
build.gradle.kts
1dependencies { 2 ... 3 4 // test 5 testImplementation("org.springframework.boot:spring-boot-starter-test") 6 7 // Testcontainer 8 testImplementation("org.testcontainers:testcontainers:1.20.4") 9 testImplementation("com.github.dasniko:testcontainers-keycloak:3.5.1") 10 ... 11}
Keycloak Testcontainerintegration
Damit ein Keycloak Testcontainer vor dem Test gestartet werden bedarf es folgender Implementierung.
UserControllerTest.kt
1import dasniko.testcontainers.keycloak.KeycloakContainer
2import org.junit.jupiter.api.AfterAll
3import org.junit.jupiter.api.BeforeAll
4import org.junit.jupiter.api.TestInstance
5import org.springframework.beans.factory.annotation.Value
6import org.springframework.boot.test.context.SpringBootTest
7import org.springframework.test.context.ActiveProfiles
8
9@ActiveProfiles("test")
10@TestInstance(TestInstance.Lifecycle.PER_CLASS)
11@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
12class UserControllerTest {
13
14// globale Keycloak Testcontainer Instanz
15 val keycloakContainer: KeycloakContainer =
16 KeycloakContainer("quay.io/keycloak/keycloak:26.0")
17 .withRealmImportFiles("/test-realm.json")
18
19
20 @BeforeAll
21 fun setup() {
22 println(">> Setup")
23 keycloakContainer.portBindings = listOf("8082:8080")
24 keycloakContainer.start()
25 }
26
27 @AfterAll
28 fun teardown() {
29 println(">> Teardown")
30 keycloakContainer.stop()
31 }
32
33 @Test
34 ...
35}
Es wird ein Keycloak Testcontainer instanziiert. Damit auch ein User zur Verfügung steht, mit dem man Keycloak Token anfordern kann muss beim erstellen des Containers ein User erstellt werden. Dieses json File ist zu erstellen unter src/main/test/resources/test-realm.json
und der Inhalt sieht folgendermaßen aus:
test-realm.json
1{ 2 "realm": "master", 3 "enabled": true, 4 "users": [ 5 { 6 "username": "testadmin", 7 "firstName": "Example", 8 "lastName": "User", 9 "email": "example@keycloak.org", 10 "enabled": true, 11 "credentials": [ 12 { 13 "type": "password", 14 "value": "s3cret!Password" 15 } 16 ], 17 "realmRoles": [ 18 "admin", 19 "default-roles-master" 20 ] 21 } 22 ], 23 "clients": [ 24 { 25 "clientId": "integration-test", 26 "enabled": true, 27 "protocol": "openid-connect", 28 "redirectUris": [ 29 ], 30 "publicClient": true, 31 "directAccessGrantsEnabled": true 32 } 33 ] 34}
Vor allen Tests wird dieser mit @BeforeAll
gestartet und auf den lokalen port 8082
gemappt. Mit @Afterall
wird der Container gestoppt. Somit wird sichergestellt, dass die Tests die notwendigen Elemente für die Authentifizierung beinhaltet.
Implementierung der Integration Tests
Um die Authentifizierung sauber zu testen ohne diese mocken zu müssen, werden Integration Tests implementiert.
- Test, dass
/user
ohne Authentifizierung eine valide Antwort liefert. - Test, dass
/authenticated-user
ohne Authentifizierung ein HTTP 401 Unauthorized liefert. - Test, dass
/authenticated-user
mit Authentifizierung eine valide Antwort liefert.
Für die Integration Tests wird im folgenden TestRestTemplate
verwendet. Zunächst wird eine globale Instanz von TestRestTemplate
mit @Autowired
als dependency injected. Und damit man auf den zufällig erzeugten Port Zugriff hat, wird noch eine globale Variable erzeugt die auf die implizit gesetzte Property local.server.port
zugreift. Dies ist für die kommenden Tests notwendig um eine vollständige URL zu haben um Anfragen abzusetzen.
UserControllerTest.kt
1import org.springframework.boot.test.web.client.TestRestTemplate
2import org.springframework.beans.factory.annotation.Value
3
4class UserControllerTest {
5
6 @Value("\${local.server.port}")
7 private var port: Int = 0
8
9 @Autowired
10 private lateinit var restTemplate: TestRestTemplate
11
12 @Test
13 ...
14}
Implementierung zu 1.
UserControllerTest.kt
1@Test
2 fun `should return 200 OK response when invoking a non authenticated endpoint`() {
3
4 // when
5 val users = restTemplate.exchange("http://localhost:$port/user", HttpMethod.GET, null, String::class.java)
6
7 // then
8 assertThat(users.statusCode).isEqualTo(HttpStatus.OK)
9 assertThat(users.body).isEqualTo("Publicly accessible")
10 }
Implementierung zu 2.
UserControllerTest.kt
1@Test
2 fun `should return 401 when invoking an authenticated endpoint`() {
3
4 // when
5 val authenticatedRequest =
6 restTemplate.exchange(
7 "http://localhost:$port/authenticated-user",
8 HttpMethod.GET,
9 null,
10 String::class.java
11 )
12
13 // then
14 assertThat(authenticatedRequest.statusCode).isEqualTo(HttpStatus.UNAUTHORIZED)
15 }
Info
Dieser Test wird fehlschlagen (und das sollte er auch), da die Implementierung dazu noch fehlt
Implementierung zu 3.
Um einen Token von der Keycloak umgebung zu erhalten muss eine Anfrage direkt an die Keycloak Testcontainer Instanz durchgeführt werden. Dadurch kann ein valider Test implementiert werden.
Die Implementierung dazu ist hier beschrieben.
UserControllerTest.kt
1@Test
2fun `should return 200 when invoking an authenticated endpoint with authorization header`() {
3
4 // given
5
6 // this is a helper function which requests an access token directly from keycloak testcontainer
7 val accessToken =
8 AccessTokenHelper.getAccessToken(
9 keycloakTokenEndpoint = "http://localhost:8082/realms/master/protocol/openid-connect/token",
10 username = "testadmin",
11 password = "s3cret!Password"
12 )
13 val headers = HttpHeaders()
14 headers.add(HttpHeaders.AUTHORIZATION, "Bearer $accessToken")
15 val requestEntity = HttpEntity<String>(headers)
16
17 // when
18 val authenticatedRequest =
19 restTemplate.exchange(
20 "http://localhost:$port/authenticated-user",
21 HttpMethod.GET,
22 requestEntity,
23 String::class.java
24 )
25
26 // then
27 assertThat(authenticatedRequest.statusCode).isEqualTo(HttpStatus.OK)
28 assertThat(authenticatedRequest.body).isEqualTo("Accessible only for authenticated users")
29}
Aktuell liefert jeder Endpoint eine valide Response mit dem Statuscode 200 (HTTP OK) zurück. Somit schlägt nur der Test für die Prüfung auf den Httpstatus 401 (HTTP UNAUTHORIZED) fehl.
Security Implementierung
Spring security dependency
Es gibt eine starter dependency von Spring Boot um OAuth direkt einzubauen. Diese liefert neben OAuth spezifischen dependencies auch spring-security-core
und spring-security-config
mit. Diese werden benötigt um eine SecurityConfig
zu erstellen.
build.gradle.kts
1dependencies { 2 ... 3 implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.0") 4 ... 5}
SecurityConfig Implementierung
Im Folgenden ist eine minimale Implementierung der Security Configuratioon.
Hier wird definiert, dass alle Pfade die mit /users
anfangen für jeden Request erlaubt sind, wohingegen die Pfade /authenticated-user
für jeden authentifizierten User erlaubt sind.
Der Block http.oauth2ResourceServer
konfiguriert einen OAuth2 Resource Server. Dieser wird so definiert, dass JSON Web Token basierte Authentifizierung für geschützte Endpunkte, unterstützt wird. In diesem fall /authenticated-user
.
Die Spring Security validiert die JWTs dann automatisch anhand des Issuers aus der application.yml
. Der Issuer definiert den Aussteller der Token. Dieser Endpunkt von Keycloak muss als Wert für den Key spring.security.oauth2.resourceserver.jwt.issuer-uri
definiert werden. In diesem Fall sieht die application.yml folgendermaßen aus.
application.yml
1spring: 2 security: 3 oauth2: 4 resourceserver: 5 jwt: 6 issuer-uri: http://localhost:8081/realms/master
Die Keycloak Instanz wurde unter dem Abschnitt Keycloak erstellt und in einem Docker container hochgefahren.
Ganz generell sind die verschiedenen Endpoints von Keycloak unter http://localhost:8081/realms/master/.well-known/openid-configuration
abrufbar.
SecurityConfig.kt
1import org.springframework.context.annotation.Bean
2import org.springframework.context.annotation.Configuration
3import org.springframework.security.config.annotation.web.builders.HttpSecurity
4import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
5import org.springframework.security.web.SecurityFilterChain
6
7@Configuration
8@EnableWebSecurity
9class SecurityConfig {
10
11 @Bean
12 fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
13
14 // Require authentication for all requests
15 http.authorizeHttpRequests {
16 it.requestMatchers("/user/**").permitAll()
17 it.requestMatchers("/authenticated-user/**").authenticated()
18 }
19
20 // Customize OAuth2 resource server configuration
21 http.oauth2ResourceServer {
22 it.jwt { }
23 }
24 return http.build()
25 }
26}
Es gibt noch andere Möglichkeiten den JSON Web Token zu decodieren. Eine weitere wäre es, eine Bean zu definieren die einen Custom decoder konfiguriert. Folgendes Beispiel wäre eine Möglichkeit.
SecurityConfig.kt
1@Bean
2fun jwtDecoder(): JwtDecoder {
3 val jwtDecoder: JwtDecoder = NimbusJwtDecoder.withIssuerLocation("http://localhost:8081/realms/master").build()
4
5 return JwtDecoder { token ->
6 println("token: $token")
7 val jwt = jwtDecoder.decode(token)
8 println("jwt: $jwt")
9 jwt
10 }
11}
Erneutes Ausführen der Tests
Nachdem die Tests erneut ausgeführt werden, sollten diese nun alle grün sein. Vor allem die Prüfung darauf ob ein HTTP 401 UNAUTHORIZED übermittelt wird.
Weitere Beiträge
von Ege Inanc
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Ege Inanc
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.