Web Service con User y Password en Spring Boot

De ChuWiki

Veamos en este ejemplo como crear un web service con Spring Boot a los que sólo puedan llamar usuarios autentificados en el servidor y si además tienen permiso para ellos. En [boot security|https://github.com/chuidiang/chuidiang-ejemplos/tree/master/JAVA/boot-security] tienes el código completo del ejemplo

Cosas que necesitamos en el servidor[editar]

En el lado del servidor, donde está publicado el web service, necesitamos varias cosas:

  • Una clase, MyUserDataBase que sea capaz de decir si un usuario y password son correctos y nos diga qué permisos concretos tiene ese usuario.
  • Una clase de configuración de spring, HelloWebSecurityConfiguration que enganche el servidor web de spring boot con MyUserDataBase y que le diga que no admita peticiones de usuarios no autentificados.
  • Una clase web service, WebService, con el web service y las anotaciones necesarias para saber si un determinado usuario/rol tiene permiso para llamar a ese método concreto del web service.

Vamos con un poco de detalle

Base de datos de usuarios[editar]

Por supuesto, nuestros usuarios, passwords, permisos pueden estar guardados donde quermos, una base de datos estándar, un servidor LDAP o cualquier otro mecanismo. La clase MyUserDataBase debería consultar esa base de datos y devolverle a spring boot los resultados: si el usuario/password es correcto y a qué acciones tiene permisos.

Por simplicidad en este ejemplo, no vamos a hacer eso. Simplemente haremos una clase con todo directamente en código, sin consultas a bases de datos externas. El código puede ser este

package com.chuidiang.examples.boot_security.server;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.ArrayList;

@Component
public class MyUserDataBase implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String name = authentication.getName();
        String password = authentication.getCredentials().toString();

        if (name.equals(password)) {
            SimpleGrantedAuthority rolAdmin = new SimpleGrantedAuthority("ROLE_admin");
            SimpleGrantedAuthority rolUser = new SimpleGrantedAuthority("ROLE_user");
            SimpleGrantedAuthority permisoBorrar = new SimpleGrantedAuthority("borrar");
            SimpleGrantedAuthority permisoCrear = new SimpleGrantedAuthority("crear");
            ArrayList<GrantedAuthority> permisos = new ArrayList<>();
            if ("admin".equals(name)){
                permisos.add(rolAdmin);
            }
            if ("borrador".equals(name)) {
                permisos.add(rolUser);
                permisos.add(permisoBorrar);
            }
            if ("user".equals(name)){
                permisos.add(rolUser);
            }
            if ("creador".equals(name)){
                permisos.add(permisoCrear);
            }
            return new UsernamePasswordAuthenticationToken(name, password, permisos);
        }
        return null;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(
                UsernamePasswordAuthenticationToken.class);
    }
}

Veamos algunos detalles.

La clase debe implementar AuthenticationProvider. Para ello, tiene que implementar dos métodos:

  • authenticate() que es al que llamará spring boot cuando un usuario quiera haga login
  • supports() que es al que llamará spring boot para saber si nuestra aplicación soporta el tipo de autentificación de la aplicación.

Nuestra aplicación es una aplicación web con web services y vamos a usar, como configuraremos más adelante, un mecanismos de autentificación de [Basic Authentication|https://es.wikipedia.org/wiki/Autenticaci%C3%B3n_de_acceso_b%C3%A1sica]. Con este tipo de autentificación, la clase que se usará para tener los datos del usuario y sus permisos es UsernamePasswordAuthenticationToken.


Así que nuestro método authenticate() debe devolver rellena una clase UsernamePasswordAuthenticationToken. Su constructor requiere tres parámetros:

  • nombre de usuario
  • password
  • Lista de roles y permisos que tiene ese usuario. Esta lista simplemente es un ArrayList de SimpleGrantedAuthority donde cada uno de estos representa un permiso o un rol.

Hacemos primero una pequeña comprobación de que el usuario y password son correctos. En este ejemplo vale si el nombre y la password son iguales. En una aplicación real deberíamos consultar LDAP o una base de datos. Luego creamos el ArrayList de SimpleGrantedAuthority de momento vacío. Según que usuario concreto sea, ponemos unos roles y permisos u otros. Nuevamente, en una aplicación real, estos roles o permisos se consultarían sobre LDAP o una base de datos. Aquí por simplificar el código para el ejemplo, lo hacemos directamente en código.

Un detalle, tanto roles como permisos son instancias de SimpleGrantedAuthority. Ambas se instancian pasando un String que represente el rol/permiso en el constructor. La única diferencia entre rol y permiso es que los roles deben empezar por "ROLE_", vermos más adelante por qué.

Una vez tenemos relleno el ArrayList de SimpleGrantedAuthority, instanciamos UsernamePasswordAuthenticationToken pasando en el constructor nombre de usuario, password y la lista de permisos/roles. Devolvemos esta instancia.

Si el usuario no es válido, podemos lanzar una AuthenticationException o bien null.

Al método supports lo llamará Spring para saber si nuestra clase admite un determinado tipo de autentificación, en nuestro caso, como es una aplicación web con basic autentication, nos llamarça para preguntarnos si soportamos UsernamePasswordAuthenticationToken. Así que devolvemos true si el parámetro authentication es de la clase UsernamePasswordAuthenticationToken

Configuración de Spring Boot[editar]

La clase java de cofiguración para Spring Boot que habilita y configura todo esto es la siguiente

package com.chuidiang.examples.boot_security.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity()
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class HelloWebSecurityConfiguration
        extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private MyUserDataBase authProvider;

    @Override
    protected void configure(
        AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authProvider);
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
            .and()
            .httpBasic();
    }    
}

Como antes, veamos algunos detalles.

La clase HelloWebSecurityConfiguration lleva las anotaciones:

  • @Configuration para indicarle a Spring Boot que es una clase de configuración que debe tener en cuenta.
  • @EnableWebSecurity() para que Spring Boot habilite el mecanismo de seguridad para aplicación web y lo integre en el modelo MVC de Spring Web.
  • @EnableGlobalMethodSecurity(prePostEnabled=true) para indicarle a Spring Boot que en nuestros web services queremos usar anotaciones para indicar si el usuario puede o no llamar a ese web service en función de sus permisos y roles. Si sólo queremos que esté autentificado y no queremos hacer más comprobaciones de permisos/roles, esta anotación no es necesaria.

Algunos detalles sobre estas anotaciones. @EnableWebSecurity() habilita el mecanismo estándar de seguridad de spring para web. Incluye @EnableGlobalMethodSecurity, por lo que estrictamente no es necesario poner los dos si no fuera por una pequeña pega. ¿Cual es esa pega? Que por defecto @EnableWebSecurity() no habilita las anotaciones de roles/permisos en los web services, es decir, deja prePostEnabled a false, por lo que sería necesario en el método configure() poner en código todas las url y permisos que queremos, algo como esto

/** Los de rol ADMIN puede acceder a /admin/**, lo de rol USER pueden acceder a /protected/** */
@Override
protected void configure(HttpSecurity http) {
    http.authorizeRequests()
      .antMatchers("/admin/**")
      .hasRole("ADMIN")
      .antMatchers("/protected/**")
      .hasRole("USER");
}

Así que para evitar esto, añadimos también la anotación @EnableGlobalMethodSecurity(prePostEnabled=true). Esta anotación podemos ponerla en esta clase, así afectará a todos los web services, o bien podríamos ponerlo en cada una de las clases concretas de web service en la que queramos que se habiliten las anotaciones. En este ejemplo la ponemos en esta clase, para que afecte a todos nuestros web services.

Web Services[editar]

Bien, ahora solo nos queda hacer nuestra clase con algún web service. La siguiente nos vale para el ejemplo

package com.chuidiang.examples.boot_security.server;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("mi/double/")
public class WebService {

    private static final String CAN_BORRAR="hasRole('" +
            Roles.ROL_ADMIN +
            "') or hasAuthority('" +
            Permissions.PERMISSION_BORRAR +
            "')";

    private static final String CAN_CREAR="hasRole('" +
            Roles.ROL_ADMIN +
            "') or hasAuthority('" +
            Permissions.PERMISSION_CREAR +
            "')";

    @PreAuthorize(CAN_CREAR)
    @RequestMapping("crear")
    public double crear(){
        System.out.println("Procedo a crear");
        return Math.random();
    }

    @PreAuthorize(CAN_BORRAR)
    @RequestMapping("borrar")
    public double borrar(){
        System.out.println("Procedo a borrar");
        return Math.random();
    }

    @PreAuthorize("hasRole('admin')")
    @RequestMapping("destruyeTodo")
    public double destruyeTodo(){
        System.out.println("Me lo cargo todo");
        return Math.random();
    }

}

Lleva las anotaciones @RestController y @RequestMapping("mi/double/") para indicar que es un web service REST y que la URL de dicho web service es "mi/double".

Tiene tres métodos que no hacen nada, solo sacar por pantalla que los han llamado y devolver un double aleatorio. Al sacar por pantalla que los han llamado veremos si el usuario que los llama consigue o no llamarlos según sus permisos: crear(), borrar y destruyeTodo

Hemos creado ahí dos constantes String CAN_BORRAR y CAN_CREAR para definir quién puede llamar al método borrar y al método crear. El del método destruyeTodo es más sencillo y no hemos puesto constante.

  • CAN_BORRAR se define como los que tengan rol de administrador hashRole() o el usuario tenga permiso para borrar hasAuthority()
  • CAN_CREAR se define como los que tengan rol de administrador hashRole() o el usuario tenga permiso para crear hasAuthority()

Las constantes Roles y Permissions que usamos ahí arriba las hemos definido así

package com.chuidiang.examples.boot_security.server;

public class Roles {
    public static final String ROL_ADMIN="admin";
    public static final String ROL_USER="user";
}
package com.chuidiang.examples.boot_security.server;

public class Permissions {
    public static final String PERMISSION_CREAR="crear";
    public static final String PERMISSION_BORRAR="borrar";
}

Un detalle importante, fíjate en la clase MyUserDataBase que hemos usado más arriba. Ahí damos a cada usuario sus roles y permisos. Realmente, no hay diferencia entre roles y permisos, ya que todos son clases SimpleGrantedAuthority, pero a los roles les hemos puesto delante el prefijo "ROLE_", mientras que en el web service NO hemos puesto ese prefijo. Esto es un detalle/convención interna de Spring y tenemos que tenerlo en cuenta si queremos usar hasRole() en nuestras anotaciones.

Ya solo nos queda poner en cada método su anotación concreta, vemos solo uno

  • Le ponemos @RequestMapping() con el path debajo del de la misma anotacion en la clase. Por ejemplo, en el método borrar() sería "mi/double/borrar"
  • Le ponemos @PreAuthorize() pasando como parámetro la cadena de texto que define quién tiene permiso para acceder. En el caso de borrar() sería hasRole('admin') or hasAuthority('borrar')

y listo, con esto nuestro web service queda protegido

  • La clase HelloWebSecurityConfiguration en su método configure dice que sólo pueden acceder usuarios autorizados
  • La clase con el Web Service WebService define para cada método que permisos o roles tiene que tener adicionalmente el que quiera acceder.

Cosas que necesitamos en el cliente[editar]

Vamos ahora con el ciente de los web services. Al ser web services, nuestro cliente en principio será otro programa, java en nuestro caso. Necesitamos que las llamadas que hagamos con este cliente sean con un usuario autentificado, validado por un nombre de usuario y password.

El siguiente código hace las veces de cliente

package com.chuidiang.examples.boot_security.client;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import javax.annotation.PostConstruct;

@Component
public class Client {
    public Client() {
        System.out.println("cliente lanzado");
    }

    @Autowired
    RestTemplateBuilder restTemplateBuilder;

    RestTemplate restTemplate;

    @PostConstruct
    public void useWebService (){
        // Este usuario puede borrar pero no crear.
        restTemplate= restTemplateBuilder.basicAuthentication("borrador", "borrador").build();
        new Thread(){
            public void run(){
                try {
                    Thread.sleep(1000);

                    Double value = restTemplate.getForObject("http://localhost:8080/mi/double/borrar", Double.class);
                    System.out.println("borrar = " +value);
                } catch (Exception e) {
                    System.out.println("No puedo borrar "+e.getMessage());
                }

                try {
                    Double value = restTemplate.getForObject("http://localhost:8080/mi/double/crear", Double.class);
                    System.out.println("crear = "+value);
                } catch (Exception e){
                    System.out.println("No puedo crear "+e.getMessage());
                }

            }
        }.start();

    }
}

Unos detalles que puedes ignorar, pero explico por claridad. Este cliente lo vamos a instanciar con Spring, por eso lleva la anotación @Component. Queremos que nada más arrancar empiece a hacer peticiones, es por evitarnos tener que hacer una interface de usuario con botones para hacer peticiones o cualquier otro mecanismo más complejo. Así que ponemos un método useWebService y le ponemos la anotación @PostConstruct, que hará que Spring invoque a este método una vez la clase esté correctamente construida e inicializada.

Ahora los detalles importantes. Para llamar a un web service como cliente, necesitamos una instancia de la clase RestTemplate. En condiciones normales, esta clase la puede inyectar Spring, pero necesitamos una configuración concreta, que básicamente es que tenga nuestro usuario y password. Así que en vez de obtener de Spring directamente la clase RestController, vamos a obtener la clase RestTemplateBuilder que nos permite construir nuestro RestTemplate con nuestra configuración específica.

Así que ponemos un atributo de clase RestTemplateBuilder restTemplateBuilder; con la anotación @Autowired para que Spring nos la pase automáticamente. En el método con la anotación @PostConstruct usamos esta instancia para obtener nuestro RestTemplate, tal que así restTemplate= restTemplateBuilder.basicAuthentication("borrador", "borrador").build();.

Si recuerdas de nuestra clase simplona MyUserDataBase valía cualquier usuario cuyo nombre y password coincidieran y luego, se daban roles/permisos en algunos casos. En concreto, el usuario "borrador" tenía permisos para borrar.

Así que con esto ya tenemos un RestTemplate que cuando lo usemos se autentificará con el usuario "borrador".

Así que ya solo queda el código de prueba, llamar al método borrar() y llamar al método crear(). Como en nuestro web service no pusimos si las llamadas eran GET,PUT,POST,DELETE, son todas GET por defecto. Así que en nuestro cliente hacemos llamadas del estilo restTemplate.getForObject(), pasando la url del web service e indicando, como segundo parámetro, que esperamos un double.

La primera llamada a borrar() debería funcionar correctamente. La segunda llamada saltar con una excepción indicando que no tenemos permisos. Esta es la salida esperada

cliente lanzado
borrar = 0.600840807379097
No puedo crear 403 : "{"timestamp":"2022-02-27T09:27:57.514+00:00","status":403,"error":"Forbidden","path":"/mi/double/crear"}"

Los main[editar]

Ya solo por tener el ejemplo completo, los main de servidor y cliente

package com.chuidiang.examples.boot_security.server;

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

@SpringBootApplication
public class ServerMain {

	public static void main(String[] args) {
		SpringApplication.run(ServerMain.class, args);
	}
}
package com.chuidiang.examples.boot_security.client;

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

@SpringBootApplication
public class ClientMain {
    public static void main(String[] args) {
        SpringApplication springApplication = new SpringApplication(ClientMain.class);
        springApplication.setWebApplicationType(WebApplicationType.NONE);
        springApplication.run(args);
    }
}

El del cliente tiene un pequeño truco. Al poner que es una aplicación web de spring boot, porque necesitamos web services y en concreto RestTemplate, nos arrancará un servidor Tomcat y todo el servidor web, aunque no lo necesitamos en el lado del cliente. Por eso no llamamos directamente a SpringApplication.run() como en el servidor, sino que obtenemos una instancia de SpringApplication indicando cual es nuestra clase main, le decimos que no queremos servidor web y luego ya sí la arrancamos.

Posiblemente, si servidor y cliente los tienes en proyectos gradle/maven separados, puedes jugar con las dependencias para conseguir el mismo efecto de forma más limpia, pero en este ejemplo simple, ambas clases, cliente y servidor, están en el mismo proyecto y con las mismas depencencias.