Proyecto práctico: API REST segura en Java con Spring Boot, JWT y refresh tokens

java Proyecto práctico: API REST segura en Java con Spring Boot, JWT y refresh tokens

Proyecto práctico: API REST segura en Java con Spring Boot, JWT y refresh tokens

Objetivo: construir una API REST con autenticación basada en JWT (access + refresh tokens), roles, almacenamiento de refresh tokens para revocación, y despliegue mínimo con Docker. Código funcional y explicaciones de por qué cada componente existe.

Requisitos previos

  • Java 17+
  • Maven
  • Docker (opcional para contenedor)
  • Conocimientos básicos de Spring Boot y JPA

Estructura de carpetas

demo-secure-api/
├─ pom.xml
├─ Dockerfile
├─ docker-compose.yml (opcional)
└─ src/
   └─ main/
      ├─ java/com/example/demo/
      │  ├─ DemoApplication.java
      │  ├─ config/SecurityConfig.java
      │  ├─ config/JwtFilter.java
      │  ├─ controller/AuthController.java
      │  ├─ controller/TestController.java
      │  ├─ dto/AuthRequest.java
      │  ├─ dto/AuthResponse.java
      │  ├─ model/User.java
      │  ├─ model/Role.java
      │  ├─ model/RefreshToken.java
      │  ├─ repository/UserRepository.java
      │  ├─ repository/RefreshTokenRepository.java
      │  ├─ service/UserService.java
      │  └─ util/JwtUtil.java
      └─ resources/application.yml

pom.xml (dependencias principales)

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>demo-secure-api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <properties>
    <java.version>17</java.version>
    <spring-boot.version>3.1.3</spring-boot.version>
  </properties>

  <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>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

application.yml

server:
  port: 8080

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

app:
  jwt:
    secret: verySecretKeyThatShouldBeStoredSafelyAndLongEnough123456
    access-exp-ms: 900000   # 15 min
    refresh-exp-ms: 2592000000 # 30 days

DemoApplication.java

package com.example.demo;

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

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

model/Role.java

package com.example.demo.model;

public enum Role {
    ROLE_USER, ROLE_ADMIN
}

model/User.java

package com.example.demo.model;

import jakarta.persistence.*;
import java.util.Set;

@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;

    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set roles;

    // getters and 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; }
    public Set getRoles() { return roles; }
    public void setRoles(Set roles) { this.roles = roles; }
}

model/RefreshToken.java

package com.example.demo.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;

    @Column(nullable = false)
    private Instant expiryDate;

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

    // getters and 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 Instant getExpiryDate() { return expiryDate; }
    public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
}

repository/UserRepository.java

package com.example.demo.repository;

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

public interface UserRepository extends JpaRepository {
    Optional findByUsername(String username);
    boolean existsByUsername(String username);
}

repository/RefreshTokenRepository.java

package com.example.demo.repository;

import com.example.demo.model.RefreshToken;
import com.example.demo.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);
    int deleteByUser(User user);
}

dto/AuthRequest.java

package com.example.demo.dto;

public class AuthRequest {
    private String username;
    private String password;

    // getters/setters
    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; }
}

dto/AuthResponse.java

package com.example.demo.dto;

public class AuthResponse {
    private String accessToken;
    private String refreshToken;

    public AuthResponse(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }

    // getters
    public String getAccessToken() { return accessToken; }
    public String getRefreshToken() { return refreshToken; }
}

util/JwtUtil.java

package com.example.demo.util;

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;
import java.util.Map;

@Component
public class JwtUtil {

    private final Key key;
    private final long accessExpMs;

    public JwtUtil(@Value("${app.jwt.secret}") String secret,
                   @Value("${app.jwt.access-exp-ms}") long accessExpMs) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
        this.accessExpMs = accessExpMs;
    }

    public String generateAccessToken(String username, Map claims) {
        Date now = new Date();
        Date exp = new Date(now.getTime() + accessExpMs);
        return Jwts.builder()
                .setSubject(username)
                .addClaims(claims)
                .setIssuedAt(now)
                .setExpiration(exp)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return parseClaims(token).getSubject();
    }

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

    public boolean validateToken(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException ex) {
            return false;
        }
    }
}

service/UserService.java

package com.example.demo.service;

import com.example.demo.model.RefreshToken;
import com.example.demo.model.Role;
import com.example.demo.model.User;
import com.example.demo.repository.RefreshTokenRepository;
import com.example.demo.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.Map;
import java.util.Optional;
import java.util.UUID;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    private final long refreshExpMs;

    public UserService(UserRepository userRepository,
                       RefreshTokenRepository refreshTokenRepository,
                       @Value("${app.jwt.refresh-exp-ms}") long refreshExpMs) {
        this.userRepository = userRepository;
        this.refreshTokenRepository = refreshTokenRepository;
        this.refreshExpMs = refreshExpMs;
    }

    public User register(String username, String rawPassword) {
        if (userRepository.existsByUsername(username)) throw new RuntimeException("Usuario ya existe");
        User u = new User();
        u.setUsername(username);
        u.setPassword(passwordEncoder.encode(rawPassword));
        u.setRoles(java.util.Set.of(Role.ROLE_USER));
        return userRepository.save(u);
    }

    public Optional findByUsername(String username) {
        return userRepository.findByUsername(username);
    }

    public boolean checkPassword(User user, String rawPassword) {
        return passwordEncoder.matches(rawPassword, user.getPassword());
    }

    public RefreshToken createRefreshToken(User user) {
        RefreshToken rt = new RefreshToken();
        rt.setUser(user);
        rt.setToken(UUID.randomUUID().toString());
        rt.setExpiryDate(Instant.now().plusMillis(refreshExpMs));
        return refreshTokenRepository.save(rt);
    }

    public Optional<RefreshToken> findRefreshToken(String token) {
        return refreshTokenRepository.findByToken(token);
    }

    public void deleteUserRefreshTokens(User user) {
        refreshTokenRepository.deleteByUser(user);
    }
}

