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/:rw
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
Die 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.sql
Datei ü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:
- net
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}
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
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();
}
...
}
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 Secret
erzeugt, dass in der Springboot-Anwendung hinterlegt werden muss.
Je nach Anforderung können noch Rollen angelegt werden. Diese werden durch den
GrantedAuthoritiesMapperImpl
schließlich in denSecurityContextHolder
gemapped 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.