Spring SecurityLección 06
Errores de seguridad, 401 frente a 403
Quién decide entre 401 y 403, por qué tu 400 a veces se convierte en 401, y cómo responder errores JSON estándar con ProblemDetail.
Al terminar sabrás
- Explicar cómo decide
ExceptionTranslationFilterentre 401 y 403. - Conocer el
AuthenticationEntryPointpor defecto de cada mecanismo. - Evitar las tres trampas clásicas:
/error,@ControllerAdvicey excepciones en filtros. - Responder errores de seguridad en
application/problem+json.
401 Unauthorized significa “no sé quién eres, identifícate”. 403 Forbidden significa “sé quién eres y no puedes”. El nombre de 401 es un error histórico del estándar HTTP: debería llamarse Unauthenticated. Spring Security decide entre ambos en un único sitio.
Cómo decide Spring
Sección titulada «Cómo decide Spring»ExceptionTranslationFilter · catchAuthenticationExceptionAccessDeniedExceptionExceptionTranslationFilter envuelve el resto de la cadena (incluido tu controlador) en un try/catch. Simplificado:
try { chain.doFilter(request, response);} catch (AuthenticationException ex) { startAuthentication(request, response, ex); // → 401 (entry point)} catch (AccessDeniedException ex) { if (isAnonymous(currentAuthentication())) { startAuthentication(request, response, ex); // anónimo → 401: que se identifique } else { accessDeniedHandler.handle(request, response, ex); // autenticado → 403 }}“Iniciar la autenticación” guarda primero la petición en el RequestCache: en apps con formulario, tras el login te devuelve a la URL que intentabas abrir. En una API stateless no sirve de nada; puedes desactivarlo con http.requestCache(c -> c.disable()).
Qué respuesta sale por defecto
Sección titulada «Qué respuesta sale por defecto»| Mecanismo activo | AuthenticationEntryPoint por defecto | Respuesta |
|---|---|---|
formLogin | LoginUrlAuthenticationEntryPoint | 302 a /login |
httpBasic | BasicAuthenticationEntryPoint | 401 + WWW-Authenticate: Basic realm="Realm" |
oauth2ResourceServer | BearerTokenAuthenticationEntryPoint | 401 + WWW-Authenticate: Bearer … con el motivo |
| Ninguno | Http403ForbiddenEntryPoint | 403, aunque sea falta de autenticación |
Si activas varios, Spring elige según la cabecera Accept y el tipo de petición. Por eso un navegador ve el formulario de login y curl ve un 401.
Tres trampas que cuestan horas
Sección titulada «Tres trampas que cuestan horas»Respuestas JSON con ProblemDetail
Sección titulada «Respuestas JSON con ProblemDetail»Una API debería responder sus errores en el formato estándar RFC 9457 (application/problem+json), también los de seguridad:
@Componentclass ProblemAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final JsonMapper json; // tools.jackson.databind.json.JsonMapper (Jackson 3, Spring Boot 4)
ProblemAuthenticationEntryPoint(JsonMapper json) { this.json = json; }
@Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.UNAUTHORIZED, "Necesitas autenticarte para acceder a este recurso"); problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"notes\""); response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); json.writeValue(response.getOutputStream(), problem); }}El AccessDeniedHandler es igual, con handle(...) y HttpStatus.FORBIDDEN. Se registran así (Basic tiene su propio entry point, que también hay que sustituir):
http .authorizeHttpRequests(auth -> auth .requestMatchers("/error").permitAll() .anyRequest().authenticated()) .httpBasic(basic -> basic.authenticationEntryPoint(entryPoint)) .exceptionHandling(ex -> ex .authenticationEntryPoint(entryPoint) .accessDeniedHandler(deniedHandler));Novedad de la 7.0: exceptionHandling permite asociar un manejador a una authority que falta con defaultDeniedHandlerForMissingAuthority(...), por ejemplo para redirigir al segundo factor cuando falta FACTOR_OTT (lo verás en Spring Security 7).
Mini lab · 25 min
Errores que una API puede consumir
Objetivo: Que todos los errores de seguridad de /api/** respondan application/problem+json, sin romper los errores de validación.
- Implementa
ProblemAuthenticationEntryPointy unProblemAccessDeniedHandlerequivalente. - Regístralos en la cadena de la API, junto con
.requestMatchers("/error").permitAll(). - Prueba los tres casos:
curl -i http://localhost:8080/api/notes # sin credencialescurl -i -u ana:contraseña-incorrecta http://localhost:8080/api/notes # credenciales malascurl -i -u ana:ana-password-123 http://localhost:8080/api/admin/stats # sin permiso- Añade un
@RestControllerAdvicecon@ExceptionHandler(Exception.class)que devuelva 500, pon@PreAuthorize("hasRole('ADMIN')")en el método de/api/admin/stats(necesitas@EnableMethodSecurity) y repite la tercera petición.
Preguntas:
- ¿Las respuestas a “sin credenciales” y “credenciales malas” son distintas para el cliente? ¿Deberían serlo?
- En el paso 4, ¿qué código recibes y por qué? ¿Cómo lo corriges?
- Quita temporalmente
/errordelpermitAlly envía un JSON inválido aPOST /api/auth/register. ¿Qué código recibes y por qué no es 400?
Pista
Para la última pregunta: ¿qué petición hace Spring Boot internamente cuando tu controlador falla, y con qué usuario llega a la cadena?
Mostrar respuestaOcultar respuesta
- Ambas dan 401 con el mismo
ProblemDetail. Es lo correcto: distinguirlas solo ayuda a un atacante. El motivo exacto está en el logTRACE. - Recibes 500: el
@ExceptionHandler(Exception.class)atrapa laAccessDeniedExceptionde@PreAuthorizeantes que Spring Security. Se corrige relanzandoAccessDeniedExceptionyAuthenticationExceptionen el handler genérico (o con handlers específicos que devuelvan 403). - Recibes 401 (o 403). El 400 de validación provoca un forward a
/error, que vuelve a pasar por la cadena; como la petición a/errorno es pública, la regla de autorización la rechaza y tu 400 se pierde. Con/errorenpermitAllvuelves a ver el 400 correcto.