config/SecurityConfig.java

package com.example.demo.config;

import com.example.demo.util.JwtUtil;
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.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtFilter jwtFilter;

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

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

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

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

config/JwtFilter.java

package com.example.demo.config;

import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtil;
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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserService userService;

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

    @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);
                var claims = jwtUtil.parseClaims(token);
                var roles = (List<String>)claims.get("roles");
                var authorities = roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
                var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        filterChain.doFilter(request, response);
    }
}

controller/AuthController.java

package com.example.demo.controller;

import com.example.demo.dto.AuthRequest;
import com.example.demo.dto.AuthResponse;
import com.example.demo.model.RefreshToken;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

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

    private final UserService userService;
    private final JwtUtil jwtUtil;

    public AuthController(UserService userService, JwtUtil jwtUtil) {
        this.userService = userService;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody AuthRequest req) {
        User u = userService.register(req.getUsername(), req.getPassword());
        return ResponseEntity.ok("Usuario creado: " + u.getUsername());
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody AuthRequest req) {
        var userOpt = userService.findByUsername(req.getUsername());
        if (userOpt.isEmpty() || !userService.checkPassword(userOpt.get(), req.getPassword())) {
            return ResponseEntity.status(401).body("Credenciales inválidas");
        }
        User user = userOpt.get();
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", user.getRoles().stream().map(Enum::name).toList());
        String accessToken = jwtUtil.generateAccessToken(user.getUsername(), claims);
        RefreshToken refreshToken = userService.createRefreshToken(user);
        return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken.getToken()));
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@RequestBody Map<String, String> body) {
        String rtoken = body.get("refreshToken");
        if (rtoken == null) return ResponseEntity.badRequest().body("refreshToken missing");
        var rtOpt = userService.findRefreshToken(rtoken);
        if (rtOpt.isEmpty()) return ResponseEntity.status(401).body("Refresh token inválido");
        RefreshToken rt = rtOpt.get();
        if (rt.getExpiryDate().isBefore(java.time.Instant.now())) {
            return ResponseEntity.status(401).body("Refresh token expirado");
        }
        User user = rt.getUser();
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", user.getRoles().stream().map(Enum::name).toList());
        String newAccess = jwtUtil.generateAccessToken(user.getUsername(), claims);
        return ResponseEntity.ok(new AuthResponse(newAccess, rtoken));
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        var uOpt = userService.findByUsername(username);
        if (uOpt.isPresent()) {
            userService.deleteUserRefreshTokens(uOpt.get());
        }
        return ResponseEntity.ok("Logged out");
    }
}

controller/TestController.java

package com.example.demo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @GetMapping("/api/hello")
    public String hello() {
        return "Hola usuario autenticado";
    }

    @GetMapping("/api/admin")
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public String admin() { return "Zona admin"; }
}

Dockerfile

FROM eclipse-temurin:17-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/demo-secure-api-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

Por qué esta arquitectura

  • Access tokens cortos (stateless): uso para autorizar peticiones sin consultar la DB en cada request.
  • Refresh tokens persistidos: permiten revocar tokens (p. ej. en logout) y limitar sesiones. Si se almacenaran sólo en cliente no podrías invalidarlos.
  • BCrypt para contraseñas: estándar seguro y lento para resistir ataques por fuerza bruta.
  • Filtro JWT que coloca Authentication en SecurityContext: mantiene el modelo de seguridad de Spring.
  • Roles en claims: simplifica autorización basada en roles sin ir a DB en cada request. Si necesitas cambios inmediatos de roles deberías validar con DB.

Pruebas rápidas

  1. Levanta la app (mvn spring-boot:run).
  2. POST /auth/register {"username":"u","password":"p"}
  3. POST /auth/login con mismas credenciales → recibirás accessToken y refreshToken.
  4. GET /api/hello con header Authorization: Bearer <accessToken> → OK.
  5. Cuando expire el accessToken, POST /auth/refresh {"refreshToken":"..."} para obtener nuevo accessToken.

Notas de seguridad y siguientes pasos

  • No guardes secretos (app.jwt.secret) en el repositorio: usa vault o variables de entorno en producción.
  • Protege el endpoint de refresh: podrías añadir fingerprinting (IP, user-agent), rotación de refresh tokens y registro de uso para detectar abuso.
  • Considera implementar limitación de tasa (rate limiting) y lista negra de refresh tokens comprometidos.

Consejo avanzado: usa refresh tokens rotativos —cada vez que uses un refresh token para obtener un nuevo access token, emite un nuevo refresh token y marca el anterior como inválido; así reduces el riesgo de uso repetido de tokens robados. Y una advertencia: nunca confíes en JWTs sin validar firma y expiración, y evita incluir información sensible en los claims.

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