· Jose Antonio López  · 20 min lectura

Autenticación en Spring Security con JWT - Guía Completa

Implementación paso a paso y mejores prácticas para asegurar APIs REST en Java con tokens JSON Web Token, persistencia en PostgreSQL y gestión efectiva de roles y permisos.

Implementación paso a paso y mejores prácticas para asegurar APIs REST en Java con tokens JSON Web Token, persistencia en PostgreSQL y gestión efectiva de roles y permisos.

Objetivo

Hacer una guía real de cómo se configura un proyecto de Spring Boot 3.4.1 con Spring Security 6.4.2 :

CaracterísticaDescripción
Spring SecuritySeguridad para la aplicación utilizando Spring Security.
JWT que viene en un header llamado Set-CookieUso de JWT en la autenticación
Usuarios en base de datosUsuarios guardados en una base de datos PostgreSQL

¿Por qué escribo este artículo?

Hay muchos artículos, tutoriales e incluso cursos que son poco claros para implementar una autenticación con JWT en Spring.

Gran parte del contenido enseña la forma pero no hace referencia a la documentación oficial y mucho menos se acerca a código real.

Se pierden detalles y no profundizan en aspectos importantes como guardar usarios en base de datos.

Prerequisitos

Para seguir este artículo necesitas tener instalado:

Herramienta/PasoEnlace de descarga
JavaDescargar Java
MavenDescargar Maven
Docker DesktopDescargar Docker Desktop
IntelliJ IDEA CommunityDescargar IntelliJ IDEA Community
Proyecto con Spring Boot 3.4.1Crear proyecto Spring Boot
Instalar base de datos PostgreSQLGuía de instalación de PostgreSQL

Dependencias

Las dependencias con las que se va trabajar son:

DependenciaDescripción
spring-boot-starter-data-jpaSoporte a Java Persistance API para interactuar con bases de datos. Más en la documentación oficial.
spring-boot-starter-securitySoporte de seguridad para la aplicación. Más en la documentación oficial.
spring-boot-starter-oauth2-authorization-serverProporciona un servidor de autorización OAuth2 para gestionar tokens de acceso. Más en la documentación oficial.
spring-boot-starter-validationSoporte para la validación de datos utilizando la especificación Bean Validation. Más en la documentación oficial.
spring-boot-starter-webDependencias necesarias para construir aplicaciones web con servlet. Más en la documentación oficial.
spring-boot-docker-composeFacilita la integración con Docker Compose para gestionar contenedores Docker. Más detalle en esta guía del blog
postgresqlControlador JDBC para conectar y trabajar con bases de datos PostgreSQL. Es bastante denso, pero Más en la documentación oficial.
spring-boot-devtoolsHerramientas de desarrollo que permiten compilaciones incrementales durante el desarrollo. Más detalle en esta guía del blog
lombokBiblioteca que reduce el código repetitivo mediante anotaciones. Más en la documentación oficial.

He elegido Maven como gestor de dependencias porque me siento más cómodo. Si prefieres Gradle, puedes usarlo sin problemas.

Decisiones de diseño

Onion Architecture

La arquitectura va a ser del tipo onion architecture. Permite separar responsabilidades en tres grandes capas:

CapaDescripción
DomainEntidades, interfaces de los servicios y excepciones de dominio.
ApplicationImplementación de servicios de la aplicación, mappers y DTOs.
InfrastructureControladores, filtros, configuración de la aplicación, interfaz del repositorio JPA e implementación del repositorio.

He escogido esta arquitectura porque me parece una buena forma de organizar el código. Es un inpas para todos aquellos programadores que tradicionalmente han trabajado con arquitecturas por capas o layered architecture.

Moverse a una arquitectura hexagonal o clean architecture puede ser un cambio muy brusco.

A continuación puedes ver la estructura:

auth
├── application
│   ├── AuthCookieConstants.java
│   ├── mappers
│   │   └── AuthMapper.java
│   └── services
│       ├── AuthServiceImpl.java
│       └── TokenServiceImpl.java
├── domain
│   ├── AuthException.java
│   ├── Role.java
│   ├── services
│   │   ├── AuthService.java
│   │   └── TokenService.java
│   ├── User.java
│   └── UserRepository.java
└── infrastructure
    ├── config
    │   ├── EncoderConfig.java
    │   └── SecurityConfig.java
    ├── controllers
    │   └── AuthController.java
    ├── dtos
    │   ├── CreateUserDto.java
    │   ├── LoginRequestDTO.java
    │   └── UserResponseDTO.java
    ├── filters
    │   ├── JwtAuthenticationFilter.java
    └── persistance
        └── PostgresUserRepository.java

Inyección de dependencias mediante constructor

El tipo de inyección de dependencias que he usado es mediante constructor. Es la mejor forma de inyectar dependencias en Spring y la forma recomendada en la documentación oficial.

Si sueles usar inyección de dependencias mediante campos, te recomiendo que lo cambies a inyección de dependencias mediante constructor.

Lombok

Para reducir el código repetitivo, he usado la biblioteca Lombok que reduce el código visible mediante anotaciones. En este caso, se usan anotaciones @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor y @Builder.

Otras anotaciones que se pueden usar son @Data, @EqualsAndHashCode y @ToString aunque considero que tocan temas muy delicados y recomiendo implementarlos a mano si llega el caso.

Entidades de dominio anotadas con JPA

Las entidades de dominio son clases que representan cosas del problema que resolvemos. En este caso una entidadsería User.

Estas clases están anotadas con @Entity y @Table para indicar que son también entidades de JPA.

Anotar entidades de dominio con anotaciones de un framework puede ser considerado mala práctica. El término técnico se llama contaminación de dominio. He decidido hacerlo para simplificar el código y no tener que crear mappers innecesarias que añaden complejidad.

La autenticación se va a hacer con JWT y se va a enviar en un header llamado Set-Cookie. Las razones principales son debidas a que la API se va a consumir desde un cliente web y es más seguro enviar el token en una cookie con las siguientes características:

  • HttpOnly: La cookie no es accesible desde JavaScript.
  • Secure: La cookie solo se envía a través de HTTPS.
  • SameSite: La cookie no se envía en peticiones de terceros.
  • Max-Age: La cookie expira en X minutos.

Si quieres profundizar más en el tema, te recomiendo leer la documentación de mozzila.org.

Configuración de la clase WebSecurityConfig

La clase WebSecurityConfig es la configuración principal de Spring Security. Se encarga de configurar la seguridad de la aplicación y de definir qué rutas son públicas y cuáles no.

Te dejo el enlace a la guía para implementarla y entenderla en este mismo blog.

Usuarios en base de datos

Entidad User

El primer paso es definir la entidad User que representa a un usuario. La entidad User implementa la interfaz UserDetails de Spring Security.

La interfaz define algunos métodos con tipo default que ya están implementados.

Personalmente no me convence tener implementaciones por defecto en una interfaz, pero es lo que hay.

User.java
package com.auth.domain;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
public class User implements UserDetails {
    @Id
    @UuidGenerator(style = UuidGenerator.Style.AUTO)
    private UUID id;
    private String username;
    private String password;
    private String firstName;
    private String surnames;
    @Column(unique = true)
    private String email;
    private String phoneNumber;
    @Column(name = "role", nullable = true)
    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(role);
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }
}

¿Por qué UserDetails?

UserDetails es una interfaz que representa a un usuario en Spring Security y es usada por un servicio llamado UserDetailsService.

Esto es necesario para que Spring Security pueda autenticar a un usuario sin implementar código adicional.

Explicación de anotaciones

@Entity: Indica que la clase es una entidad de JPA. Más información en la documentación oficial.

@Table(name = "users"): Indica que la entidad se va a guardar en una tabla llamada users. Si no se especifica, JPA usa el nombre de la clase como nombre de la tabla.

@Id: Indica que el campo id es la clave primaria de la entidad.

@UuidGenerator: Genera un UUID automáticamente. Por defecto, Hibernate genera UUIDs de versión 4.

@Column: Indica que el campo email es único en la base de datos. Para no alargar el código, solo añado campos imprescindibles con @Column.

@Enumerated(EnumType.STRING): Indica que el campo role es un Enum y que se va a guardar como un String en la base de datos.

Repositorio User en el dominio

Primero hay que definir una interfaz repositorio en el dominio. Esto controla las operaciones que tienen sentido para el problema que se resuelve.

UserRepository.java
package com.auth.domain;

import java.util.Optional;
import java.util.UUID;

public interface UserRepository {
    Optional<User> findByEmail(String email);
    User save(User user);
    Optional<User> findById(UUID id);
}

Información complementaria

En el contexto de Domain-Driven Design (DDD) o patrones como Repository Pattern, un repositorio actúa como una abstracción sobre la capa de persistencia de datos.

Repositorio User en la infraestructura

También hay que definir un repositorio en la infraestructura. No confundir con el repositorio del dominio.

UserRepository.java
package com.auth.infrastructure.persistance;

@Repository
public interface PostgresUserRepository extends JpaRepository<User, UUID>, UserRepository {
}

Información complementaria

La infraestructura actúa sobre la base de datos y permite que el dominio permanezca independiente a los detalles de implementación.

@Repository: Indica que la clase es un componente de Spring y se va a usar para acceder a la base de datos. Se menciona de pasada en la documentación oficial.

PostgresUserRepository: Indica claramente que sirve para la implementación de una base de datos PostgreSQL.

JPARepository: Proporciona métodos para interactuar con la base de datos. Recuerda pasar como ID el tipo de dato de la clave primaria. Más información en la documentación oficial.

Tip

Puedes comprobar que la interface debe ir en la infraestructura porque está anotado con @Repository. En el dominio nunca se debe usar anotaciones de Spring. Una excepción es la de anotar entidades con @Entity y siempre con la debida justificación.

DTOs

Los DTOs (Data Transfer Objects) son objetos que se usan para transferir datos entre capas. En este caso, se usan para transferir datos entre la capa de infraestructura y la capa de aplicación.

CreateUserDto

Para crear un usuario se define un DTO que contiene los campos necesarios. Puedes añadir o quitar campos según las necesidades de tu aplicación. Para el ejemplo, se usan los campos email, firstName y password.

CreateUserDto.java
package com.auth.infrastructure.dtos;

public record CreateUserDto(
    @Email
    @NotBlank
    String email,

    @NotBlank
    String firstName,

    @NotBlank
    String password
) {
}

Explicación de anotaciones

@NotBlank: Indica que el campo no puede estar vacío. @Email: Indica que el campo debe ser un email válido. Puede ser que se quede corto, pero es un buen comienzo.

Tip

Un DTO es un objeto con información inmutable y nunca debe ser modificada. Para los DTOs siempre aconsejo usar record introducidos en Java 14. Usalo en java 17 o superior que es cuando se estabilizó.

LoginRequestDTO

El siguiente DTO es para el login y tiene los campos necesarios para autenticar a un usuario.

LoginRequestDTO.java
package com.auth.infrastructure.dtos;
public record LoginRequestDTO(
    @Email
    @NotBlank
    String email,

    @NotBlank
    String password
) {
}

UserResponseDTO

El último DTO es para devolver datos del usuario

UserResponseDTO.java
package com.auth.infrastructure.dtos;
public record UserResponseDTO(
    UUID id,
    String name,
    String email,
    Role role
) {
}

Super Importante

Nunca devuelvas datos sensibles como la contraseña del usuario. El DTO UserResponseDTO no devuelve la contraseña del usuario.

AuthMapper

El AuthMapper es una clase que transforma DTOs a entidades de dominio o al revés. Es muy buena práctica tener un mapper para únicamente transformar datos entre capas.

AuthMapper.java
package com.auth.application.mappers;

public class AuthMapper {
    private AuthMapper() {
        throw new UnsupportedOperationException("This class should never be instantiated");
    }

    public static User fromDto(final CreateUserDto createUserDto) {
        return User.builder()
            .email(createUserDto.email())
            .firstName(createUserDto.firstName())
            .build();
    }

    public static Authentication fromDto(final LoginRequestDTO loginRequestDTO) {
        return new UsernamePasswordAuthenticationToken(loginRequestDTO.email(), loginRequestDTO.password());
    }

    public static UserResponseDTO toDto(final User user) {
        return new UserResponseDTO(user.getId(), user.getFirstName(), user.getEmail(), user.getRole());
    }
}

Tips

Para entidades de mapeo me gusta seguir las siguientes buenas prácticas:

  • No usar librerías de mapeo: Prefiero no usar librerías como MapStruct. Prefiero tener control total y evitar dependencias innecesarias.
  • Clase puramente estática: La clase AuthMapper es puramente estática. Es una clase de utilidad que solo tiene métodos estáticos.
  • No instanciar la clase: La clase AuthMapper no debe ser instanciada y lanza una excepción si se intenta instanciar. Uso UnsupportedOperationException para indicar a otros desarrolladores que la clase no debe ser instanciada.

AuthService

AuthService en dominio

Este servicio es de los más importantes y se encarga de la lógica relacionada con autenticación y gestión de usuarios.

AuthService.java

package com.auth.domain.services;

public interface AuthService {
    String login(LoginRequestDTO loginRequestDTO);
    boolean validateToken(String token);
    String getUserFromToken(String token);
    void createUser(CreateUserDto createUserDto);
    User getUser(UUID id);
}

Tip

Durante el desarrollo estuve tentado a extender UserDetailsService directamente. Eso no sería del todo correcto porque UserDetailsService es una interfaz de Spring Security y no debería ser usada en la capa de dominio.

Implementación de AuthService en aplicación

AuthServiceImpl.java
package com.auth.application.services;

@Service
public class AuthServiceImpl implements AuthService, UserDetailsService {
    private static final Logger logger = LogManager.getLogger(AuthServiceImpl.class);

    private final UserRepository userRepository;
    private final TokenService tokenService;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationConfiguration authenticationConfiguration;


    public AuthServiceImpl(
        UserRepository userRepository,
        TokenService tokenService,
        PasswordEncoder passwordEncoder,
        AuthenticationConfiguration authenticationConfiguration
    ) {
        this.userRepository = userRepository;
        this.tokenService = tokenService;
        this.passwordEncoder = passwordEncoder;
        this.authenticationConfiguration = authenticationConfiguration;
    }

    @Override
    public void createUser(final CreateUserDto createUserDto) {
        final User createUser = AuthMapper.fromDto(createUserDto);
        createUser.setPassword(passwordEncoder.encode(createUserDto.password()));
        final User user = userRepository.save(createUser);
        logger.info("[USER] : User successfully created with id {}", user.getId());
    }


    @Override
    public User getUser(final UUID id) {
        return userRepository.findById(id)
            .orElseThrow(() -> {
                logger.error("[USER] : User not found with id {}", id);
                return new FincasException(FincasErrorMessage.USER_NOT_FOUND);
            });
    }

    @Override
    public String login(final LoginRequestDTO loginRequest) {
        try {
            final AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
            final Authentication authRequest = AuthMapper.fromDto(loginRequest);
            final Authentication authentication = authenticationManager.authenticate(authRequest);
            return tokenService.generateToken(authentication);

        } catch (Exception e) {
            logger.error("[USER] : Error while trying to login", e);
            throw new ProviderNotFoundException("Error while trying to login");
        }
    }


    @Override
    public boolean validateToken(final String token) {
        return tokenService.validateToken(token);
    }

    @Override
    public String getUserFromToken(final String token) {
        return tokenService.getUserFromToken(token);
    }

    @Override
    public UserDetails loadUserByUsername(final String username) {
        return userRepository.findByEmail(username)
            .orElseThrow(() -> {
                logger.error("[USER] : User not found with email {}", username);
                return new UsernameNotFoundException("User not found");
            });
    }
}

Explicación del código

@Service: Indica que la clase es un componente de Spring y se va a usar para la lógica de negocio.

loadUserByUsername: Método de la interfaz UserDetailsService que Spring Security llama internamente.

AuthenticationConfiguration: Clase que se encarga de configurar la autenticación en Spring Security.

Tip

Aquí tiene sentido implementar UserDetailsService. La interfaz es propia de Spring Security y la implementación del servicio es propia de la aplicación. AuthenticationConfiguration ayuda romper una dependencia circular que me apareció entre AuthService y SecurityConfig. La dependencia circular aparece entre AuthenticationManager y AuthService.

Clava secreta y tiempo de expiración

application.yml

Usaremos application.ymlcon propiedades personalizadas. Configurar estos parámetros en un archivo de propiedades es una buena práctica porque se pueden cambiar sin recompilar el código.

application.yml
application:
  security:
    jwt:
      secret-key: 9a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3q2r1s0t
      expiration: 15 # minutes

Explicación de las propiedades

secret-key: Clave secreta para firmar el token JWT. Es importante que sea una clave segura y no se comparta con nadie. expiration: Tiempo de expiración del token JWT en minutos. Se suele poner en milisegundos, pero para simplificar lo he puesto en minutos.

El tiempo de expiración va a depender mucho de la aplicación y de la seguridad quieras implementar. Si hay un tiempo de expiración muy corto, los usuarios tendrán que hacer login constantemente. Si es muy largo, el token puede ser robado y usado por un atacante. Con 15 minutos opino que es suficiente para la mayoría de aplicaciones.

EncoderConfig

La codificación y decodificación de JWTs se hace en Spring Security mediante Beans. En este caso, se usan las implementaciones de Nimbus JOSE + JWT SDK.

EncoderConfig.java
@Configuration
package com.auth.infrastructure.config;

public class EncoderConfig {
    @Value("${application.security.jwt.secret-key}")
    private String jwtKey;

    @Bean
    JwtEncoder jwtEncoder() {
        return new NimbusJwtEncoder(new ImmutableSecret<>(jwtKey.getBytes()));
    }

    @Bean
    JwtDecoder jwtDecoder() {
        byte[] bytes = jwtKey.getBytes();
        SecretKeySpec originalKey = new SecretKeySpec(bytes,0,bytes.length,"HmacSHA256");
        return NimbusJwtDecoder.withSecretKey(originalKey).macAlgorithm(MacAlgorithm.HS256).build();
    }

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

¿Qué hace Nimbus JOSE + JWT SDK?

Spring Security proporciona la interfaz JwtEncoder y JwtDecoder para codificar y decodificar JWTs. Son interfaces funcionales que tienen un método encode y decode respectivamente.

Las implementaciones que vienen en Spring Security son NimbusJwtEncoder y NimbusJwtDecoder respectivamente.

Nimbus JOSE + JWT SDK es una biblioteca de Java que permite la creación y verificación de JSON Web Tokens (JWT) y JSON Web Signature (JWS).

El JwtEncoder es como una máquina que hace tres cosas:

  1. Toma tu información (datos del usuario)
  2. La convierte en un formato especial llamado JWT (JSON Web Token)
  3. La “firma” digitalmente usando una llave secreta (proporcionada por el JWKSource)

El JWS Compact Serialization format es la forma en que se organiza la información. Así todos los sistemas puedan entender y verificar los tokens.

La parte de JWKSource es el guardián de las llaves. Proporciona la llave secreta para “sellar” (firmar) el token.

¿Qué hace BCryptPasswordEncoder?

BCryptPasswordEncoder es una implementación de PasswordEncoder que usa el algoritmo de cifrado BCrypt. Por detrás se usa una función de tipo hash. Las funciones hash reciben una entrada y devuelven una salida. Son funciones unidireccionales, es decir, no hay manera de descubrir la contraseña original a partir de la salida.

TokenService en el dominio

El TokenService es el servicio que se encarga de generar y validar tokens JWT.

TokenService.java
package com.hey.auth.domain.services;

import org.springframework.security.core.Authentication;

public interface TokenService {
    String generateToken(Authentication authentication);
    String getUserFromToken(String token);
    boolean validateToken(String token);
}

¿TokenService en domain o application?

He decidido ponerlo en la capa de dominio. Desde mi punto de vista, la aplicación es una API REST y el token es parte del dominio. La seguridad y la futura aplicación coexistirían en el mismo proyecto. Si la seguridad estuviera en un microservicio, el proyecto se movería tal cual moviendo la carpeta auth al nuevo microservicio.

Implementación de TokenService en aplicación

La implementación de TokenService es la que se encarga de generar y validar los tokens JWT. En ese artículo se usa la biblioteca Nimbus JOSE + JWT SDK.

TokenServiceImpl.java
package com.auth.application.services;

@Service
public class TokenServiceImpl implements TokenService {
    private final static Logger logger = LogManager.getLogger(TokenServiceImpl.class);
    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

    @Value("${application.security.jwt.expiration}")
    private int jwtExpiration;

    private final JwtEncoder jwtEncoder;
    private final JwtDecoder jwtDecoder;

    public TokenServiceImpl(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder) {
        this.jwtEncoder = jwtEncoder;
        this.jwtDecoder = jwtDecoder;
    }

    @Override
    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        String scope = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(" "));

        User currentUser = (User) authentication.getPrincipal();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .subject(currentUser.getEmail())
            .issuedAt(now)
            .expiresAt(now.plus(jwtExpiration, ChronoUnit.MINUTES))
            .build();

        var jwtEncoderParameters = JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims);
        return this.jwtEncoder.encode(jwtEncoderParameters).getTokenValue();
    }

    @Override
    public String getUserFromToken(String token) {
        Jwt jwtToken = jwtDecoder.decode(token);
        return jwtToken.getSubject();
    }

    @Override
    public boolean validateToken(String token) {
        try {
            jwtDecoder.decode(token);
            return true;
        } catch (Exception exception) {
            logger.error("[USER] : Error while trying to validate token", exception);
            throw new BadJwtException("Error while trying to validate token");
        }
    }
}

Información complementaria

Código

  • @Value: Indica que la propiedad se va a inyectar desde el archivo de propiedades.
  • JwtClaimsSet: Clase que representa los claims del token JWT.
  • JwtEncoderParameters: Clase que representa los parámetros del token JWT. Se guarda el algoritmo de firma y los claims.
  • JwtClaimsSet.Builder: Clase que permite construir el token JWT. Se pueden añadir claims y configurar la fecha de expiración.

Claims

Los claims es la información que se guarda en el token. En este caso, se guarda el email del usuario y la fecha de expiración. El claim scope es un claim que puedes usar para guardar información sobre permisos. En este caso, no lo uso para nada, pero es un buen ejemplo de cómo se pueden recuperarlos permisos. Luuego puedes usar el claim scope para guardar información sobre permisos.

AuthService

Inicialmente, el AuthService tenía toda la lógica de generación y validación de tokens. Decidí moverlo a su propio servicio para separar responsabilidades. De todas formas, puede ser válido que el AuthService tenga lógica de generación y validación de tokens. Personalmente prefieron que el TokenService se encargue de generar y validar tokens y que el AuthService autentique y gestione usuarios.

JWTAuthenticationFilter

El JWTAuthenticationFilter es el filtro que se encarga de validar que el token JWT sea válido y que el usuario tenga permisos para acceder a la ruta.

JwtAuthenticationFilter.java
package com.auth.infrastructure.filters;

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final AuthService authService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(AuthService authService, UserDetailsService userDetailsService) {
        this.authService = authService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        final String requestURI = request.getRequestURI();
        return requestURI.equals(SecurityConfig.LOGIN_URL_MATCHER);
    }

    @Override
    protected void doFilterInternal(@NotNull HttpServletRequest request,
                                    @NotNull HttpServletResponse response,
                                    @NotNull FilterChain filterChain) throws ServletException, IOException {

        final Optional<String> token = getJwtFromCookie(request);

        if (token.isEmpty() || !authService.validateToken(token.get())) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            throw new BadCredentialsException("Invalid token");
        }

        String userName = authService.getUserFromToken(token.get());
        UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        authenticationToken.setDetails(userDetails);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(request, response);
    }

    private Optional<String> getJwtFromCookie(HttpServletRequest request) {
        final Cookie[] cookies = request.getCookies();
        if (cookies == null || ArrayUtils.isEmpty(cookies)) {
            return Optional.empty();
        }
        return (Arrays.stream(cookies)
            .filter(cookie -> cookie.getName().equals(AuthCookieConstants.TOKEN_COOKIE_NAME))
            .map(Cookie::getValue)
            .findFirst());
    }
}

Información complementaria

Código

  • OncePerRequestFilter: Spring Security recomienda extender OncePerRequestFilter para crear filtros personalizados. Este filtro se ejecuta una vez por cada petición. Puedes leer más en la documentación oficial.

  • shouldNotFilter: El filtro no se ejecuta si la ruta es de login. Durante el loginno hay tokem, así que no debe saltar.

  • doFilterInternal: Se valida el token y se pasa al contexto de seguridad de Spring Security los valores extraidos del token.

  • HttpServletRequest: Representa la petición HTTP entrante.

  • HttpServletResponse: Representa la respuesta HTTP saliente. Se devuelve un error 401 si el token no es válido.

  • FilterChain: Se usa para pasar la petición al siguiente filtro.

  • getJwtFromCookie: Recupera el token del header Set-Cookie. Si no hay token, se devuelve un Optional vacío.

  • UsernamePasswordAuthenticationToken: Representa un token de autenticación. Guarda el usuario y los permisos del usuario.

  • BadCredentialsException: Excepción específica de Spring Security para cuando el token no es válido.

Tip

El JwtAuthenticationFilter se ejecuta antes de que el controlador maneje la petición. Si el token no es válido, el controlador no se ejecuta y se devuelve un error 401. Esto es una forma limpia de manejar la seguridad en la aplicación.

En otros frameworks como NestJS, est parte se suele manejarcon un interceptor dentro del middleware. En Spring Security se hace con un filtro de jakarta.servlet.Filter que forma parte de la API Servlet.

También es importante que no declares el filtro como un @Component. Puede pasar que el filtro se ejecute por parte del contenedor de Spring y por Spring Security. Declaralo sin la anotación @Componento @Bean. Puedes encontrar más información en la documentación oficial.

AuthController

El AuthController se encarga de gestionar las peticiones relacionadas con el login y logout de los usuarios. En un futuro se puede añadir la gestión de usuarios, pero por ahora solo se va a gestionar el login y logout.

AuthController.java
package com.auth.infrastructure.controllers;
@RestController
@RequestMapping(ApiConfig.API_BASE_PATH + "/auth")
public class AuthController {
    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping
    public void createUser(@RequestBody @Valid CreateUserDto createUserDto) {
        authService.createUser(createUserDto);
    }

