Ir al contenido

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.

Intermedio14 min de lectura

Al terminar sabrás

  • Explicar cómo decide ExceptionTranslationFilter entre 401 y 403.
  • Conocer el AuthenticationEntryPoint por defecto de cada mecanismo.
  • Evitar las tres trampas clásicas: /error, @ControllerAdvice y 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.

Sube una excepción de seguridadExceptionTranslationFilter · catch
AuthenticationException
401entry point → pide credencialesguarda la petición (RequestCache)
AccessDeniedException
¿El usuario es anónimo?
sí
401entry point → pide credenciales
no
403access denied handler
La pregunta clave no es qué excepción se lanzó, sino si había alguien autenticado.

ExceptionTranslationFilter 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()).

Mecanismo activoAuthenticationEntryPoint por defectoRespuesta
formLoginLoginUrlAuthenticationEntryPoint302 a /login
httpBasicBasicAuthenticationEntryPoint401 + WWW-Authenticate: Basic realm="Realm"
oauth2ResourceServerBearerTokenAuthenticationEntryPoint401 + WWW-Authenticate: Bearer … con el motivo
NingunoHttp403ForbiddenEntryPoint403, 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.

Una API debería responder sus errores en el formato estándar RFC 9457 (application/problem+json), también los de seguridad:

ProblemAuthenticationEntryPoint.java
@Component
class 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):

SecurityConfig.java
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.

  1. Implementa ProblemAuthenticationEntryPoint y un ProblemAccessDeniedHandler equivalente.
  2. Regístralos en la cadena de la API, junto con .requestMatchers("/error").permitAll().
  3. Prueba los tres casos:
Ventana de terminal
curl -i http://localhost:8080/api/notes # sin credenciales
curl -i -u ana:contraseña-incorrecta http://localhost:8080/api/notes # credenciales malas
curl -i -u ana:ana-password-123 http://localhost:8080/api/admin/stats # sin permiso
  1. Añade un @RestControllerAdvice con @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 /error del permitAll y envía un JSON inválido a POST /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 log TRACE.
  • Recibes 500: el @ExceptionHandler(Exception.class) atrapa la AccessDeniedException de @PreAuthorize antes que Spring Security. Se corrige relanzando AccessDeniedException y AuthenticationException en 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 /error no es pública, la regla de autorización la rechaza y tu 400 se pierde. Con /error en permitAll vuelves a ver el 400 correcto.