Folosind Spring Boot pentru OAuth2 și JWT REST Protection
Publicat: 2022-03-11Acest articol este un ghid despre cum să configurați o implementare pe partea de server a JSON Web Token (JWT) - cadrul de autorizare OAuth2 folosind Spring Boot și Maven.
O înțelegere inițială a OAuth2 este recomandată și poate fi obținută citind schița de mai sus sau căutând informații utile pe web ca aceasta sau aceasta.
OAuth2 este un cadru de autorizare care îl înlocuiește pe prima versiune OAuth, creat în 2006. Acesta definește fluxurile de autorizare între clienți și unul sau mai multe servicii HTTP pentru a obține acces la resursele protejate.
OAuth2 definește următoarele roluri pe partea serverului:
- Resource Owner: Serviciul responsabil cu controlul accesului resurselor
- Server de resurse: Serviciul care furnizează efectiv resursele
- Server de autorizare: Procesul de autorizare care gestionează serviciul care acționează ca intermediar între client și proprietarul resursei
JSON Web Token, sau JWT, este o specificație pentru reprezentarea revendicărilor care urmează să fie transferate între două părți. Revendicările sunt codificate ca obiect JSON folosit ca sarcină utilă a unei structuri criptate, permițând revendicărilor să fie semnate sau criptate digital.
Structura care conține poate fi JSON Web Signature (JWS) sau JSON Web Encryption (JWE).
JWT poate fi ales ca format pentru jetoanele de acces și de reîmprospătare utilizate în cadrul protocolului OAuth2.
OAuth2 și JWT au câștigat o popularitate uriașă în ultimii ani datorită următoarelor caracteristici:
- Oferă un sistem de autorizare fără stat pentru protocolul REST fără stat
- Se potrivește bine într-o arhitectură de microservicii în care mai multe servere de resurse pot partaja un singur server de autorizare
- Conținut de token ușor de gestionat din partea clientului datorită formatului JSON
Cu toate acestea, OAuth2 și JWT nu sunt întotdeauna cea mai bună alegere în cazul în care următoarele considerente sunt importante pentru proiect:
- Un protocol fără stat nu permite revocarea accesului pe partea de server
- Durata de viață fixă pentru jeton adaugă complexitate suplimentară pentru gestionarea sesiunilor de lungă durată fără a compromite securitatea (de exemplu, jetonul de reîmprospătare)
- O cerință pentru un depozit securizat pentru un simbol pe partea clientului
Fluxul de protocol așteptat
În timp ce una dintre principalele caracteristici ale OAuth2 este introducerea unui strat de autorizare pentru a separa procesul de autorizare de proprietarii de resurse, de dragul simplității, rezultatul articolului este construirea unei singure aplicații care uzurpă identitatea tuturor proprietarilor de resurse , serverului de autorizare și rolurile de server de resurse . Din acest motiv, comunicarea va circula doar între două entități, server și client.
Această simplificare ar trebui să ajute să se concentreze asupra scopului articolului, adică configurarea unui astfel de sistem în mediul Spring Boot.
Fluxul simplificat este descris mai jos:
- Solicitarea de autorizare este trimisă de la client la server (acționând ca proprietar de resurse) folosind acordarea de autorizare a parolei
- Jetonul de acces este returnat clientului (împreună cu jetonul de reîmprospătare)
- Jetonul de acces este apoi trimis de la client la server (acționând ca server de resurse) la fiecare cerere de acces la resurse protejate
- Serverul răspunde cu resursele protejate necesare
Spring Security și Spring Boot
În primul rând, o scurtă introducere în stiva de tehnologie selectată pentru acest proiect.
Instrumentul de management de proiect ales este Maven, dar din cauza simplității proiectului, nu ar trebui să fie dificil să treceți la alte instrumente precum Gradle.
În continuarea articolului, ne concentrăm doar pe aspectele Spring Security, dar toate fragmentele de cod sunt preluate dintr-o aplicație pe server care funcționează complet, codul sursă este disponibil într-un depozit public împreună cu un client care își consumă resursele REST.
Spring Security este un cadru care oferă servicii de securitate aproape declarative pentru aplicațiile bazate pe Spring. Rădăcinile sale sunt de la începutul primăverii și este organizat ca un set de module datorită numărului mare de tehnologii de securitate diferite acoperite.
Să aruncăm o privire rapidă asupra arhitecturii Spring Security (un ghid mai detaliat poate fi găsit aici).
Securitatea se referă în principal la autentificare , adică la verificarea identității, și la autorizare , la acordarea drepturilor de acces la resurse.
Securitatea Spring acceptă o gamă largă de modele de autentificare, fie furnizate de terți, fie implementate nativ. O listă poate fi găsită aici.
În ceea ce privește autorizarea, sunt identificate trei domenii principale:
- Web solicită autorizare
- Autorizare la nivel de metodă
- Autorizarea accesului la instanțele obiectelor de domeniu
Autentificare
Interfața de bază este AuthenticationManager
, care este responsabil să furnizeze o metodă de autentificare. UserDetailsService
este interfața legată de colectarea informațiilor utilizatorului, care ar putea fi implementată direct sau utilizată intern în cazul metodelor standard JDBC sau LDAP.
Autorizare
Interfața principală este AccessDecisionManager
; care implementări pentru toate cele trei zone enumerate mai sus le delegă unui lanț de AccessDecisionVoter
. Fiecare instanță a ultimei interfețe reprezintă o asociere între o Authentication
(o identitate de utilizator, numită principal), o resursă și o colecție de ConfigAttribute
, setul de reguli care descrie modul în care proprietarul resursei a permis accesul la resursa în sine, poate prin intermediul utilizarea rolurilor de utilizator.
Securitatea pentru o aplicație web este implementată folosind elementele de bază descrise mai sus într-un lanț de filtre servlet, iar clasa WebSecurityConfigurerAdapter
este expusă ca o modalitate declarativă de a exprima regulile de acces ale resursei.
Securitatea metodei este mai întâi activată prin prezența @EnableGlobalMethodSecurity(securedEnabled = true)
, apoi prin utilizarea unui set de adnotări specializate care să fie aplicate fiecărei metode care trebuie protejată, cum ar fi @Secured
, @PreAuthorize
și @PostAuthorize
.
Spring Boot adaugă la toate acestea o colecție de configurații de aplicații obișnuite și biblioteci terță parte pentru a ușura dezvoltarea, menținând în același timp un standard de calitate ridicat.
JWT OAuth2 cu Spring Boot
Să trecem acum la problema inițială pentru a configura o aplicație care implementează OAuth2 și JWT cu Spring Boot.
În timp ce în lumea Java există mai multe biblioteci OAuth2 pe partea de server (o listă poate fi găsită aici), implementarea bazată pe primăvară este alegerea naturală, deoarece ne așteptăm să o găsim bine integrată în arhitectura Spring Security și, prin urmare, evităm nevoia de a gestiona mult. a detaliilor de nivel inferior pentru utilizarea acestuia.
Toate dependențele de bibliotecă legate de securitate sunt gestionate de Maven cu ajutorul Spring Boot, care este singura componentă care necesită o versiune explicită în fișierul de configurare al lui Maven pom.xml (adică versiunile de bibliotecă sunt deduse automat de către Maven alegând cea mai recentă versiune). versiune compatibilă cu versiunea Spring Boot introdusă).
Găsiți mai jos fragmentul din fișierul de configurare al lui Maven pom.xml care conține dependențele legate de securitatea 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>
Aplicația acționează atât ca server de autorizare OAuth2/proprietar de resurse, cât și ca server de resurse.
Resursele protejate (ca server de resurse) sunt publicate sub calea /api/ , în timp ce calea de autentificare (ca proprietar de resurse/server de autorizare) este mapată la /oauth/token , după implicit propus.
Structura aplicației:
- pachet
security
care conține configurația de securitate - pachet de
errors
care conține gestionarea erorilor -
users
, pacheteglee
pentru resurse REST, inclusiv model, depozit și controler
Următoarele paragrafe acoperă configurația pentru fiecare dintre cele trei roluri OAuth2 menționate mai sus. Clasele aferente se află în pachetul security
:
-
OAuthConfiguration
, extindereaAuthorizationServerConfigurerAdapter
-
ResourceServerConfiguration
, extinzândResourceServerConfigurerAdapter
-
ServerSecurityConfig
, extindereaWebSecurityConfigurerAdapter
-
UserService
, implementândUserDetailsService
Configurare pentru proprietarul resurselor și serverul de autorizare
Comportamentul serverului de autorizare este activat de prezența adnotării @EnableAuthorizationServer
. Configurația sa este îmbinată cu cea legată de comportamentul proprietarului resursei și ambele sunt cuprinse în clasa AuthorizationServerConfigurerAdapter
.
Configurațiile aplicate aici sunt legate de:
- Acces client (folosind
ClientDetailsServiceConfigurer
)- Selectarea utilizării unui spațiu de stocare în memorie sau bazat pe JDBC pentru detaliile clientului cu metode
inMemory
saujdbc
- Autentificarea de bază a clientului utilizând
clientId
șiclientSecret
(codat cu beanulPasswordEncoder
ales) - Timp de valabilitate pentru accesarea și reîmprospătarea token-urilor folosind atributele
accessTokenValiditySeconds
șirefreshTokenValiditySeconds
- Tipuri de grant permise folosind atributul
authorizedGrantTypes
- Definește domeniile de acces cu metoda
scopes
- Identificați resursele accesibile clientului
- Selectarea utilizării unui spațiu de stocare în memorie sau bazat pe JDBC pentru detaliile clientului cu metode
- Punct final al serverului de autorizare (folosind
AuthorizationServerEndpointsConfigurer
)- Definiți utilizarea unui token JWT cu
accessTokenConverter
- Definiți utilizarea unei
UserDetailsService
șiAuthenticationManager
pentru a efectua autentificare (ca proprietar de resurse)
- Definiți utilizarea unui token JWT cu
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; } }
Următoarea secțiune descrie configurația de aplicat serverului de resurse.

Configurare pentru Resource Server
Comportamentul serverului de resurse este activat prin utilizarea adnotării @EnableResourceServer
și configurația sa este conținută în clasa ResourceServerConfiguration
.
Singura configurație necesară aici este definirea identificării resurselor pentru a se potrivi cu accesul clientului definit în clasa anterioară.
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"); } }
Ultimul element de configurare este despre definirea securității aplicațiilor web.
Configurare securitate web
Configurația de securitate web Spring este conținută în clasa ServerSecurityConfig
, activată prin utilizarea adnotării @EnableWebSecurity
. @EnableGlobalMethodSecurity
permite specificarea securității la nivel de metodă. Atributul său proxyTargetClass
este setat pentru ca acest lucru să funcționeze pentru metodele lui RestController
, deoarece controlerele sunt de obicei clase, care nu implementează nicio interfață.
Acesta definește următoarele:
- Furnizorul de autentificare de utilizat, definind bean
authenticationProvider
- Codificatorul de parolă de utilizat, definind codificatorul bean
passwordEncoder
- Beanul managerului de autentificare
- Configurația de securitate pentru căile publicate folosind
HttpSecurity
- Utilizarea unui
AuthenticationEntryPoint
personalizat pentru a gestiona mesajele de eroare în afara operatorului standard de erori Spring RESTResponseEntityExceptionHandler
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()); } }
Extrasul de cod de mai jos este despre implementarea interfeței UserDetailsService
pentru a oferi autentificarea proprietarului resursei.
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)); } }
Următoarea secțiune este despre descrierea implementării unui controler REST pentru a vedea cum sunt mapate constrângerile de securitate.
Controler REST
În interiorul controlerului REST putem găsi două moduri de a aplica controlul accesului pentru fiecare metodă de resursă:
- Folosind o instanță de
OAuth2Authentication
transmisă de Spring ca parametru - Folosind adnotările
@PreAuthorize
sau@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<>()); } } }
Concluzie
Spring Security și Spring Boot permit configurarea rapidă a unui server complet de autorizare/autentificare OAuth2 într-o manieră aproape declarativă. Configurarea poate fi scurtată și mai mult prin configurarea proprietăților clientului OAuth2 direct din fișierul application.properties/yml
, așa cum este explicat în acest tutorial.
Tot codul sursă este disponibil în acest depozit GitHub: spring-glee-o-meter. Un client Angular care consumă resursele publicate poate fi găsit în acest depozit GitHub: glee-o-meter.