Zur Verbindung einer Springboot-Anwendung mit Keycloak gibt es wahrlich genug Tutorials im Netz… Allerdings gibt es in den neuesten Versionen beider Anwendungen einige Dinge zu beachten die sich geändert haben. Und kaum ein Tutorial berücksichtigt PROD-Umgebungen, das Zusammenspiel mit einem Reverse-Proxy und allgemein die Verwendung von docker-compose. Natürlich wird auch die lokale Entwicklung möglich sein.

Genau darum geht es jetzt.

Genereller Aufbau der Anwendungen

Systemübersicht
Systemübersicht

Nach Außen zum Internet haben wir einen öffentlichen Server mit öffentlichem DNS-Namen. Wir unterstützen auch HTTPS, wofür ein Let’s-Encrypt-Zertifikat genutzt wird. Zur besseren Trennung der Anwendung mit seinen Umsystemen und dem Server verwenden wir zwei docker-compose Dateien. Datei 1 beschreibt den Server: Wir starten hier einfach 2 Services, nginx und einen Certificate-bot für das Zertifikat. Beide laufen im host Netzwerk-Modus.

services:

  webserver:

    image: nginx:latest

    network_mode: host

    ports:

      - 80:80

      - 443:443

    restart: always

    volumes:

      - ./nginx/conf/:/etc/nginx/conf.d/:ro

      - ./certbot/conf/:/etc/letsencrypt/:ro


  certbot:

    image: CERTBOT-IMAGE

    network_mode: host

    environment:

      - DOMAIN=mein-server.de

    volumes:

      - ./certbot/conf/:/etc/letsencrypt/:rw

      - /var/log/letsencrypt/:/var/log/letsencrypt/:rw
Reverse Proxy - docker-compose.yaml

Der nginx dient dabei als Reverse-Proxy der das SSL terminiert und zur Springboot-Anwendung und dem Keycloak weiterleitet:

server {
    listen 443 default_server ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name my-own-server.com;
    ssl_certificate /etc/letsencrypt/live/my-own-server.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/my-own-server.com/privkey.pem;

    location / {
        proxy_pass                          http://localhost:8080;
        proxy_set_header Host               $host;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto  $scheme;
    }

    location /auth {
        proxy_pass                          http://localhost:7080;
        proxy_set_header Host               $host;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Port   $server_port;
        proxy_set_header X-Forwarded-Proto  $scheme;
    }
}

Datei 2 beschreibt nun die Services unserer Anwendung: die eigentliche Springboot-Anwendung, Keycloak und die Datenbank die von beiden Systemen genutzt werden (jeweils mit eigener Umgebung).

networks:

  net:

    driver: bridge

    enable_ipv6: true


volumes:

  postgres_data:



services:

  db:

    image: postgres:latest

    container_name: postgres

    environment:

      POSTGRES_USER: ${POSTGRES_USER}

      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

    ports:

      - 5532:5432

    networks:

      - net

    env_file:

      - .env

    restart: unless-stopped

    volumes:

      - postgres_data:/var/lib/postgresql/data

      - ./init-db:/docker-entrypoint-initdb.d


  meinservice:

    container_name: meinservice

    image: meinservice:latest

    environment:

      DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}

      DATASOURCE_USERNAME: ${POSTGRES_USER}

      DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}

      KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}

      # Spring security prüft den iss(Issuer) Claim im JWT mit diesem Wert, der hostname muss exakt gleich sein!

      KEYCLOAK_ISSUER_URI: ${KEYCLOAK_ISSUER_URI}

      KEYCLOAK_AUTH_URI: ${KEYCLOAK_AUTH_URI}

    ports:

      - 8080:8080

    depends_on:

      - db

      - keycloak

    env_file:

      - .env

    networks:

      - net


  keycloak:

    image: quay.io/keycloak/keycloak:25.0.2

    container_name: keycloak

    command: start-dev

    environment:

      KC_DB: postgres

      KC_DB_URL_HOST: db

      KC_DB_USERNAME: ${POSTGRES_USER}

      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}

      KC_DB_DATABASE: ${KEYCLOAK_POSTGRES_DB}

      KC_DB_SCHEMA: public

      KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}

      KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}

      KC_HTTP_RELATIVE_PATH: "/auth"

      KC_PROXY_HEADERS: xforwarded

      KC_HEALTH_ENABLED: true

      KC_LOG_LEVEL: info

    ports:

      - 8443:8443

      - 7080:8080

    depends_on:

      - db

    env_file:

      - .env

    networks:

      - net

    restart: always
Springboot, Keycloak und Datenbank für Produktion - docker-compose.yaml

Die interessanten Einstellungen hier sind folgende:

Der Netzwerk-Modus ist hier als bridge gewählt. Da der Cloud-Server nicht über eine öffentliche IPv4-Adresse verfügt, muss auch das IPv6-Protokoll dafür aktiviert sein.

Für eine lokale Entwicklung bietet es sich an, eine separate docker-compose-testbed.yml Datei zu verwenden, so können die Datenbank und der Keycloak mit einer Testkonfiguration verwendet werden:

networks:

  net:

    driver: bridge


volumes:

  postgres_data_testbed:



services:

  db:

    image: postgres:latest

    container_name: postgres-testbed

    environment:

      POSTGRES_USER: username

      POSTGRES_PASSWORD: password

    ports:

      - 5532:5432

    networks:

      - net

    volumes:

      - postgres_data_testbed:/var/lib/postgresql/data

      - ./init-db:/docker-entrypoint-initdb.d


  keycloak:

    image: quay.io/keycloak/keycloak:25.0.2

    container_name: keycloak-testbed

    command: start-dev

    environment:

      KC_DB: postgres

      KC_DB_URL_HOST: db

      KC_DB_USERNAME: username

      KC_DB_PASSWORD: password

      KC_DB_DATABASE: keycloak

      KC_DB_SCHEMA: public

      KEYCLOAK_ADMIN: admin

      KEYCLOAK_ADMIN_PASSWORD: adminadmin

    ports:

      - 7080:8080

    depends_on:

      - db

    networks:

    - net
Das Testbed mit Keycloak und Datenbank für die lokale Entwicklung - docker-compose-testbed.yaml

So kann lokal das Testbed mittels docker compose -f docker-compose-testbed.yml up -d gestartet werden und dazu die Springboot-Anwendung in der Entwicklungsumgebung (auch für Debugging). Damit das Zusammenspiel aller Komponenten funktioniert sind noch zwei Dinge nötig: Die Springboot-Anwendung braucht eine entsprechende application.properties, sowie die Keycloak-Anwendung eine finale Konfiguration.

Die Springboot-Anwendung

server:

  port: 8080

  error:

    whitelabel:

      enabled: false


spring:

  application.name: meinservice


  jpa:

    hibernate:

      ddl-auto: validate

    show-sql: false

    open-in-view: false

    properties:

      hibernate:

        format_sql: true

  datasource:

    url: ${DATASOURCE_URL}

    username: ${DATASOURCE_USERNAME}

    password: ${DATASOURCE_PASSWORD}


  flyway:

    enabled: true

    locations: classpath:db

    baseline-on-migrate: true

    validate-on-migrate: true


  security:

    oauth2:

      client:

        registration:

          keycloak:

            client-id: meinservice-client

            client-secret: ${KEYCLOAK_CLIENT_SECRET}

            authorization-grant-type: authorization_code

            scope: openid,profile,email,offline_access

        provider:

          keycloak:

            issuer-uri: ${KEYCLOAK_ISSUER_URI}

            authorization-uri: ${KEYCLOAK_AUTH_URI}
Springboot - application.properties

Bzw. für die lokale Entwicklung eine angepasste Version (die Springboot-Anwendung muss dann mit dem entsprechenden Profile gestartet werden):

spring:

  datasource:

    url: jdbc:postgresql://localhost:5532/db1

    username: username

    password: password


  security:

    oauth2:

      client:

        registration:

          keycloak:

            client-secret: CLIENT-SECRET

        provider:

          keycloak:

            issuer-uri: http://localhost:7080/realms/meinservice

            authorization-uri: http://localhost:7080/realms/meinservice/protocol/openid-connect/auth
Springboot für lokale Entwicklung ('local' Profile) - application-local.properties

Neben der so bereitgestellten Verbindung der Springboot-Anwendung zu Keycloak muss auch die SecurityConfiguration in Springboot bereitgestellt werden:

@Autowired
    private KeycloakLogoutHandler keycloakLogoutHandler;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
	    http
  	        .csrf(AbstractHttpConfigurer::disable);

	    http
	        .authorizeRequests(request -> request
                .requestMatchers("/css/**", "/js/**", "/assets/**").permitAll() // Statische Dateien erlauben

                .requestMatchers("/").permitAll()
                .requestMatchers("/**").hasAnyAuthority("ROLE_ADMIN")
                .requestMatchers("/gesichert/**").hasAnyAuthority("ROLE_ZUSCHAUER", "ROLE_ADMIN")

	    http
	        .oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout
                .addLogoutHandler(keycloakLogoutHandler)
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID")
                .logoutSuccessUrl("/"));

	    return http.build();
    }

  ...
}
SecurityConfiguration Klasse

