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.