Usando Spring Boot para proteção OAuth2 e JWT REST

Publicados: 2022-03-11

Este artigo é um guia sobre como configurar uma implementação do lado do servidor do JSON Web Token (JWT) - estrutura de autorização OAuth2 usando Spring Boot e Maven.

Uma compreensão inicial do OAuth2 é recomendada e pode ser obtida lendo o rascunho do link acima ou procurando informações úteis na web como esta ou esta.

OAuth2 é uma estrutura de autorização que substitui a primeira versão OAuth, criada em 2006. Ela define os fluxos de autorização entre clientes e um ou mais serviços HTTP para obter acesso a recursos protegidos.

OAuth2 define as seguintes funções do lado do servidor:

  • Proprietário do recurso: o serviço responsável por controlar o acesso dos recursos
  • Servidor de recursos: o serviço que realmente fornece os recursos
  • Authorization Server: O processo de autorização de manipulação de serviço que atua como intermediário entre o cliente e o proprietário do recurso

JSON Web Token, ou JWT, é uma especificação para a representação de reivindicações a serem transferidas entre duas partes. As declarações são codificadas como um objeto JSON usado como carga útil de uma estrutura criptografada, permitindo que as declarações sejam assinadas ou criptografadas digitalmente.

A estrutura que a contém pode ser JSON Web Signature (JWS) ou JSON Web Encryption (JWE).

O JWT pode ser escolhido como o formato para tokens de acesso e atualização usados ​​dentro do protocolo OAuth2.

OAuth2 e JWT ganharam uma enorme popularidade nos últimos anos devido aos seguintes recursos:

  • Fornece um sistema de autorização sem estado para protocolo REST sem estado
  • Adapta-se bem a uma arquitetura de microsserviços na qual vários servidores de recursos podem compartilhar um único servidor de autorização
  • Conteúdo de token fácil de gerenciar no lado do cliente devido ao formato JSON

No entanto, OAuth2 e JWT nem sempre são a melhor escolha caso as seguintes considerações sejam importantes para o projeto:

  • Um protocolo sem estado não permite a revogação de acesso no lado do servidor
  • Vida útil fixa para token adiciona complexidade adicional para gerenciar sessões de longa duração sem comprometer a segurança (por exemplo, token de atualização)
  • Um requisito para um armazenamento seguro para um token no lado do cliente

Fluxo de protocolo esperado

Embora um dos principais recursos do OAuth2 seja a introdução de uma camada de autorização para separar o processo de autorização dos proprietários de recursos, para simplificar, o resultado do artigo é a construção de um único aplicativo representando todos os proprietários de recursos , servidor de autorização e funções de servidor de recursos . Por isso, a comunicação fluirá apenas entre duas entidades, o servidor e o cliente.

Essa simplificação deve ajudar a focar no objetivo do artigo, ou seja, a configuração de tal sistema em um ambiente Spring Boot.

O fluxo simplificado é descrito abaixo:

  1. A solicitação de autorização é enviada do cliente para o servidor (agindo como proprietário do recurso) usando a concessão de autorização de senha
  2. O token de acesso é retornado ao cliente (junto com o token de atualização)
  3. O token de acesso é então enviado do cliente para o servidor (agindo como servidor de recursos) em cada solicitação de acesso a recursos protegidos
  4. O servidor responde com os recursos protegidos necessários

Diagrama de fluxo de autenticação

Spring Security e Spring Boot

Em primeiro lugar, uma breve introdução à pilha de tecnologia selecionada para este projeto.

A ferramenta de gerenciamento de projetos escolhida é o Maven, mas devido à simplicidade do projeto, não deve ser difícil mudar para outras ferramentas como o Gradle.

Na continuação do artigo, focamos apenas nos aspectos do Spring Security, mas todos os trechos de código são retirados de um aplicativo do lado do servidor totalmente funcional, cujo código-fonte está disponível em um repositório público junto com um cliente consumindo seus recursos REST.

