API REST en Java: Usuarios con Spring Boot, JWT, JPA y Docker
Proyecto práctico: construir una API de gestión de usuarios con autenticación JWT, persistencia en PostgreSQL, validación y configuración lista para Docker. Incluye estructura, los archivos principales y explicación de decisiones arquitectónicas.
Requisitos previos
- JDK 17+
- Maven 3.6+
- Docker y Docker Compose (opcional pero recomendado)
- Conocimientos básicos de Spring Boot, JPA y REST
Qué vamos a construir
Una API con endpoints para registrarse, autenticarse (obtener JWT) y consultar/actualizar el perfil del usuario. Seguridad por JWT (sin refresco para mantener la implementación simple). Persistencia en PostgreSQL. La configuración será modular y extensible.
Estructura de carpetas
user-api/
├─ pom.xml
├─ Dockerfile
├─ docker-compose.yml
├─ src/
│ ├─ main/
│ │ ├─ java/com/example/userapi/
│ │ │ ├─ UserApiApplication.java
│ │ │ ├─ controller/
│ │ │ │ ├─ AuthController.java
│ │ │ │ └─ UserController.java
│ │ │ ├─ dto/
│ │ │ │ ├─ AuthRequest.java
│ │ │ │ ├─ AuthResponse.java
│ │ │ │ ├─ RegisterRequest.java
│ │ │ │ └─ UserDto.java
│ │ │ ├─ entity/
│ │ │ │ └─ User.java
│ │ │ ├─ repository/
│ │ │ │ └─ UserRepository.java
│ │ │ ├─ security/
│ │ │ │ ├─ JwtFilter.java
│ │ │ │ ├─ JwtUtil.java
│ │ │ │ └─ SecurityConfig.java
│ │ │ └─ service/
│ │ │ └─ UserService.java
│ │ └─ resources/
│ │ └─ application.yml
└─
pom.xml (dependencias clave)
<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>user-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.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>org.postgresql</groupId>
<artifactId>postgresql</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-validation</artifactId>
</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:postgresql://localhost:5432/userdb
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
show-sql: true
jwt:
secret: verySecretKeyChangeMe
expiration-ms: 3600000
Clase principal
package com.example.userapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UserApiApplication {
public static void main(String[] args) {
SpringApplication.run(UserApiApplication.class, args);
}
}
Entity: User
package com.example.userapi.entity;
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; // guardado en hash
@Column(nullable = false)
private String email;
// getters y 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 getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Repository
package com.example.userapi.repository;
import com.example.userapi.entity.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.userapi.dto;
public class AuthRequest {
public String username;
public String password;
}
public class AuthResponse {
public String token;
public AuthResponse(String token) { this.token = token; }
}
public class RegisterRequest {
public String username;
public String password;
public String email;
}
public class UserDto {
public Long id;
public String username;
public String email;
}
Servicio: UserService
package com.example.userapi.service;
import com.example.userapi.dto.RegisterRequest;
import com.example.userapi.entity.User;
import com.example.userapi.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User register(RegisterRequest req) {
if (userRepository.existsByUsername(req.username)) {
throw new RuntimeException("Username exists");
}
User u = new User();
u.setUsername(req.username);
u.setPassword(passwordEncoder.encode(req.password));
u.setEmail(req.email);
return userRepository.save(u);
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
public Optional<User> findById(Long id) { return userRepository.findById(id); }
public User update(Long id, User updated) {
User u = userRepository.findById(id).orElseThrow(() -> new RuntimeException("Not found"));
u.setEmail(updated.getEmail());
return userRepository.save(u);
}
}
Seguridad: JwtUtil
package com.example.userapi.security;
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 expirationMs;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.expirationMs = 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(key)
.compact();
}
public String validateAndGetUsername(String token) {
try {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return claims.getBody().getSubject();
} catch (JwtException e) {
return null;
}
}
}
Filtro JWT
package com.example.userapi.security;
import com.example.userapi.service.UserService;
import org.springframework.http.HttpHeaders;
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.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
@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(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
String username = jwtUtil.validateAndGetUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var opt = userService.findByUsername(username);
if (opt.isPresent()) {
UserDetails ud = User.withUsername(username).password(opt.get().getPassword()).authorities(Collections.emptyList()).build();
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(ud, null, ud.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
}
filterChain.doFilter(request, response);
}
}
Configuración de seguridad
package com.example.userapi.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.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().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
Controller: AuthController
package com.example.userapi.controller;
import com.example.userapi.dto.AuthRequest;
import com.example.userapi.dto.AuthResponse;
import com.example.userapi.dto.RegisterRequest;
import com.example.userapi.entity.User;
import com.example.userapi.security.JwtUtil;
import com.example.userapi.service.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
private final BCryptPasswordEncoder encoder;
private final AuthenticationManager authenticationManager;
public AuthController(UserService userService, JwtUtil jwtUtil, BCryptPasswordEncoder encoder, AuthenticationManager authenticationManager) {
this.userService = userService;
this.jwtUtil = jwtUtil;
this.encoder = encoder;
this.authenticationManager = authenticationManager;
}
@PostMapping("/register")
public User register(@RequestBody RegisterRequest req) {
return userService.register(req);
}
@PostMapping("/login")
public AuthResponse login(@RequestBody AuthRequest req) {
try {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(req.username, req.password));
} catch (AuthenticationException ex) {
throw new RuntimeException("Invalid credentials");
}
var opt = userService.findByUsername(req.username).orElseThrow(() -> new RuntimeException("User not found"));
if (!encoder.matches(req.password, opt.getPassword())) {
throw new RuntimeException("Bad credentials");
}
String token = jwtUtil.generateToken(opt.getUsername());
return new AuthResponse(token);
}
}
Controller: UserController
package com.example.userapi.controller;
import com.example.userapi.dto.UserDto;
import com.example.userapi.entity.User;
import com.example.userapi.service.UserService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping("/me")
public UserDto me(@AuthenticationPrincipal UserDetails ud) {
var u = userService.findByUsername(ud.getUsername()).orElseThrow();
UserDto dto = new UserDto();
dto.id = u.getId();
dto.username = u.getUsername();
dto.email = u.getEmail();
return dto;
}
@PutMapping("/{id}")
public UserDto update(@PathVariable Long id, @RequestBody UserDto body) {
User u = new User();
u.setEmail(body.email);
var updated = userService.update(id, u);
UserDto dto = new UserDto();
dto.id = updated.getId();
dto.username = updated.getUsername();
dto.email = updated.getEmail();
return dto;
}
}
Dockerfile
FROM eclipse-temurin:17-jdk
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
docker-compose.yml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_DB: userdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/userdb
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: postgres
JWT_SECRET: verySecretKeyChangeMe
depends_on:
- db
ports:
- "8080:8080"
volumes:
pgdata:
Por qué se hace así (decisiones clave)
- Spring Security con filtro JWT: mantenemos la aplicación stateless y sencilla, el filtro intercepta la cabecera Authorization y pone el Authentication en el SecurityContext.
- BCrypt para hashear passwords: estándar seguro y resistente a ataques por fuerza bruta.
- JJWT (io.jsonwebtoken): API simple para firmar/validar tokens; en producción considera rotación de claves y gestión segura del secreto.
- Separación en capas (controller, service, repository): facilita tests y mantenimiento.
- Docker + Postgres: entorno reproducible para desarrollo y CI.
Cómo probar (rápido)
- Construye: mvn clean package -DskipTests
- Levanta con docker-compose up --build
- Registrar: POST http://localhost:8080/auth/register con body {"username":"test","password":"1234","email":"a@b.com"}
- Login: POST http://localhost:8080/auth/login con {"username":"test","password":"1234"} → obtén token
- Llamar endpoint protegido: GET http://localhost:8080/users/me con header Authorization: Bearer <token>
Notas adicionales y buenas prácticas
- No guardes el secreto JWT en el código ni en application.yml; usa un secret manager o variables de entorno en producción.
- Considera refresh tokens y revocación si necesitas logout forzado o sesiones largas.
- Implementa control de errores centralizado (ControllerAdvice) y validaciones con javax/hibernate-validator para inputs.
- Para mayor rendimiento y escalabilidad, externaliza la gestión de sesiones y tokens (Redis, OAuth2 providers, etc.).
Siguiente paso sugerido: añade pruebas unitarias y de integración (Spring Boot Test) para cubrir el flujo de registro/login y el filtro JWT; también implementa el manejo de excepciones con respuestas JSON estandarizadas y métricas básicas (Prometheus) para supervisar intentos de autenticación fallidos.
Advertencia de seguridad: cambia y gestiona el secreto JWT con rotación periódica y protege la comunicación con HTTPS en producción.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación