Proyecto: API REST con Spring Boot y JWT (access + refresh tokens) — Java

java Proyecto: API REST con Spring Boot y JWT (access + refresh tokens) — Java

Proyecto: API REST con Spring Boot y JWT (access + refresh tokens)

En este tutorial crearás una API REST en Java usando Spring Boot que implementa autenticación con JWT (access token) y refresh tokens almacenados en base de datos. Incluye registro, login, refresh y un endpoint protegido. Código completo y explicaciones de por qué se eligen ciertas decisiones.

Requisitos previos

  • Java 17+
  • Maven
  • IDE (IntelliJ, VSCode, Eclipse)
  • Conocimientos básicos de Spring Boot y JPA

Estructura de carpetas

src/main/java/com/example/jwtproject
├── JwtProjectApplication.java
├── config
│   ├── SecurityConfig.java
│   ├── JwtFilter.java
│   └── JwtUtil.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── AuthRequest.java
│   └── AuthResponse.java
├── model
│   ├── User.java
│   └── RefreshToken.java
├── repository
│   ├── UserRepository.java
│   └── RefreshTokenRepository.java
└── service
    └── AuthService.java

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>jwtproject</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.0</version>
  </parent>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <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>
  </dependencies>
</project>

application.properties (mínimo)

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update

jwt.secret=ChangeThisVerySecretKeyForDemoOnly
jwt.access.expiration=900000    # 15 minutes in ms
jwt.refresh.expiration=604800000 # 7 days in ms

Clase principal

package com.example.jwtproject;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JwtProjectApplication {
    public static void main(String[] args) {
        SpringApplication.run(JwtProjectApplication.class, args);
    }
}

Modelo: User

package com.example.jwtproject.model;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false)
    private String username;
    @Column(nullable = false)
    private String password; // BCrypt

    // getters/setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

Modelo: RefreshToken

package com.example.jwtproject.model;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String token;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    private Instant expiryDate;

    // getters/setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getToken() { return token; }
    public void setToken(String token) { this.token = token; }
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
    public Instant getExpiryDate() { return expiryDate; }
    public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
}

Repositorios

package com.example.jwtproject.repository;

import com.example.jwtproject.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository {
    Optional<User> findByUsername(String username);
}

package com.example.jwtproject.repository;

import com.example.jwtproject.model.RefreshToken;
import com.example.jwtproject.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);
    void deleteByUser(User user);
}

DTOs

package com.example.jwtproject.dto;

public record AuthRequest(String username, String password) {}

package com.example.jwtproject.dto;

public record AuthResponse(String accessToken, String refreshToken) {}

JwtUtil (gestiona creación y validación)

package com.example.jwtproject.config;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {

    private final Key key;
    private final long accessExpirationMs;

    public JwtUtil(@Value("${jwt.secret}") String secret,
                   @Value("${jwt.access.expiration}") long accessExpirationMs) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
        this.accessExpirationMs = accessExpirationMs;
    }

    public String generateAccessToken(String username) {
        Date now = new Date();
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessExpirationMs))
                .signWith(key)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token).getBody().getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

JwtFilter (aplica el token a la seguridad de Spring)

package com.example.jwtproject.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.User;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;

@Component
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsernameFromToken(token);
                UserDetails userDetails = User.withUsername(username).password("").authorities(Collections.emptyList()).build();
                UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        filterChain.doFilter(request, response);
    }
}

SecurityConfig

package com.example.jwtproject.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    public SecurityConfig(JwtFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            );

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

AuthService

package com.example.jwtproject.service;

import com.example.jwtproject.config.JwtUtil;
import com.example.jwtproject.dto.AuthRequest;
import com.example.jwtproject.dto.AuthResponse;
import com.example.jwtproject.model.RefreshToken;
import com.example.jwtproject.model.User;
import com.example.jwtproject.repository.RefreshTokenRepository;
import com.example.jwtproject.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;
    private final long refreshTokenDurationMs;

    public AuthService(UserRepository userRepository,
                       RefreshTokenRepository refreshTokenRepository,
                       BCryptPasswordEncoder passwordEncoder,
                       JwtUtil jwtUtil,
                       @Value("${jwt.refresh.expiration}") long refreshTokenDurationMs) {
        this.userRepository = userRepository;
        this.refreshTokenRepository = refreshTokenRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtUtil = jwtUtil;
        this.refreshTokenDurationMs = refreshTokenDurationMs;
    }

    public void register(AuthRequest req) {
        if (userRepository.findByUsername(req.username()).isPresent()) {
            throw new RuntimeException("Username already taken");
        }
        User u = new User();
        u.setUsername(req.username());
        u.setPassword(passwordEncoder.encode(req.password()));
        userRepository.save(u);
    }

    public AuthResponse login(AuthRequest req) {
        User user = userRepository.findByUsername(req.username())
                .orElseThrow(() -> new RuntimeException("Invalid credentials"));
        if (!passwordEncoder.matches(req.password(), user.getPassword())) {
            throw new RuntimeException("Invalid credentials");
        }
        String accessToken = jwtUtil.generateAccessToken(user.getUsername());
        RefreshToken refreshToken = createRefreshToken(user);
        return new AuthResponse(accessToken, refreshToken.getToken());
    }

    private RefreshToken createRefreshToken(User user) {
        // remove existing
        refreshTokenRepository.deleteByUser(user);
        RefreshToken rt = new RefreshToken();
        rt.setUser(user);
        rt.setToken(UUID.randomUUID().toString());
        rt.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
        return refreshTokenRepository.save(rt);
    }

    public AuthResponse refreshToken(String token) {
        RefreshToken rt = refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new RuntimeException("Refresh token not found"));
        if (rt.getExpiryDate().isBefore(Instant.now())) {
            refreshTokenRepository.delete(rt);
            throw new RuntimeException("Refresh token expired");
        }
        String accessToken = jwtUtil.generateAccessToken(rt.getUser().getUsername());
        // issue new refresh token (rotating)
        refreshTokenRepository.delete(rt);
        RefreshToken newRt = createRefreshToken(rt.getUser());
        return new AuthResponse(accessToken, newRt.getToken());
    }

    public void logout(String username) {
        Optional<User> u = userRepository.findByUsername(username);
        u.ifPresent(user -> refreshTokenRepository.deleteByUser(user));
    }
}

AuthController

package com.example.jwtproject.controller;

import com.example.jwtproject.dto.AuthRequest;
import com.example.jwtproject.dto.AuthResponse;
import com.example.jwtproject.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService authService;

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

    @PostMapping("/register")
    public ResponseEntity<Void> register(@RequestBody AuthRequest req) {
        authService.register(req);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
        return ResponseEntity.ok(authService.login(req));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(@RequestBody String refreshToken) {
        // expect raw token in body; in prod, wrap in JSON
        return ResponseEntity.ok(authService.refreshToken(refreshToken.replaceAll("\"", "")));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestParam String username) {
        authService.logout(username);
        return ResponseEntity.ok().build();
    }
}

UserController (endpoint protegido)

package com.example.jwtproject.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/me")
    public String me(@AuthenticationPrincipal UserDetails ud) {
        return "Hello, " + ud.getUsername();
    }
}

Por qué estas decisiones

  • H2 para demo: arranque rápido sin infra adicional.
  • BCrypt para almacenar contraseñas: estándar fuerte y adaptativo.
  • Access token corto (15m) y refresh token persistente: minimiza exposición y permite renovar sin re-login.
  • Refresh tokens en DB: permite revocación y rotación (eliminamos el antiguo al emitir uno nuevo).
  • JwtFilter simple: no cargamos UserDetails desde DB por cada petición (trade-off). En APIs más completas cargarías roles/claims.

Cómo probar

  1. Levanta la app: mvn spring-boot:run
  2. POST /auth/register Body: {"username":"alice","password":"pass"}
  3. POST /auth/login Body: {"username":"alice","password":"pass"} — recibirás accessToken y refreshToken
  4. GET /api/me con header Authorization: Bearer <accessToken>
  5. Cuando caduque accessToken, POST /auth/refresh con body el refreshToken (raw string o JSON según implementación) para obtener tokens nuevos.

Mejoras y advertencias de seguridad

  • En producción, usa una clave JWT de al menos 256 bits guardada en un vault (no en properties).
  • Considera incluir jti y aud/iss claims, y una lista de revocación si necesitas forzar logout de tokens activos.
  • Para mayor seguridad, haz que los refresh tokens sean httpOnly cookies y protege endpoints contra CSRF si despliegas en navegadores.
  • Mantén la rotación de refresh tokens y monitorea intentos de uso repetido (indicador de robo de token).

Siguiente paso: integra roles y scopes, y reemplaza el filtro que solo crea una autenticación básica por un UserDetailsService que cargue roles desde la base de datos para autorización basada en roles/permiso.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión