Tabla de Contenidos

Seguridad: Filtros

En un sistema de login, varios elementos están vinculadas entre sí para garantizar la autenticación y la experiencia del usuario:

El proceso típico de login en un navegador implica varias etapas:

En conjunto, este proceso garantiza que el usuario pueda autenticarse en la aplicación web y acceder a los recursos autorizados, mientras se mantiene su sesión activa mediante el uso de cookies.

Además, hay que tener en cuenta diferentes aspectos como:

Aunque la mayoría de los frameworks modernos, como Spring, ofrecen herramientas integradas para gestionar la autenticación y el sistema de login en nuestras aplicaciones web, es esencial comprender los principios subyacentes y el funcionamiento interno de estas funcionalidades. En este apartado, nos embarcaremos en la tarea de construir un sistema de login básico desde cero, prescindiendo de herramientas externas como Spring Security. Al hacerlo, obtendremos una comprensión más profunda de los conceptos fundamentales involucrados en la autenticación de usuarios y la gestión de sesiones en una aplicación web Spring.

Proyecto Spring

Para comenzar, crearemos un nuevo proyecto Spring utilizando Spring Initializr. Asegúrate de incluir las dependencias necesarias para nuestra aplicación, que incluyen Spring Web y Thymeleaf para la gestión de plantillas HTML. Estas dependencias nos permitirán construir un sistema de login funcional con un controlador principal y las páginas necesarias para la interfaz de usuario.

Una vez que el proyecto esté creado, organizaremos la estructura de archivos para incluir un controlador principal y, por ahora, 5 páginas html: index.html, login.html, logout.html, helloUser.html y helloAdmin.html. El login.html puede tener un aspecto parecido a esto:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <h1>Login</h1>
    <form action="/login" method="post">
        <div>
            <label for="username">Username</label>
            <input type="text" id="username" name="username">
        </div>
        <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password">
        </div>
        <button type="submit">Login</button>
    </form>
</body>
</html>

En la página de logout, pediremos una confirmación para el cierre de sesión:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<h1>Confirmación de Logout</h1>
<p>¿Estás seguro de que deseas cerrar sesión?</p>
<form action="/logout" method="post">
    <button type="submit">Confirmar Logout</button>
    <a href="/dashboard">Cancelar</a>
</form>
</body>
</html>

El resto de páginas puedes crearlas a tu gusto, por ejemplo, mostrando la frase “Hello, User!” en helloUser.html, “Hello, Admin!” en helloAdmin.html y “Welcome to my website!” en index.html.

En nuestro controlador principal, cargaremos las páginas correspondientes según las rutas:

@Controller
public class MainController {

    @GetMapping("/helloUser")
    public String hello() {
        return "helloUser";
    }

    @GetMapping("/helloAdmin")
    public String hello2() {
        return "helloAdmin";
    }

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

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

Filtros

En el desarrollo de aplicaciones web, los filtros son componentes importantes que permiten interceptar y procesar tanto las solicitudes entrantes como las respuestas salientes. Funcionan como una capa intermedia entre el cliente y el servidor, lo que permite realizar tareas de manipulación y procesamiento de los datos en cada etapa del ciclo de vida de la solicitud.

Los filtros se utilizan para una variedad de propósitos, como la autenticación, la autorización, la compresión de datos, la gestión de sesiones, el registro de solicitudes, la seguridad y más. Permiten realizar acciones comunes de manera centralizada y reutilizable en toda la aplicación, lo que mejora la modularidad y la mantenibilidad del código.

En el contexto de Spring Framework, los filtros se pueden configurar fácilmente utilizando anotaciones o XML, lo que facilita su integración en la arquitectura de la aplicación. Nosotros vamos a construir una serie de filtros básicos de autenticación.

Filtros en Spring

En Spring, una forma conveniente de crear filtros es utilizando la anotación @WebFilter. Esta anotación permite declarar y configurar el filtro directamente en una clase Java. Al utilizar @WebFilter, podemos definir el comportamiento del filtro y especificar la URL o el patrón de URL al que se aplicará.

Existen muchas formas de definir filtros, aunque una práctica común es implementar la interfaz jakarta.servlet.Filter o alguna de sus clases herederas. En nuestro caso, vamos a usar la clase abstractaOncePerRequestFilter.

OncePerRequestFilter es una clase proporcionada por Spring para simplificar la implementación de filtros que deben ejecutarse una vez por cada solicitud HTTP. Esta clase garantiza que el filtro se ejecute solo una vez por solicitud, incluso si la solicitud debe pasar por múltiples filtros en la cadena de filtros.

La razón principal para utilizar OncePerRequestFilter es evitar la ejecución repetida del filtro para una misma solicitud. Esto es especialmente útil para operaciones que deben realizarse una vez por solicitud, como la autenticación, la autorización o la manipulación de encabezados HTTP.

Al extender OncePerRequestFilter, debemos implementar el método doFilterInternal(), que es donde colocaremos la lógica del filtro:

jakarta.servletorg.springframework.web.filtermy.package.filterFilterinit(FilterConfig filterConfig)doFilter(ServletRequest request, ServletResponse response, FilterChain chain)destroy()GenericFilterBeaninit()doFilter()destroy()OncePerRequestFilterdoFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)MyFilter1doFilterInternal()MyFilter2doFilterInternal()

Spring escaneará automáticamente las clases para detectar anotaciones como @WebFilter solo si se habilita la detección de componentes servlet. Para lograr esto, podemos utilizar la anotación @ServletComponentScan en una clase de configuración de Spring (lo podemos hacer en nuestra clase principal) o configurar la detección de componentes en archivos XML si se prefiere la configuración basada en XML.

@SpringBootApplication
// Necesario para que Spring detecte los filtros y los registre automáticamente. Si utilizamos @Component en el filtro, no es necesario
@ServletComponentScan
public class CustomSpringSecurityApplication {

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

}

Con la anotación @WebFilter, podemos crear filtros de manera rápida y sencilla, lo que simplifica la configuración y hace que el código sea más legible y mantenible.

El método doFilter(request, response) se utiliza para invocar el siguiente filtro en la cadena de filtros o, si no hay más filtros en la cadena, invocar finalmente el recurso solicitado.

Por ejemplo, vamos a crear un filtro básico que se ejecute con cada petición:

@WebFilter("/*")
public class BasicFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //pre-processing
        LogManager.getLogManager().getLogger("").info(">>> FILTER basicFilter...");
        //passing the request along the filter chain
        filterChain.doFilter(request, response);
        //post-processing
        LogManager.getLogManager().getLogger("").info("<<< FILTER basicFilter...");
    }
}

Si ejecutamos la aplicación ahora y accedemos a cualquier ruta, veremos como en la terminal nos muestra la ejecución del filtro:

2024-04-02T09:32:16.630+02:00  INFO 12801 --- [custom-spring-security] [nio-8080-exec-1]                                          : >>> FILTER basicFilter...
2024-04-02T09:32:16.718+02:00  INFO 12801 --- [custom-spring-security] [nio-8080-exec-1]                                          : <<< FILTER basicFilter...

Filtro login

El primer filtro que vamos a crear será para las peticiones POST /login. Aquí, comprobaremos las credenciales enviadas por el usuario y, si son correctas, dejaremos pasar la petición. Si no, redirigiremos otra vez a la página de login:

@WebFilter("/login")
@Order(1)
public class LoginFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (!request.getMethod().equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }

        LogManager.getLogManager().getLogger("").info(">>> FILTER LoginPostFilter...");

        String username = request.getParameter("username");
        String password = request.getParameter("password");

        LogManager.getLogManager().getLogger("").info("Credentials: " + username + " - " + password);

        if (username == null || password == null) {
            response.sendRedirect(request.getContextPath() + "/login");
            return;
        }

        if (username.equals("user") && password.equals("1234")) {
            // Autenticación exitosa, establecer atributo de sesión para el usuario y redirigir a la página principal
            LogManager.getLogManager().getLogger("").info("authentication successful!");
            request.getSession().setAttribute("user", username);
            response.sendRedirect(request.getContextPath() + "/");
            return;
        }

        LogManager.getLogManager().getLogger("").info("authentication failed!");
        // Autenticación fallida, redirigir a la página de inicio de sesión
        response.sendRedirect(request.getContextPath() + "/login");
        LogManager.getLogManager().getLogger("").info("<<< FILTER LoginPostFilter...");
    }
}

Varias cosas a comentar del método:

Si accedemos a la ruta /login en nuestro navegador y enviamos el formulario, la aplicación nos debería loguear si las credenciales son correctas.

Filtro logout

Ahora que ya tenemos nuestro primer filtro funcionando, vamos a crear el siguiente para el cierre de sesión:

@WebFilter("/logout")
@Order(2)
public class LogoutFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (!request.getMethod().equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }
        LogManager.getLogManager().getLogger("").info(">>> FILTER LogoutFilter...");
        request.getSession().invalidate();
        response.sendRedirect(request.getContextPath() + "/");
        LogManager.getLogManager().getLogger("").info("logout successful!");
        LogManager.getLogManager().getLogger("").info("<<< FILTER LogoutFilter...");
    }

Este filtro está configurado para la ruta POST /logout. Si se cumplen las condiciones, se destruye la sesión con el método invalidate() y se redirige a la página principal.

Filtro de autenticación

Ahora que ya podemos iniciar y cerrar sesión en nuestra web, el siguiente paso sería proteger rutas. Por ejemplo, vamos a hacer que la ruta principal sea accesible para todos. Las rutas /helloUser y /helloAdmin sólo se podrán acceder si el usuario está logueado. Para eso, debemos crear el correspondiente filtro. Pero antes, vamos a añadir una nueva clase donde estará la configuración de nuestras rutas:

public class ResourcesConfig {

    private static Set<String> PUBLIC_RESOURCES = Set.of("/", "/login", "/logout", "/css", "/js", "/images");

    public static boolean isPublicResourceRequest(String uri) {
        return PUBLIC_RESOURCES.contains(uri);
    }


}

La clase es muy sencilla. Tenemos un conjunto de recursos públicos (página principal, login y logout, css, js, imágenes….) y un método que comprueba si la URI es un recurso público o no.

Obviamente, esta no es la mejor manera de gestionar nuestras rutas. Deberíamos hacer un sistema donde seamos capaces de restringir rutas de forma dinámica, pero, como hemos dicho antes, el objetivo de este tema es entender como funcionan los filtros, con lo que simplificamos bastante el proceso.

Ahora ya estamos listos para crear el filtro de autenticación:

@WebFilter("/*")
@Order(3)
public class AuthenticateFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (ResourcesConfig.isPublicResourceRequest(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }

        LogManager.getLogManager().getLogger("").info(">>> FILTER AuthenticateFilter...");
        if(request.getSession().getAttribute("user") == null) {
            response.sendRedirect(request.getContextPath() + "/login");
            return;
        }
        filterChain.doFilter(request, response);
        LogManager.getLogManager().getLogger("").info("<<< FILTER AuthenticateFilter...");
    }
}

Como ves, el filtro es muy sencillo. Primero comprueba si la ruta es pública. Si lo es, pasa el control al siguiente filtro sin hacer nada más. Si está protegida, comprueba que existe una sesión con el atributo “user” (recuerda que éste se crea al hacer login) y, si no existe, redirige al login.

De nuevo, es importante remarcar que no es la mejor manera de comprobar si un usuario está logueado, pero nos basta para nuestro propósito.

Filtro de autorización

Vamos con nuestro último filtro: autorización. La idea es implementar un sistema de roles. Tendremos dos roles: “ROL_ADMIN” y “ROL_USER”. A la página /helloUser sólo se podrá acceder si tiene el rol usuario, y /helloAdmin si tiene cualquiera de los dos. Pero antes, vamos a hacer un par de cambios.

Primero, vamos a añadir las diferentes rutas y sus métodos asociados a nuestro fichero de configuración de recursos:

public class ResourcesConfig {

    private static Set<String> PUBLIC_RESOURCES = Set.of("/", "/login", "/logout", "/css", "/js", "/images", "/error401");

    private static Set<String> USER_RESOURCES = Set.of("/helloUser");
    private static Set<String> ADMIN_RESOURCES = Set.of("/helloAdmin");

    public static boolean isPublicResourceRequest(String uri) {
        return PUBLIC_RESOURCES.contains(uri);
    }

    public static boolean isUserResourceRequest(String uri) {
        return USER_RESOURCES.contains(uri);
    }

    public static boolean isAdminResourceRequest(String uri) {
        return ADMIN_RESOURCES.contains(uri) || isUserResourceRequest(uri);
    }

}

Después, cuando nos logueamos correctamente en el filtro de login guardaremos, además del username, el rol del usuario:

@WebFilter("/login")
@Order(1)
public class LoginFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (!request.getMethod().equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }

        LogManager.getLogManager().getLogger("").info(">>> FILTER LoginPostFilter...");

        String username = request.getParameter("username");
        String password = request.getParameter("password");

        LogManager.getLogManager().getLogger("").info("Credentials: " + username + " - " + password);

        if (username == null || password == null) {
            response.sendRedirect(request.getContextPath() + "/login");
            return;
        }

        if (username.equals("user") && password.equals("1234")) {
            // Autenticación exitosa, establecer atributo de sesión para el usuario y redirigir a la página principal
            LogManager.getLogManager().getLogger("").info("authentication successful!");
            request.getSession().setAttribute("user", username);
            request.getSession().setAttribute("role", "ROLE_USER");
            response.sendRedirect(request.getContextPath() + "/");
            return;
        }

        LogManager.getLogManager().getLogger("").info("authentication failed!");
        // Autenticación fallida, redirigir a la página de inicio de sesión
        response.sendRedirect(request.getContextPath() + "/login");
        LogManager.getLogManager().getLogger("").info("<<< FILTER LoginPostFilter...");
    }
}

Además, crearemos una página error401.html para mostrar cuando el usuario no está autorizado:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    401 - Unauthorized
</body>
</html>

Y añadiremos la ruta en nuestro controlador:

@Controller
public class MainController {

    @GetMapping("/helloUser")
    public String hello() {
        return "helloUser";
    }

    @GetMapping("/helloAdmin")
    public String hello2() {
        return "helloAdmin";
    }

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

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

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

Listo, ya podemos crear nuestro filtro de autorización:

@WebFilter("/*")
@Order(4)
public class AuthorizeFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        LogManager.getLogManager().getLogger("").info(">>> FILTER AuthorizeFilter...");
        if(ResourcesConfig.isPublicResourceRequest(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }
        LogManager.getLogManager().getLogger("").info("Role: " + request.getSession().getAttribute("role"));
        if(!isAuthorized(request.getRequestURI(), (String) request.getSession().getAttribute("role"))) {
            LogManager.getLogManager().getLogger("").info("Unauthorized access!");
            response.sendRedirect(request.getContextPath() + "/error401");
            return;
        }
        LogManager.getLogManager().getLogger("").info("Authorized access!");
        filterChain.doFilter(request, response);
        LogManager.getLogManager().getLogger("").info("<<< FILTER AuthorizeFilter...");
    }

    private boolean isAuthorized(String uri, String role) {
        if(ResourcesConfig.isUserResourceRequest(uri) && (role.equals("ROLE_USER"))) {
            return true;
        }
        if(ResourcesConfig.isAdminResourceRequest(uri) && role.equals("ROLE_ADMIN")) {
            return true;
        }
        return false;
    }
}

El filtro comprueba primero si la ruta es pública. Si no lo es, llama al método isAuthorized(), donde se le pasa la URI y el rol del usuario y devuelve un boolean indicando si está autorizado o no. Si no lo está, redirige la petición a /error401.

Listo, ya tenemos nuestro sistema de login con filtros finalizados. Si todo ha funcionado correctamente, tendremos nuestras rutas protegidas con un sistema de roles.

Como hemos ido comentando a lo largo del tema, este sistema de login es muy rudimentario y nunca se debería emplear en aplicaciones reales. El único propósito es aprender como funcionan los filtros y un sistema de seguridad como Spring Security. Ten en cuenta que no hemos implementado ningún tipo de seguridad (comprobar que la sesión es válida, el formato del usuario y el password, evitar ataques csrf…)