Spring Security é uma estrutura que fornece serviços de segurança quase declarativos para aplicativos baseados em Spring. Suas raízes são desde o início do Spring e está organizado como um conjunto de módulos devido ao alto número de diferentes tecnologias de segurança cobertas.

Vamos dar uma olhada rápida na arquitetura Spring Security (um guia mais detalhado pode ser encontrado aqui).

A segurança é principalmente sobre autenticação , ou seja, a verificação da identidade, e autorização , a concessão de direitos de acesso aos recursos.

A segurança do Spring suporta uma enorme variedade de modelos de autenticação, fornecidos por terceiros ou implementados nativamente. Uma lista pode ser encontrada aqui.

Em relação à autorização, são identificadas três áreas principais:

  1. Autorização de solicitações da Web
  2. Autorização de nível de método
  3. Autorização de acesso a instâncias de objetos de domínio

Autenticação

A interface básica é AuthenticationManager que é responsável por fornecer um método de autenticação. O UserDetailsService é a interface relacionada à coleta de informações do usuário, que pode ser implementada diretamente ou usada internamente no caso de métodos JDBC ou LDAP padrão.

Autorização

A interface principal é AccessDecisionManager ; quais implementações para todas as três áreas listadas acima delegam para uma cadeia de AccessDecisionVoter . Cada instância desta última interface representa uma associação entre uma Authentication (uma identidade de usuário, nomeada principal), um recurso e uma coleção de ConfigAttribute , o conjunto de regras que descreve como o proprietário do recurso permitiu o acesso ao próprio recurso, talvez através do uso de funções de usuário.

A segurança de uma aplicação web é implementada utilizando os elementos básicos descritos acima em uma cadeia de filtros de servlet, e a classe WebSecurityConfigurerAdapter é exposta como uma forma declarativa de expressar as regras de acesso ao recurso.

A segurança do método é habilitada primeiro pela presença da @EnableGlobalMethodSecurity(securedEnabled = true) e, em seguida, pelo uso de um conjunto de anotações especializadas a serem aplicadas a cada método a ser protegido, como @Secured , @PreAuthorize e @PostAuthorize .

O Spring Boot adiciona a tudo isso uma coleção de configurações de aplicativos opinativos e bibliotecas de terceiros para facilitar o desenvolvimento, mantendo um alto padrão de qualidade.

JWT OAuth2 com Spring Boot

Vamos agora avançar no problema original para configurar um aplicativo que implementa OAuth2 e JWT com Spring Boot.

Embora existam várias bibliotecas OAuth2 do lado do servidor no mundo Java (uma lista pode ser encontrada aqui), a implementação baseada em mola é a escolha natural, pois esperamos encontrá-la bem integrada à arquitetura Spring Security e, portanto, evitar a necessidade de lidar com muito dos detalhes de baixo nível para seu uso.

Todas as dependências de biblioteca relacionadas à segurança são tratadas pelo Maven com a ajuda do Spring Boot, que é o único componente que requer uma versão explícita dentro do arquivo de configuração do maven pom.xml (ou seja, as versões da biblioteca são inferidas automaticamente pelo Maven escolhendo a versão mais atualizada versão compatível com a versão do Spring Boot inserida).

Veja abaixo o trecho do arquivo de configuração do maven pom.xml contendo as dependências relacionadas à segurança do 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>

O aplicativo atua como servidor de autorização/proprietário do recurso OAuth2 e como servidor de recursos.

Os recursos protegidos (como servidor de recursos) são publicados no caminho /api/ , enquanto o caminho de autenticação (como proprietário do recurso/servidor de autorização) é mapeado para /oauth/token , seguindo o padrão proposto.

Estrutura do aplicativo:

  • pacote de security contendo configuração de segurança
  • pacote errors contendo tratamento de erros
  • users , pacotes glee para recursos REST, incluindo modelo, repositório e controlador

Os próximos parágrafos cobrem a configuração de cada uma das três funções do OAuth2 mencionadas acima. As classes relacionadas estão dentro do pacote security :

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

Configuração do Proprietário do Recurso e do Servidor de Autorização

O comportamento do servidor de autorização é habilitado pela presença da anotação @EnableAuthorizationServer . Sua configuração é mesclada com aquela relacionada ao comportamento do proprietário do recurso e ambas estão contidas na classe AuthorizationServerConfigurerAdapter .

As configurações aplicadas aqui estão relacionadas a:

  • Acesso de cliente (usando ClientDetailsServiceConfigurer )
    • Seleção de usar um armazenamento baseado em memória ou JDBC para detalhes do cliente com métodos inMemory ou jdbc
    • Autenticação básica do cliente usando os atributos clientId e clientSecret (codificado com o bean PasswordEncoder escolhido)
    • Tempo de validade para tokens de acesso e atualização usando os atributos accessTokenValiditySeconds e refreshTokenValiditySeconds
    • Tipos de concessão permitidos usando o atributo authorizedGrantTypes
    • Define escopos de acesso com o método scopes
    • Identifique os recursos acessíveis do cliente
  • Endpoint do servidor de autorização (usando AuthorizationServerEndpointsConfigurer )
    • Defina o uso de um token JWT com accessTokenConverter
    • Defina o uso de interfaces UserDetailsService e AuthenticationManager para realizar a autenticação (como proprietário do recurso)
 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; } }

A próxima seção descreve a configuração a ser aplicada ao servidor de recursos.

Configuração do servidor de recursos

O comportamento do servidor de recursos é habilitado pelo uso da anotação @EnableResourceServer e sua configuração está contida na classe ResourceServerConfiguration .

A única configuração necessária aqui é a definição da identificação do recurso para corresponder ao acesso do cliente definido na classe anterior.

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

O último elemento de configuração é sobre a definição de segurança de aplicativos da web.

Configuração de segurança da Web

A configuração de segurança da web do Spring está contida na classe ServerSecurityConfig , habilitada pelo uso da anotação @EnableWebSecurity . O @EnableGlobalMethodSecurity permite especificar a segurança no nível do método. Seu atributo proxyTargetClass é definido para que isso funcione para os métodos do RestController , porque os controladores geralmente são classes, não implementando nenhuma interface.

Ele define o seguinte:

  • O provedor de autenticação a ser usado, definindo o bean authenticationProvider
  • O codificador de senha a ser usado, definindo o bean passwordEncoder
  • O bean gerenciador de autenticação
  • A configuração de segurança para os caminhos publicados usando HttpSecurity
  • Uso de um AuthenticationEntryPoint personalizado para lidar com mensagens de erro fora do manipulador de erros Spring REST padrão 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()); } }

O trecho de código abaixo trata da implementação da interface UserDetailsService para fornecer a autenticação do proprietário do recurso.

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

A próxima seção é sobre a descrição de uma implementação de controlador REST para ver como as restrições de segurança são mapeadas.

Controlador REST

Dentro do controlador REST podemos encontrar duas formas de aplicar o controle de acesso para cada método de recurso:

  • Usando uma instância de OAuth2Authentication passada pelo Spring como um parâmetro
  • Usando anotações @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<>()); } } }

Conclusão

O Spring Security e o Spring Boot permitem configurar rapidamente um servidor de autorização/autenticação OAuth2 completo de maneira quase declarativa. A configuração pode ser ainda mais reduzida configurando as propriedades do cliente OAuth2 diretamente do arquivo application.properties/yml , conforme explicado neste tutorial.

Todo o código-fonte está disponível neste repositório do GitHub: spring-glee-o-meter. Um cliente Angular que consome os recursos publicados pode ser encontrado neste repositório do GitHub: glee-o-meter.