How can I get Spring-Security to return a 401 resp

2020-02-23 06:43发布

I have a ReST API to an application with all controllers found under /api/, these all return JSON responses with a @ControllerAdvice which handles all exceptions to map to JSON formatted results.

This works great as of spring 4.0 @ControllerAdvice now supports matching on annotations. What I can't work out is how to return a JSON result for a 401 - Unauthenticated and 400 - Bad Request responses.

Instead Spring is simply returning the response to the container (tomcat) which renders this as HTML. How can I intercept this and render a JSON result using the same technique that my @ControllerAdvice is using.

security.xml

<bean id="xBasicAuthenticationEntryPoint"
      class="com.example.security.XBasicAuthenticationEntryPoint">
  <property name="realmName" value="com.example.unite"/>
</bean>
<security:http pattern="/api/**"
               create-session="never"
               use-expressions="true">
  <security:http-basic entry-point-ref="xBasicAuthenticationEntryPoint"/>
  <security:session-management />
  <security:intercept-url pattern="/api/**" access="isAuthenticated()"/>
</security:http>

XBasicAuthenticationEntryPoint

public class XBasicAuthenticationEntryPoint extends BasicAuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,
                               authException.getMessage());
    }
}

I can solve 401 by using the BasicAuthenticationEntryPoint to write directly to the output stream, but I'm not sure it's the best approach.

public class XBasicAuthenticationEntryPoint extends BasicAuthenticationEntryPoint {

    private final ObjectMapper om;

    @Autowired
    public XBasicAuthenticationEntryPoint(ObjectMapper om) {
        this.om = om;
    }

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        om.writeValue(httpResponse.getOutputStream(),
                      new ApiError(request.getRequestURI(),
                      HttpStatus.SC_UNAUTHORIZED,
                      "You must sign in or provide basic authentication."));
    }

}

I am yet to figure out how to handle 400 though, I once tried a catch all controller which did work, but it seemed that sometimes it would have odd conflicting behaviour with other controllers that I don't want to revisit.

My ControllerAdvice implementation has a catch all which if spring throws any exception for bad request (400) it should in theory capture it, but it does not:

@ControllerAdvice(annotations = {RestController.class})
public class ApiControllerAdvisor {
    @ExceptionHandler(Throwable.class)
    public ResponseEntity<ApiError> exception(Throwable exception,
                                              WebRequest request,
                                              HttpServletRequest req) {
        ApiError err = new ApiError(req.getRequestURI(), exception);
        return new ResponseEntity<>(err, err.getStatus());
    }
}

4条回答
\"骚年 ilove
2楼-- · 2020-02-23 07:09

I have solved this by implementing my own version of HandlerExceptionResolver and subclassing DefaultHandlerExceptionResolver. It took a bit to work this out and you must override most methods, though the following has done exactly what I'm after.

Firstly the basics are to create an implementation.

public class CustomHandlerExceptionResolver extends DefaultHandlerExceptionResolver {
    private final ObjectMapper om;
    @Autowired
    public CustomHandlerExceptionResolver(ObjectMapper om) {
        this.om = om;
        setOrder(HIGHEST_PRECEDENCE);
    }
    @Override
    protected boolean shouldApplyTo(HttpServletRequest request, Object handler) {
        return request.getServletPath().startsWith("/api");
    }
}

And now register it in your servlet context.

This now does nothing different, but will be the first HandlerExceptionResolver to be tried and will match any request starting with a context path of /api (note: you could make this a configurable parameter).

Next, we now can override any method that spring encounters errors on, there are 15 on my count.

What I have found is that I can write to the response directly and return an empty ModelAndView object or return null if my method did not eventually handle the fault which causes the next exception resolver to be tried.

As an example to handle situations where a request binding fault occurred I have done the following:

@Override
protected ModelAndView handleServletRequestBindingException(ServletRequestBindingException ex, HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
    ApiError ae = new ApiError(request.getRequestURI(), ex, HttpServletResponse.SC_BAD_REQUEST);
    response.setStatus(ae.getStatusCode());
    om.writeValue(response.getOutputStream(), ae);
    return new ModelAndView();
}

The only disadvantage here is that because I'm writing to the stream myself I'm not using any message converters to handle writing the response, so if I wanted to support XML and JSON API's at the same time it would not be possible, fortunately I'm only interested in supporting JSON, but this same technique could be enhanced to use view resolvers to determine what to render in etc.

If anyone has a better approach I'd still be interested in knowing how to deal with this.

查看更多
在下西门庆
3楼-- · 2020-02-23 07:15

You should look out to set error code in ResponseEntity, as below:

new ResponseEntity<String>(err, HttpStatus.UNAUTHORIZED);
查看更多
你好瞎i
4楼-- · 2020-02-23 07:17

Simply catch the exception in your controller and return response you want to send.

   try {
    // Must be called from request filtered by Spring Security, otherwise SecurityContextHolder is not updated
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
    token.setDetails(new WebAuthenticationDetails(request));
    Authentication authentication = this.authenticationProvider.authenticate(token);
    logger.debug("Logging in with [{}]", authentication.getPrincipal());
    SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
    SecurityContextHolder.getContext().setAuthentication(null);
    logger.error("Failure in autoLogin", e);
}
查看更多
我欲成王,谁敢阻挡
5楼-- · 2020-02-23 07:22

I actually found myself asking the very same question a few weeks ago - As Dirk pointed out in the comments, @ControllerAdvice will only kick in if an exception is thrown from within a controller method, so will inevitably not catch all things (I was actually trying to solve the case for a JSON response for a 404 error).

The solution I settled on, although not entirely pleasant (hopefully if you get a better answer I will change my approach) is handling the error mapping in the Web.xml - I added the following, which will override the tomcat default error pages with specific URL mappings:

<error-page>
    <error-code>404</error-code>
    <location>/errors/resourcenotfound</location>
</error-page>
<error-page>
    <error-code>403</error-code>
    <location>/errors/unauthorised</location>
</error-page>
<error-page>
    <error-code>401</error-code>
    <location>/errors/unauthorised</location>
</error-page>

Now, if any page returns a 404, it is handled by my error controller something like this:

@Controller
@RequestMapping("/errors")
public class ApplicationExceptionHandler {


    @ResponseStatus(HttpStatus.NOT_FOUND)
    @RequestMapping("resourcenotfound")
    @ResponseBody
    public JsonResponse resourceNotFound(HttpServletRequest request, Device device) throws Exception {
        return new JsonResponse("ERROR", 404, "Resource Not Found");
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @RequestMapping("unauthorised")
    @ResponseBody
    public JsonResponse unAuthorised(HttpServletRequest request, Device device) throws Exception {
        return new JsonResponse("ERROR", 401, "Unauthorised Request");
    }
}

It all still feels pretty grim - and of course is a global handling of errors - so if you didn't always want a 404 response to be json (if you were serving a normal webapp of the same application) then it doesnt work so well. But like I said, its what I settled on to get moving, here's hoping there is a nicer way!

查看更多
登录 后发表回答