spring拦截器中修改响应消息头

Posted Simple is Awesome

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了spring拦截器中修改响应消息头相关的知识,希望对你有一定的参考价值。

问题描述

前后端分离的项目,前端使用Vue,后端使用Spring MVC。
显然,需要解决浏览器跨域访问数据限制的问题,在此使用CROS协议解决。
由于该项目我在中期加入的,主要负责集成shiro框架到项目中作为权限管理组件,之前别的同事已经写好了部分接口,我负责写一部分新的接口。
之前同事解决跨域问题使用Spring提供的@CrossOrigin注解:

@RequestMapping(value = "/list.do", method = RequestMethod.GET)
@ResponseBody
@CrossOrigin(origins="*")
@RequiresPermissions({"edge:manage"})
public JSONObject deviceList(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // do something
    return new Object();
}

我进入项目的时候觉得这种方式太繁琐了,需要在每一个Controller方法中都明确使用@CrossOrigin注解。
于是,我就使用Filter的方式解决我新写的这部分接口,如下:

public class CROSFilter implements Filter {
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest req = (HttpServletRequest)request;
		HttpServletResponse resp = (HttpServletResponse)response;
		
		String origin = req.getHeader("Origin");
        if(origin == null) {
            String referer = req.getHeader("Referer");
            if(referer != null) {
                origin = referer.substring(0, referer.indexOf("/", 7));
            }
        }
		resp.setHeader("Access-Control-Allow-Origin", origin);            // 允许指定域访问跨域资源
		resp.setHeader("Access-Control-Allow-Credentials", "true");
		
		if(RequestMethod.OPTIONS.toString().equals(req.getMethod())) {
			String allowMethod = req.getHeader("Access-Control-Request-Method");
			String allowHeaders = req.getHeader("Access-Control-Request-Headers");
			resp.setHeader("Access-Control-Max-Age", "86400");            // 浏览器缓存预检请求结果时间,单位:秒
			resp.setHeader("Access-Control-Allow-Methods", allowMethod);  // 允许浏览器在预检请求成功之后发送的实际请求方法名
			resp.setHeader("Access-Control-Allow-Headers", allowHeaders); // 允许浏览器发送的请求消息头
			return;
		}

		chain.doFilter(request, response);
	}
}

OK,到目前为止,访问我新写的接口没任何问题,但是访问同事之前写好的接口,在浏览器console中报错:

Failed to load http://10.100.157.34:8080/devicemanager/device/list.do: The \'Access-Control-Allow-Origin\' header contains 
multiple values \'http://192.168.252.138:8000, http://192.168.252.138:8000\', but only one is allowed. 
Origin \'http://192.168.252.138:8000\' is therefore not allowed access.
main.js:162 Error: Network Error
    at FtD3.t.exports (createError.js:16)
    at XMLHttpRequest.f.onerror (xhr.js:87)

根据日志描述,客户端报错是因为服务端返回的响应消息头Access-Control-Allow-Origin包含了2个值。

错误原因

项目中涉及跨域访问数据的问题,同时还需要跨域传递Cookie,根据CROS协议的规定,响应消息头Access-Control-Allow-Origin值只能为指定单一域名(注:不能为通配符“*”)。
但是,现在服务端返回的响应消息头Access-Control-Allow-Origin包含了多个值,客户端认为不符合CROS协议,所以报错。
那为什么会返回多个值呢?是因为请求在我写的Filter中已经设置了一次,而到Controller方法时又通过Spring的@CrossOrigin注解添加了一次。

解决办法

既然是同一个消息头返回了多个值不合法,那么就需要控制服务端只能返回一个值,这是解决问题的思路和方向。
显然,在Filter中是不能达到这个目的的。

1.使用Spring拦截器修改响应消息头

第一个想法是通过自定义拦截器实现在Controller方法执行完毕之后修改响应消息头值,其他不做任何修改。

public class CrossFilter extends HandlerInterceptorAdapter {
	public void postHandle(HttpServletRequest request, HttpServletResponse response, 
        Object handler, ModelAndView modelAndView) throws Exception {
        // 如果已经设置了消息头,确保只设置一个值
		String originHeader = "Access-Control-Allow-Origin";
		if(response.containsHeader(originHeader)) {
			String origin = request.getHeader("Origin");
			if(origin == null) {
				String referer = request.getHeader("Referer");
				if(referer != null) {
					origin = referer.substring(0, referer.indexOf("/", 7));
				}
			}
			response.setHeader("Access-Control-Allow-Origin", origin);
		}
		
		String credentialHeader = "Access-Control-Allow-Credentials";
		if(response.containsHeader(credentialHeader)) {
			response.setHeader("Access-Control-Allow-Credentials", "true");
		}
    }
}

在Spring中添加拦截器配置:

<!-- 拦截器:对特定路径进行拦截 -->
<mvc:interceptors>
    <mvc:interceptor>
        <mvc:mapping path="/**" />
        <bean class="org.chench.test.filter.CrossFilter" />
    </mvc:interceptor>
</mvc:interceptors>

但是,调试时发现:虽然在postHandle方法中已经明确设置了消息头为一个值,但是返回到浏览器客户端的依然是2个值!
百思不得解!
于是开始Google相关问题,终于找到了一篇博文:https://mtyurt.net/2015/07/20/spring-modify-response-headers-after-processing/。
博主也是想在Controller方法执行之后添加响应消息头,但是采用Spring拦截器的方式也是不生效。
真正的原因是SpringMVC框架的限制,详见:https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc。
在Spring的文档中搜索关键字:postHandle,看到如下声明:

Note that postHandle is less useful with @ResponseBody and ResponseEntity methods for which a the response is written 
and committed within the HandlerAdapter and before postHandle. That means its too late to make any changes to the 
response such as adding an extra header. For such scenarios you can implement ResponseBodyAdvice and either declare it as 
an Controller Advice bean or configure it directly on RequestMappingHandlerAdapter.

What?原来是因为@ResponseBody注解的原因,导致无法通过拦截器的方式实现修改响应消息头的目的。

2.在ResponseBodyAdvice中修改响应消息头

由于Controller方法中已经使用了@ResponseBody注解返回json数据,故不能通过Spring拦截器修改响应消息头。
但是Spring同时还提供了一个ResponseBodyAdvice接口,允许在这种场景下实现对响应消息头的控制。

@ControllerAdvice
public class HeaderModifierAdvice implements ResponseBodyAdvice<Object> {
	@Override
	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
		return true;
	}

	@Override
	public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
			Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
			ServerHttpResponse response) {
		ServletServerHttpRequest ssReq = (ServletServerHttpRequest)request;
		ServletServerHttpResponse ssResp = (ServletServerHttpResponse)response;
		if(ssReq == null || ssResp == null
				|| ssReq.getServletRequest() == null
				|| ssResp.getServletResponse() == null) {
			return body;
		}
		
		// 对于未添加跨域消息头的响应进行处理
		HttpServletRequest req = ssReq.getServletRequest();
		HttpServletResponse resp = ssResp.getServletResponse();
		String originHeader = "Access-Control-Allow-Origin";
		if(!resp.containsHeader(originHeader)) {
			String origin = req.getHeader("Origin");
			if(origin == null) {
				String referer = req.getHeader("Referer");
				if(referer != null) {
					origin = referer.substring(0, referer.indexOf("/", 7));
				}
			}
			resp.setHeader("Access-Control-Allow-Origin", origin);
		}
		
		String credentialHeader = "Access-Control-Allow-Credentials";
		if(!resp.containsHeader(credentialHeader)) {
			resp.setHeader(credentialHeader, "true");
		}
		return body;
	}
}

OK,完美解决!
当然,对应我写的Filter还需要对应调整一下:

public class CROSFilter implements Filter {
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if(logger.isDebugEnabled()) {
			logger.debug(String.format("CORS filter do filter"));
		}
		
		// 不再对所有请求都添加跨域消息头
        // 在Filter中只对OPTIONS请求进行处理,跨域消息头放在ResponseBodyAdvice中解决
		if(RequestMethod.OPTIONS.toString().equals(req.getMethod())) {
            HttpServletRequest req = (HttpServletRequest)request;
		    HttpServletResponse resp = (HttpServletResponse)response;
			String origin = req.getHeader("Origin");
			resp.setHeader("Access-Control-Allow-Origin", origin);            // 允许指定域访问跨域资源
			resp.setHeader("Access-Control-Allow-Credentials", "true");
			String allowMethod = req.getHeader("Access-Control-Request-Method");
			String allowHeaders = req.getHeader("Access-Control-Request-Headers");
			resp.setHeader("Access-Control-Max-Age", "86400");            // 浏览器缓存预检请求结果时间,单位:秒
			resp.setHeader("Access-Control-Allow-Methods", allowMethod);  // 允许浏览器在预检请求成功之后发送的实际请求方法名
			resp.setHeader("Access-Control-Allow-Headers", allowHeaders); // 允许浏览器发送的请求消息头
			return;
		}

		chain.doFilter(request, response);
	}
}

总结

1.对于项目中需要解决浏览器跨域问题的方案应该统一,要么使用Filter方式,要么使用@CrossOrigin注解,这个必须一开始就全局统一规划好。
而我不得不使用上述方式解决问题,是因为前期已经写好了很多代码,不希望再去修改,不得已而为之。
2.对于使用了@ResponseBody注解的场景,如果需要统一调整响应消息头,只能通过自定义ResponseBodyAdvice实现来完成。
3.建议通过Filter方式解决跨域问题,而不要直接使用Spring的注解@CrossOrigin,太繁琐。

【参考】
http://www.cnblogs.com/nuccch/p/7875189.html 跨域请求传递Cookie问题
https://www.w3.org/TR/cors/ Cross-Origin Resource Sharing
https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc SpringMVC文档

以上是关于spring拦截器中修改响应消息头的主要内容,如果未能解决你的问题,请参考以下文章

servlet和filter的区别

在 Spring Boot 中设置响应头

Fiddler拦截http请求修改数据

JAVAWEB项目报"xxx响应头缺失“漏洞处理方案

nginx拦截请求修改返回数据

servlet,filter,listener,intercepter区别