Utilisation de Spring Boot pour la protection OAuth2 et JWT REST
Publié: 2022-03-11Cet article est un guide sur la configuration d'une implémentation côté serveur de JSON Web Token (JWT) - Cadre d'autorisation OAuth2 à l'aide de Spring Boot et Maven.
Une compréhension initiale d'OAuth2 est recommandée et peut être obtenue en lisant le brouillon lié ci-dessus ou en recherchant des informations utiles sur le Web comme ceci ou cela.
OAuth2 est un cadre d'autorisation remplaçant sa première version OAuth, créée en 2006. Il définit les flux d'autorisation entre les clients et un ou plusieurs services HTTP afin d'accéder aux ressources protégées.
OAuth2 définit les rôles côté serveur suivants :
- Propriétaire des ressources : le service chargé de contrôler l'accès aux ressources
- Resource Server : Le service qui fournit réellement les ressources
- Serveur d'autorisation : le service qui gère le processus d'autorisation agissant comme intermédiaire entre le client et le propriétaire de la ressource
JSON Web Token, ou JWT, est une spécification pour la représentation des revendications à transférer entre deux parties. Les revendications sont encodées sous la forme d'un objet JSON utilisé comme charge utile d'une structure chiffrée, permettant aux revendications d'être signées numériquement ou chiffrées.
La structure contenante peut être JSON Web Signature (JWS) ou JSON Web Encryption (JWE).
JWT peut être choisi comme format pour les jetons d'accès et d'actualisation utilisés dans le protocole OAuth2.
OAuth2 et JWT ont gagné en popularité ces dernières années grâce aux fonctionnalités suivantes :
- Fournit un système d'autorisation sans état pour le protocole REST sans état
- S'intègre bien dans une architecture de micro-services dans laquelle plusieurs serveurs de ressources peuvent partager un seul serveur d'autorisation
- Contenu du jeton facile à gérer côté client grâce au format JSON
Cependant, OAuth2 et JWT ne sont pas toujours le meilleur choix si les considérations suivantes sont importantes pour le projet :
- Un protocole sans état ne permet pas la révocation d'accès côté serveur
- La durée de vie fixe du jeton ajoute une complexité supplémentaire pour la gestion des sessions de longue durée sans compromettre la sécurité (par exemple, le jeton d'actualisation)
- Une exigence pour un magasin sécurisé pour un jeton côté client
Flux de protocole attendu
Alors que l'une des principales caractéristiques d'OAuth2 est l'introduction d'une couche d'autorisation afin de séparer le processus d'autorisation des propriétaires de ressources, par souci de simplicité, le résultat de l'article est la construction d'une seule application se faisant passer pour tous les propriétaires de ressources , serveur d'autorisation et rôles de serveur de ressources . De ce fait, la communication ne circulera qu'entre deux entités, le serveur et le client.
Cette simplification devrait aider à se concentrer sur le but de l'article, c'est-à-dire la mise en place d'un tel système dans un environnement Spring Boot.
Le flux simplifié est décrit ci-dessous :
- La demande d'autorisation est envoyée du client au serveur (agissant en tant que propriétaire de la ressource) à l'aide d'un accord d'autorisation par mot de passe
- Le jeton d'accès est renvoyé au client (avec le jeton d'actualisation)
- Le jeton d'accès est ensuite envoyé du client au serveur (agissant en tant que serveur de ressources) à chaque demande d'accès aux ressources protégées
- Le serveur répond avec les ressources protégées requises
Spring Security et Spring Boot
Tout d'abord, une brève introduction à la pile technologique sélectionnée pour ce projet.
L'outil de gestion de projet de choix est Maven, mais en raison de la simplicité du projet, il ne devrait pas être difficile de passer à d'autres outils comme Gradle.
Dans la suite de l'article, nous nous concentrons uniquement sur les aspects de Spring Security, mais tous les extraits de code proviennent d'une application côté serveur entièrement fonctionnelle dont le code source est disponible dans un référentiel public avec un client consommant ses ressources REST.
Spring Security est un framework fournissant des services de sécurité presque déclaratifs pour les applications basées sur Spring. Ses racines remontent au début du printemps et il est organisé en un ensemble de modules en raison du nombre élevé de technologies de sécurité différentes couvertes.
Jetons un coup d'œil à l'architecture de Spring Security (un guide plus détaillé peut être trouvé ici).
La sécurité concerne principalement l' authentification , c'est-à-dire la vérification de l'identité, et l' autorisation , l'octroi de droits d'accès aux ressources.
Spring Security prend en charge une vaste gamme de modèles d'authentification, fournis par des tiers ou implémentés de manière native. Une liste peut être trouvée ici.
Concernant l'autorisation, trois grands domaines sont identifiés :
- Autorisation des requêtes Web
- Autorisation au niveau de la méthode
- Autorisation d'accès aux instances d'objet de domaine
Authentification
L'interface de base est AuthenticationManager
qui se charge de fournir une méthode d'authentification. Le UserDetailsService
est l'interface liée à la collecte d'informations de l'utilisateur, qui peut être directement implémentée ou utilisée en interne dans le cas des méthodes JDBC ou LDAP standard.
Autorisation
L'interface principale est AccessDecisionManager
; dont les implémentations pour les trois domaines énumérés ci-dessus délèguent à une chaîne de AccessDecisionVoter
. Chaque instance de cette dernière interface représente une association entre une Authentication
(une identité d'utilisateur, un principal nommé), une ressource et une collection de ConfigAttribute
, l'ensemble de règles qui décrit comment le propriétaire de la ressource a autorisé l'accès à la ressource elle-même, peut-être via le l'utilisation des rôles d'utilisateur.
La sécurité d'une application Web est implémentée à l'aide des éléments de base décrits ci-dessus dans une chaîne de filtres de servlet, et la classe WebSecurityConfigurerAdapter
est exposée comme un moyen déclaratif d'exprimer les règles d'accès aux ressources.
La sécurité des méthodes est d'abord activée par la présence de l' @EnableGlobalMethodSecurity(securedEnabled = true)
, puis par l'utilisation d'un ensemble d'annotations spécialisées à appliquer à chaque méthode à protéger telles que @Secured
, @PreAuthorize
et @PostAuthorize
.
Spring Boot ajoute à tout cela une collection de configurations d'applications avisées et de bibliothèques tierces afin de faciliter le développement tout en maintenant un standard de qualité élevé.
JWT OAuth2 avec Spring Boot
Passons maintenant au problème initial pour mettre en place une application implémentant OAuth2 et JWT avec Spring Boot.
Alors que plusieurs bibliothèques OAuth2 côté serveur existent dans le monde Java (une liste peut être trouvée ici), l'implémentation basée sur Spring est le choix naturel car nous nous attendons à ce qu'elle soit bien intégrée dans l'architecture Spring Security et évite ainsi d'avoir à gérer beaucoup des détails de bas niveau pour son utilisation.
Toutes les dépendances de bibliothèque liées à la sécurité sont gérées par Maven à l'aide de Spring Boot, qui est le seul composant nécessitant une version explicite dans le fichier de configuration de maven pom.xml (c'est-à-dire que les versions de bibliothèque sont automatiquement déduites par Maven en choisissant la plus à jour version compatible avec la version Spring Boot insérée).
Retrouvez ci-dessous l'extrait du fichier de configuration pom.xml de maven contenant les dépendances liées à la sécurité 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'application agit à la fois en tant que serveur d'autorisation/propriétaire de ressources OAuth2 et en tant que serveur de ressources.
Les ressources protégées (en tant que serveur de ressources) sont publiées sous /api/ path, tandis que le chemin d'authentification (en tant que propriétaire de ressource/serveur d'autorisation) est mappé sur /oauth/token , suivant la valeur par défaut proposée.
Structure de l'application :
- package de
security
contenant la configuration de sécurité - package d'
errors
contenant la gestion des erreurs -
users
, packagesglee
pour les ressources REST, y compris le modèle, le référentiel et le contrôleur
Les paragraphes suivants couvrent la configuration de chacun des trois rôles OAuth2 mentionnés ci-dessus. Les classes associées se trouvent dans le package de security
:
-
OAuthConfiguration
, étendantAuthorizationServerConfigurerAdapter
-
ResourceServerConfiguration
, extension deResourceServerConfigurerAdapter
-
ServerSecurityConfig
, extensionWebSecurityConfigurerAdapter
-
UserService
, implémentantUserDetailsService
Configuration du propriétaire de la ressource et du serveur d'autorisation
Le comportement du serveur d'autorisation est activé par la présence de l'annotation @EnableAuthorizationServer
. Sa configuration est fusionnée avec celle liée au comportement du propriétaire de la ressource et les deux sont contenues dans la classe AuthorizationServerConfigurerAdapter
.
Les configurations appliquées ici sont liées à :
- Accès client (à l'aide
ClientDetailsServiceConfigurer
)- Sélection de l'utilisation d'un stockage en mémoire ou basé sur JDBC pour les détails du client avec les méthodes
inMemory
oujdbc
- Authentification de base du client à l'aide des
clientId
etclientSecret
(encodés avec le beanPasswordEncoder
choisi) - Durée de validité des jetons d'accès et d'actualisation à l'aide des attributs
accessTokenValiditySeconds
etrefreshTokenValiditySeconds
- Types d'attribution autorisés à l'aide de l'attribut
authorizedGrantTypes
- Définit les étendues d'accès avec la méthode des
scopes
- Identifier les ressources accessibles du client
- Sélection de l'utilisation d'un stockage en mémoire ou basé sur JDBC pour les détails du client avec les méthodes
- Point de terminaison du serveur d'autorisation (à l'aide
AuthorizationServerEndpointsConfigurer
)- Définir l'utilisation d'un jeton JWT avec
accessTokenConverter
- Définir l'utilisation des interfaces
UserDetailsService
etAuthenticationManager
pour effectuer l'authentification (en tant que propriétaire de la ressource)
- Définir l'utilisation d'un jeton JWT avec
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 section suivante décrit la configuration à appliquer au serveur de ressources.

Configuration du serveur de ressources
Le comportement du serveur de ressources est activé par l'utilisation de l'annotation @EnableResourceServer
et sa configuration est contenue dans la classe ResourceServerConfiguration
.
La seule configuration nécessaire ici est la définition de l'identification des ressources afin de faire correspondre l'accès du client défini dans la classe précédente.
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"); } }
Le dernier élément de configuration concerne la définition de la sécurité des applications Web.
Configuration de la sécurité Web
La configuration de la sécurité Web Spring est contenue dans la classe ServerSecurityConfig
, activée par l'utilisation de l'annotation @EnableWebSecurity
. Le @EnableGlobalMethodSecurity
permet de spécifier la sécurité au niveau de la méthode. Son attribut proxyTargetClass
est défini afin que cela fonctionne pour les méthodes de RestController
, car les contrôleurs sont généralement des classes, n'implémentant aucune interface.
Il définit les éléments suivants :
- Le fournisseur d'authentification à utiliser, en définissant le bean
authenticationProvider
- L'encodeur de mot de passe à utiliser, définissant le bean
passwordEncoder
- Le bean gestionnaire d'authentification
- La configuration de sécurité pour les chemins publiés à l'aide
HttpSecurity
- Utilisation d'un
AuthenticationEntryPoint
personnalisé afin de gérer les messages d'erreur en dehors du gestionnaire d'erreurs Spring REST standardResponseEntityExceptionHandler
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'extrait de code ci-dessous concerne l'implémentation de l'interface UserDetailsService
afin de fournir l'authentification du propriétaire de la ressource.
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 section suivante concerne la description d'une implémentation de contrôleur REST afin de voir comment les contraintes de sécurité sont mappées.
Contrôleur REST
À l'intérieur du contrôleur REST, nous pouvons trouver deux façons d'appliquer le contrôle d'accès pour chaque méthode de ressource :
- Utilisation d'une instance de
OAuth2Authentication
transmise par Spring en tant que paramètre - Utilisation des annotations
@PreAuthorize
ou@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<>()); } } }
Conclusion
Spring Security et Spring Boot permettent de mettre en place rapidement un serveur d'autorisation/authentification OAuth2 complet de manière quasi déclarative. La configuration peut être encore raccourcie en configurant les propriétés du client OAuth2 directement à partir du fichier application.properties/yml
, comme expliqué dans ce tutoriel.
Tout le code source est disponible dans ce référentiel GitHub : spring-glee-o-meter. Un client Angular qui consomme les ressources publiées peut être trouvé dans ce référentiel GitHub : glee-o-meter.