Computación Web (2023/24)

Práctica 6: Desarrollo de una aplicación Web con Spring MVC (II)

Comprobación de tu base de datos

Comprobaremos primero el acceso a las bases de datos que se te han asignado en la infraestructura del laboratorio. Deberías haber recibido por correo electrónico las credenciales para acceder a cuatro bases de datos. Tu nombre de usuario es 24_comweb_XX, donde XX es un número de dos cifras. Tus bases de datos son 24_comweb_XXa, 24_comweb_XXb, 24_comweb_XXc y 24_comweb_XXd.

Estas bases de datos están gestionadas por un servidor de bases de datos MariaDB que se encuentra en la infraestructura de los laboratorios del Departamento de Ingeniería Telemática. MariaDB es un fork del proyecto de software libre MySQL, y es totalmente compatible con este.

Por razones de seguridad, el servidor de bases de datos solo aceptará conexiones desde los ordenadores de los laboratorios del Departamento de Ingeniería Telemática, ya sean los físicos de 7.0.J02, 7.0.J03, 4.1.B01 y 4.1.B02 o los virtuales de https://aulavirtual.lab.it.uc3m.es/.

Si quieres conectarte con tu usuario 24_comweb_XX a tu base de datos 24_comweb_XXc (recuerda sustituir XX por el número que te haya tocado), ejecuta el siguiente comando desde un terminal:

mysql -h mysql -u 24_comweb_XX -D 24_comweb_XXc -p

El programa te pedirá la contraseña que también has recibido con las credenciales de tu base de datos. Es mejor que copies y pegues la contraseña de tu correo electrónico en lugar de escribirla, para evitar errores. Una vez conectado, puedes ejecutar comandos SQL en este terminal, que deben terminar con un punto y coma para que se ejecuten. Puedes salir del terminal con la combinación de teclas Ctrl.-D.

Si tu conexión ha funcionado, puedes pasar al siguiente ejercicio.

Integración de la base de datos

Utilizaremos Spring Data JPA para integrar la base de datos en nuestra aplicación. Por tanto, la aplicación trabajará con instancias de las clases User y Message, las cuales se mapearán automáticamente a tablas en la base de datos. Spring Data JPA construirá automáticamente las consultas SQL que sean necesarias para almacenar y recuperar los objetos de estas clases.

Para ello, debemos primero configurar Gradle para que cargue las bibliotecas necesarias para utilizar Spring Data JPA con una base de datos MySQL o MariaDB. Añade a la sección dependencies del fichero build.gradle las siguientes tres líneas:

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
runtimeOnly 'com.mysql:mysql-connector-j'

La primera indica que deseas utilizar Spring Data JPA en tu aplicación. La segunda, que utilizarás un entorno de validación de atributos en objetos Java. La tercera, que accederás a una base de datos MySQL o MariaDB.

A continuación debes añadir al fichero src/main/resources/application.properties la información necesaria para que el sistema se pueda conectar a tu base de datos. Asumiendo que el número de base de datos que has recibido es XX y que la contraseña de tu usuario en base de datos es YYYYYYYYY, estos datos serían los siguientes (cambia XX e YYYYYYYYY por, respectivamente, el número de base de datos y contraseña que hayas recibido):

spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:mysql.lab.it.uc3m.es}/24_comweb_XXc?serverTimezone=UTC
spring.datasource.username=24_comweb_XX
spring.datasource.password=YYYYYYYYY

Por último, debes anotar la clase User de tu modelo para que el sistema de JPA sepa cómo debe manipularla. Reemplaza el código actual de esta clase por el siguiente:

package es.uc3m.microblog.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;


@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;

    @Column(nullable = false, length = 64)
    @NotBlank
    @Size(max = 64)
    private String name;

    @Column(unique = true, nullable = false, length = 64)
    @Email
    @NotBlank
    @Size(max = 64)
    private String email;

    @Lob
    private String description;

    @Column(nullable = false)
    @NotBlank
    private String password;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public String toString() {
        return "User: " + name + " <" + email + ">";
    }
}

Verás que se utilizan una serie de anotaciones de JPA para marcar la clase y sus propiedades. Su significado es el siguiente:

Fíjate en que, conforme a las anotaciones que se indican en el código de esta clase, todos los campos excepto la descripción del usuario deben tomar un valor ni nulo ni vacío. El sistema de validación de Spring hará cumplir esta restricción a todos los objetos de la clase User.

Por último, será necesario disponer del código que permita crear, leer, actualizar y borrar usuarios (CRUD, del inglés create, read, update, delete) en la base de datos. Crea para ello una nueva interfaz llamada es.uc3m.microblog.model.UserRepository con el siguiente contenido:

package es.uc3m.microblog.model;

import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, Integer> {

}

Aunque luego necesitaremos añadirle algún método, por el momento no necesitaremos ninguno y dejaremos la interfaz vacía. Cuenta ya, no obstante, con los métodos que proporciona la interfaz CrudRepository (pincha en el enlace si quieres echar un ojo). Es más, no necesitas programar una implementación de la interfaz UserRepository porque el entorno de Spring lo hará automáticamente por ti.

Para probar el código de este ejercicio, debes esperar a avanzar con los siguientes ejercicios. Sin embargo, es recomendable que pares y reinicies el servidor para comprobar que no tengas errores de configuración o compilación.

Formulario de creación de cuentas de usuario

Aunque autenticación y autorización parezcan, a primera vista, lo mismo, no lo son. La autenticación consiste en identificar a un usuario dadas las credenciales que este proporciona. Lo más habitual a día de hoy es que las credenciales consistan en una dirección de correo electrónico y una contraseña, aunque las buenas prácticas dictan actualmente que se use algún factor de autenticación adicional o incluso que no se usen contraseñas en absoluto.

La autorización consiste en, dado un recurso y un usuario que ya ha sido autenticado previamente, verificar si a dicho usuario se le concede permiso para acceder al recurso. Por ejemplo, para acceder al recurso que elimina una publicación concreta en una red social solo el usuario que la haya creado, o determinados administradores de la red social, deberían estar autorizados.

El siguiente paso para construir la aplicación es gestionar la autenticación de usuarios, de tal forma que los usuarios puedan registrarse, iniciar sesión y cerrar sesión. En este ejercicio comenzaremos a trabajar en esa dirección permitiendo a los usuarios crear nuevas cuentas.

Primero, crea una nueva plantilla para el formulario de registro y guárdala como src/main/resources/templates/signup.html. Puedes utilizar el siguiente código de ejemplo o escribir el tuyo propio:

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
    <head>
        <title>Sign-up</title>
        <link rel="stylesheet" href="public/microblog.css">
    </head>
    <body>
        <h1>Sign Up!!!</h1>
        <form th:action="@{/signup}" method="post">
            <div>
                <label>User name: <input type="text" name="name" required></label>
            </div>
            <div>
                <label>Email: <input type="email" name="email" required></label>
            </div>
            <div>
                <label>Password: <input type="password" name="password" required></label>
            </div>
            <div>
                <label>
                    Repeat password:
                    <input type="password" name="passwordRepeat" required>
                </label>
            </div>
            <div>
                <input type="submit" value="Sign Up">
            </div>
        </form>
    </body>
</html>

El formulario recogerá del usuario cuatro parámetros, llamados "name", "email", "password" y "passwordRepeat". Cuando el usuario presione el botón Sign Up, se enviará una petición HTTP POST con esos parámetros a la ruta /signup, cuyo código programaremos a continuación.

Para que el formulario se muestre al usuario, debemos programar un método de controlador que responda a peticiones GET a la ruta /signup. Añade el siguiente método al controlador principal (la clase MainController), que simplemente le indica al entorno que cargue la plantilla anterior:

@GetMapping(path = "/signup")
public String signUpForm() {
    return "signup";
}

Carga el formulario desde tu navegador accediendo a la URL http://localhost:8080/signup. En el siguiente ejercicio programaremos el método de controlador que recibe los datos del formulario.

Inserción de usuarios en la base de datos

Antes de programar el método del controlador que recibe los datos del formulario, crearemos un servicio que inserte usuarios en la base de datos.

Este servicio se encargará de dos cosas:

Para cifrar las contraseñas, utilizaremos el algoritmo bcrypt, que se usa frecuentemente con este propósito. Spring ya proporciona una implementación de este algoritmo. Añade el siguiente método a la clase es.uc3m.microblog.WebSecurityConfig:

// Nuevas sentencias import:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

// Nuevo método:
@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Cada vez que necesites usar este codificador de contraseñas para cifrar o comprobar una contraseña, puedes pedir al sistema de inyección de dependencias de Spring una instancia de este bean.

Ahora, crea la interfaz UserService en el paquete es.uc3m.microblog.services escribiendo el fichero src/main/java/es/uc3m/microblog/services/UserService.java con el siguiente contenido:

package es.uc3m.microblog.services;

import es.uc3m.microblog.model.User;

public interface UserService {
    void register(User user);
}

Necesitas proporcionar una implementación para esta interfaz, que será la clase UserServiceImpl que crearás en el mismo paquete:

package es.uc3m.microblog.services;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import es.uc3m.microblog.model.User;
import es.uc3m.microblog.model.UserRepository;

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void register(User user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        userRepository.save(user);
    }
}

La anotación @Service indica al entorno que esta clase implementa un servicio. Una instancia de este servicio estará disponible para otras partes de la aplicación a través del sistema de inyección de dependencias de Spring.

De hecho, obtener objetos del sistema de inyección de dependencias es tan sencillo como añadir la anotación @Autowired. En el código anterior, el método register tiene acceso a las instancias del repositorio de usuarios y del codificador de contraseñas gracias a este mecanismo.

La inyección de dependencias libera al desarrollador de la tarea de escribir el código que crea u obtiene esos objetos.

Además, puedes ver que el método register simplemente reemplaza la contraseña del usuario por su versión cifrada (usando el bean del codificador de contraseñas) y, finalmente, almacena el objeto en la base de datos con el método save de UserRepository, que heredan todas las interfaces de repositorio de CrudRepository.

Dado que las direcciones de correo electrónico de los usuarios deben ser únicas, cuando recibamos los datos del formulario necesitaremos comprobar si ya existe un usuario con esa dirección de correo electrónico en la base de datos. Tener un método que obtiene un usuario por dirección de correo electrónico con Spring Data JPA es tan sencillo como añadir la siguiente declaración de método a la interfaz UserRepository:

User findByEmail(String email);

El entorno proporciona automáticamente la implementación de este método.

Todo el código que has escrito en este ejercicio simplifica la programación del método del controlador que recibe los datos del formulario. Añade el siguiente método al controlador principal:

// Nuevas sentencias import:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import es.uc3m.microblog.model.UserRepository;
import es.uc3m.microblog.services.UserService;

// Nuevos atributos:
@Autowired
private UserRepository userRepository;

@Autowired
private UserService userService;

// Nuevo método del controlador:
@PostMapping(path = "/signup")
public String signUp(@ModelAttribute("user") User user,
                     @RequestParam(name = "passwordRepeat") String passwordRepeat) {
    if (userRepository.findByEmail(user.getEmail()) != null) {
        return "redirect:signup?duplicate_email";
    }
    if (!user.getPassword().equals(passwordRepeat)) {
        return "redirect:signup?passwords";
    }
    userService.register(user);
    return "redirect:login?registered";
}

Observa que:

Para probar esta funcionalidad, accede al formulario y registra un nuevo usuario. Deberías recibir un mensaje de error debido a que la ruta /login aún no está programada. Sin embargo, el usuario debería haber sido insertado en la base de datos.

Conéctate a la base de datos con el cliente de línea de comandos y comprueba que el usuario está allí.

Observa también que la contraseña está cifrada.

Autenticación de usuarios

Una vez hemos implementado la funcionalidad para el registro de usuarios, programaremos el sistema de autenticación. Spring ya proporciona funcionalidad de seguridad. Construiremos sobre ella la autorización y la autenticación de usuarios.

El sistema de seguridad de Spring representa las credenciales y roles de los usuarios con la clase org.springframework.security.core.userdetails.User. Necesitamos proporcionarle un servicio que implemente la interfaz UserDetailsService. Esta interfaz tiene un único método, loadUserByUsername, que devuelve una instancia de la clase User mencionada dada la dirección de correo electrónico proporcionada en el formulario de autenticación. Cuando el sistema de autenticación de Spring necesite comprobar las credenciales de un usuario, llamará a este método con la dirección de correo electrónico, y obtendrá de él la contraseña cifrada del usuario y su rol o roles.

Guarda la siguiente implementación de ese servicio en el directorio services de tu árbol de código:

package es.uc3m.microblog.services;

import java.util.HashSet;
import java.util.Set;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.transaction.annotation.Transactional;

import es.uc3m.microblog.model.User;
import es.uc3m.microblog.model.UserRepository;

public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByEmail(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
        grantedAuthorities.add(new SimpleGrantedAuthority("USER"));
        return new org.springframework.security.core.userdetails.User(
            user.getEmail(), user.getPassword(), grantedAuthorities);
    }
}

Para que el sistema de seguridad use este servicio, necesitas proporcionarlo como un bean añadiendo el siguiente método a la clase WebSecurityConfig:

// Nuevas sentencias import:
import org.springframework.security.core.userdetails.UserDetailsService;
import es.uc3m.microblog.services.UserDetailsServiceImpl;

// Nuevo método:
@Bean
UserDetailsService userDetailsService() {
    return new UserDetailsServiceImpl();
}

El siguiente paso es crear la página de inicio de sesión. Para hacerlo, añade al controlador principal el siguiente método:

@GetMapping(path = "/login")
public String loginForm() {
    return "login";
}

Este método responde a peticiones GET para la ruta /login, y le indica al sistema que cargue la plantilla login. Puedes guardar una primera versión de la plantilla con el siguiente código de Thymeleaf en src/resources/templates/login.html:

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
    <head>
        <title>Sign-in</title>
        <link rel="stylesheet" href="public/microblog.css">
    </head>
    <body>
        <div th:if="${param.error}">
            Invalid username and password.
        </div>
        <div th:if="${param.logout}">
            You have been logged out.
        </div>
        <div th:if="${param.registered}">
            Your new user has been registered.
        </div>
        <form th:action="@{/login}" method="post">
            <div><label>Email: <input type="email" name="username" required/></label></div>
            <div><label>Password: <input type="password" name="password" required/></label></div>
            <div><input type="submit" value="Sign In"/></div>
        </form>
        <p>
            <a th:href="@{/signup}">Sign up</a> if you don't have an account yet.
        </p>
    </body>
</html>

Las peticiones GET a la ruta /login llegarán sin parámetros o, en algunos casos, con un parámetro que especifica una causa, que el código anterior usa para mostrar el mensaje adecuado al usuario:

Como puedes ver, la acción del formulario de inicio de sesión anterior es también la ruta /login, pero con una petición POST esta vez. El sistema de seguridad de Spring ya proporciona ese recurso, por lo que no necesitas programarlo.

Por último, necesitamos configurar el sistema de seguridad de Spring para que el acceso a todos los recursos esté restringido a usuarios autenticados, excepto para el formulario de inicio de sesión, el formulario de registro y los recursos estáticos públicos. Reemplaza el método securityFilterChain de la clase WebSecurityConfig con el siguiente código:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((authorize) -> authorize
            .requestMatchers("/login", "/signup", "/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(formLogin -> formLogin
            .loginPage("/login")
            .permitAll());
    return http.build();
}

En el método anterior, la llamada authorizeHttpRequests configura las restricciones de acceso mencionadas anteriormente. La llamada formLogin configura nuestro formulario de inicio de sesión como una página de inicio de sesión personalizada que se usará en lugar de la proporcionada por defecto por el sistema de seguridad de Spring.

Para comprobar que el sistema de autenticación que has preparado en este ejercicio funciona, inicia la aplicación y trata de acceder a su recurso / (simplemente accede a http://localhost:8080/ desde tu navegador). Dado que no te has autenticado todavía, debería aparecer el formulario de autenticación. Trata de autenticarte primero con credenciales incorrectas (deberías ver el formulario de nuevo con el mensaje de error asociado) y luego con las correctas. Ahora, la página principal de tu aplicación debería cargarse.

Datos del usuario y cierre de sesión

Modifica las vistas de tu aplicación para que muestren el nombre del usuario con sesión iniciada y un botón que le permita cerrar sesión. El nombre del usuario se puede mostrar en plantillas Thymeleaf con:

<div th:text="${#authentication.principal.username}">
    User name here
</div>

El botón de cierre de sesión se puede mostrar con:

<form th:action="@{/logout}" method="post">
    <input type="submit" value="Sign Out"/>
</form>

El botón envía una solicitud POST a la ruta /logout. No necesitas programar un controlador para esta ruta porque lo proporciona el sistema de seguridad de Spring.

Comprueba de nuevo que todo funcione correctamente, incluido el cierre de sesión. Recuerda que debes parar el servidor (Ctrl. + C) y volver a iniciarlo para que los cambios que has hecho tengan efecto.

Personalización del formulario de autenticación

Personaliza el contenido y la apariencia de la página que contiene el formulario de inicio de sesión para que sea coherente con el estilo del resto de tu aplicación.

Recuerda que, si utilizas desde esta página algún recurso estático como hojas de estilos, imágenes o código JavaScript, estos ficheros deben estar ubicados bajo el directorio src/resources/static/public, puesto que de lo contrario los usuarios sin sesión iniciada no los podrían cargar.

Gestión de errores en la creación de usuarios

Podrían ocurrir varios errores durante el procesamiento del registro de un nuevo usuario. Principalmente:

Tu implementación ya maneja los dos últimos tipos de errores. En este ejercicio, utilizarás el sistema de validación de Spring para verificar todos los campos en el formulario de registro y mostrar realimentación al usuario en caso de error.

Para ello, debes, en primer lugar, añadir un parámetro User user al método que procesa las peticiones GET a /signup. En ese parámetro recibirá los valores previos que hubiese introducido el usuario, en caso de que estos fuesen incorrectos:

A continuación, modifica la plantilla signup.html para que se rellenen automáticamente dichos datos en caso necesario, y que se muestre el mensaje de error apropiado. Adapta el código siguiente al esquema de tu propio formulario:

<form th:action="@{/signup}" th:object="${user}" method="post">
    <div>
        <label>User name: <input type="text" th:field="*{name}" required></label>
        <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Error</span>
    </div>
    <div>
        <label>Email: <input type="email" th:field="*{email}" required></label>
        <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Error</span>
    </div>
    <div>
        <label>Password: <input type="password" th:field="*{password}" required></label>
        <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}">Password Error</span>
    </div>
    <div><label>Repeat password: <input type="password" name="passwordRepeat" required></label></div>
    <div><input type="submit" value="Sign Up"></div>
</form>

La declaración th:object="${user}" indica a Thymeleaf que debe rellenar los controles con los valores del objeto user (el mismo que acabas de declarar en el método del controlador). Por tanto, si se vuelve a este formulario debido a un error en algún dato, se mostrarán los datos que el usuario hubiese introducido previamente.

Para que esto funcione, cada control se anota con th:field, que indica qué campo del objeto user se corresponde con el mismo. Gracias a esta anotación, ya no es necesario el atributo name, puesto que lo pondrá Thymeleaf automáticamente. Fíjate en que, sin embargo, se deja el control passwordRepeat sin tocar, dado que no es una propiedad del objeto Usuario.

Además, en caso de error en algún campo se mostrará el mensaje correspondiente mediante el elemento span que se añade a continuación de cada control.

Finalmente, modifica el método que recibe las peticiones POST:

// Nuevas clases a importar
import org.springframework.validation.BindingResult;
import jakarta.validation.Valid;

// Código alternativo para el método "signup" con mensaje POST:
@PostMapping(path = "/signup")
public String signUp(@Valid @ModelAttribute("user") User user,
                     BindingResult bindingResult,
                     @RequestParam(name = "passwordRepeat") String passwordRepeat) {
    if (bindingResult.hasErrors()) {
        return "signup";
    }
    // (...continúa con el código que ya tenías...)
}

La etiqueta @Valid indica a Spring que debe validar los datos. Los posibles errores de validación estarán disponibles en bindingResult. En caso de haberlos, se mostrará de nuevo la vista del formulario, el cual, de forma automática, mostrará los mensajes de error apropiados.

Comprueba que todo funcione correctamente y que los mensajes de error se muestren en caso de que el usuario introduzca datos incorrectos, como un nombre de usuario o una dirección de correo electrónico demasiado largas. Si deseas comprobar que se detectan errores de sintaxis en las direcciones de correo electrónico, cambia temporalmente el control de formulario de tipo email a text.