Ir al contenido

Spring SecurityLección 03

Autenticación por dentro

Cinco interfaces hacen todo el trabajo de autenticar. Cada mecanismo (Basic, formulario, JWT, OAuth2) solo cambia quién arma la solicitud y qué provider la verifica.

Intermedio18 min de lectura

Al terminar sabrás

  • Seguir una autenticación por AuthenticationManager, ProviderManager y AuthenticationProvider.
  • Leer el usuario actual con SecurityContextHolder y @AuthenticationPrincipal.
  • Cargar usuarios desde base de datos con un UserDetailsService y JPA.
  • Escribir un AuthenticationProvider propio para una API key.

Autenticar es convertir una Authentication no verificada (“dice ser ana, con esta contraseña”) en una verificada (“es ana, tiene ROLE_USER”) y dejarla en el SecurityContextHolder para el resto de la petición.

  1. Filtro de autenticaciónBasicAuthenticationFilter, UsernamePasswordAuthenticationFilter, BearerTokenAuthenticationFilter…
  2. AuthenticationManager → ProviderManagerrecorre sus providers y usa el primero que soporta el tipo de token
  3. AuthenticationProviderverifica de verdadUserDetailsService: carga el usuarioPasswordEncoder: compara el hash
  4. SecurityContextHolderdisponible para el resto de la petición
El filtro nunca verifica nada: arma la solicitud y se la pasa al AuthenticationManager.

Si el provider lanza AuthenticationException (contraseña incorrecta, cuenta deshabilitada…), el filtro limpia el contexto y delega en el AuthenticationEntryPoint, que responde 401.

EstadoEjemploisAuthenticated()
Solicitud“dice ser ana, con contraseña x”false
Resultado“es ana, tiene ROLE_USER” (credenciales borradas)true

El mismo tipo viaja por todo el sistema; lo que cambia es su estado. Sus partes: el principal (quién: un UserDetails, un Jwt, un OidcUser), las credentials (la prueba, que se borra tras autenticar) y las authorities (lo que puede hacer).

SecurityContextHolder: dónde vive el usuario actual

Sección titulada «SecurityContextHolder: dónde vive el usuario actual»

Guarda el SecurityContext del hilo actual (por defecto, en un ThreadLocal). Desde cualquier parte del código:

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

En un controlador es más limpio pedirlo como parámetro:

MeController.java
@GetMapping("/api/me")
Map<String, Object> me(@AuthenticationPrincipal UserDetails user) {
return Map.of("user", user.getUsername(), "roles", user.getAuthorities());
}

AuthenticationManager es una interfaz de un solo método: Authentication authenticate(Authentication). Devuelve la autenticación verificada o lanza AuthenticationException. Su implementación estándar, ProviderManager, no sabe autenticar nada: recorre una lista de AuthenticationProvider y le pasa el token al primero que diga supports(ese tipo).

Token que llegaProvider que lo verificaMecanismo
UsernamePasswordAuthenticationTokenDaoAuthenticationProviderFormulario, HTTP Basic, login JSON propio
BearerTokenAuthenticationTokenJwtAuthenticationProviderResource server con JWT
OAuth2LoginAuthenticationTokenOidcAuthorizationCodeAuthenticationProviderLogin con Google, Keycloak…
OneTimeTokenAuthenticationTokenOneTimeTokenAuthenticationProviderEnlace mágico

UserDetailsService carga un usuario por nombre y devuelve un UserDetails (username, hash, authorities y los flags enabled, accountNonLocked…). Solo lo usa DaoAuthenticationProvider: si tu API valida JWT emitidos por otro servidor, probablemente no lo necesites.

PasswordEncoder transforma contraseñas en hashes y los compara. Usa siempre el codificador delegante (repasa contraseñas y hashing):

@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
UserAccount.java
@Entity
@Table(name = "users")
public class UserAccount {
@Id @GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password; // hash con prefijo: {bcrypt}$2a$10$...
private String role; // USER, ADMIN
private boolean enabled = true;
// getters, setters
}
interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
Optional<UserAccount> findByUsername(String username);
}
DbUserDetailsService.java
@Service
class DbUserDetailsService implements UserDetailsService {
private final UserAccountRepository repo;
DbUserDetailsService(UserAccountRepository repo) {
this.repo = repo;
}
@Override
public UserDetails loadUserByUsername(String username) {
UserAccount u = repo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("no existe"));
return User.withUsername(u.getUsername())
.password(u.getPassword())
.roles(u.getRole()) // añade el prefijo ROLE_
.disabled(!u.isEnabled())
.build();
}
}

Con un solo bean UserDetailsService y un PasswordEncoder, Spring Boot configura el DaoAuthenticationProvider por ti.

Solo lo necesitas cuando la credencial no es usuario y contraseña contra tu base de datos: una API key, un sistema legado, un LDAP peculiar. Las tres piezas son las del diagrama: un tipo de token, un provider y un filtro.

