Spring Boot JWT Authentication - Guia Completa¶
Introducció a JWT¶
JWT (JSON Web Token) és un mecanisme d'autenticació stateless (sense sessions) que permet validar usuaris de manera segura en aplicacions web i APIs REST.
Estructura d'un JWT¶
Un JWT consisteix en tres parts separades per punts (.):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MjMxMTExNjAyIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
Header.Payload.Signature
| Part | Contingut |
|---|---|
| Header | Algoritme de signatura (HS256, RS256) i tipus (JWT) |
| Payload | Dades de l'usuari (claims) |
| Signature | Signatura per validar integritat |
Comparació: Sessions vs JWT¶
| Aspecte | Sessions Tradicionals | JWT |
|---|---|---|
| Emmagatzematge | Server-side | Client-side (token) |
| Escalabilitat | Difícil (sticky sessions) | Fàcil (stateless) |
| CORS | Problemàtic | Solució natural |
| Seguretat | Bona | Excel·lent si es usa HTTPS |
| Revocació | Inmediata | Complexa (blacklist) |
| Microserveis | No escalable | Ideal |
Instal·lació i Configuració¶
1. Dependencies¶
<!-- pom.xml -->
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT Library (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
2. Configuració application.yml¶
spring:
application:
name: jwt-auth-app
datasource:
url: jdbc:mysql://localhost:3306/jwt_db
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
servlet:
context-path: /api
server:
port: 8080
# JWT Configuration
jwt:
secret: your-very-long-secret-key-that-should-be-at-least-256-bits-long-for-security
expiration: 86400000 # 24 hores en milisegons
refresh-expiration: 604800000 # 7 dies
Estructura de Projecte¶
my-app/
├── src/main/java/com/example/myapp/
│ ├── MyAppApplication.java
│ ├── config/
│ │ └── SecurityConfig.java
│ ├── controller/
│ │ └── AuthController.java
│ ├── service/
│ │ ├── AuthService.java
│ │ ├── JwtService.java
│ │ └── UserDetailsServiceImpl.java
│ ├── security/
│ │ ├── JwtAuthenticationFilter.java
│ │ └── JwtAuthenticationEntryPoint.java
│ ├── entity/
│ │ ├── User.java
│ │ └── Role.java
│ ├── repository/
│ │ ├── UserRepository.java
│ │ └── RoleRepository.java
│ ├── dto/
│ │ ├── LoginRequest.java
│ │ ├── SignupRequest.java
│ │ ├── AuthResponse.java
│ │ └── UserDTO.java
│ └── exception/
│ ├── ApiException.java
│ └── GlobalExceptionHandler.java
Implementació Completa¶
1. Entity: User¶
package com.example.myapp.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Column(nullable = false, unique = true)
private String username;
@NotBlank
@Email
@Column(nullable = false, unique = true)
private String email;
@NotBlank
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
@Column(nullable = false)
private boolean enabled = true;
}
2. Entity: Role¶
package com.example.myapp.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "roles")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
private String description;
}
3. Servei JWT¶
package com.example.myapp.service;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Service
@Slf4j
@RequiredArgsConstructor
public class JwtService {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpirationMs;
@Value("${jwt.refresh-expiration}")
private long refreshTokenExpirationMs;
/**
* Generar JWT desde Authentication
*/
public String generateToken(Authentication authentication) {
UserDetails userPrincipal = (UserDetails) authentication.getPrincipal();
return generateTokenFromUsername(userPrincipal.getUsername());
}
/**
* Generar JWT desde username
*/
public String generateTokenFromUsername(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(key(), SignatureAlgorithm.HS512)
.compact();
}
/**
* Generar Refresh Token
*/
public String generateRefreshToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMs))
.signWith(key(), SignatureAlgorithm.HS512)
.compact();
}
/**
* Extreure username del token
*/
public String getUsernameFromToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
} catch (SecurityException e) {
log.error("Invalid JWT signature: {}", e);
} catch (MalformedJwtException e) {
log.error("Invalid JWT token: {}", e);
} catch (ExpiredJwtException e) {
log.error("Expired JWT token: {}", e);
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token: {}", e);
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty: {}", e);
}
return null;
}
/**
* Validar JWT token
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key())
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException e) {
log.error("Invalid JWT signature");
} catch (MalformedJwtException e) {
log.error("Invalid JWT token");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token");
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty");
}
return false;
}
/**
* Obtenir la clau de signatura
*/
private Key key() {
return Keys.hmacShaKeyFor(
jwtSecret.getBytes(StandardCharsets.UTF_8)
);
}
}
4. JWT Authentication Filter¶
package com.example.myapp.security;
import com.example.myapp.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
// Extreure JWT del header "Authorization: Bearer <token>"
String jwt = extractTokenFromRequest(request);
if (jwt != null && jwtService.validateToken(jwt)) {
String username = jwtService.getUsernameFromToken(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// Caregar detalls de l'usuari
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// Crear token d'autenticació
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// Establir autenticació al context
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
log.debug("Usuari autenticat: {}", username);
}
}
} catch (Exception e) {
log.error("Error al processar JWT: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
/**
* Extreure JWT del header Authorization
*/
private String extractTokenFromRequest(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7); // Eliminar "Bearer "
}
return null;
}
}
5. JWT Authentication Entry Point¶
package com.example.myapp.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
log.error("Responding with unauthorized error. Message: {}", authException.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", "No s'ha autenticat correctament");
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}
6. Security Configuration¶
package com.example.myapp.config;
import com.example.myapp.security.JwtAuthenticationEntryPoint;
import com.example.myapp.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.security.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
// Endpoints públicos
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/auth/refresh-token").permitAll()
// Endpoints protegits
.requestMatchers("/api/users/**").hasRole("USER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Tot el demés requereix autenticació
.anyRequest().authenticated()
);
// Afegir filtres de JWT
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
7. Auth Controller¶
package com.example.myapp.controller;
import com.example.myapp.dto.LoginRequest;
import com.example.myapp.dto.SignupRequest;
import com.example.myapp.dto.AuthResponse;
import com.example.myapp.service.AuthService;
import com.example.myapp.service.JwtService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
@CrossOrigin(origins = "http://localhost:4200")
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
/**
* Registrar un nou usuari
*/
@PostMapping("/register")
public ResponseEntity<AuthResponse> registerUser(@Valid @RequestBody SignupRequest signupRequest) {
log.info("Registrando nuevo usuario: {}", signupRequest.getUsername());
// Verificar que l'usuari no existeixi
if (authService.userExists(signupRequest.getUsername())) {
return ResponseEntity
.badRequest()
.body(new AuthResponse(null, "Username ja existeix!", false));
}
if (authService.emailExists(signupRequest.getEmail())) {
return ResponseEntity
.badRequest()
.body(new AuthResponse(null, "Email ja està registrat!", false));
}
// Crear l'usuari
authService.registerUser(signupRequest);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new AuthResponse(null, "Usuario registrado correctamente!", true));
}
/**
* Login - generar JWT token
*/
@PostMapping("/login")
public ResponseEntity<AuthResponse> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
log.info("Usuario intentando login: {}", loginRequest.getUsername());
try {
// Autenticar usuari
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
)
);
// Establir autenticació al context
SecurityContextHolder.getContext().setAuthentication(authentication);
// Generar JWT token
String token = jwtService.generateToken(authentication);
String refreshToken = jwtService.generateRefreshToken(loginRequest.getUsername());
return ResponseEntity.ok(
new AuthResponse(token, "Autenticación exitosa!", true, refreshToken)
);
} catch (Exception e) {
log.error("Error en login: {}", e.getMessage());
return ResponseEntity
.badRequest()
.body(new AuthResponse(null, "Usuario o contraseña incorrectos", false));
}
}
/**
* Refresh Token - obtenir nou token
*/
@PostMapping("/refresh-token")
public ResponseEntity<AuthResponse> refreshToken(@RequestHeader("Authorization") String refreshToken) {
try {
if (refreshToken.startsWith("Bearer ")) {
refreshToken = refreshToken.substring(7);
}
if (jwtService.validateToken(refreshToken)) {
String username = jwtService.getUsernameFromToken(refreshToken);
String newToken = jwtService.generateTokenFromUsername(username);
return ResponseEntity.ok(
new AuthResponse(newToken, "Token actualizado", true)
);
}
return ResponseEntity
.badRequest()
.body(new AuthResponse(null, "Token inválido", false));
} catch (Exception e) {
log.error("Error al refrescar token: {}", e.getMessage());
return ResponseEntity
.badRequest()
.body(new AuthResponse(null, "Error al refrescar el token", false));
}
}
}
8. DTOs¶
// LoginRequest
package com.example.myapp.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@NotBlank(message = "Username és obligatori")
private String username;
@NotBlank(message = "Password és obligatori")
private String password;
}
// SignupRequest
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequest {
@NotBlank(message = "Username és obligatori")
private String username;
@NotBlank(message = "Email és obligatori")
@Email(message = "Email ha de ser vàlid")
private String email;
@NotBlank(message = "Password és obligatori")
@Size(min = 6, message = "Password ha de tenir mínim 6 caràcters")
private String password;
private String firstName;
private String lastName;
}
// AuthResponse
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private String accessToken;
private String message;
private Boolean success;
private String refreshToken;
public AuthResponse(String accessToken, String message, Boolean success) {
this.accessToken = accessToken;
this.message = message;
this.success = success;
}
}
Endpoints d'Exemple¶
Registre¶
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "john_doe",
"email": "john@example.com",
"password": "securePassword123",
"firstName": "John",
"lastName": "Doe"
}'
Login (Obtenir Token)¶
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "john_doe",
"password": "securePassword123"
}'
Resposta:
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqb2huX2RvZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDg2NDAwfQ.xyz",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqb2huX2RvZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwNjg2NDAwfQ.abc",
"message": "Autenticación exitosa!",
"success": true
}
Usar Token en Request Protegit¶
curl -X GET http://localhost:8080/api/users/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqb2huX2RvZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDg2NDAwfQ.xyz"
Refresh Token¶
curl -X POST http://localhost:8080/api/auth/refresh-token \
-H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqb2huX2RvZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwNjg2NDAwfQ.abc"
Millors Pràctiques de Seguretat¶
1. Secret Key Fort¶
# NO HACER:
jwt.secret: mysecret
# HACER:
jwt.secret: your-very-long-secret-key-minimum-256-bits-that-should-be-random-and-secure
2. Externalitzar Secrets¶
# Usar variables d'entorn
jwt.secret: ${JWT_SECRET}
jwt.expiration: ${JWT_EXPIRATION:86400000}
3. HTTPS en Producció¶
server:
ssl:
key-store: classpath:keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
4. Token Revocation (Blacklist)¶
@Service
public class TokenBlacklistService {
private final Set<String> blacklist = new ConcurrentHashSet<>();
public void revoke(String token) {
blacklist.add(token);
}
public boolean isRevoked(String token) {
return blacklist.contains(token);
}
}
5. Expiració Breu¶
jwt:
expiration: 900000 # 15 minuts
refresh-expiration: 604800000 # 7 dies
6. Rate Limiting¶
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
// Implementar limite de tries de login
}
Comparació: JWT vs Sessions¶
| Aspecto | JWT | Sessions |
|---|---|---|
| Escalabilitat | Excellent | Pobre |
| Seguretat | Alta (si HTTPS) | Alta |
| Revocació | Difícil | Fàcil |
| CORS | Senzill | Complex |
| Microserveis | Ideal | No escalable |