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.
Al terminar sabrás
- Seguir una autenticación por
AuthenticationManager,ProviderManageryAuthenticationProvider. - Leer el usuario actual con
SecurityContextHoldery@AuthenticationPrincipal. - Cargar usuarios desde base de datos con un
UserDetailsServicey JPA. - Escribir un
AuthenticationProviderpropio 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.
- Filtro de autenticación
BasicAuthenticationFilter,UsernamePasswordAuthenticationFilter,BearerTokenAuthenticationFilter… AuthenticationManager→ProviderManagerrecorre sus providers y usa el primero que soporta el tipo de tokenAuthenticationProviderverifica de verdadUserDetailsService: carga el usuarioPasswordEncoder: compara el hashSecurityContextHolderdisponible para el resto de la petición
Si el provider lanza AuthenticationException (contraseña incorrecta, cuenta deshabilitada…), el filtro limpia el contexto y delega en el AuthenticationEntryPoint, que responde 401.
Las piezas, una por una
Sección titulada «Las piezas, una por una»Authentication: una interfaz con dos vidas
Sección titulada «Authentication: una interfaz con dos vidas»| Estado | Ejemplo | isAuthenticated() |
|---|---|---|
| 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:
@GetMapping("/api/me")Map<String, Object> me(@AuthenticationPrincipal UserDetails user) { return Map.of("user", user.getUsername(), "roles", user.getAuthorities());}AuthenticationManager y ProviderManager
Sección titulada «AuthenticationManager y ProviderManager»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).
AuthenticationProvider: quien verifica
Sección titulada «AuthenticationProvider: quien verifica»| Token que llega | Provider que lo verifica | Mecanismo |
|---|---|---|
UsernamePasswordAuthenticationToken | DaoAuthenticationProvider | Formulario, HTTP Basic, login JSON propio |
BearerTokenAuthenticationToken | JwtAuthenticationProvider | Resource server con JWT |
OAuth2LoginAuthenticationToken | OidcAuthorizationCodeAuthenticationProvider | Login con Google, Keycloak… |
OneTimeTokenAuthenticationToken | OneTimeTokenAuthenticationProvider | Enlace mágico |
UserDetailsService y PasswordEncoder
Sección titulada «UserDetailsService y PasswordEncoder»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):
@BeanPasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder();}Usuarios en base de datos con JPA
Sección titulada «Usuarios en base de datos con JPA»@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);}@Serviceclass 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.
Un AuthenticationProvider propio
Sección titulada «Un AuthenticationProvider propio»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.
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; }}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 }}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.
- Crea la entidad
UserAccount, su repositorio yDbUserDetailsService(borra elInMemoryUserDetailsManager). - Crea
POST /api/auth/register(público) que recibausernameypassworden un DTO, valide con@NotBlanky@Size(min = 15), guardeencoder.encode(password)con rolUSERy devuelva 201. - Crea un
CommandLineRunnerque inserte un admin si no existe. - Implementa
GET /api/mecon@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 = falsea un usuario e intenta autenticarte. ¿Qué excepción aparece en el logTRACE? - ¿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 delDelegatingPasswordEncoder). Los hashes no coinciden: cadaencodeusa una sal aleatoria distinta. - No se distinguen: ambos casos producen
401con el mismo cuerpo, porqueDaoAuthenticationProviderconvierteUsernameNotFoundExceptionenBadCredentialsException. 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”):DaoAuthenticationProvidercomprueba los flags deUserDetailsantes de aceptar la autenticación. Para el cliente sigue siendo un 401. - Porque sería asignación masiva: cualquiera podría registrarse como
ADMINenviando"role": "ADMIN". El DTO de registro solo debe tenerusernameypassword, y el rol lo decide el servidor.