    @PostMapping("/login")
    public void login(@RequestBody @Valid LoginRequestDTO loginRequestDTO, HttpServletResponse response) {
        final String token = authService.login(loginRequestDTO);
        final Cookie cookie = createAuthCookie(token);
        response.addCookie(cookie);
    }

    @PostMapping("/logout")
    public void logout(HttpServletResponse response) {
        final Cookie cookie = new Cookie(AuthConstants.TOKEN_COOKIE_NAME, StringUtils.EMPTY);
        cookie.setMaxAge(0);
    }

    private Cookie createAuthCookie(String token) {
        final String SAME_SITE_KEY = "SameSite";
        final Cookie cookie = new Cookie(AuthConstants.TOKEN_COOKIE_NAME, token);
        cookie.setHttpOnly(AuthConstants.HTTP_ONLY);
        cookie.setSecure(AuthConstants.COOKIE_SECURE);
        cookie.setMaxAge(AuthConstants.COOKIE_MAX_AGE);
        cookie.setAttribute(SAME_SITE_KEY, AuthConstants.SAME_SITE);
        return cookie;
    }
}

Endpoint de login

El endpoint de login se encarga únicamente de pasar los datos que vienen en el cuerpo de la petición al servicio AuthService. Si el login es correcto, el servicio genera un String llamado token. Para adjuntar el token en la respuesta, se crea una cookie usando la clase Cookie de jakarta.servlet.http.Cookie.

El token se guarda en una cookie llamada auth-token y se envía al cliente. La cookie tiene las siguientes propiedades:

  • HttpOnly: La cookie no es accesible desde JavaScript.
  • Secure: La cookie solo se envía a través de HTTPS.
  • SameSite: La cookie no se envía en peticiones de terceros.
  • Max-Age: La cookie expira en X minutos.

El SameSite Strict es la configuración más estricta y evita que la cookie se envíe en peticiones de terceros. Enviar en peticiones de terceros quire decir que la cookie no se envía si la petición no va al mismo dominio.

Si necesitas más flexibilidad en el tipo de cookie, puedes consultar el atributo en la documentación de MDN.

Endpoint de logout

El endpoint de logout elimina la cookie del cliente. En Java la opción más sencilla es:

  • Crear una cookie con el mismo nombre que la cookie que quieres eliminar.
  • Establecer el tiempo de vida de la cookie a 0.
  • Enviar la cookie al cliente.

Seteando el valor a StringUtils.EMPTY se asegura que la cookie no tenga valor. Junto con maxAge a 0, la cookie se elimine del cliente.

Para evitar la contaminación de dominio, he creado una clase AuthConstants que contiene las constantes de la cookie.

AuthCookieConstants.java
package com.auth.application.AuthCookieConstants;
public class AuthConstants {
    private AuthConstants() {
      throw new UnsupportedOperationException("This class should never be instantiated");
    }
    public static final String TOKEN_COOKIE_NAME = "auth-token";
    public static final boolean HTTP_ONLY = true;
    public static final boolean COOKIE_SECURE = true;
    public static final int COOKIE_MAX_AGE = 60 * 12; // 12 min
    public static final String SAME_SITE = "Strict";
}

Alternativa más limpia a AuthCookieConstants

Si no quieres crear puramente estática una clase con constantes, puedes usar un archivo de propiedades. En este caso, el archivo de propiedades se vería así:

application.yml
application:
  security:
    cookie:
      name: auth-token
      http-only: true
      secure: true
      max-age: 60 * 12 # 12 min
      same-site: Strict

Posteriormente, puedes importar las propiedades en la clase AuthController y usarlas directamente. Este enfoque es más limpio ya que desacopla la configuración de la implementación. Esta implementación la detallaré mejor cuando cree una entrada exclusiva para las Cookies

Fuentes consultadas

  • Spring Security
  • Spring Boot
Compartir: