Spring Security \ Spring Boot: segurización API REST con BASIC y base de datos (SQL y Spring Data JPA). Testing.

Última actualización: 25/01/2022
logo spring

¡Seguridad ante todo! Hay muchos peligros ahí fuera y el mal nunca descansa 😈. En este tutorial veremos con un enfoque práctico cómo utilizar Spring Security para configurar el sistema de autenticación y autorización de una API REST. Iremos de la mano de Spring Boot, el mejor aliado para trabajar con Spring Framework.

Índice

  1. Introducción
  2. Proyecto de ejemplo
    1. Spring Boot
    2. API REST
    3. Base de datos con HyperSQL
    4. Esquema de la base de datos
  3. Autenticación con BASIC y JDBC (SQL)
    1. Método Basic
    2. Obtención de usuarios con JdbcUserDetailsManager
    3. Pruebas manuales
    4. Gestión de usuarios con UserDetailsManager
  4. Obtener los usuarios con Spring Data
  5. Autenticación con InMemoryUserDetailsManager
  6. Testing con MockMVC
  7. Autorización de urls con SecurityFilterChain
  8. Autorización de métodos con anotaciones
  9. Cómo acceder al usuario
  10. Configuración en XML
  11. Resumen final
  12. Código de ejemplo

Introducción

Antes de empezar, quiero aclarar dos conceptos que mencioné en la entradilla. Aparecen siempre que se habla de seguridad.

Por autenticación, se entiende el procedimiento que verifica si los usuarios de un sistema son quienes afirman ser en función de los datos de identificación que proporcionan. Por su parte, la autorización es el procedimiento encargado de asegurar que esos usuarios solo accedan a los recursos para los que tienen permiso. Estamos ante dos conceptos distintos, pero íntimamente relacionados. En conjunto permiten establecer la política de seguridad de nuestras aplicaciones.

Spring Security es el poderoso módulo del ecosistema Spring responsable de la seguridad (autenticación y autorización) de las aplicaciones. Es mucho lo que ofrece, incluyendo la afamada flexibilidad de la familia Spring para que nada nos limite -interfaces y clases abstractas por doquier-. Y, como cabría esperar, Spring Boot facilita su empleo con diversos automatismos.

Una buena muestra de la potencia de Spring Security es el soporte de primera que ofrece para los mecanismos y protocolos de autenticación más populares. Abarca los omnipresentes formularios de login, HTTP BASIC, HTTP Digest, LDAP, OpenID, OAuth2, Kerberos y CAS, entre otros.

En este artículo veremos cómo asegurar con autenticación y autorización una API REST desarrollada con Spring Boot. Elegiremos el estándar BASIC, basado en el tándem usuario\contraseña. Admito que no es sistema de seguridad muy sofisticado —la tendencia actual en las APIs REST es usar OAuth2 con tokens JWT—, pero al ser es sencillo resulta ideal para dar los primeros pasos con Spring Security. Incluso en algunos proyectos es posible que sea suficiente, en especial cuando provean de APIs privadas de uso interno.

¿Quiénes serán los usuarios? Contemplaremos el escenario más clásico: sus datos estarán almacenados en una base de datos relacional, incluyendo sus permisos o roles. También examinaremos una alternativa más simple (usuarios en memoria) ideal para armar pequeñas demos.

Nota. La segurización será incompleta hasta que configures tu servidor web para usar siempre el protocolo seguro HTTPS. Si estás creando un proyecto personal para publicarlo en internet, Lest’s Encrypt ofrece certificados gratuitos aceptados por todos los navegadores.

Proyecto de ejemplo

Spring Boot

Supondré que conoces los fundamentos del desarrollo de APIs REST con Spring Boot. Si recién empiezas en este mundo, te aconsejo el siguiente tutorial introductorio:

El proyecto Maven de ejemplo con el que vamos a trabajar consiste en una API REST construida con Spring Boot 2.7. Esta versión lleva Spring Security 5.7 y requiere Java 8.

Este es el pom, con cuatro starters: web, jdbc (base de datos relacional), testing y security:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.2</version>
        <relativePath />
    </parent>
    <groupId>com.danielme</groupId>
    <artifactId>spring-security-rest-basic</artifactId>
    <version>1.0.0</version>
    <name>spring-security-basic-rest</name>
    <description>Demo project for Spring REST API with Spring Security - BASIC + JDBC</description>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>     

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

La clase @SpringBootApplication con el método main que ejecuta el proyecto es la más simple posible:

package com.danielme.springsecuritybasic;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringBootApp {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootApp.class, args);
    }

}

Si quieres ver qué sucede tras las bambalinas, activa las trazas de Spring Security en el application.properties para que aparezcan en la bitácora o log:

logging.level.org.springframework.security=TRACE
API REST

El proyecto tiene dos clases controladoras. La primera de ellas ofrece las operaciones GET y DELETE para trabajar con el recurso Country. En su url recibimos el identificador del país.

package com.danielme.springsecuritybasic.controllers;

import com.danielme.springsecuritybasic.model.Country;
import com.danielme.springsecuritybasic.services.CountryService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(CountryRestController.COUNTRIES_RESOURCE)
public class CountryRestController {

    public static final String COUNTRY_RESOURCE = "/countries";
    public static final String COUNTRIES_ID_PATH = "/{id}";

    private final CountryService countryService;

    public CountryRestController(CountryService countryService) {
        this.countryService = countryService;
    }

    @GetMapping(value = COUNTRIES_ID_PATH)
    public ResponseEntity<Country> getById(@PathVariable("id") Long id) {
        return countryService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @DeleteMapping(value = COUNTRIES_ID_PATH)
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteById(@PathVariable("id") Long id) {
        countryService.deleteById(id);
    }
    
}

CountryRestController depende de una clase de servicio sin mucho interés. Para no complicar el ejemplo, devuelve datos simulados:

@Service
public class CountryService {

    private final List<Country> countries;

    public CountryService() {
        countries = new ArrayList<>();
        countries.add(new Country(1L, "Spain", 49067981));
        countries.add(new Country(2L, "Mexico", 130497248));
    }

    public Optional<Country> findById(Long id) {
        return countries.stream().filter(c -> c.getId().equals(id)).findFirst();
    }

    public void deleteById(Long id) {
        findById(id).ifPresent(countries::remove);
    }

}

El otro controlador expone un GET que responde con un status 204:

package com.danielme.springsecuritybasic.controllers;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(TestRestController.TEST_RESOURCE)
public class TestRestController {

    public static final String TEST_RESOURCE = "/test";

    @GetMapping
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void testEndpoint() {
        // nothing
    }
}

Estos son los endpoints.

Como raíz de todas las url he añadido /api/ en el fichero application.properties:

server.servlet.context-path=/api
Base de datos con HyperSQL

Usaremos HyperSQL, una de las bases de datos embebidas soportadas por Spring Boot. Este tipo de bases de datos se empaquetan en la aplicación y se ejecutan dentro de ella. Esto implica que cuando la aplicación arranque o se detenga, la base de datos hará lo mismo.

La vamos a utilizar en modo in-memory (datos volátiles en memoria) para que todos los datos, incluyendo esquema y usuarios, se pierdan tras su detención. Así, cada vez que la aplicación arranque, HyperSQL será un lienzo en blanco. Además, la base de datos irá a la velocidad de la luz.

Mi objetivo es que puedas ejecutar tanto la aplicación como las pruebas automáticas sin instalar una base de datos o usar Docker. Cuanto más fácil, mejor 😊.

Incluimos en el pom la dependencia hsqldb:

 <dependency>
     <groupId>org.hsqldb</groupId>
     <artifactId>hsqldb</artifactId>
     <scope>runtime</scope>
 </dependency>

No se requiere configurar los datos de conexión; Spring Boot se hace cargo.

Esquema de la base de datos

El fichero /src/main/resources/schema.sql contiene el script de creación de la base de datos:

CREATE TABLE users
(
    id       INTEGER      NOT NULL,
    enabled  BOOLEAN      NOT NULL,
    name     VARCHAR(20)  NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    rol      VARCHAR(10)  NOT NULL,
    PRIMARY KEY (id)
);

Se limita a crear una tabla llamada users. Para cada usuario, además de una clave primaria numérica, almacena su nombre, la contraseña, el rol (debe comenzar por el prefijo «ROLE_») y un lógico que indica si está activo. Todas las columnas deben tener valor. Por supuesto, el nombre de cada usuario será único.

Spring Security propone unas tablas que puedes ver en este documento y que no tienen nada que ver con el contenido de schema.sql. ¿Qué locura es esta? 🤪

Si tenemos las tablas sugeridas, nos beneficiaremos de la gestión de usuarios y roles que ya está implementada. Pero estamos adaptando nuestra base de datos a los requisitos de un marco de trabajo. A veces podremos amoldarnos sin dificultad; en otros casos será complicado o imposible.

La buena noticia es que podemos diseñar la estructura de tablas que queramos y seguir beneficiándonos de Spring Security. Veremos cómo crear sentencias SQL que le proporcionen los datos que precise. Y en última instancia, siempre será posible escribir el código que obtenga los usuarios apoyándonos, por ejemplo, en un repositorio de Spring Data JPA.

Este otro fichero, cuya ruta es /src/main/resources/data.sql, define dos usuarios activos de credenciales user\user y admin\admin:

INSERT INTO users (id, enabled, name, password, rol)
VALUES (1, true, 'admin', '$2a$10$tnC4pYqrAwCnDCFkFbjxV.PDE/b.fKI0aygmMQO0vKx5dki7WFT46', 'ROLE_ADMIN'),
       (2, true, 'user', '$2a$10$PR4ElawJcWhuLoBPnP4CDeG1c0NSyGPteTq9AYcbDl8vB8sMZ/C4K', 'ROLE_USER');

Un momento, ¿por qué la contraseña es una ristra larga e incomprensible de caracteres? Estamos ante una medida de seguridad fundamental: las contraseñas JAMÁS deben guardarse tal cual. Las estoy codificando con el algoritmo de hashing bcrypt, ya incluido en Spring Security.

Por cierto, fíjate en un detalle curioso: las cadenas cifradas con bcrypt empiezan por $2a$10$.

Ya está todo lo que respecta a la base de datos. Spring Boot ejecutará el contenido de los ficheros schema.sql y data.sql cada vez que se inicie la aplicación.

Autenticación con BASIC y JDBC (SQL)

Método Basic

Procedamos a configurar la autenticación. Para aplicar el mecanismo BASIC hay que hacer… ¡nada! Spring Boot añade autenticación BASIC a todos los endpoint con solo incluir spring-boot-starter-security en el proyecto.

Obtención de usuarios con JdbcUserDetailsManager

Lo siguiente es configurar la obtención de los datos de los usuarios. Necesitamos una implementación de la interfaz UserDetailsService. Su único método recibe el nombre de un usuario y devuelve una instancia de UserDetails con los datos de ese usuario requeridos por Spring Security para efectuar los procesos de autenticación y autorización. Si el usuario no existe, en lugar de devolverse null se lanza la excepción UsernameNotFoundException.

Aquí tienes la declaración de la interfaz:

package org.springframework.security.core.userdetails;

public interface UserDetailsService {

	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

Por fortuna, para crear los objetos UserDetails a partir de la información existente en nuestra tabla users no necesitamos implementar la interfaz, algo que sí haremos cuando hablemos de Spring Data. Spring Security nos obsequia con JdbcUserDetailsManager, un UserDetailsService que recupera los detalles del usuario de una base de datos mediante consultas SQL.

Pongámonos manos a la obra. En primer lugar, creamos una clase de tipo @Configuration llamada, por ejemplo, SpringSecurityConfig. Aunque nos serviría la clase SpringBootApp porque @SpringBootAplication incluye @Configuration, recomiendo organizar en otras clases las configuraciones según su propósito. Esta estrategia nos dará flexibilidad de cara al desarrollo de los tests y al diseño de configuraciones complejas que requieran el empleo de perfiles para activar o desactivar funcionalidades.

En un método factoría anotado con @Bean devolvemos nuestro UserDetailsService con la forma de JdcbUserDetailsManager. En otro establecemos bcrypt como el codificador de contraseñas (PasswordEncoder) predeterminado. Será aplicado de forma automática cuando sea necesario.

Esta es la clase SpringSecurityConfig al completo:

@Configuration
class SpringSecurityConfig {

