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
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/:rwDer 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: alwaysDie interessanten Einstellungen hier sind folgende:
- für Keycloak der 
KC_HTTP_RELATIVE_PATH, der exakt dem Pfad im nginx für location verwendet wird - für die Datenbank das Volume für 
docker-entrypoint-initdb.d. Hier wird ein Pfad mit einerinit.sqlDatei übergeben, die beim Start der Service ausgeführt wird und mit deren Hilfe zwei getrennte Datenbanken für Springboot und Keycloak erstellt werden. - für Springboot die beiden 
KEYCLOAK_*URI. Diese URIs müssen auf die öffentliche Adresse des Keycloak zeigen, damit eine Weiterleitung dorthin funktioniert; ein internes http://keycloak/ ist hier nicht ausreichend! - bei allen Services wird eine 
env-Datei mitgegeben. Hier sind alle Variablen (URIs, Passworte etc.) hinterlegt. Diese sollte nicht – zumindest nicht im Klartext - im Repository hinterlegt sein… 
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:
    - netSo 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}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/authNeben 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();
    }
  ...
}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");
        }
    }
}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();
    }
}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:
Für diesen Realm wird ein neuer Client erzeugt - das ist die Abbildung der Springboot-Anwendung in Keycloak:
Bei der Konfiguration ist es wichtig den öffentlichen DNS-Namen zu verwenden - der gleiche, der auch in der nginx-Konfiguration verwendet wird.
Abschließend wird für diesen Client das
Client Secreterzeugt, dass in der Springboot-Anwendung hinterlegt werden muss.
Je nach Anforderung können noch Rollen angelegt werden. Diese werden durch den
GrantedAuthoritiesMapperImplschließlich in denSecurityContextHoldergemapped und können zur Steuerung der Autorisation einzelner Ressourcen verwendet werden.
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.