Proyecto práctico: API REST con Spring Boot, Spring Security y JWT
Objetivo: crear una API REST en Java que permita registrar usuarios, autenticarlos con username/password y emitir JWT para proteger rutas. El proyecto usa Spring Boot, Spring Security, JPA (H2 para demo) y jjwt para tokens.
Requisitos previos
- Java 17+
- Maven
- IDE (IntelliJ, VSCode, Eclipse)
- Postman o curl para probar endpoints
Por qué esta arquitectura
- JWT permite una autenticación stateless: el servidor no mantiene sesión.
- Spring Security maneja la autenticación/autoridad y la integración con filtros.
- BCrypt para hashing de contraseñas — no almacenar texto plano.
- H2 como base para demo; fácilmente cambiable por PostgreSQL o MySQL.
Estructura de carpetas
spring-jwt-demo/
├─ src/main/java/com/example/demo/
│ ├─ DemoApplication.java
│ ├─ config/
│ │ └─ SecurityConfig.java
│ ├─ controller/
│ │ ├─ AuthController.java
│ │ └─ HelloController.java
│ ├─ dto/
│ │ ├─ LoginRequest.java
│ │ ├─ LoginResponse.java
│ │ └─ RegisterRequest.java
│ ├─ filter/
│ │ └─ JwtAuthenticationFilter.java
│ ├─ model/
│ │ └─ User.java
│ ├─ repository/
│ │ └─ UserRepository.java
│ ├─ service/
│ │ └─ UserDetailsServiceImpl.java
│ └─ util/
│ └─ JwtUtils.java
└─ src/main/resources/
└─ application.properties
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>spring-jwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.1.4</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.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=update
# JWT settings (cambia en producción)
jwt.secret=MySuperSecretKeyForJwtDontUseInProdChangeThis
jwt.expirationMs=3600000
Archivos Java principales (código completo)
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/User.java
package com.example.demo.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;
// Para simplificar, roles como CSV ("ROLE_USER,ROLE_ADMIN")
private String roles;
public User() {}
public User(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
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 String getRoles() { return roles; }
public void setRoles(String roles) { this.roles = roles; }
}
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);
}
dto/RegisterRequest.java
package com.example.demo.dto;
public class RegisterRequest {
private String username;
private String password;
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/LoginRequest.java
package com.example.demo.dto;
public class LoginRequest {
private String username;
private String password;
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/LoginResponse.java
package com.example.demo.dto;
public class LoginResponse {
private String token;
public LoginResponse(String token) { this.token = token; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
}
service/UserDetailsServiceImpl.java
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
List<GrantedAuthority> authorities = Arrays.stream(user.getRoles().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.build();
}
}
util/JwtUtils.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;
@Component
public class JwtUtils {
private final Key key;
private final long expirationMs;
public JwtUtils(@Value("${jwt.secret}") String secret, @Value("${jwt.expirationMs}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.expirationMs = expirationMs;
}
public String generateToken(String username) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.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;
}
}
}
filter/JwtAuthenticationFilter.java
package com.example.demo.filter;
import com.example.demo.service.UserDetailsServiceImpl;
import com.example.demo.util.JwtUtils;
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.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserDetailsServiceImpl userDetailsService) {
this.jwtUtils = jwtUtils;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
String token = null;
String username = null;
if (header != null && header.startsWith("Bearer ")) {
token = header.substring(7);
if (jwtUtils.validateToken(token)) {
username = jwtUtils.getUsernameFromToken(token);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
config/SecurityConfig.java
package com.example.demo.config;
import com.example.demo.filter.JwtAuthenticationFilter;
import com.example.demo.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(UserDetailsServiceImpl userDetailsService, JwtAuthenticationFilter jwtAuthenticationFilter) {
this.userDetailsService = userDetailsService;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/api/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated();
// H2 console frames
http.headers().frameOptions().sameOrigin();
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(provider);
}
}
controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.dto.RegisterRequest;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.JwtUtils;
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.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtUtils jwtUtils;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder,
AuthenticationManager authenticationManager, JwtUtils jwtUtils) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtUtils = jwtUtils;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
if (userRepository.existsByUsername(req.getUsername())) {
return ResponseEntity.badRequest().body("Username already taken");
}
// Por simplicidad, todos los usuarios registrados reciben ROLE_USER
User user = new User(req.getUsername(), passwordEncoder.encode(req.getPassword()), "ROLE_USER");
userRepository.save(user);
return ResponseEntity.ok("User registered");
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
);
String token = jwtUtils.generateToken(req.getUsername());
return ResponseEntity.ok(new LoginResponse(token));
}
}
controller/HelloController.java
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/api/hello")
public String hello() {
return "Hello secured world!";
}
}
Cómo probar
- Levanta la app: mvn spring-boot:run
- Registrar un usuario (POST /api/auth/register)
{ "username": "alice", "password": "secret" } - Login (POST /api/auth/login) — recibirás { "token": "..." }
- Acceder a /api/hello con header Authorization: Bearer <token>
Por qué implementé ciertos elementos (explicación)
- Filtro JwtAuthenticationFilter: se ejecuta una vez por petición y extrae el token del header Authorization; si es válido coloca la Authentication en el SecurityContext para que Spring Security permita el acceso a endpoints protegidos.
- JwtUtils usa una Key HMAC derivada del secret. Estoy usando HS256 por simplicidad. En producción considera RS256 (clave pública/privada) para poder rotar claves y verificar en microservicios sin exponer la privada.
- UserDetailsServiceImpl traduce tu entidad User a UserDetails. Mantener la lógica de roles centralizada aquí facilita añadir conversiones, claims adicionales o atributos.
- SecurityConfig usa AuthenticationManager con DaoAuthenticationProvider y BCrypt para autenticar usuario/contraseña. SessionManagement.STATELESS garantiza que no se crea sesión HTTP.
Mejoras y consideraciones
- Agregar refresh tokens para poder renovar JWT sin pedir re-login.
- Implementar revocación de tokens (lista negra) si necesitas invalidar tokens antes de expirar.
- Almacenar secret en vault o variables de entorno, nunca en repositorio ni properties en claro.
- Rotación de claves: usar pares RS/ES para permitir verificación distribuida.
- Agregar validaciones y manejo de errores más robusto (DTOs, excepciones controladas).
Consejo avanzado: si vas a montar un ecosistema de microservicios, emite JWT con claims mínimos (sub, roles) y usa OAuth2/OpenID Connect para delegar autenticación y autorización; así puedes aprovechar refresh tokens y revocación centralizada. Atención a la seguridad: las claves secretas y la configuración de CORS deben tratarse con cuidado para evitar fugas o CSRF en aplicaciones web.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación