Proyecto práctico: API REST con Spring Boot, JPA y JWT (Autenticación)
En este tutorial construirás una API minimal pero funcional en Java usando Spring Boot, JPA (PostgreSQL) y autenticación basada en JWT. Verás cómo registrar usuarios, loguear y proteger endpoints. Incluyo la estructura de carpetas, los archivos clave y explicaciones de por qué tomar cada decisión.
Requisitos previos
- JDK 17+
- Maven 3.6+
- PostgreSQL (o cambia a H2 si prefieres)
- IDE (IntelliJ, VS Code) y herramientas HTTP (curl, httpie o Postman)
Estructura de carpetas
src/main/java/com/example/demo
├─ DemoApplication.java
├─ controller
│ ├─ AuthController.java
│ └─ UserController.java
├─ dto
│ ├─ AuthRequest.java
│ ├─ AuthResponse.java
│ └─ RegisterRequest.java
├─ entity
│ └─ AppUser.java
├─ repository
│ └─ UserRepository.java
├─ security
│ ├─ JwtFilter.java
│ ├─ JwtUtil.java
│ ├─ SecurityConfig.java
│ ├─ CustomUserDetailsService.java
│ └─ UserDetailsImpl.java
└─ service
└─ AuthService.java
src/main/resources
└─ application.properties
pom.xml
pom.xml (depende de Maven)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<properties>
<java.version>17</java.version>
</properties>
</project>
application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/demo_db
spring.datasource.username=demo_user
spring.datasource.password=demo_pass
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# JWT
app.jwt.secret=ChangeThisSecretToAStrongRandomString
app.jwt.expiration-ms=3600000
Archivos Java clave (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);
}
}
entity/AppUser.java
package com.example.demo.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password; // hashed
@Column(nullable = false)
private String roles; // comma-separated roles, e.g. "ROLE_USER"
// 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; }
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.entity.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
boolean existsByUsername(String username);
}
dto/RegisterRequest.java
package com.example.demo.dto;
import jakarta.validation.constraints.NotBlank;
public class RegisterRequest {
@NotBlank
private String username;
@NotBlank
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/AuthRequest.java
package com.example.demo.dto;
public class AuthRequest {
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/AuthResponse.java
package com.example.demo.dto;
public class AuthResponse {
private String token;
public AuthResponse(String token) { this.token = token; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
}
service/AuthService.java
package com.example.demo.service;
import com.example.demo.dto.RegisterRequest;
import com.example.demo.entity.AppUser;
import com.example.demo.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public AppUser register(RegisterRequest req) {
if (userRepository.existsByUsername(req.getUsername())) {
throw new IllegalArgumentException("username already exists");
}
AppUser u = new AppUser();
u.setUsername(req.getUsername());
u.setPassword(passwordEncoder.encode(req.getPassword()));
u.setRoles("ROLE_USER");
return userRepository.save(u);
}
}
security/UserDetailsImpl.java
package com.example.demo.security;
import com.example.demo.entity.AppUser;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;
public class UserDetailsImpl implements UserDetails {
private final AppUser user;
public UserDetailsImpl(AppUser user) { this.user = user; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(user.getRoles().split(","))
.map(String::trim)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override public String getPassword() { return user.getPassword(); }
@Override public String getUsername() { return user.getUsername(); }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
public Long getId() { return user.getId(); }
}
security/CustomUserDetailsService.java
package com.example.demo.security;
import com.example.demo.entity.AppUser;
import com.example.demo.repository.UserRepository;
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;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser u = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new UserDetailsImpl(u);
}
}
security/JwtUtil.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration-ms}")
private long expirationMs;
public String generateToken(String username) {
Date now = new Date();
Date exp = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public String extractUsername(String token) {
Claims c = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return c.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception ex) {
return false;
}
}
}
security/JwtFilter.java
package com.example.demo.security;
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 JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
public JwtFilter(JwtUtil jwtUtil, CustomUserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
String username = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
username = jwtUtil.extractUsername(token);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails ud = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
ud, null, ud.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
security/SecurityConfig.java
package com.example.demo.security;
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 JwtFilter jwtFilter;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtFilter jwtFilter, CustomUserDetailsService userDetailsService) {
this.jwtFilter = jwtFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.AuthRequest;
import com.example.demo.dto.AuthResponse;
import com.example.demo.dto.RegisterRequest;
import com.example.demo.entity.AppUser;
import com.example.demo.security.JwtUtil;
import com.example.demo.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public AuthController(AuthService authService, AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
this.authService = authService;
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest req) {
AppUser saved = authService.register(req);
return ResponseEntity.ok("user created: " + saved.getUsername());
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
);
} catch (BadCredentialsException ex) {
return ResponseEntity.status(401).build();
}
String token = jwtUtil.generateToken(req.getUsername());
return ResponseEntity.ok(new AuthResponse(token));
}
}
controller/UserController.java
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 ud) {
return ud != null ? ud.getUsername() : "anonymous";
}
}
Por qué estas decisiones (no solo cómo)
- Spring Boot: arranque rápido, ecosistema integrado para web, seguridad y JPA.
- JPA + PostgreSQL: persistencia robusta; en dev puedes cambiar a H2 ajustando properties.
- JWT: transporte sin estado de credenciales; ideal para APIs escalables y microservicios.
- OncePerRequestFilter (JwtFilter): valida el token antes de alcanzar controladores y setea SecurityContext.
- PasswordEncoder (BCrypt): hashing fuerte, evita almacenar passwords en texto.
- CustomUserDetailsService: adapta tu modelo de usuario a Spring Security (separation of concerns).
Pruebas rápidas (ejemplos HTTP)
- Registrar:
POST /api/auth/register Content-Type: application/json { "username": "alice", "password": "secret" } - Login:
POST /api/auth/login Content-Type: application/json { "username": "alice", "password": "secret" } Response: { "token": "ey..." } - Acceder endpoint protegido:
GET /api/users/me Authorization: Bearer ey...
Notas prácticas y mejoras recomendadas
- En producción, guarda la clave JWT en un secret manager y usa claves más largas y rotación.
- Considera refresh tokens si quieres tokens de corta vida y mejorar seguridad frente a leaks.
- Valida entradas con javax/hibernate-validator para evitar datos inválidos y ataques de inyección.
- Agrega logs y métricas (actuator) y pruebas de integración.
Consejo avanzado: para mayor seguridad, usa firma asimétrica (RS256) en lugar de HS512; así puedes separar la clave privada (firma) de la pública (verificación) y facilitar la rotación de claves sin invalidar tokens instantáneamente.
Advertencia de seguridad: nunca expongas tu secret JWT en el repositorio ni lo hardcodees en el código. Además, considera invalidar tokens en logout si tu aplicación requiere revocación inmediata (p. ej. mediante una blacklist corta en Redis).
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación