Utilizzo di Spring Boot per OAuth2 e JWT REST Protection

Pubblicato: 2022-03-11

Questo articolo è una guida su come configurare un'implementazione lato server di JSON Web Token (JWT) - Framework di autorizzazione OAuth2 utilizzando Spring Boot e Maven.

Si consiglia una prima conoscenza di OAuth2 che può essere ottenuta leggendo la bozza collegata sopra o cercando informazioni utili sul web come questa o questa.

OAuth2 è un framework di autorizzazione che sostituisce la prima versione OAuth, creata nel 2006. Definisce i flussi di autorizzazione tra i client e uno o più servizi HTTP per ottenere l'accesso alle risorse protette.

OAuth2 definisce i seguenti ruoli lato server:

  • Proprietario della risorsa: il servizio responsabile del controllo dell'accesso alle risorse
  • Resource Server: il servizio che fornisce effettivamente le risorse
  • Server di autorizzazione: il processo di autorizzazione alla gestione del servizio che funge da intermediario tra il client e il proprietario della risorsa

JSON Web Token, o JWT, è una specifica per la rappresentazione dei reclami da trasferire tra due parti. Le attestazioni sono codificate come un oggetto JSON utilizzato come carico utile di una struttura crittografata, consentendo alle attestazioni di essere firmate o crittografate digitalmente.

La struttura contenitore può essere JSON Web Signature (JWS) o JSON Web Encryption (JWE).

JWT può essere scelto come formato per i token di accesso e aggiornamento utilizzati all'interno del protocollo OAuth2.

OAuth2 e JWT hanno guadagnato un'enorme popolarità negli ultimi anni grazie alle seguenti caratteristiche:

  • Fornisce un sistema di autorizzazione stateless per il protocollo REST stateless
  • Si adatta bene a un'architettura di microservizi in cui più server di risorse possono condividere un unico server di autorizzazione
  • Contenuto token facile da gestire da parte del cliente grazie al formato JSON

Tuttavia, OAuth2 e JWT non sono sempre la scelta migliore nel caso in cui le seguenti considerazioni siano importanti per il progetto:

  • Un protocollo stateless non consente la revoca dell'accesso sul lato server
  • La durata fissa del token aggiunge ulteriore complessità per la gestione di sessioni di lunga durata senza compromettere la sicurezza (ad es. aggiornamento del token)
  • Un requisito per un archivio sicuro per un token sul lato client

Flusso del protocollo previsto

Sebbene una delle caratteristiche principali di OAuth2 sia l'introduzione di un livello di autorizzazione per separare il processo di autorizzazione dai proprietari delle risorse, per semplicità, il risultato dell'articolo è la creazione di un'unica applicazione che impersona tutti i proprietari delle risorse , i server di autorizzazione e ruoli del server delle risorse . Per questo motivo, la comunicazione fluirà solo tra due entità, il server e il client.

Questa semplificazione dovrebbe aiutare a focalizzare l'obiettivo dell'articolo, ovvero la configurazione di un tale sistema in un ambiente Spring Boot.

Il flusso semplificato è descritto di seguito:

  1. La richiesta di autorizzazione viene inviata dal client al server (che funge da proprietario della risorsa) utilizzando la concessione dell'autorizzazione tramite password
  2. Il token di accesso viene restituito al client (insieme al token di aggiornamento)
  3. Il token di accesso viene quindi inviato dal client al server (che funge da server di risorse) su ogni richiesta di accesso alle risorse protette
  4. Il server risponde con le risorse protette richieste

Diagramma di flusso di autenticazione

Spring Security e Spring Boot

Prima di tutto, una breve introduzione allo stack tecnologico selezionato per questo progetto.

Lo strumento di gestione dei progetti preferito è Maven, ma data la semplicità del progetto, non dovrebbe essere difficile passare ad altri strumenti come Gradle.

Nella continuazione dell'articolo, ci concentriamo solo sugli aspetti di Spring Security, ma tutti gli estratti di codice sono presi da un'applicazione lato server completamente funzionante il cui codice sorgente è disponibile in un repository pubblico insieme a un client che consuma le sue risorse REST.

Spring Security è un framework che fornisce servizi di sicurezza quasi dichiarativi per applicazioni basate su Spring. Le sue radici risalgono al primo inizio della primavera ed è organizzato come un insieme di moduli a causa dell'elevato numero di diverse tecnologie di sicurezza coperte.

Diamo una rapida occhiata all'architettura di Spring Security (una guida più dettagliata può essere trovata qui).

La sicurezza riguarda principalmente l' autenticazione , ovvero la verifica dell'identità, e l' autorizzazione , la concessione dei diritti di accesso alle risorse.

Spring Security supporta una vasta gamma di modelli di autenticazione, forniti da terze parti o implementati in modo nativo. Un elenco può essere trovato qui.

Per quanto riguarda l'autorizzazione, si individuano tre aree principali:

  1. Il Web richiede l'autorizzazione
  2. Autorizzazione a livello di metodo
  3. Accesso all'autorizzazione delle istanze degli oggetti di dominio

Autenticazione

L'interfaccia di base è AuthenticationManager che è responsabile di fornire un metodo di autenticazione. UserDetailsService è l'interfaccia relativa alla raccolta delle informazioni dell'utente, che potrebbe essere implementata direttamente o utilizzata internamente in caso di metodi JDBC o LDAP standard.

Autorizzazione

L'interfaccia principale è AccessDecisionManager ; quali implementazioni per tutte e tre le aree sopra elencate delegano a una catena di AccessDecisionVoter . Ogni istanza di quest'ultima interfaccia rappresenta un'associazione tra Authentication (un'identità utente, denominata principal), una risorsa e una raccolta di ConfigAttribute , l'insieme di regole che descrive come il proprietario della risorsa ha consentito l'accesso alla risorsa stessa, magari attraverso il utilizzo dei ruoli utente.

La sicurezza per un'applicazione Web viene implementata utilizzando gli elementi di base descritti sopra in una catena di filtri servlet e la classe WebSecurityConfigurerAdapter viene esposta come modo dichiarativo per esprimere le regole di accesso della risorsa.

La sicurezza del metodo è prima abilitata dalla presenza dell'annotazione @EnableGlobalMethodSecurity(securedEnabled = true) , quindi dall'uso di una serie di annotazioni specializzate da applicare a ciascun metodo da proteggere come @Secured , @PreAuthorize e @PostAuthorize .

Spring Boot aggiunge a tutto questo una raccolta di configurazioni di applicazioni supposte e librerie di terze parti al fine di facilitare lo sviluppo mantenendo uno standard di qualità elevato.

JWT OAuth2 con avvio a molla

Passiamo ora al problema originale per configurare un'applicazione che implementa OAuth2 e JWT con Spring Boot.

Sebbene esistano più librerie OAuth2 lato server nel mondo Java (un elenco può essere trovato qui), l'implementazione basata su primavera è la scelta naturale poiché ci aspettiamo di trovarla ben integrata nell'architettura di Spring Security ed evitare quindi la necessità di gestire molto dei dettagli di basso livello per il suo utilizzo.

Tutte le dipendenze della libreria relative alla sicurezza sono gestite da Maven con l'aiuto di Spring Boot, che è l'unico componente che richiede una versione esplicita all'interno del file di configurazione di Maven pom.xml (cioè le versioni della libreria vengono automaticamente dedotte da Maven scegliendo la più aggiornata compatibile con la versione Spring Boot inserita).

Trova di seguito l'estratto dal file di configurazione di Maven pom.xml contenente le dipendenze relative alla sicurezza di Spring Boot:

 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>

L'app funge sia da server di autorizzazione/proprietario della risorsa OAuth2 che da server di risorse.

Le risorse protette (come server delle risorse) sono pubblicate in /api/ path, mentre il percorso di autenticazione (come proprietario della risorsa/server di autorizzazione) è mappato su /oauth/token , seguendo l'impostazione predefinita proposta.

Struttura dell'app:

  • pacchetto di security contenente la configurazione di sicurezza
  • pacchetto degli errors contenente la gestione degli errori
  • users , pacchetti glee per risorse REST, inclusi modello, repository e controller

I prossimi paragrafi trattano la configurazione per ciascuno dei tre ruoli OAuth2 sopra menzionati. Le classi correlate sono all'interno del pacchetto security :

  • OAuthConfiguration , estendendo AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration , estendendo ResourceServerConfigurerAdapter
  • ServerSecurityConfig , estendendo WebSecurityConfigurerAdapter
  • UserService , implementando UserDetailsService

Configurazione per il proprietario della risorsa e il server di autorizzazione

Il comportamento del server di autorizzazione è abilitato dalla presenza dell'annotazione @EnableAuthorizationServer . La sua configurazione è fusa con quella relativa al comportamento del proprietario della risorsa ed entrambe sono contenute nella classe AuthorizationServerConfigurerAdapter .

Le configurazioni qui applicate sono relative a:

  • Accesso client (tramite ClientDetailsServiceConfigurer )
    • Selezione dell'utilizzo di uno storage in-memory o basato su JDBC per i dettagli del cliente con metodi inMemory o jdbc
    • Autenticazione di base del client mediante gli attributi clientId e clientSecret (codificato con il bean PasswordEncoder scelto)
    • Tempo di validità per l'accesso e l'aggiornamento dei token utilizzando gli attributi accessTokenValiditySeconds e refreshTokenValiditySeconds
    • Tipi di concessione consentiti utilizzando authorizedGrantTypes
    • Definisce gli ambiti di accesso con il metodo scopes
    • Identificare le risorse accessibili del cliente
  • Endpoint del server di autorizzazione (usando AuthorizationServerEndpointsConfigurer )
    • Definire l'uso di un token JWT con accessTokenConverter
    • Definire l'uso di un'interfaccia UserDetailsService e AuthenticationManager per eseguire l'autenticazione (come proprietario della risorsa)
 package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }

La sezione successiva descrive la configurazione da applicare al server delle risorse.

Configurazione per il server delle risorse

Il comportamento del server di risorse è abilitato dall'uso dell'annotazione @EnableResourceServer e la sua configurazione è contenuta nella classe ResourceServerConfiguration .

L'unica configurazione necessaria qui è la definizione dell'identificazione della risorsa per far corrispondere l'accesso del client definito nella classe precedente.

 package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }

L'ultimo elemento di configurazione riguarda la definizione della sicurezza delle applicazioni web.

Configurazione della sicurezza web

La configurazione della sicurezza Web di primavera è contenuta nella classe ServerSecurityConfig , abilitata dall'uso dell'annotazione @EnableWebSecurity . @EnableGlobalMethodSecurity consente di specificare la sicurezza a livello di metodo. Il suo attributo proxyTargetClass è impostato in modo che funzioni per i metodi di RestController , poiché i controller sono generalmente classi, che non implementano alcuna interfaccia.

Definisce quanto segue:

  • Il provider di autenticazione da utilizzare, che definisce il bean authenticationProvider
  • Il codificatore di password da utilizzare, che definisce il bean passwordEncoder
  • Il bean di gestione dell'autenticazione
  • La configurazione di sicurezza per i percorsi pubblicati utilizzando HttpSecurity
  • Utilizzo di un AuthenticationEntryPoint personalizzato per gestire i messaggi di errore al di fuori del gestore di errori Spring REST standard ResponseEntityExceptionHandler
 package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }

L'estratto di codice seguente riguarda l'implementazione dell'interfaccia UserDetailsService per fornire l'autenticazione del proprietario della risorsa.

 package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }

La sezione successiva riguarda la descrizione di un'implementazione del controller REST per vedere come vengono mappati i vincoli di sicurezza.

Controller REST

All'interno del controller REST possiamo trovare due modi per applicare il controllo di accesso per ogni metodo di risorsa:

  • Utilizzo di un'istanza di OAuth2Authentication passata da Spring come parametro
  • Usando le annotazioni @PreAuthorize o @PostAuthorize
 package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }

Conclusione

Spring Security e Spring Boot consentono di configurare rapidamente un server di autorizzazione/autenticazione OAuth2 completo in modo quasi dichiarativo. L'impostazione può essere ulteriormente abbreviata configurando le proprietà del client OAuth2 direttamente dal file application.properties/yml , come spiegato in questo tutorial.

Tutto il codice sorgente è disponibile in questo repository GitHub: spring-glee-o-meter. Un client Angular che consuma le risorse pubblicate può essere trovato in questo repository GitHub: glee-o-meter.