Spring Security e OpenID Connect

Tieni presente che questo articolo è stato aggiornato al nuovo stack Spring Security OAuth 2.0. Tuttavia, il tutorial che utilizza lo stack legacy è ancora disponibile.

1. Panoramica

In questo breve tutorial, ci concentreremo sulla configurazione di OpenID Connect (OIDC) con Spring Security.

Presenteremo diversi aspetti di questa specifica, quindi vedremo il supporto che Spring Security offre per implementarla su un client OAuth 2.0.

2. Introduzione a Quick OpenID Connect

OpenID Connect è un livello di identità basato sul protocollo OAuth 2.0.

Pertanto, è davvero importante conoscere OAuth 2.0 prima di immergersi in OIDC, in particolare il flusso del codice di autorizzazione.

La suite di specifiche OIDC è ampia; include caratteristiche principali e molte altre capacità opzionali, presentate in diversi gruppi. I principali sono:

  • Core: autenticazione e utilizzo delle attestazioni per comunicare le informazioni sull'utente finale
  • Scoperta: stabilisce come un client può determinare dinamicamente le informazioni sui provider OpenID
  • Registrazione dinamica: determina come un cliente può registrarsi con un provider
  • Gestione sessioni: definisce come gestire le sessioni OIDC

Inoltre, i documenti distinguono i server di autenticazione OAuth 2.0 che offrono supporto per questa specifica, riferendosi a loro come "OpenID Provider" (OP) e i client OAuth 2.0 che utilizzano OIDC come Relying Party (RP). Ci atterremo a questa terminologia in questo articolo.

Vale anche la pena sapere che un client può richiedere l'uso di questa estensione aggiungendo l' ambito openid nella sua richiesta di autorizzazione.

Infine, un altro aspetto utile da comprendere per questo tutorial è il fatto che gli OP emettono informazioni sull'utente finale come un JWT chiamato "ID Token".

Ora sì, siamo pronti per immergerci più a fondo nel mondo OIDC.

3. Configurazione del progetto

Prima di concentrarci sullo sviluppo effettivo, dovremo registrare un client OAuth 2.o con il nostro provider OpenID.

In questo caso, utilizzeremo Google come provider OpenID. Possiamo seguire queste istruzioni per registrare la nostra applicazione client sulla loro piattaforma. Si noti che l' ambito openid è presente per impostazione predefinita.

L'URI di reindirizzamento che abbiamo impostato in questo processo è un endpoint nel nostro servizio: // localhost: 8081 / login / oauth2 / code / google.

Dovremmo ottenere un ID client e un segreto client da questo processo.

3.1. Configurazione Maven

Inizieremo aggiungendo queste dipendenze al nostro file pom del progetto:

 org.springframework.boot spring-boot-starter-oauth2-client 2.2.6.RELEASE 

L'artefatto di avvio aggrega tutte le dipendenze relative al client Spring Security, tra cui:

  • la dipendenza spring-security-oauth2-client per l'accesso OAuth 2.0 e la funzionalità client
  • la libreria JOSE per il supporto JWT

Come al solito, possiamo trovare l'ultima versione di questo artefatto utilizzando il motore di ricerca Maven Central.

4. Configurazione di base mediante Spring Boot

In primo luogo, inizieremo configurando la nostra applicazione per utilizzare la registrazione del client che abbiamo appena creato con Google.

L'uso di Spring Boot lo rende molto semplice, poiché tutto ciò che dobbiamo fare è definire due proprietà dell'applicazione:

spring: security: oauth2: client: registration: google: client-id:  client-secret: 

Lanciamo la nostra applicazione e proviamo ad accedere a un endpoint ora. Vedremo che verremo reindirizzati a una pagina di accesso di Google per il nostro client OAuth 2.0.

Sembra davvero semplice, ma ci sono un sacco di cose che stanno succedendo qui sotto il cofano. Successivamente, esploreremo come Spring Security riesce a risolverlo.

In precedenza, nel nostro post di supporto WebClient e OAuth 2, abbiamo analizzato gli aspetti interni su come Spring Security gestisce i server di autorizzazione e i client OAuth 2.0.

Lì, abbiamo visto che dobbiamo fornire dati aggiuntivi, oltre all'ID client e al client secret, per configurare correttamente un'istanza ClientRegistration . Allora, come funziona?

La risposta è che Google è un provider ben noto e quindi il framework offre alcune proprietà predefinite per semplificare le cose.

Possiamo dare un'occhiata a queste configurazioni nell'enumerazione CommonOAuth2Provider .

Per Google, il tipo enumerato definisce proprietà come:

  • gli ambiti predefiniti che verranno utilizzati
  • l'endpoint di autorizzazione
  • l'endpoint del token
  • l'endpoint UserInfo, che fa anche parte della specifica OIDC Core

4.1. Accesso alle informazioni utente

Spring Security offre un'utile rappresentazione di un Principal utente registrato con un provider OIDC, l' entità OidcUser .

Oltre ai metodi OAuth2AuthenticatedPrincipal di base , questa entità offre alcune funzionalità utili:

  • recuperare il valore del token ID e le attestazioni che contiene
  • ottenere le attestazioni fornite dall'endpoint UserInfo
  • generare un aggregato dei due insiemi

Possiamo accedere facilmente a questa entità in un controller:

@GetMapping("/oidc-principal") public OidcUser getOidcUserPrincipal( @AuthenticationPrincipal OidcUser principal) { return principal; }

O utilizzando SecurityContextHolder in un bean:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication.getPrincipal() instanceof OidcUser) { OidcUser principal = ((OidcUser) authentication.getPrincipal()); // ... }

Se esaminiamo il principale, vedremo molte informazioni utili qui, come il nome dell'utente, l'e-mail, l'immagine del profilo e la lingua.

Inoltre, è importante notare che Spring aggiunge autorizzazioni al principal in base agli ambiti ricevuti dal provider, preceduti da " SCOPE_ ". Ad esempio, l' ambito openid diventa un'autorità concessa a SCOPE_openid .

Queste autorizzazioni possono essere utilizzate per limitare l'accesso a determinate risorse, ad esempio :

@EnableWebSecurity public class MappedAuthorities extends WebSecurityConfigurerAdapter { protected void configure(HttpSecurity http) { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/my-endpoint") .hasAuthority("SCOPE_openid") .anyRequest().authenticated() ); } }

5. OIDC in azione

Finora, abbiamo imparato come implementare facilmente una soluzione di accesso OIDC utilizzando Spring Security

Abbiamo visto il vantaggio che porta delegando il processo di identificazione dell'utente a un provider OpenID, che, a sua volta, fornisce informazioni utili dettagliate, anche in modo scalabile.

Ma la verità è che finora non abbiamo avuto a che fare con alcun aspetto specifico dell'OIDC. Ciò significa che la primavera sta facendo la maggior parte del lavoro per noi.

Quindi, vedremo cosa sta succedendo dietro le quinte per capire meglio come questa specifica viene messa in azione ed essere in grado di ottenere il massimo da essa.

5.1. Il processo di accesso

Per vederlo chiaramente, abilitiamo i log di RestTemplate per vedere le richieste che il servizio sta eseguendo:

logging: level: org.springframework.web.client.RestTemplate: DEBUG

Se ora chiamiamo un endpoint protetto, vedremo che il servizio esegue il normale flusso di codice di autorizzazione OAuth 2.0. Questo perché, come abbiamo detto, questa specifica si basa su OAuth 2.0. Ci sono comunque alcune differenze.

In primo luogo, a seconda del provider che stiamo utilizzando e degli ambiti che abbiamo configurato, potremmo vedere che il servizio sta effettuando una chiamata all'endpoint UserInfo che abbiamo menzionato all'inizio.

Vale a dire, se la risposta di autorizzazione recupera almeno uno tra profilo , e - mail , indirizzo o ambito telefonico , il framework chiamerà l'endpoint UserInfo per ottenere ulteriori informazioni.

Anche se tutto indicherebbe che Google dovrebbe recuperare il profilo e l' ambito dell'email , dato che li stiamo utilizzando nella richiesta di autorizzazione, l'OP recupera invece le loro controparti personalizzate, //www.googleapis.com/auth/userinfo.email e / /www.googleapis.com/auth/userinfo.profile , quindi Spring non chiama l'endpoint.

Ciò significa che tutte le informazioni che stiamo ottenendo fanno parte del token ID.

Possiamo adattarci a questo comportamento creando e fornendo la nostra istanza OidcUserService :

@Configuration public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { Set googleScopes = new HashSet(); googleScopes.add( "//www.googleapis.com/auth/userinfo.email"); googleScopes.add( "//www.googleapis.com/auth/userinfo.profile"); OidcUserService googleUserService = new OidcUserService(); googleUserService.setAccessibleScopes(googleScopes); http .authorizeRequests(authorizeRequests -> authorizeRequests .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin .userInfoEndpoint() .oidcUserService(googleUserService)); } }

La seconda differenza che osserveremo è una chiamata all'URI del set JWK. Come abbiamo spiegato nel nostro post JWS e JWK, questo viene utilizzato per verificare la firma del token ID in formato JWT.

Successivamente, analizzeremo il token ID in dettaglio.

5.2. The ID Token

Naturally, the OIDC spec covers and adapts to a lot of different scenarios. In this case, we're using the Authorization Code flow, and the protocol indicates that both the Access Token and the ID Token will be retrieved as part of the Token Endpoint response.

As we said before, the OidcUser entity contains the Claims contained in the ID Token, and the actual JWT-formatted token, which can be inspected using jwt.io.

On top of this, Spring offers many handy getters to obtain the standard Claims defined by the specification in a clean manner.

We can see the ID Token includes some mandatory Claims:

  • the issuer identifier formatted as a URL (e.g. “//accounts.google.com“)
  • a subject id, which is a reference of the End-User contained by the issuer
  • the expiration time for the token
  • time at which the token was issued
  • the audience, which will contain the OAuth 2.0 Client id we've configured

And also many OIDC Standard Claims like the ones we mentioned before (name, locale, picture, email).

As these are standard, we can expect many providers to retrieve at least some of these fields, and therefore facilitating the development of simpler solutions.

5.3. Claims and Scopes

As we can imagine, the Claims that are retrieved by the OP correspond with the scopes we (or Spring Security) configured.

OIDC defines some scopes that can be used to request the Claims defined by OIDC:

  • profile, which can be used to request default profile Claims (e.g. name, preferred_username,picture, etcetera)
  • email, to access to the email and email_verified Claims
  • address
  • phone, to requests the phone_number and phone_number_verified Claims

Even though Spring doesn't support it yet, the spec allows requesting single Claims by specifying them in the Authorization Request.

6. Spring Support for OIDC Discovery

As we explained in the introduction, OIDC includes many different features apart from its core purpose.

The capabilities we're going to analyze in this section and the following are optional in OIDC. Hence, it's important to understand that there might be OPs that don't support them.

The specification defines a Discovery mechanism for an RP to discover the OP and obtain information needed to interact with it.

In a nutshell, OPs provide a JSON document of standard metadata. The information must be served by a well-known endpoint of the issuer location, /.well-known/openid-configuration.

Spring benefits from this by allowing us to configure a ClientRegistration with just one simple property, the issuer location.

But let's jump right into an example to see this clearly.

We'll define a custom ClientRegistration instance:

spring: security: oauth2: client: registration: custom-google: client-id:  client-secret:  provider: custom-google: issuer-uri: //accounts.google.com

Now we can restart our application and check the logs to confirm the application is calling the openid-configuration endpoint in the startup process.

We can even browse this endpoint to have a look at the information provided by Google:

//accounts.google.com/.well-known/openid-configuration

We can see, for example, the Authorization, the Token and the UserInfo endpoints that the service has to use, and the supported scopes.

An especially relevant note here is the fact that if the Discovery endpoint is not available at the time the service launches, then our app won't be able to complete the startup process successfully.

7. OpenID Connect Session Management

This specification complements the Core functionality by defining:

  • different ways to monitor the End-User's login status at the OP on an ongoing basis so that the RP can log out an End-User who has logged out of the OpenID Provider
  • the possibility of registering RP logout URIs with the OP as part of the Client registration, so as to be notified when the End-User logs out of the OP
  • a mechanism to notify the OP that the End-User has logged out of the site and might want to log out of the OP as well

Naturally, not all OPs support all of these items, and some of these solutions can be implemented only in a front-end implementation via the User-Agent.

In this tutorial, we'll focus on the capabilities offered by Spring for the last item of the list, RP-initiated Logout.

At this point, if we log in to our application, we can normally access every endpoint.

If we logout (calling the /logout endpoint) and we make a request to a secured resource afterward, we'll see that we can get the response without having to log in again.

However, this is actually not true; if we inspect the Network tab in the browser debug console, we'll see that when we hit the secured endpoint the second time we get redirected to the OP Authorization Endpoint, and since we're still logged in there, the flow is completed transparently, ending up in the secured endpoint almost instantly.

Of course, this might not be the desired behavior in some cases. Let's see how we can implement this OIDC mechanism to deal with this.

7.1. The OpenID Provider Configuration

In this case, we'll be configuring and using an Okta instance as our OpenID Provider. We won't go into details on how to create the instance, but we can follow the steps of this guide, and keeping in mind that Spring Security's default callback endpoint will be /login/oauth2/code/okta.

In our application, we can define the client registration data with properties:

spring: security: oauth2: client: registration: okta: client-id:  client-secret:  provider: okta: issuer-uri: //dev-123.okta.com

OIDC indicates that the OP logout endpoint can be specified in the Discovery document, as the end_session_endpoint element.

7.2. The LogoutSuccessHandler Configuration

Next, we'll have to configure the HttpSecurity logout logic by providing a customized LogoutSuccessHandler instance:

@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/home").permitAll() .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin.permitAll()) .logout(logout -> logout .logoutSuccessHandler(oidcLogoutSuccessHandler())); }

Now let's see how we can create a LogoutSuccessHandler for this purpose using a special class provided by Spring Security, the OidcClientInitiatedLogoutSuccessHandler:

@Autowired private ClientRegistrationRepository clientRegistrationRepository; private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler( this.clientRegistrationRepository); oidcLogoutSuccessHandler.setPostLogoutRedirectUri( URI.create("//localhost:8081/home")); return oidcLogoutSuccessHandler; }

Consequently, we'll need to set up this URI as a valid logout Redirect URI in the OP Client configuration panel.

Clearly, the OP logout configuration is contained in the client registration setup, since all we're using to configure the handler is the ClientRegistrationRepository bean present in the context.

So, what will happen now?

After we login to our application, we can send a request to the /logout endpoint provided by Spring Security.

Se controlliamo i log di rete nella console di debug del browser, vedremo che siamo stati reindirizzati a un endpoint di logout OP prima di accedere finalmente all'URI di reindirizzamento che abbiamo configurato.

La prossima volta che accediamo a un endpoint nella nostra applicazione che richiede l'autenticazione, dovremo obbligatoriamente accedere nuovamente alla nostra piattaforma OP per ottenere le autorizzazioni.

8. Conclusione

Per riassumere, in questo tutorial abbiamo imparato molto sulle soluzioni offerte da OpenID Connect e su come possiamo implementarne alcune utilizzando Spring Security.

Come sempre, tutti gli esempi completi possono essere trovati nel nostro repository GitHub.