· Jose Antonio López  · 20 min lectura

Autenticació a Spring Security amb JWT - Guia Completa

Implementació pas a pas i millors pràctiques per a assegurar APIs REST en Java amb tokens JSON Web Token, persistència a PostgreSQL i gestió efectiva de rols i permisos.

Implementació pas a pas i millors pràctiques per a assegurar APIs REST en Java amb tokens JSON Web Token, persistència a PostgreSQL i gestió efectiva de rols i permisos.

Objectiu

Fer una guia real de com es configura un projecte de Spring Boot 3.4.1 amb Spring Security 6.4.2 :

CaracterísticaDescripció
Spring SecuritySeguretat per a l’aplicació utilitzant Spring Security.
JWT que ve en un header anomenat Set-CookieÚs de JWT en l’autenticació
Usuaris en base de dadesUsuaris guardats en una base de dades PostgreSQL

Per què escric aquest article?

Hi ha molts articles, tutorials i fins i tot cursos que són poc clars per a implementar una autenticació amb JWT a Spring.

Gran part del contingut ensenya la forma però no fa referència a la documentació oficial i molt menys s’acosta a codi real.

Es perden detalls i no aprofundeixen en aspectes importants com guardar usuaris en base de dades.

Prerequisits

Per a seguir aquest article necessites tenir instal·lat:

Eina/PasEnllaç de descàrrega
JavaDescarregar Java
MavenDescarregar Maven
Docker DesktopDescarregar Docker Desktop
IntelliJ IDEA CommunityDescarregar IntelliJ IDEA Community
Projecte amb Spring Boot 3.4.1Crear projecte Spring Boot
Instal·lar base de dades PostgreSQLGuia d’instal·lació de PostgreSQL

Dependències

Les dependències amb les que es treballarà són:

DependènciaDescripció
spring-boot-starter-data-jpaSuport a Java Persistance API per a interactuar amb bases de dades. Més a la documentació oficial.
spring-boot-starter-securitySuport de seguretat per a l’aplicació. Més a la documentació oficial.
spring-boot-starter-oauth2-authorization-serverProporciona un servidor d’autorització OAuth2 per a gestionar tokens d’accés. Més a la documentació oficial.
spring-boot-starter-validationSuport per a la validació de dades utilitzant l’especificació Bean Validation. Més a la documentació oficial.
spring-boot-starter-webDependències necessàries per a construir aplicacions web amb servlet. Més a la documentació oficial.
spring-boot-docker-composeFacilita la integració amb Docker Compose per a gestionar contenidors Docker. Més detall en aquesta guia del blog
postgresqlControlador JDBC per a connectar i treballar amb bases de dades PostgreSQL. És bastant dens, però Més a la documentació oficial.
spring-boot-devtoolsEines de desenvolupament que permeten compilacions incrementals durant el desenvolupament. Més detall en aquesta guia del blog
lombokBiblioteca que redueix el codi repetitiu mitjançant anotacions. Més a la documentació oficial.

He triat Maven com a gestor de dependències perquè em sento més còmode. Si prefereixes Gradle, pots usar-lo sense problemes.

Decisions de disseny

Onion Architecture

L’arquitectura serà del tipus onion architecture. Permet separar responsabilitats en tres grans capes:

CapaDescripció
DomainEntitats, interfícies dels serveis i excepcions de domini.
ApplicationImplementació de serveis de l’aplicació, mappers i DTOs.
InfrastructureControladors, filtres, configuració de l’aplicació, interfície del repositori JPA i implementació del repositori.

He triat aquesta arquitectura perquè em sembla una bona forma d’organitzar el codi. És un pas endavant per a tots aquells programadors que tradicionalment han treballat amb arquitectures per capes o layered architecture.

Moure’s a una arquitectura hexagonal o clean architecture pot ser un canvi molt brusc.

A continuació pots veure l’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

Injecció de dependències mitjançant constructor

El tipus d’injecció de dependències que he fet servir és mitjançant constructor. És la millor forma d’injectar dependències a Spring i la forma recomanada a la documentació oficial.

Si acostumes a fer servir injecció de dependències mitjançant camps, et recomano que ho canviïs a injecció de dependències mitjançant constructor.

Lombok

Per reduir el codi repetitiu, he fet servir la biblioteca Lombok que redueix el codi visible mitjançant anotacions. En aquest cas, es fan servir anotacions @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor i @Builder.

Altres anotacions que es poden fer servir són @Data, @EqualsAndHashCode i @ToString encara que considero que toquen temes molt delicats i recomano implementar-los a mà si arriba el cas.

Entitats de domini anotades amb JPA

Les entitats de domini són classes que representen coses del problema que resolem. En aquest cas una entitat seria User.

Aquestes classes estan anotades amb @Entity i @Table per indicar que són també entitats de JPA.

Anotar entitats de domini amb anotacions d’un framework pot ser considerat mala pràctica. El terme tècnic es diu contaminació de domini. He decidit fer-ho per simplificar el codi i no haver de crear mappers innecessaris que afegeixen complexitat.

L’autenticació es farà amb JWT i s’enviarà en un header anomenat Set-Cookie. Les raons principals són degudes a que l’API es consumirà des d’un client web i és més segur enviar el token en una cookie amb les següents característiques:

  • HttpOnly: La cookie no és accessible des de JavaScript.
  • Secure: La cookie només s’envia a través de HTTPS.
  • SameSite: La cookie no s’envia en peticions de tercers.
  • Max-Age: La cookie expira en X minuts.

Si vols aprofundir més en el tema, et recomano llegir la documentació de mozzila.org.

Configuració de la classe WebSecurityConfig

La classe WebSecurityConfig és la configuració principal de Spring Security. S’encarrega de configurar la seguretat de l’aplicació i de definir quines rutes són públiques i quines no.

Et deixo l’enllaç a la guia per a implementar-la i entendre-la en aquest mateix blog.

Usuaris en base de dades

Entitat User

El primer pas és definir l’entitat User que representa un usuari. L’entitat User implementa la interfície UserDetails de Spring Security.

La interfície defineix alguns mètodes amb tipus default que ja estan implementats.

Personalment no em convenç tenir implementacions per defecte en una interfície, però és el que hi ha.

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();
    }
}

Per què UserDetails?

UserDetails és una interfície que representa un usuari a Spring Security i és usada per un servei anomenat UserDetailsService.

Això és necessari perquè Spring Security pugui autenticar un usuari sense implementar codi addicional.

Explicació d’anotacions

@Entity: Indica que la classe és una entitat de JPA. Més informació a la documentació oficial.

@Table(name = "users"): Indica que l’entitat es guardarà en una taula anomenada users. Si no s’especifica, JPA fa servir el nom de la classe com a nom de la taula.

@Id: Indica que el camp id és la clau primària de l’entitat.

@UuidGenerator: Genera un UUID automàticament. Per defecte, Hibernate genera UUIDs de versió 4.

@Column: Indica que el camp email és únic a la base de dades. Per no allargar el codi, només afegeixo camps imprescindibles amb @Column.

@Enumerated(EnumType.STRING): Indica que el camp role és un Enum i que es guardarà com un String a la base de dades.

Repositori User en el domini

Primer cal definir una interfície repositori en el domini. Això controla les operacions que tenen sentit per al problema que es resol.

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ó complementària

En el context de Domain-Driven Design (DDD) o patrons com Repository Pattern, un repositori actua com una abstracció sobre la capa de persistència de dades.

Repositori User a la infraestructura

També cal definir un repositori a la infraestructura. No confondre amb el repositori del domini.

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

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

Informació complementària

La infraestructura actua sobre la base de dades i permet que el domini romangui independent als detalls d’implementació.

@Repository: Indica que la classe és un component de Spring i es farà servir per a accedir a la base de dades. S’esmenta de passada a la documentació oficial.

PostgresUserRepository: Indica clarament que serveix per a la implementació d’una base de dades PostgreSQL.

JPARepository: Proporciona mètodes per a interactuar amb la base de dades. Recorda passar com a ID el tipus de dada de la clau primària. Més informació a la documentació oficial.

Tip

Pots comprovar que la interfície ha d’anar a la infraestructura perquè està anotada amb @Repository. En el domini mai s’han de fer servir anotacions de Spring. Una excepció és la d’anotar entitats amb @Entity i sempre amb la deguda justificació.

DTOs

Els DTOs (Data Transfer Objects) són objectes que es fan servir per a transferir dades entre capes. En aquest cas, es fan servir per a transferir dades entre la capa d’infraestructura i la capa d’aplicació.

CreateUserDto

Per crear un usuari es defineix un DTO que conté els camps necessaris. Pots afegir o treure camps segons les necessitats de la teva aplicació. Per a l’exemple, es fan servir els camps email, firstName i password.

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

public record CreateUserDto(
    @Email
    @NotBlank
    String email,

    @NotBlank
    String firstName,

    @NotBlank
    String password
) {
}

Explicació d’anotacions

@NotBlank: Indica que el camp no pot estar buit. @Email: Indica que el camp ha de ser un email vàlid. Pot ser que es quedi curt, però és un bon començament.

Tip

Un DTO és un objecte amb informació immutable i mai ha de ser modificada. Per als DTOs sempre aconsello fer servir record introduïts a Java 14. Fes-lo servir a Java 17 o superior que és quan es va estabilitzar.

LoginRequestDTO

El següent DTO és per al login i té els camps necessaris per a autenticar un usuari.

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

    @NotBlank
    String password
) {
}

UserResponseDTO

L’últim DTO és per a retornar dades de l’usuari.

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

Super Important

Mai retornis dades sensibles com la contrasenya de l’usuari. El DTO UserResponseDTO no retorna la contrasenya de l’usuari.

AuthMapper

L’AuthMapper és una classe que transforma DTOs a entitats de domini o a l’inrevés. És una molt bona pràctica tenir un mapper per a únicament transformar dades entre capes.

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

Per a entitats de mapeig m’agrada seguir les següents bones pràctiques:

  • No fer servir llibreries de mapeig: Prefereixo no fer servir llibreries com MapStruct. Prefereixo tenir control total i evitar dependències innecessàries.
  • Classe purament estàtica: La classe AuthMapper és purament estàtica. És una classe d’utilitat que només té mètodes estàtics.
  • No instanciar la classe: La classe AuthMapper no ha de ser instanciada i llança una excepció si s’intenta instanciar. Faig servir UnsupportedOperationException per a indicar a altres desenvolupadors que la classe no ha de ser instanciada.

AuthService

AuthService en domini

Aquest servei és dels més importants i s’encarrega de la lògica relacionada amb l’autenticació i la gestió d’usuaris.

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

Durant el desenvolupament vaig estar temptat a estendre UserDetailsService directament. Això no seria del tot correcte perquè UserDetailsService és una interfície de Spring Security i no hauria de ser usada a la capa de domini.

Implementació d’AuthService en aplicació

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ó del codi

@Service: Indica que la classe és un component de Spring i es farà servir per a la lògica de negoci.

loadUserByUsername: Mètode de la interfície UserDetailsService que Spring Security crida internament.

AuthenticationConfiguration: Classe que s’encarrega de configurar l’autenticació a Spring Security.

Tip

Aquí té sentit implementar UserDetailsService. La interfície és pròpia de Spring Security i la implementació del servei és pròpia de l’aplicació. AuthenticationConfiguration ajuda a trencar una dependència circular que em va aparèixer entre AuthService i SecurityConfig. La dependència circular apareix entre AuthenticationManager i AuthService.

Clau secreta i temps d’expiració

application.yml

Farem servir application.yml amb propietats personalitzades. Configurar aquests paràmetres en un arxiu de propietats és una bona pràctica perquè es poden canviar sense recompilar el codi.

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

Explicació de les propietats

secret-key: Clau secreta per a firmar el token JWT. És important que sigui una clau segura i no es comparteixi amb ningú. expiration: Temps d’expiració del token JWT en minuts. Se sol posar en mil·lisegons, però per a simplificar ho he posat en minuts.

El temps d’expiració dependrà molt de l’aplicació i de la seguretat que vulguis implementar. Si hi ha un temps d’expiració molt curt, els usuaris hauran de fer login constantment. Si és molt llarg, el token pot ser robat i usat per un atacant. Amb 15 minuts opino que és suficient per a la majoria d’aplicacions.

EncoderConfig

La codificació i descodificació de JWTs es fa a Spring Security mitjançant Beans. En aquest cas, es fan servir les implementacions 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è fa Nimbus JOSE + JWT SDK?

Spring Security proporciona la interfície JwtEncoder i JwtDecoder per a codificar i descodificar JWTs. Són interfícies funcionals que tenen un mètode encode i decode respectivament.

Les implementacions que vénen a Spring Security són NimbusJwtEncoder i NimbusJwtDecoder respectivament.

Nimbus JOSE + JWT SDK és una biblioteca de Java que permet la creació i verificació de JSON Web Tokens (JWT) i JSON Web Signature (JWS).

El JwtEncoder és com una màquina que fa tres coses:

  1. Pren la teva informació (dades de l’usuari)
  2. La converteix en un format especial anomenat JWT (JSON Web Token)
  3. La “signa” digitalment fent servir una clau secreta (proporcionada pel JWKSource)

El JWS Compact Serialization format és la forma en què s’organitza la informació. Així tots els sistemes poden entendre i verificar els tokens.

La part de JWKSource és el guardià de les claus. Proporciona la clau secreta per a “segellar” (signar) el token.

Què fa BCryptPasswordEncoder?

BCryptPasswordEncoder és una implementació de PasswordEncoder que fa servir l’algorisme de xifratge BCrypt. Per darrere es fa servir una funció de tipus hash. Les funcions hash reben una entrada i retornen una sortida. Són funcions unidireccionals, és a dir, no hi ha manera de descobrir la contrasenya original a partir de la sortida.

TokenService en el domini

El TokenService és el servei que s’encarrega de generar i 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 decidit posar-lo a la capa de domini. Des del meu punt de vista, l’aplicació és una API REST i el token és part del domini. La seguretat i la futura aplicació coexistirien en el mateix projecte. Si la seguretat estigués en un microservei, el projecte es mouria tal qual movent la carpeta auth al nou microservei.

Implementació de TokenService en aplicació

L’implementació de TokenService és la que s’encarrega de generar i validar els tokens JWT. En aquest article es fa servir 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ó complementària

Codi

  • @Value: Indica que la propietat s’injectarà des de l’arxiu de propietats.
  • JwtClaimsSet: Classe que representa els claims del token JWT.
  • JwtEncoderParameters: Classe que representa els paràmetres del token JWT. Es guarda l’algorisme de signatura i els claims.
  • JwtClaimsSet.Builder: Classe que permet construir el token JWT. S’hi poden afegir claims i configurar la data d’expiració.

Claims

Els claims és la informació que es guarda en el token. En aquest cas, es guarda l’email de l’usuari i la data d’expiració. El claim scope és un claim que pots fer servir per a guardar informació sobre permisos. En aquest cas, no el faig servir per a res, però és un bon exemple de com es poden recuperar els permisos. Després pots fer servir el claim scope per a guardar informació sobre permisos.

AuthService

Inicialment, l’AuthService tenia tota la lògica de generació i validació de tokens. Vaig decidir moure-ho al seu propi servei per a separar responsabilitats. De totes maneres, pot ser vàlid que l’AuthService tingui lògica de generació i validació de tokens. Personalment prefereixo que el TokenService s’encarregui de generar i validar tokens i que l’AuthService autentiqui i gestioni usuaris.

JWTAuthenticationFilter

El JWTAuthenticationFilter és el filtre que s’encarrega de validar que el token JWT sigui vàlid i que l’usuari tingui permisos per a accedir 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ó complementària

Codi

  • OncePerRequestFilter: Spring Security recomana estendre OncePerRequestFilter per a crear filtres personalitzats. Aquest filtre s’executa un cop per cada petició. Pots llegir més a la documentació oficial.

  • shouldNotFilter: El filtre no s’executa si la ruta és de login. Durant el login no hi ha token, així que no ha de saltar.

  • doFilterInternal: Es valida el token i es passa al context de seguretat de Spring Security els valors extrets del token.

  • HttpServletRequest: Representa la petició HTTP entrant.

  • HttpServletResponse: Representa la resposta HTTP sortint. Es retorna un error 401 si el token no és vàlid.

  • FilterChain: Es fa servir per a passar la petició al següent filtre.

  • getJwtFromCookie: Recupera el token del header Set-Cookie. Si no hi ha token, es retorna un Optional buit.

  • UsernamePasswordAuthenticationToken: Representa un token d’autenticació. Guarda l’usuari i els permisos de l’usuari.

  • BadCredentialsException: Excepció específica de Spring Security per a quan el token no és vàlid.

Tip

El JwtAuthenticationFilter s’executa abans que el controlador gestioni la petició. Si el token no és vàlid, el controlador no s’executa i es retorna un error 401. Això és una forma neta de gestionar la seguretat a l’aplicació.

En altres frameworks com NestJS, aquesta part se sol gestionar amb un interceptor dins del middleware. A Spring Security es fa amb un filtre de jakarta.servlet.Filter que forma part de l’API Servlet.

També és important que no declaris el filtre com un @Component. Pot passar que el filtre s’executi per part del contenidor de Spring i per Spring Security. Declara’l sense l’anotació @Component o @Bean. Pots trobar més informació a la documentació oficial.

AuthController

El AuthController s’encarrega de gestionar les peticions relacionades amb el login i logout dels usuaris. En un futur es pot afegir la gestió d’usuaris, però per ara només es gestionarà el login i 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

L’endpoint de login s’encarrega únicament de passar les dades que vénen al cos de la petició al servei AuthService. Si el login és correcte, el servei genera un String anomenat token. Per a adjuntar el token a la resposta, es crea una cookie fent servir la classe Cookie de jakarta.servlet.http.Cookie.

El token es guarda en una cookie anomenada auth-token i s’envia al client. La cookie té les següents propietats:

  • HttpOnly: La cookie no és accessible des de JavaScript.
  • Secure: La cookie només s’envia a través de HTTPS.
  • SameSite: La cookie no s’envia en peticions de tercers.
  • Max-Age: La cookie expira en X minuts.

El SameSite Strict és la configuració més estricta i evita que la cookie s’enviï en peticions de tercers. Enviar en peticions de tercers vol dir que la cookie no s’envia si la petició no va al mateix domini.

Si necessites més flexibilitat en el tipus de cookie, pots consultar l’atribut a la documentació de MDN.

Endpoint de logout

L’endpoint de logout elimina la cookie del client. A Java l’opció més senzilla és:

  • Crear una cookie amb el mateix nom que la cookie que vols eliminar.
  • Establir el temps de vida de la cookie a 0.
  • Enviar la cookie al client.

Setejant el valor a StringUtils.EMPTY s’assegura que la cookie no tingui valor. Juntament amb maxAge a 0, la cookie s’elimina del client.

Per a evitar la contaminació de domini, he creat una classe AuthConstants que conté les constants 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 neta a AuthCookieConstants

Si no vols crear purament estàtica una classe amb constants, pots fer servir un arxiu de propietats. En aquest cas, l’arxiu de propietats es veuria així:

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

Posteriorment, pots importar les propietats a la classe AuthController i fer-les servir directament. Aquest enfocament és més net ja que desacobla la configuració de la implementació. Aquesta implementació la detallaré millor quan creï una entrada exclusiva per a les Cookies

Fonts consultades

  • Spring Security
  • Spring Boot
Compartir: