Spring REST: Securización BASIC y JDBC con Spring Security

Última actualización: 03/11/2020

logo spring

En este tutorial veremos cómo utilizar Spring Security para definir la autenticación y autorización de una Api REST. Como sistema de autenticación usaremos una base de datos relacional accesible mediante JDBC, y las credenciales deberán proporcionarse mediante el estándar BASIC. De este modo las credenciales viajan codificadas en Base 64 en el header de la petición y, aunque no están cifradas, en la práctica sólo deberíamos permitir llamadas a la Api mediante HTTPS.

Este mecanismo es sencillo de implementar aunque presenta el problema de que el cliente tiene que proporcionar la contraseña en cada petición por lo que esta deberá ser almacenada de algún modo. Sistemas más seguros y potentes, pero también un poco más difíciles de implementar, se basan en el uso de tokens con JWT) o del protocolo OAuth, aunque en ciertos escenarios la autenticación BASIC puede ser suficiente.

Nota: Para configurar BASIC y HTTPS en Tomcat 8 consultar el tutorial Servicios Web SOAP con JAX-WS, Spring y CXF (III): Securización TLS + BASIC.

Proyecto de ejemplo

El proyecto de ejemplo con el que vamos a trabajar en este tutorial es una Api REST implementada con Spring Boot que cuenta con dos controladores. El primero de ellos ofrece un GET para obtener los datos de un país y un DELETE, en ambos casos se debe proporcionar el identificador del país en la propia url.

package com.danielme.springsecuritybasic.controllers;

import java.util.Optional;

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

import com.danielme.springsecuritybasic.model.Country;
import com.danielme.springsecuritybasic.services.CountryService;

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

    public static final String COUNTRY_RESOURCE = "/country";

    private final CountryService countryService;

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

    @GetMapping(value = "/{id}/")
    public ResponseEntity<Country> getById(@PathVariable("id") Long id) {
        return countryService.findById(id)
                .map(value -> new ResponseEntity<>(value, HttpStatus.OK))
                .orElseGet(() -> new ResponseEntity<>(null, HttpStatus.NOT_FOUND));
    }

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

La lógica se delega en CountryService y por simplicidad está simulada.

package com.danielme.springsecuritybasic.services;

import java.util.LinkedList;
import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Service;

import com.danielme.springsecuritybasic.model.Country;

@Service
public class CountryService {

    private static List<Country> countries;

    public CountryService() {
        countries = new LinkedList<>();
        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(c -> countries.remove(c));
    }

}

El segundo controlador simplemente expone un GET que devuelve 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() {
        // noting
    }
}

La configuración de Spring Boot.

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

}

Como path raíz para todas las url de la aplicación he añadido /api/ en el application.properties

server.servlet.context-path=/api

Este es el pom, de momento con un único starter.

<?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.3.5.RELEASE</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</description>
    <url>https://danielme.com/2019/03/19/spring-rest-basic-spring-security/</url>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>

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

    </dependencies>

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

</project>

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 en el caso de Eclipse utilizando el plugin Spring Tools Suite.

Cursos de programación

Configuración de Spring Security

Empecemos incluyendo Spring Security en nuestra aplicación añadiendo el starter correspondiente al pom

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

Nota: Si no utilizamos Spring Boot, al incluir Spring Security en nuestro proyecto hay que tener en cuenta que su número de versionado no coincide con el del core de Spring.

Ahora procedamos a realizar la configuración de autenticación y autorización de nuestros servicios REST. De forma programática, crearemos una clase de tipo @Configuration que herede de WebSecurityConfigurerAdapter y esté anotada con @EnableWebSecurity (me gusta centralizar toda la configuración de seguridad en una misma clase).

package com.danielme.springsecuritybasic;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

}

En primer lugar vamos a sobrescribir el método configure para configurar el mecanismo de autenticación de los usuarios basado en el par nombre de usuario/contraseña, cada usuario además tendrá un único ROL. Para ello usamos el builder que recibimos como parámetro.

Definamos primero una autenticación de prueba con los usuarios en el propio código, más adelante recurriremos a una base de datos relacional.

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        auth.inMemoryAuthentication()
                .withUser(UserRol.ADMIN.name()).password(encoder.encode("admin"))
                   .roles("ADMIN").and()
                .withUser("user").password(encoder.encode("user"))
                   .roles(UserRol.USER.name());
    }

A partir de Spring 5, es obligatorio utilizar un encoder para cifrar la contraseña, algo que debería hacerse SIEMPRE, independientemente del framework utilizado.

Ahora vamos a configurar la autorización, esto es, quién puede acceder y a qué recurso. Queremos imponer las siguientes condiciones:

  • Cualquier cliente puede acceder a /api/test, esté o no esté logado.
  • Todos los usuarios puede realizar el GET de /api/country/{id}/ sin importar su rol.
  • El borrado de un país está limitado a los usuarios con el rol ADMIN.

Sobrescribimos otro método configure. Usaremos el objeto HttpSecurity y su una API fluida para configurar toda la seguridad encadenando todas las configuraciones.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and().csrf().disable()
        .authorizeRequests().antMatchers(HttpMethod.GET, "/country/*").authenticated()
                            .antMatchers(HttpMethod.DELETE, "/country/*").hasRole(UserRol.ADMIN.name())
                            .antMatchers("/**").permitAll()
        .and().httpBasic();
    }

En este método estamos haciendo lo siguiente:

  • Deshabilitamos la creación de una sesión para el usuario al logarse ya que en nuestra API REST, tal y como cabría esperar, va a ser stateless. Si estamos configurando una aplicación web no debemos hacer esto.
  • Por el mismo motivo tampoco necesitamos seguridad frente a ataques csrf.
  • Definimos las restricciones de acceso a nivel de url y, opcionalmente, el método HTTP, con los métodos antMatcher. Debemos ser cuidadosos con el orden ya que al solicitarse una url ésta se va comprobando secuencialmente con los antMatchers siguiendo el orden en el que se han definido hasta encontrar el primero que admita la petición. Para cada patrón de url indicamos la autorización siendo las opciones más habituales las siguientes:
    • authenticated(): cualquier usuario correctamente autenticado.
    • hasRole(), hasAnyRole(String):sólo los usuarios autenticados con un rol (hasRole) o cualquiera de los roles indicados (hasAnyRole).
    • permitAll(): acceso libre.
    • denyAll(): acceso siempre prohibido.
  • El método de autenticación será BASIC.

Ahora probemos, por ejemplo con POSTMAN, las llamadas a la API. Si llamamos a http://localhost:8080/api/country/1/ sin autenticación recibimos un 401.

Tendremos que autenticarnos correctamente para recibir el JSON con los datos.

Pero si hacemos la llamada el método DELETE utilizando el usuario user obtendremos un 403: la autenticación es correcta pero falla la autorización porque el usuario no tiene el rol ADMIN.

Las llamadas a http://localhost:8080/api/test se podrán realizar sin utilizar usuario alguno.

Todo el proceso de autenticación se puede revisar activando las trazas del log (propiedad logging.level.root en el application.properties).

Securización con anotaciones

La securización de los endpoints de nuestra api puede realizarse directamente en los controladores a nivel de método utilizando la anotación @PreAuthorize.

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

Para que la anotación se aplique es necesario activarla.

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

Acceder al usuario

Podemos acceder al usuario autenticado en Spring Security del siguiente modo.

  • De forma estática en cualquier lugar de la aplicación a través de la clase SecurityContextHolder
       printUser(SecurityContextHolder.getContext().getAuthentication());
    
       private void printUser(Authentication authentication) {
           authentication.getAuthorities().forEach(a -> logger.info(a.getAuthority()));
           logger.info(authentication.getName());
       }
    
    
  • En los controladores recibiendo como parámetro el objeto de la clase Authentication asociado al usuario que realiza la llamada.
    @DeleteMapping(value = "/{id}/")
    @ResponseStatus(HttpStatus.NO_CONTENT)
       public void deleteById(@PathVariable("id") Long id, Authentication authentication) {
           printUser(authentication);
           countryService.deleteById(id);
       }
    

Autenticación con base de datos JDBC

Veamos ahora un ejemplo de autenticación real utilizando una base de datos relacional accesible mediante JDBC.

En primer lugar, necesitamos definir en Spring el DataSource correspondiente. En nuestra aplicación de ejemplo añadimos al pom el starter spring-boot-starter-jdbc (o uno que lo incluya como por ejemplo spring-boot-starter-data-jpa) y el driver JDBC para la base de datos que en mi caso es MySQL.

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

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

En el application.properties ponemos los datos de la conexión.

spring.datasource.url=jdbc:mysql://localhost:3306/country
spring.datasource.username=demo
spring.datasource.password=demo

El siguiente script crea la tabla e inserta dos usuarios. Por simplicidad un usuario sólo puede un único ROL. El nombre del rol debe empezar siempre con el prefijo “ROLE_”.

CREATE TABLE IF NOT EXISTS `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `enabled` bit(1) NOT NULL,
  `name` varchar(20) NOT NULL,
  `password` varchar(255) NOT NULL,
  `rol` varchar(10) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UK_3g1j96g94xpk3lpxl2qbl985x` (`name`)
);
INSERT INTO `users` (`id`, `enabled`, `name`, `password`, `rol`) VALUES
	(1, b'1', 'admin', '$2a$10$tnC4pYqrAwCnDCFkFbjxV.PDE/b.fKI0aygmMQO0vKx5dki7WFT46', 'ROLE_ADMIN'),
	(2, b'1', 'user', '$2a$10$PR4ElawJcWhuLoBPnP4CDeG1c0NSyGPteTq9AYcbDl8vB8sMZ/C4K', 'ROLE_USER');

La nueva implementación de la autenticación queda tal que así

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    private final DataSource dataSource;

    private final String usersByUsernameQuery;

    private final String authoritiesByUsernameQuery;

    public SpringSecurityConfig(DataSource dataSource,
                                @Value("${security-jdbc.user}") String usersByUsernameQuery,
                                @Value("${security.jdbc-authorities}") String authoritiesByUsernameQuery) {
        this.dataSource = dataSource;
        this.usersByUsernameQuery = usersByUsernameQuery;
        this.authoritiesByUsernameQuery = authoritiesByUsernameQuery;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication().dataSource(dataSource).usersByUsernameQuery(usersByUsernameQuery)
                .authoritiesByUsernameQuery(authoritiesByUsernameQuery)
                .passwordEncoder(new BCryptPasswordEncoder());
    }

Necesitamos dos consultas SQL que he puesto en el application.properties.

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

Si no especificamos alguna de estas consultas, se usará una por defecto.

select username,password,enabled from users where username = ?
select username,authority from authorities where username = ?

Las contraseñas se están cifrando con el algoritmo de hashing Bcrypt.

Configuración en XML

La configuración de Spring Security puede ser realizada en ficheros XML estilo old-school. En primer lugar, es necesario levantar el servlet de Spring Security en el web.xml en el caso de que no utilicemos la anotación @EnableWebSecurity. Si tenemos varios servlets, deberemos tener cuidado con el orden en el que se configure el de Spring Security.

<filter>
   <filter-name>springSecurityFilter</filter-name>
   <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
   <filter-name>springSecurityFilter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>

El siguiente fichero replica la configuración con la autenticación en memoria.

<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="/country/**" method="GET" access="isAuthenticated()" />
        <intercept-url pattern="/country/**" method="DELETE" access="hasAuthority('ADMIN')" />
        <intercept-url pattern="/**" access="permitAll" />
    </http>

    <authentication-manager alias="authenticationManager">
        <authentication-provider>
            <user-service>
                <user name="admin" password="$2a$10$tnC4pYqrAwCnDCFkFbjxV.PDE/b.fKI0aygmMQO0vKx5dki7WFT46" authorities="ADMIN" />
                <user name="user" password="$2a$10$PR4ElawJcWhuLoBPnP4CDeG1c0NSyGPteTq9AYcbDl8vB8sMZ/C4K" authorities="USER" />
            </user-service>
            <password-encoder hash="bcrypt" />
        </authentication-provider>
    </authentication-manager>

</beans:beans>

La configuración con JDBC.

    <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>

En nuestra aplicación de ejemplo con Spring Boot podemos utilizar la configuración en XML para Spring Security si preferimos hacerlo de esta forma.

@SpringBootApplication
@EnableWebSecurity
@EnableAutoConfiguration(exclude = { SecurityFilterAutoConfiguration.class })
@ImportResource("security.xml")
public class SpringBootApp {

Código de ejemplo

El proyecto completo se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

fantmatrc

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 45. Mockito, MockMvc, REST Assured, bases de datos embebidas

Spring JDBC Template: simplificando el uso de SQL

Persistencia en BD con Spring Data JPA

Responder

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. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

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