    @Bean
    UserDetailsService jdbcUserDetailsManager(DataSource dataSource,
                                              @Value("${security-jdbc.user}") String usersByUsernameQuery,
                                              @Value("${security.jdbc-authorities}") String authoritiesByUsernameQuery) {
        JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager();
        jdbcUserDetailsManager.setDataSource(dataSource);
        jdbcUserDetailsManager.setUsersByUsernameQuery(usersByUsernameQuery);
        jdbcUserDetailsManager.setAuthoritiesByUsernameQuery(authoritiesByUsernameQuery);
        return jdbcUserDetailsManager;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

El método jdbcUserDetailsManager recibe la fuente de datos (interfaz DataSource) y dos consultas SQL definidas en el fichero application.properties. Estas consultas se requieren porque, tal y como expliqué, no tenemos en la base de datos la estructura de tablas que Spring Security espera.

Las consultas reciben el nombre del usuario que solicita la autenticación y tienen el siguiente propósito:

  • Consulta de autenticación. Devuelve el nombre del usuario, su contraseña y un número que indique si está activo (1) o deshabilitado.
    security-jdbc.user=select name, password, enabled from users where name=?
  • Consulta de autorizaciones. Devuelve el nombre del usuario y sus roles asociados.
    security.jdbc-authorities =select name, rol from users where name=? 

Si no especificamos alguna de ellas, se tomará la consulta predeterminada para cada caso. La base de datos deberá tener, pues, unas tablas y columnas válidas para esa consulta.

Estas son las consultas predeterminadas:

  • security-jdbc.user
select username,password,enabled from users where username = ?
  • security-jdbc.authorities
select username,authority from authorities where username = ?

¡Voilà! Ya está todo.

¿Qué ocurre bajo el capó? Spring Boot crea un bean de la clase DaoAuthenticationProvider para que realice la autenticación. Este bean obtiene con JdbcUserDetailsManager los datos del usuario que intenta acceder al sistema. Ambas clases son implementaciones, de manera respectiva, de las interfaces AuthenticationProvider y UserDetailsService: los componentes principales de la autenticación en Spring Security.

Pruebas manuales

La aplicación puede ejecutarse con el comando mvnw spring-boot:run utilizando el wrapper de Maven incluido en la raíz del proyecto, o bien con un entorno de desarrollo (IDE) lanzando la clase SpringBootApp. Una vez arrancada, probémosla con curl llamando al servicio GET de countries.

Si obviamos las credenciales, recibiremos el código de estado HTTP 401:

curl http://localhost:8080/api/countries/1/  -v
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /api/countries/1/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 401 

Ídem si las credenciales son incorrectas:

curl http://localhost:8080/api/countries/1/ -u "user:password" --basic -v
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
* Server auth using Basic with user 'user'
> GET /api/countries/1/ HTTP/1.1
> Host: localhost:8080
> Authorization: Basic dXNlcjpwYXNzd29yZA==
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 401 

Por último, con las credenciales correctas todo funciona a la perfección. Veremos la cadena con los datos en formato JSON:

curl http://localhost:8080/api/countries/1/ -u "user:user" --basic -v
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
* Server auth using Basic with user 'user'
> GET /api/countries/1/ HTTP/1.1
> Host: localhost:8080
> Authorization: Basic dXNlcjp1c2Vy
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 
< Content-Type: application/json
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
{"id":1,"name":"Spain","population":49067981}
Gestión de usuarios con UserDetailsManager

Si te fijas en el diagrama de clases que puse, no he hablado de un elemento: la interfaz UserDetailsManager. Como puedes ver, extiende UserDetailsService para añadir métodos que permiten gestionar los usuarios, de tal modo que dispongamos del juego de operaciones CRUD (*). Y JdbcUserDetailsManager implementa esos métodos.

(*) Acrónimo que se traduce como crear, obtener, actualizar, eliminar.

Lo habitual es que en lugar de emplearse esta interfaz se escriba un servicio para estos menesteres basado en el modelo de datos —o de entidades si tenemos JPA— de la aplicación, y en las clases que encapsulen las operaciones de persistencia, como DAOs o repositorios de Spring Data JPA.

Si decides utilizar UserDetailsManager, solo tienes que inyectarla:

@Autowired
private UserDetailsManager userDetailsManager;

¡Atención! Tendrás que definir las sentencias SQL apropiadas cuando crees JdbcUserDetailsManager, salvo que tus tablas sean compatibles con las que espera Spring Security. Lo más fácil para averiguar cuáles son esas consultas es revisar el código fuente de JdbcUserDetailsManager.

Obtener los usuarios con Spring Data

Si explotas en tu proyecto una base de datos relacional, lo más probable —y recomendable— es que confíes en Spring Data JPA. Si es tu caso, te habrá resultado raro, incluso molesto, tener que escribir consultas SQL cuando no respetes la estructura de tablas propuesta por Spring Security.

Así pues, la cuestión es obvia: ¿cómo puedo integrar Spring Data JPA con la autenticación?

Aquí entra juego la flexibilidad de Spring Security: podemos implementar un UserDetailsService que obtenga los usuarios de donde queramos. En nuestro caso, de un repositorio de Spring Data JPA. Pero podría ser cualquier repositorio con independencia de su tecnología (JPA, MongoDB, Cassandra…). O una clase DAO. ¿Captas la idea? Lo que nos convenga en cada proyecto y no esté ya cubierto por el framework, o que si lo esté y no se adapte a lo que queremos.

Dado que hablar es gratis, haré el ejercicio para el proyecto de ejemplo. Antes que nada, añadimos spring-boot-starter-data-jpa al pom:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
 </dependency>

Supón que tenemos esta entidad de JPA asociada a la tabla users:

@Entity
@Table(name = "users")
public class User {

    @Id
    private Integer id;

    private boolean enabled;
    private String name;
    private String password;
    private String rol;

Su repositorio cuenta con una consulta derivada que obtiene un usuario según su nombre:

public interface UserRepository extends Repository<User, Long> {

    Optional<User> findByName(String name);

}

Con estos mimbres, ya podemos construir un UserDetailsService personalizado. Voy a llamarlo SpringDataUserDetailsService. También podrías crear un UserDetailsManager con todas las operaciones CRUD agregando al repositorio los métodos pertinentes.

@Service
public class SpringDataUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public SpringDataUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByName(username)
                .map(this::map)
                .orElseThrow(() -> new UsernameNotFoundException("user " + username + " not found with Spring Data"));
    }

    private UserDetails map(User user) {
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(user.getRol());
        return new org.springframework.security.core.userdetails.User(user.getName(), user.getPassword(), user.isEnabled(), user.isEnabled(), user.isEnabled(), user.isEnabled(), authorities);
    }
    
}

Un toque de programación funcional y ya lo tenemos. Pedimos el usuario al repositorio para construir un objeto UserDetails. No olvides que cuando el usuario no exista, hay que lanzar UsernameNotFoundException.

Como implementación de UserDetails he recurrido a la clase org.springframework.security.core.userdetails.User. Tiene cuatro flags; en nuestro escenario todos deben coincidir con el valor de enabled de la entidad.

La anotación @Service convierte SpringDataUserDetailsService en un bean de Spring. Spring Boot lo tomará de manera automática como el UserDetailsService proveedor de usuarios, luego no hay que configurar nada.

Nota. En el proyecto de ejemplo @Service está comentada para que Spring Boot use jdbcUserDetailsManager por ser el caso de estudio principal del tutorial.

Autenticación con InMemoryUserDetailsManager

Vimos que Spring Boot aplicó autorización BASIC a todos los endpoints. Lo único que hicimos fue crear un UserDetailsService adecuado para nuestra base de datos. ¿Y si no hacemos nada, aparte de añadir el starter security al pom? ¿Tendremos autenticación o no?

La respuesta es afirmativa. Cuando no declaramos un UserDetailsService Spring Boot instancia uno con la implementación InMemoryUserDetailsManager.

Contiene un Map con los usuarios indexados por su nombre. Lo vemos claro en el siguiente fragmento de código:

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

	private final Map<String, MutableUserDetails> users = new HashMap<>();
	
    @Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		UserDetails user = this.users.get(username.toLowerCase());
		if (user == null) {
			throw new UsernameNotFoundException(username);
		}
		return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
				user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
	}

Según explica la documentación, InMemoryUserDetailsManager está pensada para pruebas y demostraciones que no requieran un sistema de persistencia que provea los usuarios (base de datos, LDAP…). Se ponen en un Map y a correr. Cuando la aplicación se detenga, bye bye usuarios.

Para que podamos autenticarnos, Spring Boot crea un usuario llamado user con una contraseña aleatoria que imprime en el log durante el arranque del contexto. Dado que esto sirve de poco, configuraremos el usuario en el application.properties:

spring.security.user.name=user
spring.security.user.password=$2a$10$PR4ElawJcWhuLoBPnP4CDeG1c0NSyGPteTq9AYcbDl8vB8sMZ/C4K
spring.security.user.roles=USER

La contraseña está codificada con bcrypt porque establecimos BCryptPasswordEncoder como PasswordEncoder. Si no lo hubiéramos hecho, tendríamos que ponerla en texto plano o bien indicar con un prefijo el formato:

spring.security.user.password={bcrypt}$2a$10$PR4ElawJcWhuLoBPnP4CDeG1c0NSyGPteTq9AYcbDl8vB8sMZ/C4K

Si queremos varios usuarios predefinidos, nos tocará construir un InMemoryUserDetailsManager a medida. Es el cometido del siguiente método que he copiado sin pudor alguno de la documentación oficial:

@Bean
public UserDetailsService users() {
	// The builder will ensure the passwords are encoded before saving in memory
	UserBuilder users = User.withDefaultPasswordEncoder();
	UserDetails user = users
		.username("user")
		.password("password")
		.roles("USER")
		.build();
	UserDetails admin = users
		.username("admin")
		.password("password")
		.roles("USER", "ADMIN")
		.build();
	return new InMemoryUserDetailsManager(user, admin);
}

InMemoryUserDetailsManager implementa UserDetailsManager, por lo que podemos gestionar los usuarios del Map con los métodos que proporciona.

Testing con MockMVC

Los tests automáticos resultan fundamentales para que nuestras aplicaciones sean robustas y fáciles de mantener y evolucionar. Podemos ejecutarlos cuando cambiemos el código para chequear que todo continúa funcionando de la forma esperada.

Implementemos en tests automáticos las comprobaciones que hicimos a mano. Explico los conocimientos necesarios en este tutorial, salvo el tratamiento de la autenticación. Llegó el momento de saldar esa deuda.

Lo más práctico es agregar al proyecto esta dependencia:

 <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
 </dependency>

Gracias a este módulo, en las pruebas de la API REST con MockMVC (@AutoConfigureMockMvc) tendremos disponibles los métodos de la clase SecurityMockMvcRequestPostProcessor. Sirven para añadir a las llamadas a los servicios los datos relativos a la autenticación. En nuestro ejemplo el método que necesitamos es httpBasic:

package com.danielme.springsecuritybasic.controllers;

import com.danielme.springsecuritybasic.services.CountryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.is;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class CountryRestControllerTest {

    private static final long MEXICO_ID = 2;
    private static final String USER_STANDARD = "user";
    private static final String USER_ADMIN = "admin";
    public static final String URL = CountryRestController.COUNTRIES_RESOURCE +   CountryRestController.COUNTRIES_ID_PATH;

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    CountryService countryService;

    @Test
    void testGetCountryNoAuthenticacion() throws Exception {
        mockMvc.perform(get(URL, MEXICO_ID))
                .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
    }

    @Test
    void testGetCountryWrongUser() throws Exception {
        mockMvc.perform(get(URL, MEXICO_ID)
                .with(httpBasic(USER_STANDARD, "password")))
                .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
    }

    @Test
    void testGetCountrySuccess() throws Exception {
        mockMvc.perform(get(URL, MEXICO_ID)
                .with(httpBasic(USER_STANDARD, USER_STANDARD)))
                .andExpect(status().is(HttpStatus.OK.value()))
                .andExpect(jsonPath("$.name", is(countryService.findById(MEXICO_ID).get().getName())));
    }

}

Las pruebas anteriores son realistas porque añaden a la petición las credenciales, emulando lo que haría cualquier cliente de la API. Otra posibilidad es recurrir a la anotación @WithMockUser y simular la presencia de un usuario autenticado. Es válida tanto a nivel de clase como de método.

Esta nueva versión de testGetCountrySuccess usa @WithMockUser en lugar del método httpBasic:

@Test
@WithMockUser
void testGetCountrySuccessMockUser() throws Exception {
    mockMvc.perform(get(URL, MEXICO_ID))
            .andExpect(status().is(HttpStatus.OK.value()))
            .andExpect(jsonPath("$.name", is(countryService.findById(MEXICO_ID).get().getName())));
}

@WithMockUser cuenta con los atributos username, roles y password. Les daremos valores cuando se requieran valores concretos:

@WithMockUser(username = "admin", roles = {"ADMIN"})

Autorización de urls con SecurityFilterChain

En el estado actual del proyecto todas las llamadas requieren autenticación. A partir de ahí, vía libre para interactuar con cualquier recurso sin limitación alguna.

Refinemos nuestras políticas de seguridad configurando la autorización. Esto es, definir qué pueden hacer los clientes de acuerdo a sus roles \ permisos \ autorizaciones, estén o no autenticados. Impongamos las siguientes reglas:

  • Todos los usuarios autenticados pueden solicitar el GET de /api/countries/{id}/ sin importar su rol.
  • El borrado de un país está reservado a los usuarios con el permiso ADMIN.
  • Permitiremos el acceso libre a /api/test. Ni siquiera se requerirá autenticación.

Lo primero es anotar SpringSecurityConfig con @EnableWebSecurity. Incluye @Configuration, así que la eliminamos por redundante:

@EnableWebSecurity
class SpringSecurityConfig {

La anotación nos permite crear un bean de tipo SecurityFilterChain con los requisitos de seguridad que deben verificar todas las peticiones. Lo construimos de manera fluida encadenando llamadas a los numerosos métodos de HttpSecurity:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and().csrf().disable()
           .authorizeRequests()
           .antMatchers(HttpMethod.GET, CountryRestController.COUNTRIES_RESOURCE+   "/*").authenticated()
           .antMatchers(HttpMethod.DELETE, CountryRestController.COUNTRIES_RESOURCE + "/*").hasRole(UserRol.ADMIN.name())
           .antMatchers("/**").permitAll()
          .and().httpBasic();
  return http.build();
}

El método filterChain requiere de un análisis pormenorizado. En él deshabilitamos la creación de sesiones porque las APIs REST son stateless (línea 4). Esta circunstancia nos lleva a desactivar, por innecesaria, la seguridad frente a ataques csrf (línea 5). También forzamos la autenticación BASIC (línea 11).

Sin embargo, lo más interesante no son las configuraciones anteriores, genéricas y trasladables a cualquier API REST, sino las que controlan el acceso a las url definidas con los métodos antMatcher. Los tienes en la siguiente tabla:

antMatchers(HttpMethod method, String… antPatterns)Recibe el método HTTP y las rutas a filtrar.
antMatchers(String… antPatterns)Recibe las rutas a filtrar. Ignora el método HTTP (se aplica a todos).
antMatchers(HttpMethod method)Todas las rutas del método HTTP indicado.

La ruta es una cadena que debe respetar el formato de patrón de textos de la veterana herramienta Apache Ant. Será interpretada por la clase AntPathMatcher.

Podemos definir una url completa tal cual o un patrón basado en los comodines que explica la documentación javadoc de la clase anterior. En mi experiencia, si la API está bien diseñada, la mayoría de casos se resuelven empleando asteriscos.

Un único asterisco es reemplazable por un nivel de la ruta, esto es, una cadena que no contenga el separador «/».

En las líneas 7 y 8 de filterChain el patrón ant es /countries/*. Acepta cualquier url que empiece por /countries/ opcionalmente seguida por una cadena con un único nivel de ruta. Lo que quiero de este patrón es que abarque urls del tipo /countries/7. Descartará urls tales como /countries/23/loquesea porque /loquesea es un nuevo nivel en la ruta.

Dos asteriscos son reemplazables por cualquier cadena, admitiéndose múltiples niveles. Por eso, el patrón de la línea 8 (/**) admite cualquier url.

¡Manos arriba! Antes de ponerte a encadenar métodos antMatcher, debes saber que cuando se solicita una url Spring la va comprobando siguiendo el orden en el que estos antMatcher aparecen en el código. Debemos ser minuciosos en extremo y definir los patrones empezando por los más específicos hasta llegar a los más generales (en el ejemplo se trata de /**). También prestaremos atención a los posibles solapamientos entre ellos. No queremos dejar fisuras en nuestro flamante sistema de seguridad por las que se puedan colar alimañas; asegúrate de cerrar bien puertas y ventanas🔒.

Si no me crees o necesitas averiguar cómo Spring Security procesa una petición HTTP, activa las trazas que indiqué antes y revisa los mensajes de la bitácora. Verás un fragmento como este:

 edFilterInvocationSecurityMetadataSource : Did not match request to Ant [pattern='/countries/*', GET] - [authenticated] (1/3)
edFilterInvocationSecurityMetadataSource : Did not match request to Ant [pattern='/countries/*', DELETE] - [hasRole('ROLE_ADMIN')] (2/3)
 o.s.s.w.a.i.FilterSecurityInterceptor    : Did not re-authenticate AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] before authorizing
o.s.s.w.a.i.FilterSecurityInterceptor    : Authorizing filter invocation [GET /test] with attributes [permitAll]
o.s.s.w.a.i.FilterSecurityInterceptor    : Authorized filter invocation [GET /test] with attributes [permitAll]

Cuando la url no verifica ningún patrón, no se aplica a la petición configuración de seguridad alguna. Esto implica que si se proporcionan credenciales, serán ignoradas.

Los antMatchers llamarán a métodos que establecen la autorización de acceso para las urls compatibles con sus patrones. Esta tabla recopila los métodos más habituales:

anonymous()Usuarios no autenticados.
authenticated()Cualquier usuario autenticado.
denyAll()Nadie puede acceder.
hasAnyRole(String… roles)Los usuarios con uno de los roles indicados.
hasRole(String role)Los usuarios con el rol indicado.
permitAll()Acceso libre para cualquiera, esté o no autenticado.

Los nombres de los roles en estos métodos no pueden tener el prefijo «ROLE_» que sí poseen en la base de datos.

Las restricciones declaradas en el método filterChain son coherentes con las pruebas que ya tenemos en CountryRestControllerTest. Escribamos un par más que comprueben que la operación DELETE solo puede realizarla un usuario con el rol de administrador:

@Test
void testDeleteCountryWrongAuthorization() throws Exception {
    mockMvc.perform(delete(URL, MEXICO_ID)
            .with(httpBasic(USER_STANDARD, USER_STANDARD)))
            .andExpect(status().is(HttpStatus.FORBIDDEN.value()));
}

@Test
void testDeleteCountrySuccessful() throws Exception {
    mockMvc.perform(delete(URL, -1)
            .with(httpBasic(USER_ADMIN, USER_ADMIN)))
            .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
}

En testDeleteCountryWrongAuthorization se testea que la llamada al método DELETE con user se rechaza, devolviéndose el código de estado 403. Informa que la autenticación es correcta, pero el acceso al recurso solicitado fue prohibido porque falló la autorización.

En cambio, testDeleteCountrySuccessful sí solicita el borrado como debe hacerse: con el usuario de rol administrador.

Queda por testear el endpoint /test; vamos a ello:

@SpringBootTest
@AutoConfigureMockMvc
class TestRestControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void testNotAuthenticated() throws Exception {
        mockMvc.perform(get(TestRestController.TEST_RESOURCE))
                .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
    }

    @Test
    void testNotAuthenticatedButWrongCredentials() throws Exception {
        mockMvc.perform(get(TestRestController.TEST_RESOURCE)
                .with(httpBasic("anything", "anything")))
                .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
    }

}

La prueba testNotAuthenticated verifica que las llamadas a /api/test funcionan sin proporcionar usuario alguno. Pero mucho cuidado: si se proveen las credenciales, deberán ser correctas. Es lo que comprueba testNotAuthenticatedButWrongCredentials.

Una ventaja de las pruebas automáticas que exploto con frecuencia es la facilidad que ofrecen para hacer experimentos y comprobar las consecuencias. Veamos qué ocurre si cambiamos el orden de la declaración de los antMatcher. Situemos en primer lugar el que contiene un patrón válido para cualquier dirección y que permite el acceso anónimo:

 http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
           .and().csrf().disable()
           .authorizeRequests()
           .antMatchers("/**").permitAll()
           .antMatchers(HttpMethod.GET, CountryRestController.COUNTRIES_RESOURCE+ "/*").authenticated()
           .antMatchers(HttpMethod.DELETE, CountryRestController.COUNTRIES_RESOURCE + "/*").hasRole(UserRol.ADMIN.name())
           .and().httpBasic();

¡Barra libre! 🥳 Todas las peticiones son aceptadas por el primer antMatcher, así que los demás resultan inútiles. En consecuencia, fallan testGetCountryNoAuthenticacion (espera que se devuelva un 401 porque no se enviaron las credenciales) y testDeleteCountryWrongAuthorization (valida que el borrado está prohibido para el rol user).

El experimento anterior pone de manifiesto la necesidad de ser rigurosos con el orden de los antMatchers, además de la utilidad del testing automático.

Autorización de métodos con anotaciones

Las restricciones de autorización declaradas con HttpSecurity también pueden definirse con la anotación @PreAuthorize, aplicable a métodos, clases e interfaces. Dentro de ella escribiremos las restricciones en una cadena con el lenguaje Spring EL usando los métodos que controlan el acceso (hasRole, isAuthenticated…). Si se incumplen los requisitos de seguridad, el método no se ejecutará y se lanzará la excepción AccessDeniedException.

Así exigimos que la llamada GET /test sea realizada por un usuario autenticado:

    @PreAuthorize("isAuthenticated()")
    @GetMapping
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void testEndpoint() {
        // nothing
    }

Para que @PreAuthorize tenga efecto la activaremos con la opción prePostEnabled de la anotación @EnableGlobalMethodSecurity.

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SpringSecurityConfig {

Con estos cambios, la prueba testAnonymous fallará porque el solicitante de la petición no está autenticado (no cumple el requisito isAuthenticated). Por este motivo, en el proyecto que encontrarás en GitHub la anotación @PreAuthorize aparece comentada.

Las reglas de acceso definidas de esta manera son reutilizables si creamos meta-anotaciones de este estilo:

@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()")
public @interface IsAuthenticated() {}

@IsAuthenticated equivale a @PreAuthorize(«isAuthenticated()»):

@IsAuthenticated
@GetMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public void testEndpoint() {
    // nothing
}

En la documentación encontrarás más opciones relativas a la segurización de métodos que escapan del alcance de este modesto tutorial. Me conformo con que sepas que las anotaciones @PreFilter, @PostAuthorize y @PostFilter, así como el lenguaje SpEL, te permitirán escribir reglas bastante complejas.

Cómo acceder al usuario

Los datos y roles del usuario que realiza una petición al sistema se guardan en una instancia de la interfaz Authentication que, a su vez, hereda de la interfaz Principal de Java.

Accedemos a ella de forma estática desde cualquier lugar de la aplicación con la clase SecurityContextHolder:

logger.info(SecurityContextHolder.getContext().getAuthentication().getName());

En los controladores podemos recibirla como un argumento:

@DeleteMapping(value = COUNTRIES_ID_PATH)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteById(@PathVariable("id") Long id, Authentication authentication) {
        logger.info(authentication.getName());

En el lenguaje SPeL se corresponde con la variable authentication.

¿Qué pasará si no hay un usuario autenticado? ¡Null Pointer! Calma; Spring Security devolverá la «autenticación» de un usuario anónimo.

La captura da una buena pista acerca de cómo averiguar si una autorización corresponde a un usuario anónimo; sería así:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof AnonymousAuthenticationToken) {

Configuración en XML

Spring Security puede configurarse con ficheros XML estilo old-school. Aunque es una práctica en claro desuso, dejo a continuación un fichero (/src/main/resources/security.xml) con la configuración equivalente a la creada en SpringSecurityConfig. Quizás consiga evocar la nostalgia de los lectores más veteranos.

<beans:beans
    xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security.xsd">

    <http use-expressions="true" create-session="stateless">
        <csrf disabled="true" />
        <http-basic />
        <intercept-url pattern="/countries/*" method="GET"
            access="isAuthenticated()" />
        <intercept-url pattern="/countries/*"
            method="DELETE" access="hasRole('ADMIN')" />
        <intercept-url pattern="/**" access="permitAll()" />
    </http>

    <authentication-manager
        alias="authenticationManager">
        <authentication-provider>
            <password-encoder hash="bcrypt" />
            <jdbc-user-service
                data-source-ref="dataSource"
                users-by-username-query="select name, password, enabled from users where name=?"
                authorities-by-username-query="select name, rol from users where name=?" />
        </authentication-provider>
    </authentication-manager>

</beans:beans>

Declaramos el fichero en una clase de configuración con @ImportResource:

@ImportResource("classpath:security.xml")
public class SpringBootApp {

Resumen final

Recopilo las claves del artículo:

  • Se requiere el starter spring-boot-starter-security. Con añadirlo al pom, Spring Boot ya protege con BASIC todos los endpoints.
  • La información de los usuarios en la base de datos y sus roles podemos recogerla como queramos, o bien respetar el esquema predeterminado.
  • Necesitamos una implementación de la interfaz UserDetailsService que, partiendo del nombre de un usuario, obtenga sus datos de donde sea preciso. Podemos usar la implementación JdbcUserDetailsManager, pero si las tablas con los usuarios no son las predeterminadas, debemos definir un par de consultas SQL. En cualquier caso, crear nuestra propia implementación basada en un DAO o repositorio de Spring Data resulta sencillo.
  • Si no configuramos un UserDetailsService, el predeterminado es InMemoryUserDetailsManager. Guarda los usuarios en un Map en memoria.
  • La dependencia spring-security-test nos da los útiles para probar endpoints con mockMvc que requieran autenticación.
  • La autorización se define un bean de tipo SecurityFilterChain. Es fundamental ser riguroso con el orden de las llamadas a los métodos antMatcher. En estos métodos se declaran restricciones de acceso aplicables a patrones de url.
  • También se puede utilizar anotaciones en métodos, clases e interfaces para definir restricciones de acceso.
  • La manera más habitual de obtener el usuario autenticado es con la clase SecurityContextHolder.

Código de ejemplo

El proyecto está en GitHub. Para más información sobre GitHub, consultar este artículo.

Otros tutoriales relacionados con Spring

Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA

Spring Boot: Gestión de errores en aplicaciones web y REST

Testing en Spring Boot con JUnit 4\5. Mockito, MockMvc, REST Assured, bases de datos embebidas

Spring JDBC Template: simplificando el uso de SQL

Persistencia en BD con Spring Data JPA

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.