Uso de Spring Boot para OAuth2 y JWT REST Protection

Publicado: 2022-03-11

Este artículo es una guía sobre cómo configurar una implementación del lado del servidor de JSON Web Token (JWT) - Marco de autorización OAuth2 usando Spring Boot y Maven.

Se recomienda una comprensión inicial de OAuth2 y se puede obtener leyendo el borrador vinculado anteriormente o buscando información útil en la web como esta o esta.

OAuth2 es un marco de autorización que reemplaza su primera versión OAuth, creada en 2006. Define los flujos de autorización entre clientes y uno o más servicios HTTP para obtener acceso a recursos protegidos.

OAuth2 define los siguientes roles del lado del servidor:

  • Propietario del recurso: el servicio responsable de controlar el acceso a los recursos.
  • Servidor de recursos: el servicio que realmente proporciona los recursos
  • Servidor de autorización: el proceso de autorización de manejo del servicio que actúa como intermediario entre el cliente y el propietario del recurso.

JSON Web Token, o JWT, es una especificación para la representación de reclamos que se transferirán entre dos partes. Los reclamos se codifican como un objeto JSON que se utiliza como carga útil de una estructura cifrada, lo que permite que los reclamos se firmen o cifren digitalmente.

La estructura contenedora puede ser JSON Web Signature (JWS) o JSON Web Encryption (JWE).

JWT se puede elegir como el formato para los tokens de acceso y actualización utilizados dentro del protocolo OAuth2.

OAuth2 y JWT ganaron una gran popularidad en los últimos años debido a las siguientes características:

  • Proporciona un sistema de autorización sin estado para el protocolo REST sin estado
  • Se adapta bien a una arquitectura de microservicios en la que varios servidores de recursos pueden compartir un único servidor de autorización.
  • Contenido de token fácil de administrar en el lado del cliente debido al formato JSON

Sin embargo, OAuth2 y JWT no siempre son la mejor opción en caso de que las siguientes consideraciones sean importantes para el proyecto:

  • Un protocolo sin estado no permite la revocación de acceso en el lado del servidor
  • La vida útil fija para el token agrega complejidad adicional para administrar sesiones de ejecución prolongada sin comprometer la seguridad (por ejemplo, token de actualización)
  • Un requisito para un almacenamiento seguro para un token en el lado del cliente

Flujo de protocolo esperado

Si bien una de las características principales de OAuth2 es la introducción de una capa de autorización para separar el proceso de autorización de los propietarios de recursos, en aras de la simplicidad, el resultado del artículo es la creación de una sola aplicación que suplanta a todos los propietarios de recursos , servidores de autorización y funciones del servidor de recursos . Debido a esto, la comunicación fluirá únicamente entre dos entidades, el servidor y el cliente.

Esta simplificación debería ayudar a centrarse en el objetivo del artículo, es decir, la configuración de dicho sistema en un entorno de Spring Boot.

El flujo simplificado se describe a continuación:

  1. La solicitud de autorización se envía del cliente al servidor (que actúa como propietario del recurso) mediante la concesión de autorización de contraseña
  2. El token de acceso se devuelve al cliente (junto con el token de actualización)
  3. Luego, el token de acceso se envía del cliente al servidor (que actúa como servidor de recursos) en cada solicitud de acceso a recursos protegidos.
  4. El servidor responde con los recursos protegidos requeridos

Diagrama de flujo de autenticación

Spring Security y Spring Boot

En primer lugar, una breve introducción a la pila de tecnología seleccionada para este proyecto.

La herramienta de gestión de proyectos de elección es Maven, pero debido a la simplicidad del proyecto, no debería ser difícil cambiar a otras herramientas como Gradle.

En la continuación del artículo, nos enfocamos solo en los aspectos de Spring Security, pero todos los extractos de código se toman de una aplicación del lado del servidor que funciona completamente y cuyo código fuente está disponible en un repositorio público junto con un cliente que consume sus recursos REST.

Spring Security es un marco que proporciona servicios de seguridad casi declarativos para aplicaciones basadas en Spring. Sus raíces se remontan a principios de Spring y está organizado como un conjunto de módulos debido a la gran cantidad de tecnologías de seguridad diferentes que cubre.

Echemos un vistazo rápido a la arquitectura de Spring Security (se puede encontrar una guía más detallada aquí).

La seguridad tiene que ver principalmente con la autenticación , es decir, la verificación de la identidad, y la autorización , la concesión de derechos de acceso a los recursos.

Spring Security admite una amplia gama de modelos de autenticación, ya sea proporcionados por terceros o implementados de forma nativa. Puede encontrar una lista aquí.

En cuanto a la autorización, se identifican tres áreas principales:

  1. Autorización de solicitudes web
  2. Autorización de nivel de método
  3. Autorización de acceso a instancias de objetos de dominio

Autenticación

La interfaz básica es AuthenticationManager , que es responsable de proporcionar un método de autenticación. UserDetailsService es la interfaz relacionada con la recopilación de información del usuario, que podría implementarse directamente o usarse internamente en el caso de métodos estándar JDBC o LDAP.

Autorización

La interfaz principal es AccessDecisionManager ; cuyas implementaciones para las tres áreas enumeradas anteriormente se delegan en una cadena de AccessDecisionVoter . Cada instancia de la última interfaz representa una asociación entre una Authentication (una identidad de usuario, principal con nombre), un recurso y una colección de ConfigAttribute , el conjunto de reglas que describe cómo el propietario del recurso permitió el acceso al recurso en sí, tal vez a través del uso de roles de usuario.

La seguridad de una aplicación web se implementa utilizando los elementos básicos descritos anteriormente en una cadena de filtros de servlet, y la clase WebSecurityConfigurerAdapter se expone como una forma declarativa de expresar las reglas de acceso a los recursos.

