API REST en Java: Usuarios con Spring Boot, JWT, JPA y Docker (tutorial práctico)

java API REST en Java: Usuarios con Spring Boot, JWT, JPA y Docker (tutorial práctico)

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)

  1. Construye: mvn clean package -DskipTests
  2. Levanta con docker-compose up --build
  3. Registrar: POST http://localhost:8080/auth/register con body {"username":"test","password":"1234","email":"a@b.com"}
  4. Login: POST http://localhost:8080/auth/login con {"username":"test","password":"1234"} → obtén token
  5. 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.

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