ApiKeyAuthentication.java
public class ApiKeyAuthentication extends AbstractAuthenticationToken {
private final String key; // credencial (solo en la solicitud)
private final String client; // principal (solo en el resultado)
public static ApiKeyAuthentication unauthenticated(String key) {
return new ApiKeyAuthentication(key, null, List.of(), false);
}
public static ApiKeyAuthentication authenticated(String client, Collection<? extends GrantedAuthority> authorities) {
return new ApiKeyAuthentication(null, client, authorities, true);
}
private ApiKeyAuthentication(String key, String client,
Collection<? extends GrantedAuthority> authorities, boolean authenticated) {
super(authorities);
this.key = key;
this.client = client;
setAuthenticated(authenticated);
}
@Override public Object getCredentials() { return key; }
@Override public Object getPrincipal() { return client; }
}
ApiKeyAuthenticationProvider.java
class ApiKeyAuthenticationProvider implements AuthenticationProvider {
private final ApiClientRepository clients;
ApiKeyAuthenticationProvider(ApiClientRepository clients) {
this.clients = clients;
}
@Override
public Authentication authenticate(Authentication authentication) {
String key = (String) authentication.getCredentials();
return clients.findByKeyHash(sha256Hex(key)) // guarda el hash de la key, no la key
.map(c -> ApiKeyAuthentication.authenticated(c.name(), List.of(new SimpleGrantedAuthority("ROLE_SERVICE"))))
.orElseThrow(() -> new BadCredentialsException("API key inválida"));
}
@Override
public boolean supports(Class<?> type) {
return ApiKeyAuthentication.class.isAssignableFrom(type); // solo mi tipo de token
}
}
ApiKeyFilter.java
class ApiKeyFilter extends OncePerRequestFilter {
private final AuthenticationManager manager;
private final AuthenticationEntryPoint entryPoint;
ApiKeyFilter(AuthenticationManager manager, AuthenticationEntryPoint entryPoint) {
this.manager = manager;
this.entryPoint = entryPoint;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String key = request.getHeader("X-API-Key");
if (key == null) { // sin credencial: no es asunto mío, deja pasar
chain.doFilter(request, response);
return;
}
try {
Authentication result = manager.authenticate(ApiKeyAuthentication.unauthenticated(key));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(result);
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
} catch (AuthenticationException e) { // credencial inválida: corta con 401
SecurityContextHolder.clearContext();
entryPoint.commence(request, response, e);
}
}
}

Y en la configuración: http.addFilterBefore(new ApiKeyFilter(new ProviderManager(provider), entryPoint), AnonymousAuthenticationFilter.class).

Mini lab · 30 min

Usuarios en base de datos

Objetivo: Reemplazar los usuarios en memoria por una tabla users con JPA y comprobar cómo se guardan y verifican las contraseñas.

  1. Crea la entidad UserAccount, su repositorio y DbUserDetailsService (borra el InMemoryUserDetailsManager).
  2. Crea POST /api/auth/register (público) que reciba username y password en un DTO, valide con @NotBlank y @Size(min = 15), guarde encoder.encode(password) con rol USER y devuelva 201.
  3. Crea un CommandLineRunner que inserte un admin si no existe.
  4. Implementa GET /api/me con @AuthenticationPrincipal.

Preguntas:

  • Mira la tabla en la consola de H2. ¿Qué prefijo tiene el hash? Registra dos usuarios con la misma contraseña: ¿los hashes coinciden?
  • Intenta autenticarte con un usuario inexistente y con uno existente pero contraseña incorrecta. ¿Las respuestas se distinguen? ¿Por qué es deseable?
  • Pon enabled = false a un usuario e intenta autenticarte. ¿Qué excepción aparece en el log TRACE?
  • ¿Por qué el endpoint de registro no debe aceptar un campo role?
Pista

La segunda pregunta se responde con hideUserNotFoundExceptions. La cuarta, con la lección de vulnerabilidades web.

Mostrar respuestaOcultar respuesta
  • El prefijo es {bcrypt} (el algoritmo por defecto del DelegatingPasswordEncoder). Los hashes no coinciden: cada encode usa una sal aleatoria distinta.
  • No se distinguen: ambos casos producen 401 con el mismo cuerpo, porque DaoAuthenticationProvider convierte UsernameNotFoundException en BadCredentialsException. Así un atacante no puede averiguar qué usuarios existen. (Spring además hace una comparación de contraseña “de relleno” cuando el usuario no existe, para que el tiempo de respuesta tampoco lo delate).
  • Aparece DisabledException (“User is disabled”): DaoAuthenticationProvider comprueba los flags de UserDetails antes de aceptar la autenticación. Para el cliente sigue siendo un 401.
  • Porque sería asignación masiva: cualquiera podría registrarse como ADMIN enviando "role": "ADMIN". El DTO de registro solo debe tener username y password, y el rol lo decide el servidor.