La seguridad del método se habilita primero mediante la presencia de la @EnableGlobalMethodSecurity(securedEnabled = true) y luego mediante el uso de un conjunto de anotaciones especializadas para aplicar a cada método que se va a proteger, como @Secured , @PreAuthorize y @PostAuthorize .

Spring Boot agrega a todo esto una colección de configuraciones de aplicaciones obstinadas y bibliotecas de terceros para facilitar el desarrollo manteniendo un alto estándar de calidad.

JWT OAuth2 con Spring Boot

Pasemos ahora al problema original para configurar una aplicación que implemente OAuth2 y JWT con Spring Boot.

Si bien existen múltiples bibliotecas OAuth2 del lado del servidor en el mundo de Java (se puede encontrar una lista aquí), la implementación basada en Spring es la elección natural, ya que esperamos encontrarla bien integrada en la arquitectura de Spring Security y, por lo tanto, evitar la necesidad de manejar mucho de los detalles de bajo nivel para su uso.

Todas las dependencias de la biblioteca relacionadas con la seguridad son manejadas por Maven con la ayuda de Spring Boot, que es el único componente que requiere una versión explícita dentro del archivo de configuración pom.xml de maven (es decir, Maven infiere automáticamente las versiones de la biblioteca eligiendo la más actualizada). versión compatible con la versión Spring Boot insertada).

Encuentre a continuación el extracto del archivo de configuración pom.xml de maven que contiene las dependencias relacionadas con la seguridad de 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>

La aplicación actúa como servidor de autorización/propietario de recursos de OAuth2 y como servidor de recursos.

Los recursos protegidos (como servidor de recursos) se publican en /api/ ruta, mientras que la ruta de autenticación (como propietario del recurso/servidor de autorización) se asigna a /oauth/token , siguiendo el valor predeterminado propuesto.

Estructura de la aplicación:

  • paquete de security que contiene la configuración de seguridad
  • paquete de errors que contiene el manejo de errores
  • users , paquetes glee para recursos REST, incluido el modelo, el repositorio y el controlador

Los siguientes párrafos cubren la configuración de cada uno de los tres roles de OAuth2 mencionados anteriormente. Las clases relacionadas están dentro del paquete de security :

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

Configuración para el propietario del recurso y el servidor de autorización

El comportamiento del servidor de autorización está habilitado por la presencia de la anotación @EnableAuthorizationServer . Su configuración se fusiona con la relacionada con el comportamiento del propietario del recurso y ambas están contenidas en la clase AuthorizationServerConfigurerAdapter .

Las configuraciones aplicadas aquí están relacionadas con:

  • Acceso de cliente (usando ClientDetailsServiceConfigurer )
    • Selección de uso de almacenamiento en memoria o basado en JDBC para los detalles del cliente con métodos inMemory o jdbc
    • Autenticación básica del cliente utilizando los clientId y clientSecret (codificados con el bean PasswordEncoder elegido)
    • Tiempo de validez para los tokens de acceso y actualización usando los atributos accessTokenValiditySeconds y refreshTokenValiditySeconds
    • Tipos de concesión permitidos utilizando el atributo authorizedGrantTypes
    • Define los ámbitos de acceso con el método de scopes .
    • Identificar los recursos accesibles del cliente
  • Punto final del servidor de autorización (usando AuthorizationServerEndpointsConfigurer )
    • Defina el uso de un token JWT con accessTokenConverter
    • Definir el uso de las interfaces UserDetailsService y AuthenticationManager para realizar la autenticación (como propietario del 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; } }

La siguiente sección describe la configuración que se aplicará al servidor de recursos.

Configuración del servidor de recursos

El comportamiento del servidor de recursos está habilitado mediante el uso de la anotación @EnableResourceServer y su configuración está contenida en la clase ResourceServerConfiguration .

La única configuración necesaria aquí es la definición de identificación de recursos para que coincida con el acceso del cliente definido en la clase 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"); } }

El último elemento de configuración trata sobre la definición de seguridad de aplicaciones web.

Configuración de seguridad web

La configuración de seguridad web de Spring está contenida en la clase ServerSecurityConfig , habilitada mediante el uso de la anotación @EnableWebSecurity . @EnableGlobalMethodSecurity permite especificar la seguridad en el nivel del método. Su atributo proxyTargetClass se establece para que esto funcione para los métodos de RestController , porque los controladores suelen ser clases, no implementan ninguna interfaz.

Define lo siguiente:

  • El proveedor de autenticación a usar, definiendo el proveedor de authenticationProvider del bean.
  • El codificador de contraseñas a usar, definiendo el bean passwordEncoder
  • El bean administrador de autenticación
  • La configuración de seguridad para las rutas publicadas usando HttpSecurity
  • Uso de un AuthenticationEntryPoint personalizado para manejar mensajes de error fuera del controlador de errores Spring REST estándar 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()); } }

El extracto de código a continuación trata sobre la implementación de la interfaz UserDetailsService para proporcionar la autenticación del propietario del 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)); } }

La siguiente sección trata sobre la descripción de la implementación de un controlador REST para ver cómo se asignan las restricciones de seguridad.

Controlador RESTO

Dentro del controlador REST podemos encontrar dos formas de aplicar el control de acceso para cada método de recurso:

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

Conclusión

Spring Security y Spring Boot permiten configurar rápidamente un servidor completo de autorización/autenticación OAuth2 de forma casi declarativa. La configuración se puede acortar aún más configurando las propiedades del cliente OAuth2 directamente desde el archivo application.properties/yml , como se explica en este tutorial.

Todo el código fuente está disponible en este repositorio de GitHub: spring-glee-o-meter. Un cliente Angular que consume los recursos publicados se puede encontrar en este repositorio de GitHub: glee-o-meter.