Używanie Spring Boot do ochrony OAuth2 i JWT REST

Opublikowany: 2022-03-11

Ten artykuł jest przewodnikiem na temat konfigurowania implementacji po stronie serwera JSON Web Token (JWT) — struktury autoryzacji OAuth2 przy użyciu Spring Boot i Maven.

Zalecane jest wstępne zapoznanie się z protokołem OAuth2, które można uzyskać, czytając wersję roboczą, do której łącze znajduje się powyżej, lub wyszukując przydatne informacje w sieci, takie jak ta lub ta.

OAuth2 to platforma autoryzacji zastępująca pierwszą wersję OAuth, stworzoną w 2006 roku. Definiuje przepływy autoryzacji między klientami a jedną lub kilkoma usługami HTTP w celu uzyskania dostępu do chronionych zasobów.

OAuth2 definiuje następujące role po stronie serwera:

  • Właściciel zasobu: Usługa odpowiedzialna za kontrolowanie dostępu do zasobów
  • Serwer zasobów: usługa, która faktycznie dostarcza zasoby
  • Serwer autoryzacji: Usługa obsługująca proces autoryzacji działająca jako pośrednik między klientem a właścicielem zasobu

JSON Web Token lub JWT to specyfikacja reprezentacji roszczeń, które mają być przenoszone między dwiema stronami. Oświadczenia są zakodowane jako obiekt JSON używany jako ładunek zaszyfrowanej struktury, co umożliwia cyfrowe podpisywanie lub szyfrowanie oświadczeń.

Struktura zawierająca może być JSON Web Signature (JWS) lub JSON Web Encryption (JWE).

JWT można wybrać jako format tokenów dostępu i odświeżania używanych w protokole OAuth2.

OAuth2 i JWT zyskały ogromną popularność w ciągu ostatnich lat ze względu na następujące funkcje:

  • Zapewnia bezstanowy system autoryzacji dla bezstanowego protokołu REST
  • Dobrze pasuje do architektury mikrousług, w której wiele serwerów zasobów może współdzielić jeden serwer autoryzacji
  • Treść tokenów łatwa do zarządzania po stronie klienta dzięki formatowi JSON

Jednak OAuth2 i JWT nie zawsze są najlepszym wyborem w przypadku, gdy dla projektu ważne są następujące kwestie:

  • Protokół bezstanowy nie pozwala na cofnięcie dostępu po stronie serwera
  • Stały czas życia tokena dodatkowo zwiększa złożoność zarządzania długotrwałymi sesjami bez narażania bezpieczeństwa (np. token odświeżania)
  • Wymóg bezpiecznego przechowywania tokena po stronie klienta

Oczekiwany przebieg protokołu

Podczas gdy jedną z głównych cech OAuth2 jest wprowadzenie warstwy autoryzacji w celu oddzielenia procesu autoryzacji od właścicieli zasobów, dla uproszczenia wynikiem artykułu jest zbudowanie jednej aplikacji podszywającej się pod wszystkich właścicieli zasobów , serwer autoryzacji i role serwera zasobów . Z tego powodu komunikacja będzie przebiegać tylko między dwoma podmiotami, serwerem i klientem.

To uproszczenie powinno pomóc skoncentrować się na celu artykułu, czyli ustawieniu takiego systemu w środowisku Spring Boot.

Poniżej opisano uproszczony przepływ:

  1. Żądanie autoryzacji jest wysyłane od klienta do serwera (działającego jako właściciel zasobu) za pomocą przyznania autoryzacji hasła
  2. Token dostępu jest zwracany do klienta (wraz z tokenem odświeżania)
  3. Token dostępu jest następnie wysyłany od klienta do serwera (działając jako serwer zasobów) przy każdym żądaniu dostępu do chronionych zasobów
  4. Serwer odpowiada wymaganymi chronionymi zasobami

Schemat przepływu uwierzytelniania

Spring Security i Spring Boot

Przede wszystkim krótkie wprowadzenie do stosu technologicznego wybranego dla tego projektu.

Preferowanym narzędziem do zarządzania projektami jest Maven, ale ze względu na prostotę projektu przejście na inne narzędzia, takie jak Gradle, nie powinno być trudne.

W dalszej części artykułu skupiamy się tylko na aspektach Spring Security, ale wszystkie fragmenty kodu pochodzą z w pełni działającej aplikacji serwerowej, której kod źródłowy jest dostępny w publicznym repozytorium wraz z klientem zużywającym jego zasoby REST.

Spring Security to framework dostarczający niemal deklaratywne usługi bezpieczeństwa dla aplikacji opartych na Spring. Jego korzenie sięgają pierwszego początku wiosny i jest zorganizowany jako zestaw modułów ze względu na dużą liczbę różnych technologii bezpieczeństwa.

