Autenticación de APIs basada en tokens con Spring y JWT

jwtlogo

En este post vamos a explicar cómo autenticar una API mediante tokens, para poder garantizar que los usuarios que consumen nuestros servicios tienen permisos para hacerlo y son quien dicen ser. 

  1. Crear un API REST con Spring Boot.
  2. Proteger recursos publicados en el API.
  3. Implementar un controlador para autenticar usuarios y generar un token de acceso.
  4. Implementar un filtro para autorizar peticiones a recursos protegidos de nuestro API.

 

Conceptos básicos

Antes de comenzar, es importante entender algunos conceptos básicos que aparecerán a lo largo de este post:

  • Servidor: Aplicación que contiene los recursos protegidos mediante API REST.
  • Cliente: Aplicación que hace las peticiones a servidor para interacturar con los recursos protegidos.
  • Autenticación: Proceso a través del cual un cliente garantiza su identidad. El ejemplo más sencillo sería el uso de usuario y contraseña.
  • Autorización: Proceso a través del cual se determina si un cliente tiene autoridad, o autorización, para acceder a ciertos recursos protegidos.


¿Que es JWT?

JSON Based Token (JWT, https://jwt.io/) es un estándar de código abierto basado en JSON para crear tokens de acceso que nos permiten securizar las comunicaciones entre cliente y servidor

¿Cómo funciona?

  1. El cliente se autentica y garantiza su identidad haciendo una petición al servidor de autenticación. Esta petición puede ser mediante usuario contraseña, mediante proveedores externos (Google, Facebook, etc) o mediante otros servicios como LDAP, Active Directory, etc.
  2. Una vez que el servidor de autenticación garantiza la identidad del cliente, se genera un token de acceso (JWT).
  3. El cliente usa ese token para acceder a los recursos protegidos que se publican mediante API.
  4. En cada petición, el servidor desencripta el token y comprueba si el cliente tiene permisos para acceder al recurso haciendo una petición al servidor de autorización.

Disponemos pues, de hasta tres servidores: el servidor de nuestra API, el servidor de autenticación y el servidor de autorización. Sin embargo, como veremos en este post, podemos implementar las tres funcionalidades en una única aplicación.

jwtexplanation

Composición del token

Estos token están compuestos por tres partes:

  • Header: contiene el hash que se usa para encriptar el token.
  • Payload: contiene una serie de atributos (clave, valor) que se encriptan en el token.
  • Firma: contiene header y payload concatenados y encriptados (Header + “.” + Payload + Secret key).

 

Manos a la obra...

La mejor forma de entenderlo es verlo funcionando, asi que... ¡¡allá vamos!! Lo primero sería crear una aplicación Spring Boot para implementar nuestro API. Esto ya lo hicimos en el webinar "Construyendo un API REST con Spring Boot".

Crear aplicación Spring Boot

Accedemos a la web de Spring Initializr y generamos un proyecto Maven con Java y Spring Boot 2.1.1, rellenamos el group, el artifact de nuestro proyecto (en este caso "es.softtek" y "jwt-demo") y añadimos dependencias para Web:

springstart

Una vez generado, descomprimimos el zip y lo construimos ejecutando el comando "mvn package" sobre la raíz de nuestro proyectoA continuación, generamos nuestro fichero .project ejecutando el comando "mvn eclipse:eclipse" desde la misma ubicación. Una vez compilado y construido, importamos el proyecto en nuestro IDE.

Implementando nuestro API

Vamos a crear un controlador REST para responder a todas las invocaciones del endpoint /hello, que simplemente devuelva un mensaje de bienvenida a todos los clientes que tengan autorización para acceder al servicio (por defecto, todos).

package es.softtek.jwtDemo.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloWorldController {

	@RequestMapping("hello")
	public String helloWorld(@RequestParam(value="name", defaultValue="World") String name) {
		return "Hello "+name+"!!";
	}
}

Básicamente hemos usado las siguientes anotaciones:

  • @RestController para habilitar esta clase como un controlador REST y que pueda interceptar peticiones al servidor.
  • @RequestMapping para habilitar a este método para interceptar una llamada al servidor, en este caso, a /hello.
  • @RequestParam para habilitar este argumento como parámetro del servicio.

Si arrancamos la aplicación (ejecutamos comando "mvn spring-boot:run" desde la raíz de nuestra aplicación) podemos testear nuestro servicio. Aún no hemos añadido ninguna configuración de seguridad, por lo que podemos invocar al servicio sin restricciones:

Si invocamos al endpoint de nuestro servicio (http://localhost:8080/hello) nos devolverá el mensaje por defecto:

hello1

 

 

 

 

También podemos indicar un nombre invocando al mismo servicio, pero añadiendo nuestro parámetro (http://localhost:8080/hello?name=Sebas):

hello2

 

 

 

A continuación, vamos a añadir configuración de seguridad a nuestra aplicación para proteger el endpoint /hello que acabamos de implementar:

Dependencias

Lo primero que necesitamos es añadir las dependencias para Spring Security y de JWT:

securityDependency

Autenticación

Vamos a crear otro controlador REST para implementar el proceso de autenticación mediante un login usuario/contraseña:

package es.softtek.jwtDemo.controller;

import java.util.Date;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import es.softtek.jwtDemo.dto.User;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@RestController
public class UserController {

	@PostMapping("user")
	public User login(@RequestParam("user") String username, @RequestParam("password") String pwd) {
		
		String token = getJWTToken(username);
		User user = new User();
		user.setUser(username);
		user.setToken(token);		
		return user;
		
	}

	private String getJWTToken(String username) {
		String secretKey = "mySecretKey";
		List grantedAuthorities = AuthorityUtils
				.commaSeparatedStringToAuthorityList("ROLE_USER");
		
		String token = Jwts
				.builder()
				.setId("softtekJWT")
				.setSubject(username)
				.claim("authorities",
						grantedAuthorities.stream()
								.map(GrantedAuthority::getAuthority)
								.collect(Collectors.toList()))
				.setIssuedAt(new Date(System.currentTimeMillis()))
				.setExpiration(new Date(System.currentTimeMillis() + 600000))
				.signWith(SignatureAlgorithm.HS512,
						secretKey.getBytes()).compact();

		return "Bearer " + token;
	}
}

El método login(...) interceptará las peticiones POST al endpoint /user y recibirá como parámetros el usuario y contraseña. Como se puede observar, para este ejemplo no se realiza ninguna validación de usuario y contraseña, por lo que para cualquier valor de dichos parámetros dejaremos paso. Obviamente, para un proyecto real, en este punto deberíamos autenticar el usuario contra nuestra base de datos o contra cualquier proveedor externo.

Utilizamos el método getJWTToken(...) para construir el token, delegando en la clase de utilidad Jwts que incluye información sobre su expiración y un objeto de GrantedAuthority de Spring que, como veremos más adelante, usaremos para autorizar las peticiones a los recursos protegidos. 

Por último, editaremos nuestra clase de arranque JwtDemoApplication para añadir la siguiente configuración:


package es.softtek.jwtDemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.web.authentication.UsernamePasswordAuthenticationFilter;

import es.softtek.jwtDemo.security.JWTAuthorizationFilter;

@SpringBootApplication
public class JwtDemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(JwtDemoApplication.class, args);
	}
	
	@EnableWebSecurity
	@Configuration
	class WebSecurityConfig extends WebSecurityConfigurerAdapter {

		@Override
		protected void configure(HttpSecurity http) throws Exception {
			http.csrf().disable()
				.addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
				.authorizeRequests()
				.antMatchers(HttpMethod.POST, "/user").permitAll()
				.anyRequest().authenticated();
		}
	}

}

La clase interna WebSecurityConfig, decorada con @EnableWebSecurity y @Configuration, nos permite especificar la configuración de acceso a los recursos publicados. En este caso se permiten todas las llamadas al controlador /user, pero el resto de las llamadas requieren autenticación.

 

En este momento, si reiniciamos la aplicación y hacemos una llamada a http://localhost:8080/hello, nos devolverá un error 403 informando al usuario de que no está autorizado para acceder a ese recurso que se encuentra protegido:

helloError403

Autorización

Por último, necesitamos implementar el proceso de autorización, que sea capaz de interceptar las invocaciones a recursos protegidos para recuperar el token y determinar si el cliente tiene permisos o no.

Para ello implementaremos el siguiente filtro, JWTAuthorizationFilter

package es.softtek.jwtDemo.security;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;

public class JWTAuthorizationFilter extends OncePerRequestFilter {

	private final String HEADER = "Authorization";
	private final String PREFIX = "Bearer ";
	private final String SECRET = "mySecretKey";

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
		try {
			if (existeJWTToken(request, response)) {
				Claims claims = validateToken(request);
				if (claims.get("authorities") != null) {
					setUpSpringAuthentication(claims);
				} else {
					SecurityContextHolder.clearContext();
				}
			}
			chain.doFilter(request, response);
		} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException e) {
			response.setStatus(HttpServletResponse.SC_FORBIDDEN);
			((HttpServletResponse) response).sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());
			return;
		}
	}	

	private Claims validateToken(HttpServletRequest request) {
		String jwtToken = request.getHeader(HEADER).replace(PREFIX, "");
		return Jwts.parser().setSigningKey(SECRET.getBytes()).parseClaimsJws(jwtToken).getBody();
	}

	/**
	 * Metodo para autenticarnos dentro del flujo de Spring
	 * 
	 * @param claims
	 */
	private void setUpSpringAuthentication(Claims claims) {
		@SuppressWarnings("unchecked")
		List authorities = (List) claims.get("authorities");

		UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(claims.getSubject(), null,
				authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
		SecurityContextHolder.getContext().setAuthentication(auth);

	}

	private boolean existeJWTToken(HttpServletRequest request, HttpServletResponse res) {
		String authenticationHeader = request.getHeader(HEADER);
		if (authenticationHeader == null || !authenticationHeader.startsWith(PREFIX))
			return false;
		return true;
	}

}

Este filtro intercepta todas las invocaciones al servidor (extiende de OncePerRequestFilter) y:

  1. Comprueba la existencia del token (existeJWTToken(...)).
  2. Si existe, lo desencripta y valida (validateToken(...)).
  3. Si está todo OK, añade la configuración necesaria al contexto de Spring para autorizar la petición (setUpSpringAuthentication(...)).

Para este último punto, se hace uso del objeto GrantedAuthority que se incluyó en el token durante el proceso de autenticación.

 

Probando nuestra API 

Una vez que hemos implementado la lógica de autenticación y autorización, vamos a volver a probar nuestro API.

Para ello podemos usar Postman, una sencilla extensión de Chrome que nos permite ejecutar y monitorizar peticiones.

1) Arrancamos nuestro servidor ejecutando el comando "mvn spring-boot:run".

2) Desde Postman, hacemos una petición GET a /hello y comprobamos que nos da un 403, pues el recurso está protegido:

postman1

 

3) Desde Postman, hacemos una petición POST a /user para autenticarnos, incluyendo usuario y contraseña, y obtenemos un token de acceso:

postman2

 

4) Volvemos a hacer la petición GET del paso 2) incluyendo una cabecera Authorization con el token generado en el punto 3)

postman3

 

Conclusiones

Hemos visto una manera sencilla de autenticar y autorizar las peticiones a un API REST construido con Java y Spring Boot.

En posteriores publicaciones veremos cómo controlar el ciclo de vida de nuestros tokens, y generar excepciones, e implementaremos la lógica de autenticación para validar nuestro usuario y contraseña contra una base de datos.

El código de la aplicación está publicado en mi Github.

¡¡Muchas gracias por leer este post, espero que haya sido de utilidad!! Para cualquier duda o consulta, podéis usar la sección de comentarios que aparece a continuación.