· 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.

Objectiu
Fer una guia real de com es configura un projecte de Spring Boot 3.4.1 amb Spring Security 6.4.2 :
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:
Dependències
Les dependències amb les que es treballarà són:
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:
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.javaInjecció 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.
JWT en un header anomenat Set-Cookie
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.
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.
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.
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.
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.
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.
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.
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
AuthMapperno ha de ser instanciada i llança una excepció si s’intenta instanciar. Faig servirUnsupportedOperationExceptionper 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.
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ó
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:
security:
jwt:
secret-key: 9a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3q2r1s0t
expiration: 15 # minutesExplicació 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.
@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:
- Pren la teva informació (dades de l’usuari)
- La converteix en un format especial anomenat JWT (JSON Web Token)
- 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.
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.
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.
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 headerSet-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.
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.
Constants de la cookie
Per a evitar la contaminació de domini, he creat una classe AuthConstants que conté les constants de la cookie.
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:
security:
cookie:
name: auth-token
http-only: true
secure: true
max-age: 60 * 12 # 12 min
same-site: StrictPosteriorment, 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