Block Image

Ultimamente a lavoro mi è capitando di dover implementare OpenID Connect e OAuth2 con Spring e utilizzare WSO2 Identity Server come Authorization Server. Ho pensato quindi di scrivere un articolo a riguardo, sperando possa essere utile a qualcuno!

In questo articolo vedremo come centralizzare la sicurezza in una architettura a microservizi con l'ausilio di Spring Cloud Gateway che avrà il ruolo di OAuth2 Client e farà da Gateway verso un semplice microservizio che avrà il ruolo invece di Resource Server.

Gestiremo le sessioni con Hazelcast, una cache distribuita, in modo tale da condividere le sessioni tra più istanze dell'applicativo di Spring Cloud Gateway.

Inoltre, utilizzeremo un Identity Server, WSO2IS come Authorization Server. Mostrerò come configurare quest'ultimo utilizzando un'immagine Docker di WSO2IS 11.

OAuth2 in breve e ruoli

In questo articolo non spiegherò nel dettaglio cosa sono OIDC e OAuth2, ma farò una breve introduzione.
OAuth2 è un protocollo di autorizzazione che permette a una applicazione di accedere a risorse di un'altra applicazione senza fornire credenziali.
Questo protocollo lo utilizziamo spesso, ad esempio quando da una applicazione utilizziamo la Social login, come ad esempio su Spotify.
In quel caso, Spotify effettua un redirect su un social come Google. Google ci chiede di confermare la condivisione di informazioni come nome, cognome, mail (condivisione di risorse) a Spotify. Spotify quindi si limiterà a prelevare da Google queste informazioni.

Il flusso di OAuth2 varia in base al grant type utilizzato. In questo articolo vedremo un esempio di flusso con authorization code.

In OAuth2 ci sono diversi ruoli che entrano in gioco nel flusso di autorizzazione:

  • Resource Owner: chi accede all'app client e dà il consenso all'accesso a una risorsa. Può essere ad esempio una persona fisica.
  • OAuth2 Client: l'applicazione che chiede l'accesso a risorse protette, come Spotify.
  • Authorization Server: server che emette l'access token all'OAuth2 Client dopo aver autenticato il Resource Owner.
  • Resource Server: applicazione che mette a disposizione risorse protette, accessibili tramite access token, come ad esempio Google o Facebook.

Per quanto riguarda OIDC, è uno standard che si trova sopra OAuth2, si occupa della fase di autenticazione.
Rilascia un token JWT chiamato ID Token, che dimostra che l'utente è stato autenticato: questo può contenere informazioni su chi è l'utente, come nome e cognome.
Comunque, il Resource Server controlla l'access token per capire se l'OAuth2 Client è autorizzato o meno ad accedere ad una risorsa. L'access token potrebbe essere un JWT, ma non è obbligatorio che lo sia.

Panoramica del progetto

Di seguito viene mostrata una panoramica del progetto che costruiremo:

Block Image

Nel dettaglio:

  • Un frontend (che non creeremo in questo articolo) chiamerà l'applicativo spring-oauth2-client sia per effettuare la login, sia per effettuare le chiamate ai servizi REST di spring-resource-server.
  • Nella fase di login, spring-oauth2-client effettuerà un redirect alla pagina di login di WSO2IS.
  • Una volta che l'utente è autenticato con successo, spring-oauth2-client riceverà da WSO2IS l'access token.
    L'app spring-oauth2-client poi creerà una sessione, che mapperà con l'access token. Verrà restituito al frontend un cookie di sessione.
  • Il frontend quindi comunicherà con spring-oauth2-client mediante cookie di sessione.
  • Quando il frontend effettua una richiesta REST a spring-oauth2-client, quest'ultimo controlla la sessione, ed effettua una redirect a spring-resource-server inviandogli l'access token associato alla sessione.
  • L'app spring-resource-server valida la firma del token con una public key ottenuta dall'endpoint jws_url (questa url viene chiesta dal Resource Server allo startup, all'Authorization Server, quindi è necessario che WSO2IS sia up & running quando avviamo il Resource Server).

Installazione di WSO2IS

Creiamo un container a partire dall'immagine ufficiale di wso2is:
docker run -it -p 9443:9443 --name is wso2/wso2is:5.11.0.

Una volta che WSO2IS è avviato, accediamo alla console di carbon alla URL https://localhost:4333/carbon e logghiamoci con user admin e password admin:

Block Image

Una volta effettuato l'accesso, dobbiamo aggiungere un Service Provider, che sarebbe l'OAuth2 Client.
Clicchiamo quindi su "Add" nella sezione Service Providers:

Block Image

Ora inseriamo come Service Provider Name "spring-oauth2-client" e clicchiamo su "Register":

Block Image

Effettuata la registrazione, andiamo poi su Inbound Authentication Configuration e OAuth OpenID Connect Configuration e clicchiamo su Configure:

Block Image

Nella text field "Callback URL", scriviamo:
regexp=(http://localhost:8082/login/oauth2/code/wso2|http://localhost:8082).
La prima URL è la redirect per login, la seconda è per la logout. Selezioniamo JWT invece di Default nella sezione "Token Issuer", così avremo un access token di tipo JWT.
A questo punto, clicchiamo sul tasto Update in basso.
Nella sezione Inbound Authentication Configuration/OAuth OpenID Connect Configuration avremo ora un Client ID ed un Client Secret:

Block Image

Queste informazioni ci serviranno poi quando utilizzeremo Spring Cloud Gateway come OAuth2 Client.

Andiamo poi nella sezione "Claim Configuration", clicchiamo su "Add Claim URI", e aggiungiamo il claim che indica i ruoli dell'utente:

Block Image

Così il JWT conterrà anche l'informazione sui ruoli dell'utente.
Abbiamo finito con WSO2IS, passiamo adesso a Spring Cloud Gateway!

Sembra che l'immagine ufficiale di WSO2IS non sia compatibile con sistemi ARM, quindi anche con Apple Silicon. In tal caso potete scaricarvi da GitHub il Dockerfile ufficiale di WSO2IS e buildarlo in locale. Comunque, sul mio GitHub posterò il Dockerfile di WSO2IS ufficiale e anche il file di config del Service Provider, in modo tale che potete importarvelo sul vostro WSO2IS e avere già tutto configurato.

Spring Cloud Gateway come OAuth2 Client

Andiamo sul sito di Spring Initializr (https://start.spring.io) per creare da zero il nostro progetto Spring Cloud Gateway. Inseriamo i dati come nella seguente figura:

Block Image

Clicchiamo su Generate e scarichiamo il progetto. Unzippiamolo, e apriamolo con un qualsiasi IDE.

Aggiungiamo Hazelcast al progetto

Aggiungiamo nel pom le seguenti dipendenze:

<dependency>
    <groupId>com.hazelcast</groupId>
    <artifactId>hazelcast</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-core</artifactId>
</dependency>

La prima dipendenza ci permette di aggiungere Hazelcast al progetto. La seconda ci permette di utilizzare Spring Session in modo tale da gestire le sessioni con Hazelcast.

Aggiungiamo una classe di configurazione per Hazelcast in un package config:

@Configuration
public class HazelcastConfig {

  private static final String HAZELCAST_INSTANCE_NAME = "session-hazelcast-instance";

  private static final String MAP_CONFIG_NAME = "session-config-map";

  @Bean
  Config config() {
    Config config = new Config();
    config.setInstanceName(HAZELCAST_INSTANCE_NAME)
            .addMapConfig(mapConfig()
            );
    return config;
  }

  private MapConfig mapConfig() {
    final MapConfig mapConfig = new MapConfig();
    mapConfig.setName(MAP_CONFIG_NAME)
            .setTimeToLiveSeconds(0)
            .setEvictionConfig(new EvictionConfig().setEvictionPolicy(EvictionPolicy.LRU)
                    .setMaxSizePolicy(MaxSizePolicy.PER_NODE)
            );
    return mapConfig;
  }
}

Il metodo "setTimeToLiveSeconds" permette di specificare un tempo di scadenza per le entries di Hazelcast (cioè le sessioni). Scaduto quel tempo, la entry verrà cancellata automaticamente. Settando questo parametro a zero, si intende che la entry non verrà cancellata in automatico (zero è anche il parametro di default).

Facoltativamente, possiamo creare un MapListener di Hazelcast per essere notificati su insert/update/delete delle sessioni:

public class SessionHazelcastListener implements EntryListener<String, Session> {

    private static final Logger log = LoggerFactory.getLogger(SessionHazelcastListener.class);

    @Override
    public void entryAdded(EntryEvent<String, Session> event) {
        log.info("Added new session, key: {}", event.getKey());
    }

    @Override
    public void entryEvicted(EntryEvent<String, Session> event) {

    }

    @Override
    public void entryExpired(EntryEvent<String, Session> event) {
        log.info("Expired session, key: {}", event.getKey());
    }

    @Override
    public void entryRemoved(EntryEvent<String, Session> event) {
        log.info("Delete session, key: {}", event.getKey());
    }

    @Override
    public void entryUpdated(EntryEvent<String, Session> event) {
        log.info("Update session, key: {}", event.getKey());
    }

    @Override
    public void mapCleared(MapEvent event) {

    }

    @Override
    public void mapEvicted(MapEvent event) {

    }
}
Aggiungiamo la configurazione per la security

Creiamo una classe di configurazione per gestire la sicurezza, nel package security:

@Configuration
@EnableWebFluxSecurity
@EnableSpringWebSession
public class SecurityConfig {

    private final HazelcastInstance hazelcastInstance;

    public SecurityConfig(HazelcastInstance hazelcastInstance) {
        this.hazelcastInstance = hazelcastInstance;
    }

    @Bean
    ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    @Bean
    ReactiveSessionRepository reactiveSessionRepository() {
        IMap<String, Session> map = hazelcastInstance.getMap("session-map");
        map.addEntryListener(new SessionHazelcastListener(), false);
        return new ReactiveMapSessionRepository(map);
    }

    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ReactiveClientRegistrationRepository clientRegistrationRepository) {
        return http
                .authorizeExchange(exchange -> exchange
                        .pathMatchers("/", "/*.css", "/*.js", "/favicon.ico").permitAll()
                        .anyExchange().authenticated()
                )
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
                .oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)))
                .csrf(csrf -> csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()))
                .build();
    }

    private ServerLogoutSuccessHandler oidcLogoutSuccessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        var oidcLogoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
        return oidcLogoutSuccessHandler;
    }

    @Bean
    WebFilter csrfWebFilter() {
        return (exchange, chain) -> {
            exchange.getResponse().beforeCommit(() -> Mono.defer(() -> {
                Mono<CsrfToken> csrfToken = exchange.getAttribute(CsrfToken.class.getName());
                return csrfToken != null ? csrfToken.then() : Mono.empty();
            }));
            return chain.filter(exchange);
        };
    }

}

Alcune considerazioni:

  1. Con ".pathMatchers(...).permitAll()" stiamo dicendo a Spring Security di non proteggere l'API "/", le url che finiscono con js, css e favicon.ico.
  2. Con ".exceptionHandling(exceptionHandling -> ..." stiamo dicendo che se si tenta di accedere alle API protette, senza essere autenticati, allora verrà restituito come HTTP Status 401.
  3. Con ".oauth2Login" abilitiamo la login tramite oauth2, quindi effettuando le redirect verso un Authorization Server (in questo caso WSO2IS).
  4. Con "logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)))" stiamo gestendo anche la logout, eliminando sia la session (gestione logout Spring), sia effettuando una redirect alla logout dell'Authorization Server.
  5. Con "csrf(...)" e il suo filtro, trasmettiamo il token CSRF al browser, in un cookie.
  6. Creiamo infine anche i bean di tipo ReactiveSessionRepository per far gestire ad Hazelcast le sessioni, un bean di tipo ServerOAuth2AuthorizedClientRepository per associare i token alle sessioni e un bean di tipo WebFilter per gestire il csrf token, altrimenti avremo un errore durante la fase di logout (https://github.com/spring-projects/spring-security/issues/5766).
Creiamo un API di welcome

Sia su WSO2IS, sia nella configurazione di Spring Security, abbiamo configurato un endpoint di redirect per la logout, "/". Scriviamo quindi un servizio REST semplice di benvenuto, che, se si è anche autenticati, restituisce anche lo username nel messaggio:

@RestController
public class WelcomeApi {

    @GetMapping
    public Mono<String> welcome(@AuthenticationPrincipal OidcUser oidcUser, WebSession webSession) {
        System.out.println(webSession.getId());
        String user;
        if(oidcUser == null) {
            user = "";
        }
        else {
            user = oidcUser.getClaimAsString("sub");
        }
        return Mono.just(String.format("Welcome to Spring Cloud Gateway %s", user));
    }
}
Aggiungiamo le properties nel file application.yml

Cancelliamo il file application.properties e creiamo un file application.yml. Scriviamo poi le seguenti properties:

server:
  port: 8082
  shutdown: graceful

spring:
  application:
    name: spring-oauth2-client
  cloud:
    gateway:
      default-filters:
        - SaveSession
        - TokenRelay
      routes:
        - id: spring-resource-server
          uri: ${RESOURCE_SERVER:http://localhost:8080}/spring-resource-server
          predicates:
            - Path=/spring-resource-server/**
  security:
    oauth2:
      client:
        registration:
          wso2:
            provider: wso2
            authorization-grant-type: authorization_code
            client-id: cMEHYsZREl1WMR1CJbzrdv8p6f4a
            client-secret: 3_ZMPh0TjY0Xz5JOhljtEsEGhzoa
            redirect-uri: "{baseUrl}/login/oauth2/code/wso2"
            scope: openid,profile
        provider:
          wso2:
            issuer-uri: ${WSO2IS_URL:https://localhost:9443}/oauth2/token

Analizziamo le properties:

  1. Nei default filters aggiungiamo dei filtri di Spring, uno per salvare la sessione (SaveSession), l'altro per trasmettere l'access token ai Resource Server (TokenRelay).
  2. nella sezione registration, settiamo le varie properties per oauth2. Client ID e Client Secret le prendiamo dalla configurazione del Service Provider di WSO2IS. La redirect url in Spring OAuth2 Client deve essere:
    {baseUrl}/login/oauth2/code/{registrationId}, per questo motivo è {baseUrl}/login/oauth2/code/wso2.
  3. Infine, configuriamo l'issuer-uri che permette di recuperare i vari endpoint (token endpoint, user info endpoint, etc) di WSO2IS.

Finito! Passiamo al Resource Server

Scriviamo il Resource Server

Andiamo ancora una volta sul sito di Spring Initializr e aggiungiamo le seguenti dipendenze:

Block Image

Creiamo adesso il file di configurazione che gestisce la sicurezza:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .oauth2ResourceServer(oauth2Conf -> oauth2Conf.jwt(Customizer.withDefaults()))
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .build();
    }

    @Bean
    JwtAuthenticationConverter jwtAuthenticationConverter() {
        var jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("groups");

        var jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

}

Analizziamo il codice:

  1. Con ..oauth2ResourceServer(oauth2Conf -> oauth2Conf.jwt(Customizer.withDefaults())) stiamo indicando a Spring Security che stiamo configurando un Resource Server e che il tipo di token è JWT.
  2. Il Resource Server deve essere STATELESS "(sessionManagement(sessionManagement...)".
  3. Configuriamo un bean di tipo JwtAuthenticationConverter per trasformare automaticamente il JWT nell'oggetto Authentication e quindi creare lo User Principal per il contesto di sicurezza di Spring. In setAuthoritiesClaimName abbiamo specificato "groups" poiché WSO2IS mappa i ruoli con quel claim.

Creiamo adesso una semplice API REST di saluto:

@RestController
public class HelloWordApi {

    @GetMapping
    public String welcome(@AuthenticationPrincipal Jwt jwt) {
        return "Hello World and " + jwt.getSubject();
    }
}

Infine, aggiungiamo le properties nel file application.yml:

spring:
  application:
    name: spring-resource-server
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${WSO2IS_URL:https://localhost:9443}/oauth2/token
server:
  servlet:
    context-path: /spring-resource-server

Finito!

Proviamo le due applicazione

Gestione certificato per WSO2IS

Per evitare errori sui certificati (stiamo utilizzando https per WSO2IS), quando avviamo i due applicativi Spring Boot, dobbiamo aggiungere queste due system properties:

  • -Djavax.net.ssl.trustStore=/Users/vracca/Documents/progetti_java/progetti_miei/spring-oauth2/client-truststore.jks

  • -Djavax.net.ssl.trustStorePassword=wso2carbon

Aggiungiamo cioè il certificato self-signed di WSO2IS. Il file client-truststore.jks lo potete trovare sul mio GitHub (link a fine pagina). Sostituite opportunamente il path del certificato.

Avviamo le due applicazioni

Avviamo, oltre a WSO2IS, le due applicazioni Spring Boot.

Da un browser, andiamo su:
http://localhost:8082/spring-resource-server.
Avremo un 401 come risposta. Questo perché non ci siamo autenticati.

Andiamo allora su localhost:8082/oauth2/authorization/wso2 (URL di default di login).
Verremo reindirizzati sulla pagina di login di WSO2IS. Inseriamo le credenziali admin/admin:

Block Image

Clicchiamo su "Continue", poi confermiamo i permessi e clicchiamo su "Allow":

Block Image

Verremo reindirizzati con successo sulla pagina Welcome di spring-oauth2-client:

Block Image

Ora chiamiamo il servizio di spring-resource-server, sempre da spring-oauth2-client con la URL http://localhost:8082/spring-resource-server:

Block Image

Avremo accesso con successo alla pagina di saluto!

Conclusioni

Abbiamo visto come creare con Spring un OAuht2 Client e un Resource Server.
Abbiamo sfruttato le potenzialità di Spring Cloud Gateway per centralizzare la sicurezza su più servizi di downstream (resource servers).
Abbiamo inoltre salvato le sessioni su Hazelcast invece di tenerle in memoria, in modo tale da poter avere più istanze del servizio spring-oauth2-client che condividono le stesse sessioni.

Potete provare su un client REST come Postman direttamente il Resource Server, recuperando il token dai log di spring-oauth2-client, oppure, senza necessità di avviare quest'ultimo, effettuando una richiesta di token direttamente a WSO2IS, utilizzando il flusso grant type "password" (https://is.docs.wso2.com/en/latest/learn/try-password-grant/).

Link GitHub del progetto completo: https://github.com/vincenzo-racca/spring-oauth2
Altri articoli su Spring: Spring

Libri consigliati su Spring:

Riferimenti