Cómo construir una API RESTful segura con Spring Boot y JWT (paso a paso)

java Cómo construir una API RESTful segura con Spring Boot y JWT (paso a paso)

Cómo construir una API RESTful segura con Spring Boot y JWT (paso a paso)

Tutorial práctico para crear desde cero una API con Spring Boot protegida por JSON Web Tokens (JWT). Incluye estructura de carpetas, configuración de seguridad, generación/validación de tokens, ejemplos de endpoints y comandos para probarla. Está pensada para desarrolladores que ya conocen Java y Maven.

Requisitos

  • JDK 11+
  • Maven
  • Conocimientos básicos de Spring Boot, Spring Security y JPA

Estructura de carpetas

src/main/java/com/example/demo
├── DemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── LoginRequest.java
│   └── SignupRequest.java
├── model
│   └── User.java
├── repository
│   └── UserRepository.java
├── security
│   ├── JwtFilter.java
│   └── JwtProvider.java
└── service
    └── UserService.java

src/main/resources
├── application.properties

pom.xml (dependencias clave)

<project ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.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>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-jackson</artifactId>
      <version>0.11.5</version>
    </dependency>
  </dependencies>
</project>

application.properties (mínimo)

server.port=8080
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
app.jwt.secret=MiSecretoUltraSeguroQueDebeSerMuyLargoYGuardadoEnVault
app.jwt.expiration-ms=3600000  # 1 hora

Model: User (simplificado)

package com.example.demo.model;

import javax.persistence.*;

@Entity
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; // almacenado con bcrypt

    // getters y setters
}

Repository

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<User, Long> {
    Optional<User> findByUsername(String username);
    boolean existsByUsername(String username);
}

DTOs

package com.example.demo.dto;

public class LoginRequest {
    public String username;
    public String password;
}

public class SignupRequest {
    public String username;
    public String password;
}

Servicio: UserService

package com.example.demo.service;

import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public User createUser(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));
        return userRepository.save(u);
    }

    public User findByUsername(String username) {
        return userRepository.findByUsername(username).orElse(null);
    }
}

JWT Provider (generar/validar)

package com.example.demo.security;

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

import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Date;

@Component
public class JwtProvider {

    @Value("${app.jwt.secret}")
    private String jwtSecret;

    @Value("${app.jwt.expiration-ms}")
    private long jwtExpirationMs;

    private Key key;

    @PostConstruct
    public void init() {
        // Usar llave simétrica derivada del secreto
        key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
    }

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

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

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

JwtFilter (intercepta peticiones y autentica)

package com.example.demo.security;

import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.User;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;

public class JwtFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;
    private final com.example.demo.service.UserService userService;

    public JwtFilter(JwtProvider jwtProvider, com.example.demo.service.UserService userService) {
        this.jwtProvider = jwtProvider;
        this.userService = userService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null && jwtProvider.validateToken(token)) {
            String username = jwtProvider.getUsernameFromToken(token);
            com.example.demo.model.User u = userService.findByUsername(username);
            if (u != null) {
                UserDetails userDetails = User.withUsername(u.getUsername()).password(u.getPassword()).authorities(Collections.emptyList()).build();
                UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

SecurityConfig

package com.example.demo.config;

import com.example.demo.security.JwtFilter;
import com.example.demo.security.JwtProvider;
import com.example.demo.service.UserService;
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.web.builders.HttpSecurity;
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
public class SecurityConfig {

    private final JwtProvider jwtProvider;
    private final UserService userService;

    public SecurityConfig(JwtProvider jwtProvider, UserService userService) {
        this.jwtProvider = jwtProvider;
        this.userService = userService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        JwtFilter jwtFilter = new JwtFilter(jwtProvider, userService);

        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
              .antMatchers("/auth/**").permitAll()
              .anyRequest().authenticated();

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

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

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

AuthController

package com.example.demo.controller;

import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.SignupRequest;
import com.example.demo.model.User;
import com.example.demo.security.JwtProvider;
import com.example.demo.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.*;

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

    private final UserService userService;
    private final JwtProvider jwtProvider;
    private final AuthenticationManager authenticationManager;

    public AuthController(UserService userService, JwtProvider jwtProvider, AuthenticationManager authenticationManager) {
        this.userService = userService;
        this.jwtProvider = jwtProvider;
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestBody SignupRequest req) {
        User u = userService.createUser(req.username, req.password);
        return ResponseEntity.ok("Usuario creado: " + u.getUsername());
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest req) {
        try {
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(req.username, req.password));
            String token = jwtProvider.generateToken(req.username);
            return ResponseEntity.ok(java.util.Collections.singletonMap("token", token));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(401).body("Credenciales inválidas");
        }
    }
}

Un endpoint protegido: UserController

package com.example.demo.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/users")
public class UserController {

    @GetMapping("/me")
    public Object me(@AuthenticationPrincipal UserDetails user) {
        return java.util.Collections.singletonMap("username", user.getUsername());
    }
}

Cómo probarlo (curl)

# 1) Registrar usuario
curl -X POST http://localhost:8080/auth/signup -H "Content-Type: application/json" -d '{"username":"alice","password":"pass123"}'

# 2) Loguear y obtener token
curl -X POST http://localhost:8080/auth/login -H "Content-Type: application/json" -d '{"username":"alice","password":"pass123"}'
# Respuesta: {"token":"eyJhbGci..."}

# 3) Acceder endpoint protegido
curl http://localhost:8080/api/users/me -H "Authorization: Bearer eyJhbGci..."

Por qué estas decisiones

  • Usamos JWT para evitar estado en el servidor (stateless). Ideal para microservicios y clientes SPAs.
  • BCrypt para almacenar contraseñas: costo ajustable y ampliamente probado.
  • Filtro JWT propio (OncePerRequestFilter): controla la extracción y validación antes del procesamiento del request.
  • Secret largo y almacenado fuera del repositorio (config en Vault/Secrets manager recomendado).
  • H2 se usa sólo para demo; en producción usar RDS/Postgres y migraciones (Flyway/Liquibase).

Errores comunes y recomendaciones de seguridad

  • No almacenar secretos en repositorios: usar variables de entorno o secret managers.
  • No confiar únicamente en la expiración: implementar revocación o uso de refresh tokens si necesitas logout forzado.
  • Evitar poner datos sensibles en el payload del JWT.
  • Configurar CORS correctamente para APIs públicas.

Cómo ejecutar

  1. mvn clean package
  2. java -jar target/demo-0.0.1-SNAPSHOT.jar

Con esto tendrás una API funcional y básica con autenticación JWT. Para producción: agrega pruebas unitarias/integración, manejo de excepciones estándar, validación de request DTOs (javax.validation), y rotación segura de claves.

Tip avanzado: considera emitir tokens cortos (ej. 15 min) y usar refresh tokens rotativos almacenados en base de datos con un identificador no predecible para permitir revocación y mitigación de robo de tokens.

Advertencia: este tutorial muestra patrones recomendados pero omite detalles de hardening en producción; revisa OWASP API Security y adapta la configuración a tu contexto.

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