Beliebte Suchanfragen
//

Authenticated REST Services mit Keycloak und Spring Boot

30.1.2025 | 9 Minuten Lesezeit

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 und docker-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.

  1. Test, dass /user ohne Authentifizierung eine valide Antwort liefert.
  2. Test, dass /authenticated-user ohne Authentifizierung ein HTTP 401 Unauthorized liefert.
  3. 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.

Beitrag teilen

//

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.