Salta el contingut

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

Recursos