Creando un login usando los últimos estándares de Spring Boot Security y JPA

En este artículo voy a compartir algo que me es realmente útil: la creación de un proyecto de inicio completamente configurado y funcional dependiente únicamente de la última versión. Este proyecto podré usarlo como molde para comenzar un nuevo desarrollo web con todo lo que usualmente necesito, (y quizás tú también). Estas cosas son:

Spring Web

Extendiendo la funcionalidad web, vamos a poder servir contenidos con plantillas dinámicas muy bien integradas con el controlador usando Thymeleaf.

Spring Security

Imprescindible para la autenticación, también dentro de mismo ecosistema que Spring Web. Nos despreocupamos de los permisos de los usuarios, las sesiones, y la exposición de los recursos.

Spring JPA

Imprescindible tener una capa de persistencia de datos escalable y mantenible.

Para mí, estos tres módulos son el núcleo de toda web en Spring y los más necesarios y por ello los voy a implementar con las configuraciones que más uso. Quizá en un futuro me animo a publicar una versión para APIs con Swagger. Pero hoy vamos a hacer una web.

La importancia de saber qué queremos usar

Si tuviera que señalar un problema con Spring, lo tendría claro: No saben deprecar.

Spring ha evolucionado mucho y especialmente, el último lustro. Con la llegada de Spring Boot, parece que la tarea se ha facilitado, sin embargo, este framework tiene un arma de doble filo muy importante: la retrocompatibilidad.

Spring se esfuerza terriblemente en tener una retrocompatibilidad asombrosa, pero esto causa que tengas que tener muy en mente lo que estás haciendo y para qué versión, ya que cosas que en mi opinión no deberían ser compatibles, lo son. Tanto es así que el objetivo principal por el que nació Spring Boot se difumina. Su grandiosa premisa consiste en brindarte una autoconfiguración mágica para que te despreocupes y desentiendas de cosas «banales».

Y este objetivo lo cumple estupendamente, sin embargo el problema llega cuando puedes sobreescribir estas configuraciónes con anotaciones de Spring que ciertamente pueden interferir en el comportamiento esperado y te van a dar una falsa ilusión de compatibilidad con las configuraciones legacy a las que tanto estás acostumbrado, además de un posible dolor de cabeza intentando averiguar porqué esa otra parte de la aplicación ya no funciona como debía.

El segundo problema llega cuando al buscar respuestas en internet, puedes encontrarte que artículos, posts en foros y tutoriales relativamente modernos hacen las cosas de muy diversas formas, y todas parecen funcionar en tu proyecto. Sin embargo, con que lo que estés leyendo tenga unos años, es probable que ya esté un poco desfasado. Sin irse lejos, con tal de que nos vayamos a principios de 2018, lo que estás leyendo ya no es Spring Boot 2.0 y sin embargo, funcionará en tu proyecto, y podrás usarlo sin darte cuenta de que no es el último estándar.

Primeros pasos

Basta de cháchara, realmente todo esto es muy sencillo si nos ceñimos a una versión, en el caso de este ejemplo, voy a usar Spring Boot 2.2.5.RELEASE y para crear la estructura del proyecto vamos a usar Spring Initializr directamente desde el IntelliJ IDEA Ultimate. Si no usas esta versión del IDE te rogaría que considerases la posibilidad de probarlo ya que la integración con Spring es sencillamente asombrosa.

Tras rellear los campos básicos, elegiremos las dependencias que queremos incluir (de ahora en adelante y para este ejemplo voy a usar una base de datos MySQL). De modo que mi selección se queda tal que así:

Como os he comentado, va a ser un proyecto muy básico sobre el cual podremos añadir el resto de componentes que necesitemos, por el momento estas 6 dependencias harán nuestras delicias.

Bien, el proyecto ya está creado y ya tenemos nuestra configuración de arranque lista.

Lo primero que vamos a hacer es renombrar el application.properties a application.yml, ya que este formato de escribir propiedades está más al día (y como es mi preferido es el que voy a usar como ejemplo). Aquí vamos a añadir unas cuantas configuraciones:

application.yml

spring:
    datasource:
        driverClassName: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/nombredelschema?serverTimezone=Europe/Madrid
        username: root
        password: miContraseñaSecreta
    jpa:
        hibernate:
            ddl-auto: update

Con esto configuramos el datasource por defecto de nuestra aplicación y el comportamiento de hibernate, para que nos actualice la estructura en función a nuestras entidades. Esto último lo dejo a gusto de cada cual, yo por ejemplo en un entorno de desarrollo muy temprano prefiero el create-drop y añadir las filas que necesito para las pruebas mediante inserciones realizadas en el @PostConstruct. Más adelante os lo enseño. Y hablando de entidades, ¿qué mejor momento para echarle un vistazo a la estructura del proyecto?

Esos son todos los archivos que vamos a necesitar realmente, no son tantos, ¿no? Podemos empezar por alguno de ellos. Ya que tenemos la conexión de la base de datos configurada, ¿qué tal si echamos un ojo a la entidad User?

User.java

@Table(name = User.T_USUARIOS)
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    public static final String T_USUARIOS = "Usuarios";

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    public static final String C_USUARIO = "usuario";
    @Column(name = C_USUARIO, unique = true)
    private String username;

    public static final String C_CONTRASEÑA = "contraseña";
    @Column(name = C_CONTRASEÑA)
    private String hashedPassword;

    public static final String C_HABILITADO = "habilitado";
    @Column(name = C_HABILITADO)
    @Builder.Default
    private boolean enabled = true;


}

Aclaración: Las anotaciones de Lombock y el constructor son opcionales y las usaré más adelante para algunos ejemplos, pero podéis omitirlo completamente.

Si no estás familiarizado con Hibernate y especialmente con JPA, la documentación oficial, particularmente la de las entidades, te puede ser realmente útil.

Seguramente hayas notado que uso unas variables estáticas con una nomenclatura especial para nombrar la tabla y las columnas y esto es para usarlas en consultas nativas más adelante sin tener que hardcodear ningún nombre. ¡Anticipación!

Configurando la seguridad para usar los usuarios de nuestra base de datos

Aprovechando que tenemos la tabla de usuarios recién definida (y recién creada si hemos arrancado la aplicación tras definir la entidad) vamos a echarle un vistazo a la configuración de seguridad que nos permitirá usar nuestra base de datos como referencia al usar el inicio de sesión de nuestra web. Voy a incluir la clase entera, pero que no cunda el pánico, la vamos a analizar método por método:

SecurityConfig.java

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private DataSource dataSource;


    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/login*").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/inicio", true);

    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/css/**");
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .usersByUsernameQuery("select " + User.C_USUARIO + ", " + User.C_CONTRASEÑA + ", " + User.C_HABILITADO + " "
                        + "from " + User.T_USUARIOS + " "
                        + "where " + User.C_USUARIO + " = ?")
                .authoritiesByUsernameQuery("select " + User.C_USUARIO + ", 'ROLE_USER' "
                        + "from " + User.T_USUARIOS + " "
                        + "where " + User.C_USUARIO + " = ?");

    }

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


}

Tras mucho refinar, puedo decir que esta sería mi configuración elegida. Me he asegurado de cumplir el estándar más reciente, por ejemplo, esta clase ya no implementa el «malamente no deprecado»WebSecurityConfigurer, sino que extiende de WebSecurityConfigurerAdapter.

Un poco más abajo se inyecta como dependencia el datasource por defecto asociado al contexto de Spring (que ya hemos configurado). Lo usaremos más abajo en el configureGlobal() para configurar el proceso de autenticación, asignándole dos consultas:

  • La consulta que se pasa como parámetro a usersByUsernameQuery() debe devolver la información del usuario que está intentando autenticarse.
  • La consulta que devuelve las authorities relacionadas con el usuario. Tal y como está, todos los usuarios van a tener el rol ROLE_USER. pero podríamos hacerlo con una columna más en la tabla. Nota: Este «hardcodeo» también lo puedes hacer con la consulta anterior si no pretendes tener una columna en tu base de datos para habilitar/deshabilitar usuarios.

Otras opciones para gestionar el inicio de sesión:

Esta opción que muestro es una opción intermedia entre una configuración por defecto y una implementación minuciosa, sin embargo, las otras opciones también están disponibles de forma nativa en Spring Boot.

Si te sirve el esquema por defecto que relaciona usuarios con permisos, puedes usar el default schema.

Si necesitas implementar una lógica al inicio de sesión, te convendría crear un UserDetailsService (mi enfoque lo implementa internamente).

Al final de la clase observamos que este proceso de autenticación usará un encoder para las contraseñas. Puedes elegir tu favorito (e incluso crear el tuyo propio) pero se recomienda el uso de BCryptPasswordEncoder entre otros motivos por su compatibilidad entre sistemas.

De los métodos configure poco puedo decir, es bastante autoexplicativo; estamos permitiendo llamadas a la página de login y exigiendo autenticación a cualquier otra. Así mismo, estamos configurando en el WebSecurity que el sistema de seguridad ignore por completo nuestro directorio del css que cuelga de static. Esto puede (y suele) hacerse directamente con un .antMatches("/css/**").permitAll() en el HttpSecurity, sin embargo esto es una ligera pérdida de recursos ya que no necesitamos securizar en absoluto esa ruta y vamos a causar que el navegador pase por el proceso de seguridad cuando quiera acceder a un recurso (aunque solo sea para permitirlo), que, como este, queremos que sea público.

Construyendo los modelos modularmente con Thymeleaf

Estupendo. Ya tenemos cubierta la clase más tediosa, para compensar, pasamos a ver el controlador del login que es extremadamente simple:

LoginController.java

@Controller
public class LoginController {

    @GetMapping("/login")
    public String showForm() {
        return "login";
    }

}

Lo que nos lleva al modelo del login y sus fragments:

login.html

<html xmlns:th="https://www.thymeleaf.org">
    <head>
        <title>El login de mi web</title>
        <th:block th:include="fragments/head :: genericHead"></th:block>
    </head>
    <body class="loginbg">
        <div class="vertical-flex-wrapper">
            <div class="login-content">
            <form name="f" th:action="@{/login}" method="post">

                <legend>Identifícate</legend>
                <div th:if="${param.error}" class="alert alert-error">
                    Usuario y/o contraseña inválidos.
                </div>
                <div th:if="${param.logout}" class="alert alert-success">
                    Has cerrado sesión.
                </div>
                <input type="text" class="form-control" id="username" name="username" placeholder="Usuario"/>
                <input type="password" class="form-control" id="password" name="password" placeholder="Contraseña"/>
                <div class="form-actions">
                    <button type="submit" class="btn btn-primary">Entrar</button>
                </div>
            </form>
            </div>
            <footer th:replace="fragments/footer :: creditFooter"/>
        </div>
    </body>
</html>

Algo interesante que hago en mis desarrollos web es tener un head genérico para todas las páginas en el que añado los links a recursos que se usan genéricamente como Bootstrap, iconos, css genérico… Para hacer eso hago uso del th:block. Si conocéis una mejor forma de hacerlo que esta os animo a discutirlo en los comentarios. De esta forma, podemos tener un fragmento en el que incluiremos todos los heads que queramos usar en las distintas zonas de la web, y sus variantes:

head.html

<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="genericHead">
    <link rel="stylesheet" href="/css/common.css"/>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
          integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"/>
</head>
</html>

Algo más común de ver en Thymeleaf es el th:replace que he usado para el footer:

footer.html

<html lang="en" xmlns:th="http://www.thymeleaf.org">
    <footer th:fragment="creditFooter" class="credit">
        <span>Ejemplo de web para el <a
                href="https://carloslopezmari.com/creando-un-login-usando-los-ultimos-estandares-de-spring-boot-security-y-jpa">tutorial de Carlos</a>.</span>
    </footer>
</html>

Y bueno, para que todo lo veáis igual que yo, el common.css que he escrito con cariño:

common.css

.loginbg {
    background: repeating-linear-gradient(45deg,
    #9ddaff,
    #9ddaff 35px,
    #6fa6ff 35px,
    #6fa6ff 50px)
}

.login-content {
    height: 100%;
    display: flex;
    align-items: center;
    text-align: center;
}

footer.credit {
    width: 100%;
    text-align: center;
    background-color: rgba(240, 248, 255, 0.5);
}

input {
    margin: 10px !important; /* sobrescribe bootstrap */
}

.vertical-flex-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    height: 100%;
}

Ahora sí, probemos el funcionamiento

Para probar el login he creado dos usuarios, uno habilitado y otro sin habilitar, para ello he usado la persistencia de Spring, por lo que lo he creado en un método @PostConstruct, donde el contexto ya está iniciado y podemos utilizar los repositorios.

MiWebBaseApplication.java

@SpringBootApplication
public class MiWebBaseApplication {

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

    }

    @Autowired
    UserRepository userRepository;


    @PostConstruct
    public void init() {

        User activado = User.builder().username("test").hashedPassword(new BCryptPasswordEncoder().encode("miContraseña")).build();
        userRepository.save(activado);

        User desactivado = User.builder().username("test2").hashedPassword( new BCryptPasswordEncoder().encode("123abc")).enabled(false).build();
        userRepository.save(desactivado);
    }

}


Y el repositorio que usamos es el básico sin ningún método propio:

UserRepository.java

@Repository
public interface UserRepository extends CrudRepository<User, Long> {

}

Y listo, nuestro login debería redirigir cualquier dirección (a excepción del css) a la pantalla de login, y cuando se efectúe una autenticación satisfactoria, deberíamos ver tu página de inicio (sea lo que sea que hayas incluido en tu inicio.html), que en mi caso es un mesaje de bienvenida muy sencillo:

inicio.html

<html xmlns:th="https://www.thymeleaf.org">
<head>
    <title>Inicio</title>
</head>
<body>
<h1>Página de inicio de <span th:text="${usuario}">ATRIBUTO_USUARIO_NO_ENCONTRADO</span>.</h1>
</body>
</html>

Seguro que tu ojo no ha pasado por alto que el nombre del usuario se lo paso a la plantilla mediante el controlador:

InicioController.java

@Controller
public class InicioController {

    @GetMapping("/inicio")
    public String showForm(Model model) {
        model.addAttribute("usuario", SecurityContextHolder.getContext().getAuthentication().getName());
        return "inicio";
    }

}

Conclusión y código fuente

Hemos recorrido un largo camino hasta llegar a este punto, quizás ha sido muy largo y sobreexplicativo, pero es como me gustaría que me lo hubiesen explicado a mi, espero que a pesar de todo haya sido ilustrativo y hayas sacado algo útil. ¡Un saludo y hasta el próximo artículo!

El código fuente de este proyecto está completo en este repositorio.

2 comentarios

  1. William Nieva
    19 de septiembre, 2020
    Responder

    Excelente ejemplo Carlos, muchas gracias, me ayudó con algunas dudas que tenía y ahora puedo comenzar bien con aquello que quiero hacer. Saludos !!!

    • clopma
      20 de septiembre, 2020
      Responder

      Me alegro mucho de que te haya servido, ¡gracias por el comentario!

Deja un comentario

Tu dirección de correo electrónico no será publicada.