Careeroza
Method Security and @RestControllerAdvice
The Catch-All Trap
An AccessDeniedException from @PreAuthorize is thrown inside your controller or service, so Spring MVC sees it before the security filters do. If you have a catch-all handler, it swallows the exception and the user gets 500 instead of 403:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) // catches AccessDeniedException too!
public ResponseEntity<ApiError> other(Exception ex, HttpServletRequest req) {
return ResponseEntity.status(500)
.body(new ApiError(500, "Internal Server Error", "Something went wrong", req.getRequestURI()));
}
}Example
curl -i -X DELETE localhost:8080/api/products/7 -H "Authorization: Bearer $ASHA_TOKEN"Example output
HTTP/1.1 500 Internal Server Error
{"status":500,"error":"Internal Server Error","message":"Something went wrong","path":"/api/products/7"}The Fix: Rethrow Security Exceptions
@RestControllerAdvice
public class GlobalExceptionHandler {
// let Spring Security's ExceptionTranslationFilter handle these,
// so 401/403 JSON comes from the entry point and denied handler
@ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
public void security(RuntimeException ex) {
throw ex;
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ApiError> notFound(OrderNotFoundException ex, HttpServletRequest req) {
return ResponseEntity.status(404).body(new ApiError(404, "Not Found", ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> other(Exception ex, HttpServletRequest req) {
return ResponseEntity.status(500)
.body(new ApiError(500, "Internal Server Error", "Something went wrong", req.getRequestURI()));
}
}Example
curl -i -X DELETE localhost:8080/api/products/7 -H "Authorization: Bearer $ASHA_TOKEN"Example output
HTTP/1.1 403 Forbidden
{"status":403,"error":"Forbidden","message":"You do not have permission to access this resource","path":"/api/products/7"}Exercise
Exercise: An expired token on GET /api/products (a permitAll URL) returns 200, but on GET /api/orders returns 401. Explain why, using the JWT filter from this level.
Show answer
The JwtAuthFilter never rejects a request itself. On an expired token it only
stores "Access token expired" in a request attribute and continues without an
Authentication (the user is anonymous).
GET /api/products is permitAll -> anonymous is allowed -> 200.
GET /api/orders needs authenticated() -> AccessDeniedException for an anonymous
user -> ExceptionTranslationFilter -> AuthenticationEntryPoint -> 401 with the
stored message "Access token expired".