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".