Hier werden einmal die einzelnen Endpunkte der Anwendung gesichert sowie ein Logout Handler konfiguriert, um den Nutzer bei Keycloak abzumelden. Ohne diesen Handler würde ein Logout zwar den Cookie in der Springboot-Anwendung löschen, allerdings werden danach die Anfragen über Keycloak geleitet – und dort wurde der Nutzer nie abgemeldet… Das würde dann dazu führen, dass Keycloak den Cookie wieder hinzufügt, ohne vorher eine erneute Abfrage der Login-Daten durchzuführen – ein Szenario, dass wir vermeiden wollen.

Der Handler ist daher entsprechend so aufgebaut:

@Component
static class KeycloakLogoutHandler implements LogoutHandler {
    private final RestTemplate restTemplate = new RestTemplate();;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        var user = (OidcUser) authentication.getPrincipal();
        String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
        UriComponentsBuilder builder = UriComponentsBuilder
            .fromUriString(endSessionEndpoint)
            .queryParam("id_token_hint", user.getIdToken().getTokenValue());

        ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
        if (!logoutResponse.getStatusCode().is2xxSuccessful()) {
            System.out.println("Could not propagate logout to Keycloak");
        }
    }
}
Logout-Handler in der SecurityConfiguration Klasse

Nach erfolgreicher Anmeldung im Keycloak fügt dieses ein JQT-Cookie mit allen Infos den Requests hinzu. Un hier ist eine weitere Besonderheit bei Keycloak zusammen mit Springboot: Die Verwendung von Rollen in Realms ist anders, die Rollen-Informationen werden an Springboot anders als von diesem erwartet übergeben; sie stehen an “falscher” Stelle im (decodierten) Json-Objekt des JWT. Wir müssen daher noch ein zusätzliches Mapping einbauen, um rollenbasierte Autorisation der Ressourcen zu ermöglichen.

@Component
@RequiredArgsConstructor
static class GrantedAuthoritiesMapperImpl implements GrantedAuthoritiesMapper {

    @Override
    public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
        authorities.forEach(authority -> {
            if (OidcUserAuthority.class.isInstance(authority)) {
                final var oidcUserAuthority = (OidcUserAuthority) authority;
                mappedAuthorities.addAll(convert(oidcUserAuthority.getIdToken().getClaims()));
            }
        });

        return mappedAuthorities;
    };

    private Collection<GrantedAuthority> convert(Map<String, Object> claims) {
        if (Objects.nonNull(claims)) {
            List<String> roles = (List<String>) claims.get("groups");
            if (Objects.nonNull(roles)) {
                return roles.stream().map(rn -> new SimpleGrantedAuthority("ROLE_" + rn.toUpperCase())).collect(Collectors.toList());
            }
        }
        return List.of();
    }
}
Rollen Mapping in der SecurityConfiguration Klasse

Keycloak

Was noch fehlt, ist das Anlegen des Realms in Keycloak und entsprechend die Erstellung des CLIENT-SECRET dort um Springboot die Verbindung zu erlauben.

Zuerst wir ein neuer Realm erstellt:

Erstellen des Realm
Erstellen des Realm

Für diesen Realm wird ein neuer Client erzeugt - das ist die Abbildung der Springboot-Anwendung in Keycloak:

Erstellen eines Clients im Realm
Erstellen eines Clients im Realm

Bei der Konfiguration ist es wichtig den öffentlichen DNS-Namen zu verwenden - der gleiche, der auch in der nginx-Konfiguration verwendet wird.

Konfiguration des Clients Teil 1
Konfiguration des Clients Teil 2

Abschließend wird für diesen Client das Client Secret erzeugt, dass in der Springboot-Anwendung hinterlegt werden muss.

Konfiguration des Client Secrets für unsere Anwendung
Konfiguration des Client Secrets für unsere Anwendung

Je nach Anforderung können noch Rollen angelegt werden. Diese werden durch den GrantedAuthoritiesMapperImpl schließlich in den SecurityContextHolder gemapped und können zur Steuerung der Autorisation einzelner Ressourcen verwendet werden.

Anlegen der Rollen
Anlegen der Rollen

Schluss

Sind nun alle Docker Container gestartet, kann https://my-own-server.com angesurft werden. Wenn eine geschützte Ressource angefordert wird, leitet Springboot an Keycloak zur Anmeldung weiter. Nach erfolreichem Login wird danach zurück zur angefragten Ressource weitergeleitet.