Rzućmy okiem na architekturę Spring Security (bardziej szczegółowy przewodnik można znaleźć tutaj).

Bezpieczeństwo to przede wszystkim uwierzytelnianie , czyli weryfikacja tożsamości, oraz autoryzacja , nadawanie praw dostępu do zasobów.

Spring Security obsługuje szeroką gamę modeli uwierzytelniania, dostarczanych przez strony trzecie lub wdrażanych natywnie. Listę można znaleźć tutaj.

Jeśli chodzi o autoryzację, zidentyfikowano trzy główne obszary:

  1. Autoryzacja żądań internetowych
  2. Autoryzacja na poziomie metody
  3. Dostęp do autoryzacji instancji obiektów domeny

Uwierzytelnianie

Podstawowym interfejsem jest AuthenticationManager , który jest odpowiedzialny za dostarczenie metody uwierzytelniania. UserDetailsService to interfejs związany z gromadzeniem informacji o użytkowniku, który może być bezpośrednio zaimplementowany lub wykorzystany wewnętrznie w przypadku standardowych metod JDBC lub LDAP.

Upoważnienie

Głównym interfejsem jest AccessDecisionManager ; które implementacje dla wszystkich trzech wymienionych powyżej obszarów delegują do łańcucha AccessDecisionVoter . Każde wystąpienie tego ostatniego interfejsu reprezentuje powiązanie między Authentication (tożsamość użytkownika, nazwany podmiot zabezpieczeń), zasobem i kolekcją ConfigAttribute , zestawu reguł opisujących, w jaki sposób właściciel zasobu zezwolił na dostęp do samego zasobu, być może za pośrednictwem wykorzystanie ról użytkowników.

Bezpieczeństwo aplikacji internetowej jest implementowane przy użyciu podstawowych elementów opisanych powyżej w łańcuchu filtrów serwletów, a klasa WebSecurityConfigurerAdapter jest prezentowana jako deklaratywny sposób wyrażania reguł dostępu do zasobów.

Zabezpieczenia metod są najpierw włączane przez obecność @EnableGlobalMethodSecurity(securedEnabled = true) , a następnie przez użycie zestawu wyspecjalizowanych adnotacji do zastosowania do każdej metody, która ma być chroniona, takich jak @Secured , @PreAuthorize i @PostAuthorize .

Spring Boot dodaje do tego wszystkiego zbiór opiniowanych konfiguracji aplikacji i bibliotek innych firm, aby ułatwić rozwój przy jednoczesnym zachowaniu wysokiego standardu jakości.

JWT OAuth2 z Spring Boot

Przejdźmy teraz do pierwotnego problemu, aby skonfigurować aplikację implementującą OAuth2 i JWT z Spring Boot.

Chociaż w świecie Javy istnieje wiele bibliotek OAuth2 po stronie serwera (lista znajduje się tutaj), implementacja oparta na sprężynie jest naturalnym wyborem, ponieważ spodziewamy się, że będzie dobrze zintegrowana z architekturą Spring Security, dzięki czemu unikniemy potrzeby obsługi wielu szczegółów niskiego poziomu do jego wykorzystania.

Wszystkie zależności bibliotek związane z bezpieczeństwem są obsługiwane przez Mavena za pomocą Spring Boot, który jest jedynym komponentem wymagającym jawnej wersji w pliku konfiguracyjnym mavena pom.xml (tj. wersje bibliotek są automatycznie wywnioskowane przez Mavena wybierając najbardziej aktualne wersja zgodna z włożoną wersją Spring Boot).

Poniżej znajduje się fragment pliku konfiguracyjnego mavena pom.xml zawierający zależności związane z bezpieczeństwem 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>

Aplikacja działa zarówno jako serwer autoryzacji/zasobu OAuth2 jak i jako serwer zasobów.

Chronione zasoby (jako serwer zasobów) są publikowane w /api/ path, podczas gdy ścieżka uwierzytelniania (jako właściciel zasobów/serwer autoryzacji) jest mapowana do /oauth/token , zgodnie z proponowaną wartością domyślną.

Struktura aplikacji:

  • pakiet security zawierający konfigurację bezpieczeństwa
  • pakiet errors zawierający obsługę błędów
  • users , pakiety glee dla zasobów REST, w tym modelu, repozytorium i kontrolera

