API de autenticación segura con Spring Boot, JWT y PostgreSQL
Proyecto práctico: construimos una pequeña API REST en Java (Spring Boot) que implementa registro y login con contraseñas hasheadas (BCrypt) y tokens JWT para autenticación stateless. Incluyo requisitos, estructura del proyecto, código completo de los archivos principales y explicaciones de por qué tomamos cada decisión.
Requisitos previos
- Java 17+
- Maven 3+
- PostgreSQL o puedes usar Docker para levantar la base de datos
- IDE (IntelliJ, VSCode...)
Estructura de carpetas
auth-demo/
├─ pom.xml
└─ src/main/java/com/example/auth/
├─ AuthDemoApplication.java
├─ controller/
│ └─ AuthController.java
├─ dto/
│ ├─ AuthRequest.java
│ └─ AuthResponse.java
├─ model/
│ └─ User.java
├─ repository/
│ └─ UserRepository.java
├─ service/
│ └─ UserService.java
└─ security/
├─ JwtUtil.java
├─ JwtFilter.java
└─ SecurityConfig.java
pom.xml (dependencias esenciales)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>auth-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<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</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</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:postgresql://localhost:5432/auth_demo
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# JWT
jwt.secret=replace_this_with_a_long_random_secret
jwt.expiration-ms=3600000
Código: archivos principales
AuthDemoApplication.java
package com.example.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@SpringBootApplication
public class AuthDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AuthDemoApplication.class, args);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
model/User.java
package com.example.auth.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; // hashed
// 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; }
}
repository/UserRepository.java
package com.example.auth.repository;
import com.example.auth.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);
}
dto/AuthRequest.java
package com.example.auth.dto;
import jakarta.validation.constraints.NotBlank;
public class AuthRequest {
@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/AuthResponse.java
package com.example.auth.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/UserService.java
package com.example.auth.service;
import com.example.auth.model.User;
import com.example.auth.repository.UserRepository;
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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User register(String username, String rawPassword) {
if (userRepository.findByUsername(username).isPresent()) {
throw new IllegalArgumentException("El usuario ya existe");
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword));
return userRepository.save(user);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado"));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
);
}
}
security/JwtUtil.java
package com.example.auth.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("${jwt.secret}")
private String secret;
@Value("${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) {
return getClaims(token).getSubject();
}
public boolean validateToken(String token) {
try {
Claims claims = getClaims(token);
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
}
security/JwtFilter.java
package com.example.auth.security;
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.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
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;
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
public JwtFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
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 userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
chain.doFilter(request, response);
}
}
security/SecurityConfig.java
package com.example.auth.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.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("/api/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
controller/AuthController.java
package com.example.auth.controller;
import com.example.auth.dto.AuthRequest;
import com.example.auth.dto.AuthResponse;
import com.example.auth.model.User;
import com.example.auth.security.JwtUtil;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
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.core.AuthenticationException;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
public AuthController(UserService userService, JwtUtil jwtUtil, AuthenticationManager authenticationManager) {
this.userService = userService;
this.jwtUtil = jwtUtil;
this.authenticationManager = authenticationManager;
}
@PostMapping("/register")
public ResponseEntity<String> register(@Valid @RequestBody AuthRequest req) {
User user = userService.register(req.getUsername(), req.getPassword());
return ResponseEntity.ok("Usuario creado: " + user.getUsername());
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest req) {
try {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
);
String token = jwtUtil.generateToken(req.getUsername());
return ResponseEntity.ok(new AuthResponse(token));
} catch (AuthenticationException ex) {
return ResponseEntity.status(401).build();
}
}
@GetMapping("/me")
public ResponseEntity<String> me(Authentication authentication) {
return ResponseEntity.ok("Usuario autenticado: " + authentication.getName());
}
}
Por qué estas decisiones
- BCrypt para el hash de contraseñas: resistente a ataques de diccionario y fácil de configurar con Spring Security.
- JWT: permite una API stateless, la token contiene la identidad y tiene expiración; evitas sesiones en servidor.
- OncePerRequestFilter (JwtFilter): valida el token en cada petición y pone la autenticación en el SecurityContext.
- Spring Data JPA para persistencia: menos código repetitivo y funciona bien con PostgreSQL.
- jwt.secret externo (application.properties): en producción debes usar un secreto largo y almacenarlo en un vault/secret manager.
Cómo probarlo
- Crear la BD: CREATE DATABASE auth_demo;
- Configurar
application.propertiescon credenciales reales o usar Docker Postgres. - Build y run: mvn spring-boot:run
- Endpoints:
- POST /api/auth/register {"username":"alice","password":"pwd"}
- POST /api/auth/login {"username":"alice","password":"pwd"} -> devuelve {"token":"..."}
- GET /api/auth/me con header Authorization: Bearer <token>
Mejoras y consideraciones de seguridad
- Valida y limita intentos de login (rate limiting) para mitigar fuerza bruta.
- Usa refresh tokens si necesitas sesiones largas y rotación segura de accesos.
- Almacena el secreto JWT en un servicio de secretos y rota periódicamente.
- Considera firmar con claves asimétricas (RS256) para mayor seguridad en entornos distribuidos.
Consejo avanzado: para escalar y poder invalidar tokens (logout forzado), incorpora una lista de revocación basada en Redis con claves por token-id (jti) o emite tokens cortos + refresh tokens almacenados con control de revocación. Además, monitoriza patrones de uso para detectar tokens comprometidos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación