Herramientas de usuario

Herramientas del sitio


clase:daw:spring:login_1

Seguridad: Filtros

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

  • Sesiones: Durante la interacción de un usuario con una aplicación web, se crea una sesión que almacena temporalmente información sobre esa sesión particular. Esta información puede incluir datos de autenticación, preferencias del usuario y otros detalles relacionados con la sesión.
  • Cookies: Las cookies son pequeños archivos de texto almacenados en el navegador del usuario. En el contexto de un sistema de login, se utilizan cookies para mantener la sesión del usuario y permitir que la aplicación lo identifique en visitas posteriores.

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

  • Envío de Credenciales: El usuario accede a la página de login de la aplicación web e ingresa su nombre de usuario y contraseña.
  • Autenticación: La aplicación web verifica las credenciales proporcionadas por el usuario. Esto implica comparar las credenciales ingresadas con las almacenadas en la aplicación para determinar si son válidas.
  • Creación de Sesión: Si las credenciales son válidas, la aplicación crea una sesión para el usuario. Cada sesión tiene un identificador de sesión único.
  • Generación y almacenamiento de Cookie: Además de crear la sesión, la aplicación genera una cookie de sesión que se almacena en el navegador del usuario. Esta cookie contiene el identificador de sesión. Esta cookie se envía automáticamente con cada solicitud subsiguiente al servidor, lo que permite que la aplicación identifique al usuario y mantenga su sesión activa.
  • Respuesta al Navegador: La aplicación responde al navegador con la página principal o la página a la que el usuario intentaba acceder después del login.

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:

  • Validación de Credenciales: Antes de autenticar al usuario, es importante realizar una validación de las credenciales proporcionadas para garantizar que sean correctas y seguras. Esto puede incluir verificar la longitud y complejidad de la contraseña, así como evitar vulnerabilidades como la inyección de SQL y la suplantación de identidad (phishing).
  • Gestión de Sesiones: Además de crear una sesión para el usuario después de la autenticación, es importante gestionar adecuadamente las sesiones para garantizar la seguridad y la privacidad del usuario. Esto puede incluir establecer tiempos de expiración de sesión, implementar políticas de cierre de sesión automático y proteger las sesiones contra ataques de secuestro de sesión.
  • Seguridad de Cookies: Las cookies de sesión deben ser seguras para evitar vulnerabilidades como la suplantación de cookies (cookie hijacking) y el robo de sesión (session hijacking). Esto puede lograrse utilizando cookies seguras que solo se envíen a través de conexiones HTTPS y estableciendo atributos de cookie como “HttpOnly” y “SameSite” para mitigar riesgos de seguridad.
  • Protección contra Fuerza Bruta: Para proteger contra ataques de fuerza bruta, donde un atacante intenta adivinar las credenciales de un usuario mediante la prueba de diferentes combinaciones de nombres de usuario y contraseñas, es importante implementar medidas de seguridad como el bloqueo de cuentas después de un número determinado de intentos fallidos de inicio de sesión.
  • Registro de Actividad: Es útil llevar un registro de actividad de inicio de sesión para realizar un seguimiento de las interacciones de los usuarios con la aplicación, incluyendo detalles como la fecha, hora y ubicación del inicio de sesión, así como cualquier acción realizada durante la sesión.

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:

  • Fíjate que en la etiqueta @WebFilter hemos añadido el la URI (“/login”) para indicar que el filtro sólo entrará en caso de que la ruta sea esa.
  • Con la etiqueta @WebFilter no podemos restringir el método HTTP, con lo que al principio del método comprobamos que sea POST. Si no es así, pasamos al siguiente filtro (si lo hubiera) y terminamos la ejecución del método (con la sentencia return)
  • Si el username o el password es null, redirigimos a login
  • Si las credenciales son correctas, se recupera la sesión del usuario (si no existe, se crea una nueva) y se añade el atributo username a la misma. Por último, se redirige a la página principal.
  • Obviamente, aquí estamos comprobando las credenciales de forma plana. Deberíamos consultar con una bbdd o similar donde estarían almacenados los usuarios.
  • Aunque aquí no lo estamos haciendo, todas las contraseñas deberían ir encriptadas.
  • Hemos utilizado la etiqueta @Order para indicar que ese será el primer filtro en ejecutarse en nuestra cadena de filtros (según en qué circunstancias, no especificar el orden de los filtros puede dar a lugar a que el sistema no funcione correctamente).

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…)
clase/daw/spring/login_1.txt · Última modificación: 2024/04/02 11:11 por cesguiro