Introducción
En artículos publicados anteriormente en esta sección explicamos la forma de crear una Rest-API con Spring Boot y cómo documentarla utilizando Swagger. En este artículo explicamos cómo agregar una capa de seguridad en Rest API con JWT para permitir el acceso solo a los usuarios autorizados.
Importante: el ejemplo que se desarrolla en este artículo fue concebido exclusivamente a los efectos de explicar y demostrar los conceptos más importantes de seguridad con JWT en una aplicación Spring Boot.
Stateless Authentication
En este ejemplo utilizaremos Autenticación sin Estado (Stateles Authentication), que en contraposición a la Stateful Authentication, es fácilmente escalable y más eficiente.
En la Autenticación sin Estado, los datos de la sesión queda almacenada del lado del cliente, y es firmada utlizando una clave privada. Estos datos se envían en cada request, por lo que del lado del API se deben verificar y validar estos datos en cada llamada antes de dar curso a la petición. Dicho de otro modo, al utilizar Stateles Authentication ya no es necesario utilizar usuario y contraseña en cada request. Simplemente se obtiene un token que contiene los datos necesarios para que el servidor pueda autenticar los requests del cliente.
JSON Web Tokens
JSON Web Token es un estándar abierto que posibilita la transmisión de información de forma segura. El uso más común de JWT es para autorización. Cuando un usuario se identifica en el sistema, éste le asigna un token que le permite acceder a determinados recursos.
En JWT, un token consta de tres partes:
- Encabezado (Header): se compone de dos pares clave/valor utilizados para indicar el tipo de token (ej: «typ»:»JWT») y el algoritmo utilizado para encriptar el token (ej: «alg»:»HS256″).
- Carga útil (Payload): en esta parte se alojan los claims, que son datos de la entidad o usuario. Estos datos se asignan en forma de pares clave/valor.
- Firma (Signature): se genera la firma encriptando el header y payload con un secreto o clave privada, dependiendo del algoritmo utilizado.
Para desarrollar el ejemplo de Seguridad en Rest API con JWT usaremos Spring Security debido a que cuenta con soporte integrado para JWT mediante OAuth2 Resource Server, por lo que no tendremos necesidad de crear un filtro personalizado para validar nuestro token.
Resource Servers
En la terminología de OAuth2 un Resource Server hace referencia al servidor de nuestra API, y se encarga de recibir las peticiones autenticadas. Los grandes desarrollos pueden constar de varios Resource Servers compartiendo un único Authorization Server encargado de generar los tokens de acceso. En nuestro ejemplo será la misma aplicación la responsable de generar los tokens mediante un endpoint destinado a tal fin.
Funcionamiento de la aplicación
El siguiente diagrama de secuencia muestra los pasos para la obtención del token y acceso al API protegida.
El componente LoginController expone el servicio POST /login de forma pública a los efectos de que los usuarios se identifiquen en el sistema. Si se identifica correctamente al usuario se genera y returna un token.
El componente ProductsController expone el servicio GET /products, que a diferencia del anterior, se encuentra protegido. Si se intenta acceder sin un token válido se obtendrá un error 401. Para obtener acceso el encabezado del request debe incluir el token obtenido en el paso anterior con el prefijo Bearer.
Implementación de la solución
Paso 1: generar el proyecto con Spring Web Starter
Acceder a la página de Spring Initializr y crear un proyecto con las dependencias Maven que se muestran a continuación:
Paso 2: importar el proyecto en Eclipse
En la carpeta de descargas tendríamos que encontrar el archivo spring-boot-jwt-authentication.zip, que contiene todos los archivos del proyecto. Descomprimir este archivo en la carpeta de nuestro workspace.
La estructura del proyecto generado es la siguiente:
Ahora debemos generar los archivos de proyecto para Eclipse. Desde una terminal y ubicados en el directorio raíz del proyecto ejecutar el siguiente comando:
mvn eclipse:eclipse
Hacer click derecho en el workspace de Eclipse para desplegar el menú contextual y seleccionar la opción Import…
En el diálogo que se despliega seleccionar Existing Projects into Workspace (dentro de la carpeta General) y luego click en Next:
En el siguiente diálogo, en la opción Select root directory seleccionar la carpeta raíz del proyecto. Opcionalmente, también se puede seleccionar el Working set para el proyecto:
Finalizada la importación del proyecto, se debería ver en el workspace de Eclipse como sigue:
Contenido relacionado
Paso 3: Generar las claves pública y privada
Para firmar nuestro token JWT usaremos el algoritmo RSA. RSA es un algoritmo de encriptación asimétrica, lo que significa que funciona utilizando dos claves diferentes, una pública y otra privada. La clave privada nos servirá para firmar el token y la clave pública para verificar el token.
Haremos uso de la herramienta Keytool para generar el Key Store (archivo jks) que almacenerá las claves generadas.
keytool -genkeypair -alias jcodepoint -keyalg RSA -keystore jcpks.jks -keysize 2048
- -genkeypair: indica que se genere un par de claves.
- -alias: nombre de la entrada generada en el Key Store. Es el identificador de nuestro par de claves.
- -keyalg: nombre del algoritmo utilizado.
- -keystore: nombre del archivo generado. En nuestro caso será jcpks.jks.
- -keysize: tamaño de la clave en bits. Usaremos 2048 en este ejemplo pero se recomienda 4096 para mayor seguridad.
Una vez generado el archivo jcpks.jks, lo moveremos al CLASSPATH de nuestro proyecto, preferentemente dentro de la carpeta /resources.
Paso 4: configurar httpSecurity
Crearemos la clase SecurityConfig en la que podremos ajustar las configuraciones de seguridad de acuerdo a las necesidades de nuestra aplicación. En este ejemplo utilizaremos la siguiente configuración básica, solo a los efectos didácticos:
package com.jcodepoint.jwt.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private PasswordEncoder passwordEncoder;
public SecurityConfig(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests()
.requestMatchers("/login")
.anonymous()
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.build();
}
}
El código anterior realiza las siguientes configuraciones:
- CSRF (Cross-Site Request Forgery) viene habilitado por defecto. Lo deshabilitamos debido a que vamos a basar la autenticación en Bearer tokens.
- Se configura la autenticación sin estado (SessionCreationPolicy.STATELESS) como se explicó anteriormente.
- Se permite acceso de forma anónima a la URL /login para que los usuarios puedan obtener el Bearer Token.
- Para el resto de las URLs se requiere que el request contenga un Bearer Token válido en el Encabezado.
- Configuramos la aplicación para que funcione como un Resource Server. Spring brinda soporte para OAuth2 Resource Server mediante la clase OAuth2ResourceServerConfigurer. En este punto le estamos indicando a la aplicación que debe autenticar mediante JWT todos los requests entrantes, salvo los que explícitamente se hayan marcado para ser accedidos de forma anónima (en nuestro caso el endpoint /login).
Paso 5: crear el JwtDecoder
Ahora que ya tenemos configurado el Resource Server necesitamos configurar un JWT Decoder para que pueda validar los Bearer Tokens en cada request con la clave pública que generamos anteriormente. Hay varias formas de hacer esto, pero en este caso lo haremos publicando un bean de tipo org.springframework.security.oauth2.jwt.JwtDecoder.
5.1: configurar las properties
Agregar las siguientes keys al archivo application.properties con los valores correspondientes al KeyStore que hayas generado.
jks.keystore-path=keys/jcpks.jks
jks.keystore-password=clave22
jks.keystore-key-alias=jcodepoint
jks.keystore-private-key-passphrase=clave22
Para acceder a estas properties podemos, a partir de Java 16, crear un record que mapee las properties que necesitamos de forma muy simple. Con la anotación @ConfigurationProperties le indicamos que considere solo las properties cuya key comienzan con el prefijo ‘jks‘.
package com.jcodepoint.jwt.util;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "jks")
public record JksProperties(String keystorePath, String keystorePassword, String keystoreKeyAlias, String keystorePrivateKeyPassphrase) {
}
La clase JksProperties no está registrada como Bean por lo que debemos habilitarla con la anotación @EnableConfigurationProperties en la clase principal.
@SpringBootApplication
@EnableConfigurationProperties(JksProperties.class)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
5.2: crear el bean
Una vez ingresadas las properties y configurado su acceso, procedemos a agregar el JWT Decoder. Para esto creamos la clase JwtConfig en la cual:
- Inyectamos el JksProperties creado en el paso anterior.
- Cargamos el KeyStore desde el archivo jks que generamos en el Paso 3.
- Obtenemos la Clave Pública (RSAPublicKey) del KeyStore cargado anteriormente.
- Creamos el JWT Decoder con la Clave Pública obtenida anteriormente. Para instanciar el Decoder hacemos uso de la clase org.springframework.security.oauth2.jwt.NimbusJwtDecoder que tenemos disponible como dependencia en Spring Security.
package com.jcodepoint.jwt.security;
import java.io.IOException;
import java.io.InputStream;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import com.jcodepoint.jwt.util.JksProperties;
@Configuration
public class JwtConfig {
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
private JksProperties jksProperties;
public JwtConfig(JksProperties jksProperties) {
this.jksProperties = jksProperties;
}
@Bean
public KeyStore keyStore() {
try {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
InputStream resourceAsStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(this.jksProperties.keystorePath());
keyStore.load(resourceAsStream, this.jksProperties.keystorePassword().toCharArray());
return keyStore;
} catch (IOException e) {
log.error("Error al cargar keystore: " + e.toString());
} catch(CertificateException e) {
log.error("Error al cargar keystore: " + e.toString());
} catch(NoSuchAlgorithmException e) {
log.error("Error al cargar keystore: " + e.toString());
} catch(KeyStoreException e) {
log.error("Error al cargar keystore: " + e.toString());
}
throw new IllegalArgumentException("No se pudo cargar el keystore");
}
@Bean
public RSAPublicKey jwtValidationKey(KeyStore keyStore) {
try {
Certificate certificate = keyStore.getCertificate(this.jksProperties.keystoreKeyAlias());
PublicKey publicKey = certificate.getPublicKey();
if (publicKey instanceof RSAPublicKey) {
return (RSAPublicKey) publicKey;
}
} catch (KeyStoreException e) {
log.error("No se pudo recuperar la clave privada del keystore");
}
throw new IllegalArgumentException("No se pudo cargar la clave publica");
}
@Bean
public JwtDecoder jwtDecoder(RSAPublicKey rsaPublicKey) {
return NimbusJwtDecoder.withPublicKey(rsaPublicKey).build();
}
}
Paso 6: crear el JWT Encoder
Con el JWT Decoder tenemos disponible el mecanismo de validación de los Bearer Tokens con la Clave Pública. Ahora tenemos crear el proceso inverso, es decir, genear los Bearer Tokens con la Clave Privada. Para hacerlo, primero vamos a la clase que creamos en el paso anterior (JwtConfig) y agregamos el método que obtiene la Clave Privada (RSAPrivateKey) del KeyStore.
@Configuration
public class JwtConfig {
@Bean
public RSAPrivateKey jwtSigningKey(KeyStore keyStore) {
try {
Key key = keyStore.getKey(this.jksProperties.keystoreKeyAlias(), this.jksProperties.keystorePrivateKeyPassphrase().toCharArray());
if (key instanceof RSAPrivateKey) {
return (RSAPrivateKey) key;
}
} catch (UnrecoverableKeyException e) {
log.error("No se pudo recuperar la clave privada del keystore: " + e.toString());
} catch (NoSuchAlgorithmException e) {
log.error("No se pudo recuperar la clave privada del keystore: " + e.toString());
} catch (KeyStoreException e) {
log.error("No se pudo recuperar la clave privada del keystore: " + e.toString());
}
throw new IllegalArgumentException("No se pudo cargar la clave privada");
}
}
Luego creamos la clase JwtUtil, en donde:
- Inyectamos las Claves Pública y Privada.
- Creamos el método encode() en donde creamos y firmamos el nuevo token. Este método deberá ser invocado desde el endpoint de autenticación de usuarios.
package com.jcodepoint.jwt.security;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.springframework.stereotype.Component;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.jcodepoint.jwt.util.JksProperties;
@Component
public class JwtUtil {
private RSAPrivateKey privateKey;
private RSAPublicKey publicKey;
public JwtUtil(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
this.privateKey = privateKey;
this.publicKey = publicKey;
}
public String encode(String subject) {
return JWT.create()
.withSubject(subject)
.withExpiresAt(null)
.sign(Algorithm.RSA256(publicKey, privateKey) );
}
}
Paso 7: crear los web methods
7.1: crear web method de autenticación
El endpoint de autenticación será público y requerirá los parámetros username y password, que serán usados para validar al usuario. Si se valida correctamente se generará y retornará un token para ser utilizado una vez configurada la seguridad en Rest API con JWT.
7.1.1: crear el Password Encoder
Necesitamos crear una clase de utilidad para encriptar las contraseñas ingresadas por los usuarios. A los efectos de demostrar el funcionamiento utilizaremos el algoritmo Bcrypt. Spring Security provee la clase org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder, por lo que podemos crear el bean a partir de ésta como sigue:
package com.jcodepoint.jwt.util;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Configuration
public class PasswordUtils {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
7.1.2: crear un usuario de test
Para poder realizar pruebas crearemos un usuario en memoria configurando un nuevo bean en base a org.springframework.security.provisioning.InMemoryUserDetailsManager.
@Bean
public InMemoryUserDetailsManager userDetails() {
return new InMemoryUserDetailsManager(
User.withUsername("jcodepoint")
.passwordEncoder(passwordEncoder::encode)
.password("password")
.authorities("read")
.build()
);
}
7.1.3: crear el servicio de autenticación
La clase LoginService será la responsable de realizar la lógica de validación de usuarios. Para hacer esto, comparamos la contraseña recibida contra la del usuario dummy valiéndonos del Password Encoder creado anteriormente. Si se pasa la validación se genera el JWT.
package com.jcodepoint.jwt.service;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import com.jcodepoint.jwt.dto.TokenDto;
import com.jcodepoint.jwt.security.JwtUtil;
@Service
public class LoginService {
private PasswordEncoder passwordEncoder;
private UserDetailsManager userDetails;
private JwtUtil jwtUtil;
public LoginService(PasswordEncoder passwordEncoder, UserDetailsManager userDetails, JwtUtil jwtUtil) {
this.passwordEncoder = passwordEncoder;
this.userDetails = userDetails;
this.jwtUtil = jwtUtil;
}
public TokenDto login(String userName, String password) {
try {
UserDetails userDetails = this.userDetails.loadUserByUsername(userName);
if (this.passwordEncoder.matches(password, userDetails.getPassword())) {
return new TokenDto(this.jwtUtil.encode(userDetails.getUsername()));
} else {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Usuario o password incorrecto");
}
} catch(UsernameNotFoundException unnfe) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Usuario o password incorrecto");
}
}
}
7.1.4: crear el controller
La finalidad de la clase LoginController es exponer el endpoint /login solicitando las credenciales al usuario mediante @RequestParam y validarlas invocando a LoginService. Recordar que este endpoint es de acceso público de acuerdo a las configuraciones de seguridad realizadas en el Paso 4.
package com.jcodepoint.jwt.controller;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.jcodepoint.jwt.dto.TokenDto;
import com.jcodepoint.jwt.service.LoginService;
@RestController
public class LoginController {
private LoginService loginService;
public LoginController(LoginService loginService) {
this.loginService = loginService;
}
@PostMapping(path = "/login", consumes = { MediaType.APPLICATION_FORM_URLENCODED_VALUE })
public TokenDto login(@RequestParam String username, @RequestParam String password) {
return this.loginService.login(username, password);
}
}
7.2: crear web method protegido
La clase ProductsController expone el endpoint /products que retornará una lista con un único Producto. Como la seguridad en Rest API con JWT se ha configurado previamente, este endpoint requiere que el encabezado del request contenga un Bearer Token válido para ejecutarse, de lo contrario obtendremos un error 401.
package com.jcodepoint.jwt.controller;
import java.util.Arrays;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.jcodepoint.jwt.response.Product;
@RestController
public class ProductsController {
@GetMapping(path = "/products")
public List<Product> listProducts() {
return Arrays.asList(new Product("001", "Resma", 100D));
}
}
Paso 8: desplegar y testear
Para levantar la aplicación ejecutamos el siguiente comando de Maven desde la terminal:
mvn spring-boot:run
En Postman creamos un request al endpoint de login y configuramos las credenciales del usuario en el Body. Una vez ejecutado obtendremos el token.
En Postman creamos un nuevo request al endpoint /products. Este método está protegido, por lo que debemos incluir en el encabezado del request el token obtenido en el paso anterior. Para esto debemos:
- Ir a la solapa Auth.
- En el campo Type seleccionar Bearer Token.
- En el campo Token pegar el token obtenido en el paso anterior.
Conclusión
La intención de este artículo es dar una introducción a la seguridad en Rest API con JWT, con un ejemplo directo y sencillo a fin de destacar los conceptos más importantes.
Te puede interesar
Consumir servicios Rest con Apache HttpClient
Apache HttpClient es una biblioteca de Java. En este artículo se demostrará como consumir servicios Rest con Apache HttpClient.
Documentar un API Rest Con Swagger
En este artículo se demostrará como documentar un API REST con swagger utilizando la implementación SpringFox de la especificación Swagger 2.
Palabras reservadas en Java
Las palabras reservadas en Java son un componente crucial de la sintaxis del lenguaje para formar los bloques básicos del lenguaje.
Crear microservicios con Spring Boot
En este artículo explicamos los pasos necesarios para crear microservicios con Spring Boot, desplegarlo y acceder a los endpoints.