Desarrollo web

Implementa un Sistema de Login Robusto con Spring Boot y Spring Security | Tucodigocotidano

¿Listo para construir un sistema de autenticación seguro y eficiente? En este tutorial completo de Tucodigocotidano, te guiaremos paso a paso para implementar un login de usuario con verificación OTP (One-Time Password) …

Publicado: 23/06/2025 Por: Juan Felipe Orozco Cortés Duración: 45-60 minutos
Contenido del tutorial

¡Hola, comunidad de "TuCodigoCotidiano"!

Hoy nos embarcaremos en un proyecto fundamental y de gran relevancia para cualquier desarrollador web: la implementación de un sistema de login robusto y seguro. En este tutorial completo, te guiaremos paso a paso a través de la creación de un sistema de autenticación de usuarios que incluye la vital verificación por OTP (One-Time Password), utilizando lo mejor de Spring Boot y Spring Security. Además, integraremos PostgreSQL para la gestión persistente de usuarios y Redis para el manejo eficiente de los códigos OTP.

Al final de este recorrido, no solo comprenderás a fondo los mecanismos de seguridad esenciales en aplicaciones web, sino que también tendrás a tu disposición el código completo para implementar tu propio sistema de autenticación desde cero.

¡Prepárate para asegurar tus aplicaciones y dominar la autenticación moderna!

Estructura de Carpetas del Proyecto

Para entender cómo está organizado nuestro proyecto de Spring Boot, a continuación se presenta un desglose de la estructura de carpetas. Esta organización sigue las convenciones estándar de Maven/Gradle y separa claramente la lógica de negocio, los controladores web, los repositorios de datos y los recursos estáticos (CSS, JavaScript, plantillas HTML):

  Organización de Archivos y Carpetas del Proyecto 

└───src
    └───main
        ├───java
        │   └───com
        │       └───hilosdememoria
        │           └───hilosdememoria
        │               ├───auth
        │               │   ├───config
        │               │   │       CustomAuthenticationFailureHandler.java
        │               │   │       SecurityConfig.java
        │               │   │
        │               │   ├───controller
        │               │   │       AuthController.java
        │               │   │
        │               │   ├───handler
        │               │   │       CustomAuthenticationSuccessHandler.java
        │               │   │
        │               │   ├───model
        │               │   │       User.java
        │               │   │
        │               │   ├───repository
        │               │   │       UserRepository.java
        │               │   │
        │               │   └───service
        │               │           CustomUserDetailsService.java
        │               │           EmailService.java
        │               │           OtpService.java
        │               │
        │               ├───dashboard
        │               │   └───controller
        │               │           DashboardController.java
        │               │
        │               ├───memories
        │               │   └───controller
        │               │           MemoriaController.java
        │               │
        │               └───HilosdememoriaApplication.java
        │
        └───resources
            ├───static
            │   ├───css
            │   │   ├───modules
            │   │   │       app-header.css
            │   │   │       button.css
            │   │   │       card.css
            │   │   │       footer.css
            │   │   │       logo.css
            │   │   │       navigation.css
            │   │   │
            │   │   ├───pages
            │   │   │       dashboard.css
            │   │   │       login.css
            │   │   │       registrarse.css
            │   │   │       verify.css
            │   │   │
            │   │   ├───base.css
            │   │   ├───layout.css
            │   │   ├───state.css
            │   │   ├───style.css
            │   │   └───theme.css
            │   │
            │   └───js
            │           contacto.js
            │
            ├───templates
            │   ├───auth
            │   │       login.html
            │   │       register.html
            │   │       verify.html
            │   │
            │   ├───dashboard
            │   │       dashboard.html
            │   │
            │   └───layout.html
            │
            └───application.properties

CustomAuthenticationFailureHandler.java

A continuación, se presenta el código de la clase CustomAuthenticationFailureHandler.java. Este componente de Spring Security es clave en nuestro sistema de autenticación, ya que su propósito es interceptar los intentos de inicio de sesión fallidos. En lugar de mostrar un error genérico, su lógica redirige específicamente a los usuarios no verificados hacia la página de validación de OTP para que completen el proceso:

  CustomAuthenticationFailureHandler.java – Manejador de Fallos de Autenticación: Redirección a Verificación OTP 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/config/CustomAuthenticationFailureHandler.java
package com.hilosdememoria.hilosdememoria.auth.config;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.FlashMap;
import org.springframework.web.servlet.FlashMapManager;
import org.springframework.web.servlet.support.SessionFlashMapManager;

import java.io.IOException;

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        FlashMapManager flashMapManager = new SessionFlashMapManager();
        FlashMap flashMap = new FlashMap();

        if (exception instanceof DisabledException) {
            String email = request.getParameter("username");
            flashMap.put("unverified", true); 
            flashMap.put("email", email); 
            flashMapManager.saveOutputFlashMap(flashMap, request, response);
            response.sendRedirect(request.getContextPath() + "/auth/verify"); 
        } else {
            flashMap.put("loginError", "Correo o contraseña incorrectos."); 
            flashMapManager.saveOutputFlashMap(flashMap, request, response);
            response.sendRedirect(request.getContextPath() + "/auth/"); 
        }
    }
}

Explicación del código: CustomAuthenticationFailureHandler.java – Manejador de Fallos de Autenticación: Redirección a Verificación OTP

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/config/CustomAuthenticationFailureHandler.java
Comentario que indica la ruta completa donde se encuentra este archivo dentro de la estructura del proyecto.

package com.hilosdememoria.hilosdememoria.auth.config;
Declara el paquete (namespace) de Java al que pertenece esta clase. Organiza el código en una jerarquía lógica, en este caso, dentro del subpaquete config del módulo auth.

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Importa clases del paquete jakarta.servlet, que son fundamentales para manejar peticiones y respuestas web en aplicaciones Java.

import org.springframework.security.authentication.DisabledException;
Importa la excepción DisabledException de Spring Security. Esta excepción se lanza específicamente cuando un usuario intenta iniciar sesión pero su cuenta está deshabilitada.

import org.springframework.security.core.AuthenticationException;
Importa la clase base AuthenticationException de Spring Security, que representa cualquier tipo de error durante el proceso de autenticación.

import org.springframework.security.web.authentication.AuthenticationFailureHandler;
Importa la interfaz AuthenticationFailureHandler de Spring Security. Las clases que implementan esta interfaz pueden definir un comportamiento personalizado cuando un inicio de sesión falla.

import org.springframework.stereotype.Component;
Importa la anotación @Component de Spring. Se usa para marcar una clase como un componente gestionado por el contenedor de Spring.

import org.springframework.web.servlet.FlashMap;
import org.springframework.web.servlet.FlashMapManager;
import org.springframework.web.servlet.support.SessionFlashMapManager;
Importa clases de Spring MVC para manejar "Flash Attributes". Estos son atributos que se almacenan temporalmente (generalmente en la sesión) para ser usados después de una redirección.

import java.io.IOException;
Importa la excepción IOException, que debe ser manejada o declarada en métodos que realizan operaciones de entrada/salida.

@Component
Anotación que le dice a Spring que esta clase es un "componente". Spring la detectará automáticamente, creará una instancia y la gestionará, permitiendo que sea inyectada en otras partes de la aplicación donde se necesite.

public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
Define la clase pública CustomAuthenticationFailureHandler. La parte implements AuthenticationFailureHandler significa que esta clase se compromete a proporcionar una implementación para los métodos definidos en esa interfaz, en este caso, el método onAuthenticationFailure.

@Override
Anotación que indica que el método siguiente (onAuthenticationFailure) está sobreescribiendo un método de la interfaz que implementa.

public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
La implementación del método que se ejecuta cada vez que un usuario falla al iniciar sesión. Recibe el objeto de la petición (request), el de la respuesta (response) y la excepción específica (exception) que causó el fallo.

FlashMapManager flashMapManager = new SessionFlashMapManager();
Crea una instancia de SessionFlashMapManager, que es el gestor encargado de guardar los "Flash Attributes" en la sesión del usuario.

FlashMap flashMap = new FlashMap();
Crea un FlashMap, que es un mapa donde almacenaremos los datos que queremos que sobrevivan a la redirección.

if (exception instanceof DisabledException) {
Comprueba si el error de autenticación (exception) fue específicamente porque la cuenta está deshabilitada (es una instancia de DisabledException). En este proyecto, esto se usa para identificar a un usuario que aún no ha verificado su cuenta con OTP.

String email = request.getParameter("username");
Si el usuario está deshabilitado, obtiene el correo electrónico que el usuario ingresó en el campo "username" del formulario de login.

flashMap.put("unverified", true);
Añade un atributo unverified con valor true al flashMap. La página de verificación usará este atributo para saber que debe mostrar un mensaje específico.

flashMap.put("email", email);
Añade el correo electrónico del usuario al flashMap para que pueda ser mostrado o reutilizado en la página de verificación.

flashMapManager.saveOutputFlashMap(flashMap, request, response);
Guarda el flashMap (con los atributos unverified y email) para que esté disponible después de la redirección.

response.sendRedirect(request.getContextPath() + "/auth/verify");
Redirige al usuario a la página /auth/verify, que es la página para la verificación por OTP.

} else {
Si el error de autenticación fue por cualquier otra razón (por ejemplo, contraseña incorrecta o usuario no existente).

flashMap.put("loginError", "Correo o contraseña incorrectos.");
Añade un atributo loginError con un mensaje de error genérico al flashMap.

flashMapManager.saveOutputFlashMap(flashMap, request, response);
Guarda el flashMap (con el atributo loginError) para que esté disponible después de la redirección.

response.sendRedirect(request.getContextPath() + "/auth/");
Redirige al usuario de vuelta a la página de inicio de sesión (/auth/) para que pueda volver a intentarlo.

}
}
}
Cierra el bloque else, el método onAuthenticationFailure y la clase CustomAuthenticationFailureHandler.

SecurityConfig.java

A continuación, se presenta el código de la clase SecurityConfig.java. Este archivo es el cerebro de la seguridad en nuestra aplicación Spring Boot. Aquí se definen las reglas de acceso a las diferentes URLs, se configura el formulario de inicio de sesión (incluyendo nuestro manejador de fallos personalizado), se gestiona el proceso de cierre de sesión y se establecen componentes esenciales como el codificador de contraseñas y el gestor de autenticación:

  SecurityConfig.java – Configuración Central de Spring Security: Reglas de Acceso y Login 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/config/SecurityConfig.java
package com.hilosdememoria.hilosdememoria.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.beans.factory.annotation.Autowired;
import com.hilosdememoria.hilosdememoria.auth.service.CustomUserDetailsService;

@Configuration
public class SecurityConfig {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler; // Inyecta el nuevo handler

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

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder
                .userDetailsService(customUserDetailsService)
                .passwordEncoder(passwordEncoder());
        return authenticationManagerBuilder.build();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/register", "/auth/verify", "/css/**", "/js/**", "/", "/error").permitAll()
                        .requestMatchers("/dashboard").hasRole("USER")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/auth/")
                        .loginProcessingUrl("/auth/") // URL donde Spring Security procesará el POST
                        .defaultSuccessUrl("/dashboard", true)
                        // Usa nuestro handler personalizado para fallos de autenticación
                        .failureHandler(customAuthenticationFailureHandler)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/auth/?logout")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                        .permitAll()
                );
        return http.build();
    }
}

Explicación del código: SecurityConfig.java – Configuración Central de Spring Security: Reglas de Acceso y Login

package com.hilosdememoria.hilosdememoria.auth.config;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro de la configuración de autenticación (auth.config).

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.beans.factory.annotation.Autowired;
import com.hilosdememoria.hilosdememoria.auth.service.CustomUserDetailsService;
Importa las clases necesarias de Spring Framework y Spring Security para configurar la seguridad de la aplicación, incluyendo beans, configuración, autenticación, seguridad web y codificación de contraseñas. También importa el CustomUserDetailsService que hemos creado.

@Configuration
Anotación que marca esta clase como una fuente de definiciones de beans para el contexto de la aplicación. Spring la usará para configurar la seguridad.

public class SecurityConfig {
Define la clase pública SecurityConfig que contendrá toda la configuración de seguridad.

@Autowired
private CustomUserDetailsService customUserDetailsService;
Inyecta una instancia de CustomUserDetailsService. @Autowired le dice a Spring que proporcione automáticamente una instancia de esta clase, que es la que se encarga de cargar los detalles del usuario desde la base de datos.

@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler; // Inyecta el nuevo handler
Inyecta una instancia de nuestra clase CustomAuthenticationFailureHandler. Spring la proveerá porque está marcada con @Component. Esta clase manejará los fallos en el inicio de sesión.

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
Define un bean de Spring para el PasswordEncoder. @Bean indica que el objeto devuelto por este método debe ser registrado como un bean en el contexto de Spring. Se utiliza BCryptPasswordEncoder, un codificador de contraseñas robusto y estándar en la industria que utiliza el algoritmo de hashing BCrypt.

@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
Define el bean AuthenticationManager, que es el componente principal en Spring Security para procesar las solicitudes de autenticación.

AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
Obtiene el AuthenticationManagerBuilder compartido desde el objeto HttpSecurity.

authenticationManagerBuilder
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
Configura el gestor de autenticación para que use nuestro customUserDetailsService para encontrar usuarios y nuestro passwordEncoder() para verificar las contraseñas.

return authenticationManagerBuilder.build();
}
Construye y devuelve el AuthenticationManager configurado.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
Define la cadena de filtros de seguridad principal de la aplicación. Este es el método más importante, donde se configuran las reglas de acceso a las URLs, el formulario de login, el logout, etc.

http
.csrf(csrf -> csrf.disable())
Deshabilita la protección contra ataques CSRF (Cross-Site Request Forgery). Es común deshabilitarla en aplicaciones que no usan el sistema tradicional de formularios de Spring o que son consumidas por APIs, aunque en producción se deben considerar las implicaciones de seguridad.

.authorizeHttpRequests(auth -> auth
Inicia la configuración de las reglas de autorización para las peticiones HTTP.

.requestMatchers("/auth/register", "/auth/verify", "/css/**", "/js/**", "/", "/error").permitAll()
Permite el acceso público (sin necesidad de autenticación) a las rutas de registro, verificación, los archivos CSS y JS, la página de inicio (/) y la página de error por defecto.

.requestMatchers("/dashboard").hasRole("USER")
Especifica que para acceder a la ruta /dashboard, el usuario debe estar autenticado y tener el rol "USER".

.anyRequest().authenticated()
Requiere que cualquier otra petición (anyRequest) que no haya sido especificada anteriormente deba ser realizada por un usuario autenticado.

)
.formLogin(form -> form
Inicia la configuración del formulario de inicio de sesión.

.loginPage("/auth/")
Especifica que nuestra página de inicio de sesión personalizada se encuentra en la ruta /auth/.

.loginProcessingUrl("/auth/") // URL donde Spring Security procesará el POST
Indica a Spring Security que debe interceptar y procesar las peticiones POST a /auth/ para la autenticación.

.defaultSuccessUrl("/dashboard", true)
Redirige al usuario a /dashboard después de un inicio de sesión exitoso. El true fuerza la redirección incluso si el usuario intentaba acceder a otra página antes de loguearse.

// Usa nuestro handler personalizado para fallos de autenticación
.failureHandler(customAuthenticationFailureHandler)
Configura Spring Security para que utilice nuestra clase customAuthenticationFailureHandler cuando un intento de inicio de sesión falle.

.permitAll()
Permite el acceso público a las URLs relacionadas con el formulario de login.

)
.logout(logout -> logout
Inicia la configuración del proceso de cierre de sesión.

.logoutUrl("/logout")
Define /logout como la URL que activará el cierre de sesión.

.logoutSuccessUrl("/auth/?logout")
Redirige al usuario a /auth/?logout después de cerrar sesión exitosamente. Este parámetro logout puede usarse para mostrar un mensaje de "Sesión cerrada".

.invalidateHttpSession(true)
Invalida la sesión HTTP del usuario al cerrar sesión, eliminando cualquier dato de sesión.

.deleteCookies("JSESSIONID")
Elimina la cookie de sesión (JSESSIONID) del navegador del usuario.

.permitAll()
Permite el acceso público a la URL de logout.

);
return http.build();
}
}
Construye y devuelve el objeto SecurityFilterChain con toda la configuración definida. Cierre de la clase SecurityConfig.

AuthController.java

El siguiente bloque de código corresponde a la clase AuthController.java. Este controlador de Spring es el núcleo del flujo de autenticación de nuestra aplicación. Se encarga de mostrar las páginas de inicio de sesión, registro y verificación, así como de procesar los datos enviados por el usuario, como la creación de una nueva cuenta y la validación del código OTP:

  AuthController.java – Controlador de Autenticación: Registro, Login y Verificación OTP 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/controller/AuthController.java
package com.hilosdememoria.hilosdememoria.auth.controller;

import com.hilosdememoria.hilosdememoria.auth.model.User;
import com.hilosdememoria.hilosdememoria.auth.repository.UserRepository;
import com.hilosdememoria.hilosdememoria.auth.service.EmailService;
import com.hilosdememoria.hilosdememoria.auth.service.OtpService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.Optional; // Import para Optional

@Controller
@RequestMapping("/auth")
public class AuthController {

    @Autowired private OtpService otpService;
    @Autowired private EmailService emailService;
    @Autowired private UserRepository userRepository;
    @Autowired private PasswordEncoder passwordEncoder;

    @GetMapping("/")
    public String showLoginForm(
            @RequestParam(value = "logout", required = false) String logout,
            Model model) {
        model.addAttribute("tituloPagina", "Iniciar Sesión - Hilos de Memoria");
        if (logout != null) {
            model.addAttribute("success", "Has cerrado sesión correctamente.");
        }
        return "auth/login";
    }

    @GetMapping("/register")
    public String showRegisterForm(Model model) {
        model.addAttribute("tituloPagina", "Registrarse - Hilos de Memoria");
        return "auth/register";
    }

    @PostMapping("/register")
    public String processRegistration(String fullname, String email, String password, String confirmPassword, RedirectAttributes redirectAttributes) {
        if (!password.equals(confirmPassword)) {
            redirectAttributes.addFlashAttribute("error", "Las contraseñas no coinciden.");
            return "redirect:/auth/register";
        }
        if (userRepository.findByEmail(email).isPresent()) {
            redirectAttributes.addFlashAttribute("error", "Este correo electrónico ya está en uso.");
            return "redirect:/auth/register";
        }
        User newUser = new User();
        newUser.setFullname(fullname);
        newUser.setEmail(email);
        newUser.setPassword(passwordEncoder.encode(password));
        newUser.setVerified(false); 
        userRepository.save(newUser);
        String otp = otpService.generateOtp(email);
        String emailBody = "Hola " + fullname + ",\n\nBienvenido/a. Tu cuenta ha sido creada.\nPara activarla, usa el código: " + otp + "\n\nEste código es válido por 10 minutos.";
        emailService.sendEmail(email, "Activa tu Cuenta - Hilos de Memoria", emailBody);
        redirectAttributes.addFlashAttribute("info", "¡Cuenta creada! Revisa tu correo para obtener el código de activación.");
        redirectAttributes.addAttribute("email", email);
        return "redirect:/auth/verify";
    }

    @GetMapping("/verify")
    public String showVerifyForm(@RequestParam(required = false) String email,
                                 @RequestParam(value = "unverified", required = false) boolean unverifiedAttempt, // Parámetro del CustomAuthenticationFailureHandler
                                 Model model,
                                 RedirectAttributes redirectAttributes) {

        model.addAttribute("tituloPagina", "Verificar Cuenta");
        model.addAttribute("email", email); 

        if (email == null || email.isEmpty()) {
            redirectAttributes.addFlashAttribute("error", "No se proporcionó correo electrónico para verificar.");
            return "redirect:/auth/"; 
        }

        Optional<User> userOptional = userRepository.findByEmail(email);
        if (userOptional.isEmpty()) {
            redirectAttributes.addFlashAttribute("error", "Usuario no encontrado para verificación.");
            return "redirect:/auth/"; 
        }

        User user = userOptional.get();

        if (user.isVerified()) {
            redirectAttributes.addFlashAttribute("success", "Tu cuenta ya está activa. Inicia sesión.");
            return "redirect:/auth/";
        }
        
        if (unverifiedAttempt || !otpService.hasActiveOtp(email)) {
            String newOtp = otpService.generateOtp(email);
            String emailBody = "Tu nuevo código de verificación es: " + newOtp + "\n\nEste código es válido por 10 minutos.";
            emailService.sendEmail(email, "Nuevo Código de Verificación - Hilos de Memoria", emailBody);
            model.addAttribute("info", "Tu cuenta no está activada o el código expiró. Hemos enviado un nuevo código de activación a tu correo.");
        } else {
            model.addAttribute("info", "Por favor, ingresa el código de activación que te hemos enviado a tu correo.");
        }

        return "auth/verify";
    }

    @PostMapping("/verify")
    public String processVerification(@RequestParam String email, @RequestParam String otp, RedirectAttributes redirectAttributes) {
        if (!otpService.validateOtp(email, otp)) {
            redirectAttributes.addFlashAttribute("error", "Código de verificación incorrecto o expirado.");
            redirectAttributes.addAttribute("email", email);
            return "redirect:/auth/verify";
        }
        User userToVerify = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("Error fatal: Usuario no encontrado durante la verificación."));
        userToVerify.setVerified(true);
        userRepository.save(userToVerify);

        redirectAttributes.addFlashAttribute("success", "¡Tu cuenta ha sido activada! Ahora puedes iniciar sesión.");
        return "redirect:/auth/"; 
    }
}

Explicación del código: AuthController.java – Controlador de Autenticación: Registro, Login y Verificación OTP

package com.hilosdememoria.hilosdememoria.auth.controller;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete controller del módulo auth.

import com.hilosdememoria.hilosdememoria.auth.model.User;
import com.hilosdememoria.hilosdememoria.auth.repository.UserRepository;
import com.hilosdememoria.hilosdememoria.auth.service.EmailService;
import com.hilosdememoria.hilosdememoria.auth.service.OtpService;
Importa las clases propias del proyecto necesarias para este controlador: User (el modelo), UserRepository (para acceder a la base de datos), y los servicios EmailService y OtpService.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
Importa las clases y anotaciones necesarias de Spring Framework para crear un controlador web, manejar peticiones, pasar datos a las vistas y realizar redirecciones.

import java.io.IOException; // Aunque no se usa directamente, es requerido por los métodos que se sobreescriben implícitamente
import java.util.Optional; // Import para Optional
Importa clases de Java. Optional se usa para manejar de forma segura valores que pueden ser nulos (como el resultado de una búsqueda en la base de datos).

@Controller
Anotación que marca esta clase como un controlador de Spring MVC. Se encargará de recibir peticiones web y devolver vistas (páginas HTML).

@RequestMapping("/auth")
Anotación que mapea todas las peticiones que comiencen con /auth a los métodos de este controlador.

public class AuthController {
Define la clase pública AuthController que contendrá la lógica para manejar las rutas de autenticación.

@Autowired private OtpService otpService;
@Autowired private EmailService emailService;
@Autowired private UserRepository userRepository;
@Autowired private PasswordEncoder passwordEncoder;
Inyección de dependencias. @Autowired le dice a Spring que proporcione automáticamente instancias de estos componentes (servicios, repositorios y el codificador de contraseñas) para que puedan ser utilizados dentro del controlador.

@GetMapping("/")
Mapea las peticiones GET a la ruta /auth/ (la raíz del controlador) a este método.

public String showLoginForm(
@RequestParam(value = "logout", required = false) String logout,
Model model) {
Define el método showLoginForm que mostrará el formulario de inicio de sesión. @RequestParam busca un parámetro logout en la URL (ej. /auth/?logout), que es opcional (required = false). Model es un objeto para pasar datos a la vista.

model.addAttribute("tituloPagina", "Iniciar Sesión - Hilos de Memoria");
Añade un atributo llamado tituloPagina al modelo, que podrá ser usado en la plantilla HTML para establecer el título de la página.

if (logout != null) {
model.addAttribute("success", "Has cerrado sesión correctamente.");
}
Si el parámetro logout está presente en la URL, añade un atributo success al modelo con un mensaje de cierre de sesión exitoso.

return "auth/login";
}
Devuelve el nombre de la vista (plantilla HTML) que se debe renderizar. Spring buscará un archivo llamado login.html dentro de una carpeta auth en el directorio de plantillas (ej. resources/templates/).

@GetMapping("/register")
Mapea las peticiones GET a la ruta /auth/register a este método.

public String showRegisterForm(Model model) {
model.addAttribute("tituloPagina", "Registrarse - Hilos de Memoria");
return "auth/register";
}
Define el método showRegisterForm que añade un título al modelo y devuelve la vista del formulario de registro.

@PostMapping("/register")
Mapea las peticiones POST a la ruta /auth/register a este método. Este método procesará los datos enviados desde el formulario de registro.

public String processRegistration(String fullname, String email, String password, String confirmPassword, RedirectAttributes redirectAttributes) {
Define el método processRegistration. Spring automáticamente enlaza los parámetros del formulario (fullname, email, etc.) a los argumentos del método. RedirectAttributes se usa para pasar mensajes a la siguiente página después de una redirección.

if (!password.equals(confirmPassword)) {
redirectAttributes.addFlashAttribute("error", "Las contraseñas no coinciden.");
return "redirect:/auth/register";
}
Comprueba si las contraseñas coinciden. Si no, añade un mensaje de error como "Flash Attribute" (que sobrevive a la redirección) y redirige al usuario de vuelta al formulario de registro.

if (userRepository.findByEmail(email).isPresent()) {
redirectAttributes.addFlashAttribute("error", "Este correo electrónico ya está en uso.");
return "redirect:/auth/register";
}
Comprueba si ya existe un usuario con ese correo electrónico en la base de datos. Si es así, añade un error y redirige de vuelta.

User newUser = new User();
newUser.setFullname(fullname);
newUser.setEmail(email);
Crea una nueva instancia del objeto User y le asigna el nombre completo y el correo electrónico del formulario.

newUser.setPassword(passwordEncoder.encode(password));
Codifica la contraseña ingresada por el usuario usando el passwordEncoder (BCrypt) y la asigna al nuevo usuario. Nunca se guarda la contraseña en texto plano.

newUser.setVerified(false); // Asegúrate de que la cuenta no esté verificada al registrarse
Establece el estado de verificación del nuevo usuario a false por defecto.

userRepository.save(newUser);
Guarda el nuevo objeto User en la base de datos a través del userRepository.

String otp = otpService.generateOtp(email);
Genera un nuevo código OTP para el correo electrónico del usuario utilizando el OtpService.

String emailBody = "Hola " + fullname + ",\n\nBienvenido/a. Tu cuenta ha sido creada.\nPara activarla, usa el código: " + otp + "\n\nEste código es válido por 10 minutos.";
Crea el cuerpo del correo electrónico de verificación, incluyendo el código OTP generado.

emailService.sendEmail(email, "Activa tu Cuenta - Hilos de Memoria", emailBody);
Utiliza el EmailService para enviar el correo de verificación al nuevo usuario.

redirectAttributes.addFlashAttribute("info", "¡Cuenta creada! Revisa tu correo para obtener el código de activación.");
Añade un mensaje informativo como "Flash Attribute" para mostrarlo en la siguiente página.

redirectAttributes.addAttribute("email", email);
Añade el correo electrónico como un parámetro en la URL de redirección (ej. /auth/verify?email=...).

return "redirect:/auth/verify";
}
Redirige al usuario a la página de verificación.

@GetMapping("/verify")
Mapea las peticiones GET a la ruta /auth/verify a este método.

public String showVerifyForm(@RequestParam(required = false) String email,
@RequestParam(value = "unverified", required = false) boolean unverifiedAttempt, // Parámetro del CustomAuthenticationFailureHandler
Model model,
RedirectAttributes redirectAttributes) {
Define el método showVerifyForm. Recibe el email y un booleano opcional unverifiedAttempt como parámetros de la URL. Este último es enviado por nuestro CustomAuthenticationFailureHandler.

model.addAttribute("tituloPagina", "Verificar Cuenta");
model.addAttribute("email", email);
Añade el título de la página y el correo electrónico al modelo para que la vista pueda usarlos.

if (email == null || email.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "No se proporcionó correo electrónico para verificar.");
return "redirect:/auth/"; // Redirigir al login si no hay email
}
Comprueba si se proporcionó un email. Si no, redirige al login con un mensaje de error.

Optional<User> userOptional = userRepository.findByEmail(email);
if (userOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "Usuario no encontrado para verificación.");
return "redirect:/auth/"; // Redirigir al login si el usuario no existe
}
Busca al usuario por su email. Si no se encuentra, redirige al login con un error.

User user = userOptional.get();
Obtiene el objeto User del Optional.

if (user.isVerified()) {
redirectAttributes.addFlashAttribute("success", "Tu cuenta ya está activa. Inicia sesión.");
return "redirect:/auth/";
}
Si el usuario ya está verificado, lo redirige al login con un mensaje de éxito.

if (unverifiedAttempt || !otpService.hasActiveOtp(email)) {
Comprueba si el usuario llegó aquí desde un intento de login fallido (unverifiedAttempt es true) O si no tiene un OTP activo (porque nunca se generó o ya expiró).

String newOtp = otpService.generateOtp(email);
String emailBody = "Tu nuevo código de verificación es: " + newOtp + "\n\nEste código es válido por 10 minutos.";
emailService.sendEmail(email, "Nuevo Código de Verificación - Hilos de Memoria", emailBody);
model.addAttribute("info", "Tu cuenta no está activada o el código expiró. Hemos enviado un nuevo código de activación a tu correo.");
} else {
Si alguna de las condiciones anteriores es cierta, genera y envía un nuevo código OTP, y añade un mensaje informativo al modelo para la vista.

model.addAttribute("info", "Por favor, ingresa el código de activación que te hemos enviado a tu correo.");
}
Si el usuario tiene un OTP activo y no viene de un intento de login fallido, simplemente se le muestra un mensaje genérico.

return "auth/verify";
}
Devuelve el nombre de la vista del formulario de verificación.

@PostMapping("/verify")
Mapea las peticiones POST a la ruta /auth/verify a este método. Procesará el código OTP enviado por el usuario.

public String processVerification(@RequestParam String email, @RequestParam String otp, RedirectAttributes redirectAttributes) {
Define el método processVerification, que recibe el email y el otp del formulario.

if (!otpService.validateOtp(email, otp)) {
redirectAttributes.addFlashAttribute("error", "Código de verificación incorrecto o expirado.");
redirectAttributes.addAttribute("email", email);
return "redirect:/auth/verify";
}
Usa el OtpService para validar el código. Si no es válido, añade un mensaje de error y redirige de vuelta a la página de verificación.

User userToVerify = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("Error fatal: Usuario no encontrado durante la verificación."));
Busca al usuario por su email. Si no lo encuentra en este punto (lo cual sería un error grave, ya que debería existir), lanza una excepción.

userToVerify.setVerified(true);
Si el OTP es válido, establece la propiedad verified del usuario a true.

userRepository.save(userToVerify);
Guarda el usuario actualizado (ahora verificado) en la base de datos.

redirectAttributes.addFlashAttribute("success", "¡Tu cuenta ha sido activada! Ahora puedes iniciar sesión.");
Añade un mensaje de éxito como "Flash Attribute".

return "redirect:/auth/"; // Redirige al login, como solicitaste
}
}
Redirige al usuario a la página de inicio de sesión, donde podrá iniciar sesión con su cuenta recién activada. Cierre de la clase AuthController.

User.java

A continuación, se presenta el código de la clase User.java. Esta clase es una entidad de JPA que modela la tabla de usuarios en nuestra base de datos PostgreSQL. Define los campos que cada usuario tendrá, como su ID, nombre, correo, contraseña y estado de verificación, utilizando anotaciones para mapear la clase a la tabla de la base de datos:

  User.java – Modelo de Entidad de Usuario: Mapeo JPA y Atributos 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/model/User.java
package com.hilosdememoria.hilosdememoria.auth.model;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String fullname;
    @Column(unique = true, nullable = false)
    private String email;
    @Column(nullable = false)
    private String password;
    @Column(nullable = false)
    private boolean verified = false;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getFullname() { return fullname; }
    public void setFullname(String fullname) { this.fullname = fullname; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public boolean isVerified() { return verified; }
    public void setVerified(boolean verified) { this.verified = verified; }
}

Explicación del código: User.java – Modelo de Entidad de Usuario: Mapeo JPA y Atributos

package com.hilosdememoria.hilosdememoria.auth.model;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete model del módulo auth.

import jakarta.persistence.*;
Importa todas las clases y anotaciones del paquete jakarta.persistence. Este paquete es parte de la especificación JPA (Jakarta Persistence API) y se utiliza para mapear objetos Java a tablas de una base de datos relacional.

@Entity
Anotación de JPA que marca esta clase User como una "entidad". Esto le indica al proveedor de persistencia (como Hibernate) que esta clase corresponde a una tabla en la base de datos.

@Table(name = "users")
Anotación de JPA que especifica el nombre de la tabla en la base de datos a la que se mapeará esta entidad. En este caso, la tabla se llama users.

public class User {
Define la clase pública User, que servirá como el modelo para los datos de un usuario.

@Id
Anotación de JPA que marca el siguiente campo (id) como la clave primaria (primary key) de la tabla.

@GeneratedValue(strategy = GenerationType.IDENTITY)
Anotación de JPA que especifica cómo se genera el valor de la clave primaria. GenerationType.IDENTITY indica que la generación del valor se delega a la base de datos, que típicamente usará una columna de tipo auto-incremento.

private Long id;
Define el campo id de tipo Long (un número largo) para almacenar el identificador único de cada usuario. Es private para encapsular los datos.

private String fullname;
Define el campo fullname de tipo String para almacenar el nombre completo del usuario.

@Column(unique = true, nullable = false)
Anotación de JPA que personaliza el mapeo de la siguiente columna (email). unique = true asegura que no puede haber dos usuarios con el mismo email en la base de datos. nullable = false indica que este campo no puede ser nulo.

private String email;
Define el campo email de tipo String para almacenar el correo electrónico del usuario.

@Column(nullable = false)
Anotación de JPA que indica que el siguiente campo (password) no puede ser nulo en la base de datos.

private String password;
Define el campo password de tipo String para almacenar la contraseña codificada (hashed) del usuario.

@Column(nullable = false)
Anotación de JPA que indica que el siguiente campo (verified) no puede ser nulo.

private boolean verified = false;
Define el campo verified de tipo boolean para almacenar si la cuenta del usuario ha sido verificada (ej. por correo/OTP). Se inicializa en false por defecto para todos los nuevos usuarios.

// Getters y Setters
Comentario que introduce los métodos de acceso a los campos privados de la clase.

public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFullname() { return fullname; }
public void setFullname(String fullname) { this.fullname = fullname; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public boolean isVerified() { return verified; }
public void setVerified(boolean verified) { this.verified = verified; }
Conjunto de métodos "getter" y "setter" públicos. Siguen la convención de JavaBeans y permiten a otras partes de la aplicación acceder y modificar de forma controlada los valores de los campos privados (id, fullname, email, etc.) de esta clase. El método isVerified() es la convención "getter" para campos booleanos.

}
Cierra la definición de la clase User.

UserRepository.java

El siguiente bloque de código corresponde a la interfaz UserRepository.java. Este componente es un repositorio de Spring Data JPA que abstrae las operaciones de la base de datos para la entidad User. Al extender JpaRepository, heredamos automáticamente métodos estándar como guardar, buscar por ID y eliminar, y además definimos un método personalizado para buscar usuarios por su correo electrónico:

  UserRepository.java – Repositorio de Datos de Usuario: JpaRepository y Búsqueda por Email 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/repository/UserRepository.java
package com.hilosdememoria.hilosdememoria.auth.repository;

import com.hilosdememoria.hilosdememoria.auth.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

Explicación del código: UserRepository.java – Repositorio de Datos de Usuario: JpaRepository y Búsqueda por Email

package com.hilosdememoria.hilosdememoria.auth.repository;
Declara el paquete (namespace) de Java al que pertenece esta interfaz, ubicándola dentro del subpaquete repository del módulo auth.

import com.hilosdememoria.hilosdememoria.auth.model.User;
Importa la clase User del paquete model. Esto permite que la interfaz UserRepository sepa qué tipo de objeto va a gestionar.

import org.springframework.data.jpa.repository.JpaRepository;
Importa la interfaz JpaRepository de Spring Data JPA. Esta es una interfaz genérica que proporciona una gran cantidad de operaciones de base de datos estándar.

import java.util.Optional;
Importa la clase Optional de Java, que se utiliza para encapsular un valor que puede ser nulo, ayudando a prevenir errores de tipo NullPointerException.

public interface UserRepository extends JpaRepository<User, Long> {
Define una interfaz pública llamada UserRepository.
extends JpaRepository<User, Long> es la parte clave:

extends JpaRepository: UserRepository hereda toda la funcionalidad de JpaRepository, lo que significa que automáticamente tendremos métodos para operaciones CRUD (Crear, Leer, Actualizar, Eliminar) como save(), findById(), findAll(), delete(), etc., sin necesidad de escribirlos.
<User, Long>: Estos son los parámetros genéricos. User indica que este repositorio gestionará entidades de tipo User. Long indica que el tipo de dato de la clave primaria (@Id) de la entidad User es Long.

Optional<User> findByEmail(String email);
Define la firma de un método personalizado. Spring Data JPA es lo suficientemente inteligente como para interpretar el nombre de este método y generar automáticamente la consulta necesaria.

findByEmail: Le dice a Spring Data que cree una consulta que busque en la entidad User un registro donde el campo email coincida con el parámetro proporcionado.
(String email): El parámetro que se usará para la búsqueda.
Optional<User>: El tipo de dato que devuelve el método. Si encuentra un usuario con ese email, devolverá un Optional conteniendo el objeto User. Si no encuentra ninguno, devolverá un Optional vacío, lo que permite un manejo más seguro de los casos en que el usuario no existe.

}
Cierra la definición de la interfaz UserRepository.

CustomUserDetailsService.java

A continuación, se presenta el código de la clase CustomUserDetailsService.java. Este servicio es un componente fundamental de Spring Security que actúa como puente entre nuestra base de datos y el mecanismo de autenticación de Spring. Su responsabilidad principal es cargar los detalles de un usuario por su nombre de usuario (en este caso, el correo electrónico) y convertirlos en un objeto que Spring Security pueda entender y utilizar para verificar las credenciales:

  CustomUserDetailsService.java – Servicio de Detalles de Usuario para Spring Security 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/service/CustomUserDetailsService.java
package com.hilosdememoria.hilosdememoria.auth.service;

import com.hilosdememoria.hilosdememoria.auth.model.User;
import com.hilosdememoria.hilosdememoria.auth.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import java.util.Collections;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado con email: " + email));

        return new org.springframework.security.core.userdetails.User(
                user.getEmail(),
                user.getPassword(),
                user.isVerified(),
                true,
                true,
                true,
                Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
        );
    }
}

Explicación del código: CustomUserDetailsService.java – Servicio de Detalles de Usuario para Spring Security

package com.hilosdememoria.hilosdememoria.auth.service;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete service del módulo auth.

import com.hilosdememoria.hilosdememoria.auth.model.User;
import com.hilosdememoria.hilosdememoria.auth.repository.UserRepository;
Importa las clases propias del proyecto User y UserRepository, que son necesarias para buscar y trabajar con los datos del usuario.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
Importa las clases y anotaciones necesarias de Spring Framework y Spring Security. Son cruciales para definir un servicio, realizar inyección de dependencias y trabajar con los objetos de seguridad de Spring (UserDetails, UserDetailsService, etc.).

import java.util.Collections;
Importa la clase Collections de Java, que proporciona métodos de utilidad para trabajar con colecciones, como crear una lista de un solo elemento.

@Service
Anotación que marca esta clase como un "Servicio" en la capa de negocio de la aplicación. Es una especialización de @Component, y le indica a Spring que gestione esta clase como un bean.

public class CustomUserDetailsService implements UserDetailsService {
Define la clase pública CustomUserDetailsService. La parte implements UserDetailsService es fundamental: obliga a esta clase a proporcionar una implementación para el método loadUserByUsername. Spring Security usará este servicio para cargar los datos de un usuario durante el proceso de autenticación.

@Autowired
private UserRepository userRepository;
Inyecta una instancia de UserRepository. @Autowired le dice a Spring que proporcione automáticamente una instancia del repositorio de usuarios para que podamos interactuar con la base de datos.

@Override
Anotación que indica que el método siguiente (loadUserByUsername) está sobreescribiendo un método de la interfaz UserDetailsService que implementa.

public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
La implementación del único método requerido por UserDetailsService. Spring Security llamará a este método cuando un usuario intente iniciar sesión. Aunque el nombre del método es loadUserByUsername, en nuestra configuración lo usamos para buscar por email.

User user = userRepository.findByEmail(email)
Utiliza el userRepository para buscar un usuario en la base de datos por el email proporcionado en el formulario de login.

.orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado con email: " + email));
El método findByEmail devuelve un Optional<User>. .orElseThrow() maneja el caso en que el Optional está vacío (usuario no encontrado). Si no se encuentra el usuario, lanza una excepción UsernameNotFoundException con un mensaje de error, lo que Spring Security interpreta como un fallo de autenticación.

return new org.springframework.security.core.userdetails.User(
Si se encuentra el usuario, crea y devuelve una nueva instancia de org.springframework.security.core.userdetails.User. Este es el objeto que Spring Security entiende y utiliza internamente para la autenticación. Se construye con los siguientes parámetros:

user.getEmail(),
El nombre de usuario para Spring Security (en este caso, el email del usuario encontrado).

user.getPassword(),
La contraseña codificada (hashed) del usuario, obtenida de la base de datos. Spring Security la comparará con la contraseña ingresada en el formulario (después de codificarla también).

user.isVerified(), 
El estado "enabled" (habilitado) de la cuenta. Aquí se usa la propiedad verified de nuestra entidad User. Si user.isVerified() devuelve false, Spring Security tratará la cuenta como deshabilitada y lanzará una DisabledException, que es interceptada por nuestro CustomAuthenticationFailureHandler.

true, 
El estado "accountNonExpired" (la cuenta no ha expirado). Se establece en true.

true, 
El estado "credentialsNonExpired" (las credenciales no han expirado). Se establece en true.


true, 
El estado "accountNonLocked" (la cuenta no está bloqueada). Se establece en true.

Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) 
Los roles o autoridades del usuario. Collections.singletonList crea una lista con un solo elemento. new SimpleGrantedAuthority("ROLE_USER") le asigna a cada usuario el rol "USER". Spring Security usa este rol (prefijado con ROLE_) para las reglas de autorización.

);
}
}
Cierra el constructor de User, el método loadUserByUsername y la clase CustomUserDetailsService.

EmailService.java

El siguiente bloque de código corresponde a la clase EmailService.java. Este servicio de Spring encapsula toda la lógica necesaria para enviar correos electrónicos. Su función es construir y despachar un mensaje simple, utilizado en este proyecto para enviar el código de verificación (OTP) a los nuevos usuarios:

  EmailService.java – Servicio de Envío de Correo Electrónico con Spring Mail 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/service/EmailService.java
package com.hilosdememoria.hilosdememoria.auth.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Service
public class EmailService {
    @Autowired
    private JavaMailSender mailSender;

    public void sendEmail(String to, String subject, String text) {
        try {
            SimpleMailMessage message = new SimpleMailMessage();
            message.setFrom("no-reply@hilosdememoria.com");
            message.setTo(to);
            message.setSubject(subject);
            message.setText(text);
            mailSender.send(message);
        } catch (Exception e) {
            System.err.println("Error al enviar el correo: " + e.getMessage());
        }
    }
}

Explicación del código: EmailService.java – Servicio de Envío de Correo Electrónico con Spring Mail

package com.hilosdememoria.hilosdememoria.auth.service;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete service del módulo auth.

import org.springframework.beans.factory.annotation.Autowired;
Importa la anotación @Autowired de Spring, utilizada para la inyección de dependencias.

import org.springframework.mail.SimpleMailMessage;
Importa la clase SimpleMailMessage, que representa un mensaje de correo electrónico simple (texto plano).

import org.springframework.mail.javamail.JavaMailSender;
Importa la interfaz JavaMailSender de Spring, que proporciona la funcionalidad principal para enviar correos electrónicos.

import org.springframework.stereotype.Service;
Importa la anotación @Service, que marca esta clase como un servicio en la capa de negocio de la aplicación.

@Service
Anotación que le dice a Spring que esta clase es un "Servicio". Spring la gestionará como un bean, permitiendo que sea inyectada en otras partes de la aplicación.

public class EmailService {
Define la clase pública EmailService que contendrá la lógica para el envío de correos.

@Autowired
private JavaMailSender mailSender;
Inyecta una instancia de JavaMailSender. @Autowired le indica a Spring que proporcione automáticamente una instancia de JavaMailSender (que debe estar configurada en otra parte del proyecto, por ejemplo, en application.properties) para que podamos usarla para enviar correos.

public void sendEmail(String to, String subject, String text) {
Define un método público llamado sendEmail que no devuelve ningún valor (void) y acepta tres parámetros de tipo String: el destinatario (to), el asunto (subject) y el cuerpo del mensaje (text).

try {
Inicia un bloque try. El código dentro de este bloque se ejecutará, y si ocurre algún error (excepción) durante su ejecución, el control pasará al bloque catch.

SimpleMailMessage message = new SimpleMailMessage();
Crea un nuevo objeto SimpleMailMessage, que usaremos para construir nuestro correo electrónico.

message.setFrom("no-reply@hilosdememoria.com");
Establece la dirección de correo electrónico del remitente. Es una buena práctica usar una dirección "no-reply" para correos automáticos.

message.setTo(to);
Establece la dirección de correo electrónico del destinatario, utilizando el parámetro to que recibió el método.

message.setSubject(subject);
Establece la línea de asunto del correo electrónico, utilizando el parámetro subject.

message.setText(text);
Establece el cuerpo del mensaje (el texto principal) del correo electrónico, utilizando el parámetro text.

mailSender.send(message);
Utiliza la instancia de JavaMailSender inyectada para enviar el objeto message (el correo electrónico que acabamos de construir).

} catch (Exception e) {
Inicia un bloque catch. Este bloque se ejecutará si ocurre cualquier tipo de excepción (Exception) dentro del bloque try.

System.err.println("Error al enviar el correo: " + e.getMessage());
En caso de error, imprime un mensaje en la consola de errores del sistema (System.err), indicando que hubo un problema y mostrando el mensaje específico de la excepción (e.getMessage()). Este es un manejo de errores básico.

}
}
}
Cierra el bloque catch, el método sendEmail y la clase EmailService.

OtpService.java

A continuación, se presenta el código de la clase OtpService.java. Este servicio de Spring es responsable de toda la lógica relacionada con las contraseñas de un solo uso (OTP), incluyendo su generación, almacenamiento temporal en Redis con un tiempo de expiración, y su posterior validación para la verificación de cuentas:

  OtpService.java – Servicio de Gestión de OTP: Generación, Validación y Almacenamiento en Redis 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/auth/service/OtpService.java
package com.hilosdememoria.hilosdememoria.auth.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@Service
public class OtpService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    private static final long OTP_VALIDITY_MINUTES = 10;

    public String generateOtp(String email) {
        String otp = String.format("%06d", new Random().nextInt(999999));
        redisTemplate.opsForValue().set("otp:" + email, otp, OTP_VALIDITY_MINUTES, TimeUnit.MINUTES);
        return otp;
    }

    public boolean validateOtp(String email, String otpToValidate) {
        String storedOtp = redisTemplate.opsForValue().get("otp:" + email);
        if (storedOtp != null && storedOtp.equals(otpToValidate)) {
            redisTemplate.delete("otp:" + email);
            return true;
        }
        return false;
    }

    public boolean hasActiveOtp(String email) {
        return redisTemplate.hasKey("otp:" + email);
    }
}

Explicación del código: OtpService.java – Servicio de Gestión de OTP: Generación, Validación y Almacenamiento en Redis

package com.hilosdememoria.hilosdememoria.auth.service;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete service del módulo auth.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
Importa las clases y anotaciones necesarias de Spring Framework y Spring Data Redis. Son cruciales para definir un servicio, realizar inyección de dependencias e interactuar con una base de datos Redis.

import java.util.Random;
Importa la clase Random de Java, utilizada para generar números aleatorios.

import java.util.concurrent.TimeUnit;
Importa la clase TimeUnit de Java, que proporciona unidades de tiempo (como minutos, segundos, etc.) de forma estandarizada.

@Service
Anotación que marca esta clase como un "Servicio" en la capa de negocio de la aplicación. Spring la gestionará como un bean, permitiendo que sea inyectada en otras partes de la aplicación.

public class OtpService {
Define la clase pública OtpService que contendrá toda la lógica relacionada con las Contraseñas de un Solo Uso (OTP).

@Autowired
private StringRedisTemplate redisTemplate;
Inyecta una instancia de StringRedisTemplate. @Autowired le indica a Spring que proporcione automáticamente una instancia de esta clase, que es una plantilla especializada para interactuar con Redis usando claves y valores de tipo String.

private static final long OTP_VALIDITY_MINUTES = 10;
Define una constante privada, estática y final llamada OTP_VALIDITY_MINUTES. Almacena el tiempo de validez de un OTP en minutos (en este caso, 10 minutos).

public String generateOtp(String email) {
Define un método público llamado generateOtp que devuelve un String (el OTP) y acepta el email del usuario como parámetro.

String otp = String.format("%06d", new Random().nextInt(999999));
Genera un número aleatorio entre 0 y 999998, y luego lo formatea como un String de 6 dígitos, rellenando con ceros a la izquierda si es necesario (ej. 001234). Este será nuestro OTP.

redisTemplate.opsForValue().set("otp:" + email, otp, OTP_VALIDITY_MINUTES, TimeUnit.MINUTES);
Almacena el OTP en Redis.

opsForValue(): Obtiene las operaciones para valores de tipo String en Redis.
.set(...): Guarda el valor.
"otp:" + email: Es la clave bajo la cual se guarda el OTP. Es única para cada usuario.
otp: Es el valor (el código de 6 dígitos) que se guarda.
OTP_VALIDITY_MINUTES, TimeUnit.MINUTES: Establece un tiempo de expiración para esta clave. Después de 10 minutos, Redis eliminará automáticamente esta entrada.

return otp;
}
Devuelve el código OTP generado, que luego será enviado al usuario por correo electrónico.

public boolean validateOtp(String email, String otpToValidate) {
Define un método público llamado validateOtp que devuelve un booleano (true si es válido, false si no) y acepta el email y el otpToValidate (el código que el usuario ingresó) como parámetros.

String storedOtp = redisTemplate.opsForValue().get("otp:" + email);
Intenta obtener el OTP que fue guardado en Redis usando la clave otp: seguida del email del usuario.

if (storedOtp != null && storedOtp.equals(otpToValidate)) {
Comprueba dos cosas: si se encontró un OTP guardado (storedOtp != null) y si el OTP guardado es igual al que el usuario proporcionó.

redisTemplate.delete("otp:" + email); 
Si la validación es exitosa, elimina la clave del OTP de Redis. Esto es una medida de seguridad importante para asegurar que cada OTP solo pueda ser usado una vez.

return true;
}
Devuelve true indicando que la validación fue exitosa.

return false;
}
Si la condición del if no se cumple (no se encontró el OTP o no coincide), devuelve false.

public boolean hasActiveOtp(String email) {
Define un método público llamado hasActiveOtp que devuelve un booleano para comprobar si un usuario ya tiene un OTP activo (no expirado).

return redisTemplate.hasKey("otp:" + email);
}
}
Utiliza el método hasKey de la plantilla de Redis para verificar si la clave otp: seguida del email del usuario existe en la base de datos de Redis. Si existe, significa que hay un OTP que aún no ha expirado. Devuelve true si la clave existe, y false en caso contrario. Cierre de la clase OtpService.

DashboardController.java

A continuación, se presenta el código de la clase DashboardController.java. Este es un controlador de Spring muy importante, ya que se encarga de manejar la página principal de la aplicación para un usuario autenticado: el Panel Principal (Dashboard). Su principal responsabilidad es obtener la información del usuario que ha iniciado sesión y pasarla a la vista para personalizar la experiencia:

  DashboardController.java – Controlador del Dashboard: Gestión de Vista Principal 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/dashboard/controller/DashboardController.java
package com.hilosdememoria.hilosdememoria.dashboard.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/dashboard")
public class DashboardController {

    @GetMapping
    public String showDashboard(Model model) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.isAuthenticated() && !("anonymousUser").equals(authentication.getPrincipal())) {
            model.addAttribute("username", authentication.getName());
        }

        return "dashboard/dashboard";
    }
}

Explicación del código: DashboardController.java – Controlador del Dashboard: Gestión de Vista Principal

package com.hilosdememoria.hilosdememoria.dashboard.controller;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete controller del módulo dashboard.

import org.springframework.security.core.Authentication;
Importa la interfaz Authentication de Spring Security, que representa la información de autenticación del usuario actual (quién es y qué permisos tiene).

import org.springframework.security.core.context.SecurityContextHolder;
Importa la clase SecurityContextHolder, que es el lugar central donde Spring Security almacena los detalles de seguridad de la aplicación, incluyendo el objeto Authentication del usuario actualmente autenticado.

import org.springframework.stereotype.Controller;
Importa la anotación @Controller, que marca esta clase como un controlador de Spring MVC.

import org.springframework.ui.Model;
Importa la interfaz Model, utilizada para pasar datos desde el controlador a la vista (plantilla HTML).

import org.springframework.web.bind.annotation.GetMapping;
Importa la anotación @GetMapping, que mapea las peticiones HTTP GET a un método específico del controlador.

import org.springframework.web.bind.annotation.RequestMapping;
Importa la anotación @RequestMapping, que se usa para mapear peticiones web a clases o métodos del controlador.

@Controller
Anotación que marca esta clase como un "Controlador". Se encargará de recibir peticiones web y devolver vistas (páginas HTML).

@RequestMapping("/dashboard")
Anotación que mapea todas las peticiones que comiencen con /dashboard a los métodos de este controlador.

public class DashboardController {
Define la clase pública DashboardController que contendrá la lógica para manejar la página del panel principal.

@GetMapping
Mapea las peticiones GET a la ruta base del controlador (en este caso, /dashboard) a este método. No se especifica una ruta adicional, por lo que responde a GET /dashboard.

public String showDashboard(Model model) {
Define el método showDashboard que se ejecutará al recibir la petición. Model model es un objeto proporcionado por Spring para pasar datos a la vista. El método devuelve un String, que será el nombre de la vista a renderizar.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Obtiene el objeto Authentication del contexto de seguridad actual. Este objeto contiene toda la información del usuario que ha iniciado sesión.

if (authentication != null && authentication.isAuthenticated() && !("anonymousUser").equals(authentication.getPrincipal())) {
Realiza una comprobación de seguridad robusta:

authentication != null: Se asegura de que haya un objeto de autenticación.
authentication.isAuthenticated(): Se asegura de que el usuario esté realmente autenticado.
!("anonymousUser").equals(authentication.getPrincipal()): Se asegura de que el usuario no sea el "usuario anónimo" por defecto de Spring Security, sino un usuario real que ha iniciado sesión.

model.addAttribute("username", authentication.getName());
Si el usuario está autenticado, añade un atributo llamado "username" al modelo. El valor de este atributo es el nombre de usuario obtenido del objeto Authentication (que, en nuestra configuración, es el correo electrónico del usuario).

}
Cierra el bloque if.

return "dashboard/dashboard"; 
}
}
Devuelve el nombre de la vista (plantilla HTML) que se debe renderizar: dashboard.html, ubicada dentro de una carpeta dashboard en el directorio de plantillas. Cierre del método y de la clase.

MemoriaController.java

A continuación, se presenta el código de la clase MemoriaController.java. Este es un controlador de Spring que actúa como el punto de entrada principal de la aplicación, manejando las peticiones a la ruta raíz (`/`). Su única función es verificar el estado de autenticación del usuario y redirigirlo a la página correspondiente: al panel principal si ya ha iniciado sesión, o a la página de login si no lo ha hecho:

  MemoriaController.java – Controlador Raíz: Redirección Basada en Autenticación 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/memories/controller/MemoriaController.java
package com.hilosdememoria.hilosdememoria.memories.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MemoriaController {

    @GetMapping("/")
    public String redirectToBasedOnAuth() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.isAuthenticated() && !("anonymousUser").equals(authentication.getPrincipal())) {
            return "redirect:/dashboard";
        } else {
            return "redirect:/auth/";
        }
    }
}

Explicación del código: MemoriaController.java – Controlador Raíz: Redirección Basada en Autenticación

package com.hilosdememoria.hilosdememoria.memories.controller;
Declara el paquete (namespace) de Java al que pertenece esta clase, ubicándola dentro del subpaquete controller del módulo memories.

import org.springframework.security.core.Authentication;
Importa la interfaz Authentication de Spring Security, que representa la información de autenticación del usuario actual.

import org.springframework.security.core.context.SecurityContextHolder;
Importa la clase SecurityContextHolder, el lugar central donde Spring Security almacena los detalles de seguridad de la aplicación.

import org.springframework.stereotype.Controller;
Importa la anotación @Controller, que marca esta clase como un controlador de Spring MVC.

import org.springframework.web.bind.annotation.GetMapping;
Importa la anotación @GetMapping, que mapea las peticiones HTTP GET a un método específico del controlador.

@Controller
Anotación que marca esta clase como un "Controlador". Se encargará de recibir peticiones web y devolver respuestas (en este caso, redirecciones).

public class MemoriaController {
Define la clase pública MemoriaController. Dado que no tiene una anotación @RequestMapping a nivel de clase, sus métodos responderán a las rutas desde la raíz de la aplicación.

@GetMapping("/")
Mapea las peticiones GET a la ruta raíz de la aplicación (/) a este método. Esto significa que cualquier usuario que acceda a la dirección principal del sitio web será manejado por este método.

public String redirectToBasedOnAuth() {
Define el método redirectToBasedOnAuth. Devuelve un String que indica a Spring a dónde redirigir al usuario.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Obtiene el objeto Authentication del contexto de seguridad actual. Este objeto contiene toda la información del usuario que ha iniciado sesión.

if (authentication != null && authentication.isAuthenticated() && !("anonymousUser").equals(authentication.getPrincipal())) {
Realiza una comprobación para determinar si el usuario está autenticado. Verifica que el objeto de autenticación no sea nulo, que el usuario esté autenticado y que no sea el "usuario anónimo" por defecto de Spring Security.

return "redirect:/dashboard";
Si el usuario está autenticado, el método devuelve la cadena "redirect:/dashboard". Esto instruye a Spring para que redirija el navegador del usuario a la URL /dashboard.

} else {
Si la condición anterior es falsa (es decir, el usuario no está autenticado o es anónimo)...

return "redirect:/auth/";
}
}
}
El método devuelve la cadena "redirect:/auth/", instruyendo a Spring para que redirija el navegador del usuario a la URL /auth/, que es la página de inicio de sesión. Cierre del método y de la clase.

HilosdememoriaApplication.java

A continuación, se presenta el código de la clase HilosdememoriaApplication.java. Este archivo es el punto de entrada principal que inicia toda la aplicación Spring Boot. Contiene el método `main` estándar de Java, que a su vez utiliza Spring Boot para lanzar el servidor web y configurar automáticamente todos los componentes de la aplicación:

  HilosdememoriaApplication.java – Clase Principal y Punto de Entrada de Spring Boot 

// Ubicación: src/main/java/com/hilosdememoria/hilosdememoria/HilosdememoriaApplication.java
package com.hilosdememoria.hilosdememoria;

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

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

Explicación del código: HilosdememoriaApplication.java – Clase Principal y Punto de Entrada de Spring Boot

package com.hilosdememoria.hilosdememoria;
Declara el paquete (namespace) raíz de Java al que pertenece esta clase.

import org.springframework.boot.SpringApplication;
Importa la clase SpringApplication, que proporciona una forma conveniente de arrancar una aplicación Spring desde un método main().

import org.springframework.boot.autoconfigure.SpringBootApplication;
Importa la anotación @SpringBootApplication, que es una anotación de conveniencia que agrega varias otras anotaciones importantes para una aplicación Spring Boot.

@SpringBootApplication
Anotación principal de Spring Boot. Es una combinación de tres anotaciones:

@Configuration: Marca la clase como una fuente de definiciones de beans para el contexto de la aplicación.
@EnableAutoConfiguration: Le dice a Spring Boot que comience a agregar beans basados en las dependencias del classpath y varias configuraciones de propiedades.
@ComponentScan: Le dice a Spring que busque otros componentes, configuraciones y servicios en el paquete actual (com.hilosdememoria.hilosdememoria) y sus subpaquetes.

public class HilosdememoriaApplication {
Define la clase pública HilosdememoriaApplication, que es el punto de partida de la aplicación.

public static void main(String[] args) {
El método main, que es el punto de entrada estándar para cualquier aplicación Java. El programa comienza a ejecutarse desde aquí.

SpringApplication.run(HilosdememoriaApplication.class, args);
}
}
Utiliza el método estático run de la clase SpringApplication para lanzar la aplicación. Este comando arranca Spring Boot, crea el contexto de la aplicación, realiza el escaneo de componentes y despliega el servidor de aplicaciones embebido (como Tomcat) para que la aplicación web esté disponible. Cierre del método main y de la clase.

login.css

El siguiente bloque de código corresponde al archivo login.css. Este archivo define un diseño de página de inicio de sesión de dos columnas, con un panel de bienvenida a un lado y el formulario de acceso en el otro. Incluye estilos para campos de formulario con etiquetas flotantes, botones de redes sociales y ajustes responsivos que ocultan el panel de bienvenida en pantallas más pequeñas:

  login.css – Estilos de Página de Login: Layout de Dos Paneles y Formularios Flotantes 

.login-page-wrapper {
    display: grid;
    grid-template-columns: 1fr 1fr;
    min-height: calc(100vh - 69px);
}

.login-welcome-panel {
    background: linear-gradient(160deg, #005f73, #00a896);
    color: white;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 2rem;
    text-align: center;
}

.welcome-content {
    max-width: 25rem;
}

.welcome-title {
    font-size: clamp(2rem, 5vw, 3rem);
    font-weight: 700;
    margin-bottom: 1rem;
}

.welcome-text {
    font-size: 1.125rem;
    line-height: 1.8;
    opacity: 0.9;
    margin-bottom: 2rem;
}

.welcome-icon {
    font-size: 4rem;
    opacity: 0.5;
}

.login-form-panel {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 2rem;
    background-color: #f4f7f6;
}

.login-form-container {
    width: 100%;
    max-width: 25rem;
    background: #ffffff;
    padding: 2.5rem;
    border-radius: 1rem;
    box-shadow: 0 0.5rem 2.5rem rgba(0, 0, 0, 0.1);
}

.login-form-container h2 {
    text-align: center;
    font-size: 2rem;
    color: #005f73;
    margin-bottom: 2rem;
}

.form-group {
    position: relative;
    margin-bottom: 2rem;
}

.form-group input {
    width: 100%;
    border: none;
    border-bottom: 2px solid #ccc;
    background: transparent;
    padding: 0.75rem 0.25rem;
    font-size: 1rem;
    color: #333;
}

.form-group label {
    position: absolute;
    top: 0.75rem;
    left: 0.25rem;
    color: #666;
    transition: all 0.3s ease;
    pointer-events: none;
}

.form-group input:focus,
.form-group input:valid {
    outline: none;
    border-color: #00a896;
}

.form-group input:focus + label,
.form-group input:valid + label {
    top: -1.25rem;
    left: 0;
    font-size: 0.875rem;
    color: #005f73;
    font-weight: 600;
}

.password-group {
    position: relative;
}

.password-toggle {
    position: absolute;
    right: 0.25rem;
    top: 0.75rem;
    cursor: pointer;
    color: #999;
    transition: color 0.3s;
}

.password-toggle:hover {
    color: #005f73;
}

.form-options {
    text-align: right;
    margin-top: -1rem;
    margin-bottom: 1.5rem;
}

.forgot-password-link {
    font-size: 0.875rem;
    color: #005f73;
    text-decoration: none;
}
.forgot-password-link:hover {
    text-decoration: underline;
}

.btn-submit {
    width: 100%;
    padding: 1rem;
    font-size: 1.125rem;
    background: #005f73;
    color: white;
    border: none;
    border-radius: 0.5rem;
    cursor: pointer;
    transition: all 0.3s ease;
}

.btn-submit:hover {
    background: #00a896;
    transform: translateY(-3px);
}

.divider {
    display: flex;
    align-items: center;
    text-align: center;
    color: #aaa;
    margin: 2rem 0;
}

.divider::before, .divider::after {
    content: '';
    flex: 1;
    border-bottom: 1px solid #ddd;
}

.divider:not(:empty)::before { margin-right: .5em; }
.divider:not(:empty)::after { margin-left: .5em; }

.social-login-buttons {
    display: flex;
    gap: 1rem;
}

.btn-social {
    flex: 1;
    padding: 0.75rem;
    border-radius: 0.5rem;
    border: 1px solid #ddd;
    background: transparent;
    cursor: pointer;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    transition: all 0.3s;
}
.btn-social i { font-size: 1.2rem; }
.btn-social.google:hover { background-color: #f1f1f1; border-color: #ccc;}
.btn-social.facebook { color: #1877F2; }
.btn-social.facebook:hover { background-color: #e7f3ff; border-color: #b8d7ff;}

.signup-link {
    text-align: center;
    margin-top: 2rem;
    font-size: 0.95rem;
}

.signup-link a {
    color: #00a896;
    font-weight: 600;
    text-decoration: none;
}
.signup-link a:hover { text-decoration: underline; }

.btn-login.active {
    background-color: #ee9b00;
    color: #333 !important;
    box-shadow: 0 0.375rem 0.9375rem rgba(238, 155, 0, 0.4);
}

@media (max-width: 992px) {
    .login-page-wrapper {
        grid-template-columns: 1fr;
    }
    .login-welcome-panel {
        display: none;
    }
    .login-form-panel {
        min-height: calc(100vh - 69px);
    }
}

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!