Kolejne akapity dotyczą konfiguracji dla każdej z trzech wymienionych powyżej ról OAuth2. Powiązane klasy znajdują się w pakiecie security :

  • OAuthConfiguration , rozszerzanie AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration , rozszerzanie ResourceServerConfigurerAdapter
  • ServerSecurityConfig , rozszerzający WebSecurityConfigurerAdapter
  • UserService , implementacja UserDetailsService

Konfiguracja właściciela zasobów i serwera autoryzacji

Zachowanie serwera autoryzacji jest włączane przez obecność adnotacji @EnableAuthorizationServer . Jego konfiguracja jest scalana z konfiguracją związaną z zachowaniem właściciela zasobu i obie są zawarte w klasie AuthorizationServerConfigurerAdapter .

Zastosowane tutaj konfiguracje dotyczą:

  • Dostęp klienta (za pomocą ClientDetailsServiceConfigurer )
    • Wybór użycia pamięci masowej opartej na pamięci lub JDBC dla szczegółów klienta za pomocą metod inMemory lub jdbc
    • Uwierzytelnianie podstawowe klienta przy użyciu clientId i clientSecret (zakodowanych wybranym ziarnem PasswordEncoder )
    • Czas ważności tokenów dostępu i odświeżania za pomocą atrybutów accessTokenValiditySeconds i refreshTokenValiditySeconds
    • Typy grantów authorizedGrantTypes przy użyciu atrybutu AuthorGrantTypes
    • Definiuje zakresy dostępu metodą scopes
    • Zidentyfikuj dostępne zasoby klienta
  • Punkt końcowy serwera autoryzacji (przy użyciu AuthorizationServerEndpointsConfigurer )
    • Zdefiniuj użycie tokena JWT za pomocą accessTokenConverter
    • Zdefiniuj użycie interfejsów UserDetailsService i AuthenticationManager do przeprowadzenia uwierzytelniania (jako właściciel zasobu)
 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; } }

W następnej sekcji opisano konfigurację, którą należy zastosować do serwera zasobów.

Konfiguracja serwera zasobów

Zachowanie serwera zasobów jest włączane za pomocą adnotacji @EnableResourceServer , a jego konfiguracja jest zawarta w klasie ResourceServerConfiguration .

Jedyną potrzebną tutaj konfiguracją jest definicja identyfikacji zasobów w celu dopasowania dostępu klienta zdefiniowanego w poprzedniej klasie.

 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"); } }

Ostatni element konfiguracji dotyczy definicji bezpieczeństwa aplikacji internetowych.

Konfiguracja bezpieczeństwa sieci

Konfiguracja bezpieczeństwa webowego Spring jest zawarta w klasie ServerSecurityConfig , włączanej przez użycie adnotacji @EnableWebSecurity . @EnableGlobalMethodSecurity pozwala określić zabezpieczenia na poziomie metody. Jego atrybut proxyTargetClass jest ustawiony tak, aby działał on dla metod RestController , ponieważ kontrolery są zwykle klasami, nie implementującymi żadnych interfejsów.

Definiuje, co następuje:

  • Dostawca uwierzytelnienia do użycia, definiujący dostawcę authenticationProvider bean
  • Koder hasła do użycia, definiujący passwordEncoder kodu fasoli
  • Fasola menedżera uwierzytelniania
  • Konfiguracja zabezpieczeń dla opublikowanych ścieżek przy użyciu HttpSecurity
  • Użycie niestandardowego punktu AuthenticationEntryPoint w celu obsługi komunikatów o błędach poza standardowym modułem obsługi błędów Spring REST 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()); } }

Poniższy fragment kodu dotyczy implementacji interfejsu UserDetailsService w celu zapewnienia uwierzytelnienia właściciela zasobu.

 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)); } }

Następna sekcja dotyczy opisu implementacji kontrolera REST, aby zobaczyć, jak mapowane są ograniczenia bezpieczeństwa.

Kontroler REST

Wewnątrz kontrolera REST możemy znaleźć dwa sposoby zastosowania kontroli dostępu dla każdej metody zasobu:

  • Używanie instancji OAuth2Authentication przekazanej przez Spring jako parametru
  • Korzystanie z adnotacji @PreAuthorize lub @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<>()); } } }

Wniosek

Spring Security i Spring Boot umożliwiają szybkie skonfigurowanie kompletnego serwera autoryzacji/uwierzytelniania OAuth2 w niemal deklaratywny sposób. Instalację można dodatkowo skrócić, konfigurując właściwości klienta OAuth2 bezpośrednio z pliku application.properties/yml , jak wyjaśniono w tym samouczku.

Cały kod źródłowy jest dostępny w tym repozytorium GitHub: spring-glee-o-meter. Klient Angular, który zużywa opublikowane zasoby, można znaleźć w tym repozytorium GitHub: glee-o-meter.