跨域、JSONP、CORS、Spring、Spring Security解决方案
概述
JavaScript出于安全方面的考虑,不允许跨域调用其他页面的对象。跨域是浏览器(如Chrome浏览器基于JS V8引擎,可以简单理解为JS解释器)的一种同源安全策略,是浏览器单方面限制脚本的跨域访问。因此,仅有客户端运行在浏览器时才存在跨域问题,才需要考虑如何解决这个问题。
浏览器控制台输出类似于:No 'Access-Control-Allow-Origin' header is present on the requested resource.
这种报错信息,即表明遇到跨域问题。
对跨域理解的一个误区是:资源跨域时无法请求。实际上,通常情况下请求是可以正常发起的(部分浏览器存在特例),后端也正常进行处理,只是在返回时被浏览器拦截,导致响应内容不可使用。可以论证这一点的著名案例就是CSRF跨站攻击。
什么情况下会出现跨域?不同源的访问,就是跨域请求。同源的定义是,即同一个请求来源,包括主机名、协议和端口号。例如:
- https://blog.csdn.net和https😕/geek.csdn.net,两个不同的二级域名,存在跨域问题
- http://blog.csdn.net和https😕/blog.csdn.net,使用不同的协议,存在跨域问题
- https://blog.csdn.net和https😕/blog.csdn.net:3000,使用不同的端口号,存在跨域问题。注:此处使用的3000端口仅仅是在举例,CSDN不会暴露出3000端口的服务。
- https://blog.csdn.net/lonelymanontheway和https://blog.csdn.net/about/,虽然文件夹不同,但是是相同域名下,不存在跨域问题。
跨域问题普遍么?在现在前后端分离,微服务化之后,会存在许多不同的域名,这种情况下,就存在非常普遍的跨域问题。
从原理上说,跨域实际上就是在HTTP请求的消息头部分新增一些字段:
// 浏览器自己设置的请求域名
Origin
// 浏览器告诉服务器请求需要用到的HTTP方法
Access-Control-Request-Method
// 浏览器告诉服务器请求需要用到的HTTP消息头
Access-Control-Request-Headers
当浏览器进行跨域请求时会和服务器端进行一次握手(OPTION请求),从响应结果中可以获取如下信息,有些是服务端必须要设置的,有些则是可选的:
// 指定哪些客户端的域名允许访问这个资源
Access-Control-Allow-Origin
// 服务器支持的HTTP方法
Access-Control-Allow-Methods
// 需要在正式请求中加入的HTTP消息头
Access-Control-Allow-Headers
// 取值为true时,浏览器会在接下来的真实请求中携带用户凭证信息(cookie等),服务器也可以使用Set-Cookie向用户浏览器写入新的cookie。使用AccessControl-Allow-Credentials时,Access-Control-Allow-Origin不应该设置为*
Access-Control-Allow-Credentials
// 用于指明本次预检请求的有效期,单位为秒。在有效期内,预检请求不需要再次发起
Access-Control-Max-Age
//
Access-Control-Expose-Headers
上述几个请求及响应头,可以在Tomcat的源码org.apache.catalina.filters.CorsFilter
里找到。
只要服务器合理设置了这些响应结果中的消息头,就相当于实现对CORS的支持,从而支持跨源通信。
结论:
跨域问题是客户端(前端、或叫浏览器)出于安全考虑引发的问题,而如何解决此问题需要依赖于服务端(后端,包括运维)。
解决方案
JSONP
JSON with Padding。由于浏览器允许一些带src属性的标签跨域,如,iframe、script、img等,所以JSONP利用script标签可以实现跨域。
JSONP是带有回调函数callback的JSON,可用于解决主流浏览器的跨域数据访问的问题。但JSONP方案的局限性在于只能实现GET请求。
JSONP实际上是在需要返回的JSON数据外,用JS函数进行封装。具体来说,服务器返回一个JS函数,参数是一个JSON数据如:callback(JSON 数据)
,AJAX不能跨域访问,但JS脚本是可以跨域执行的,因此前端将执行这个callback函数,并获取其中的JSON数据。
如果需要返回的JSON数据如下:
[{"id": 2,"name": "ipad mini","price": 2500}]
则对应的JSONP格式是:
callback([{"id":2,"name":"ipad mini","price":2500}]);
使用jQuery发送基于JSONP的AJAX请求:
$.ajax({
type: 'get',
url: 'http://localhost:8080/api/product/10086',
dataType: 'jsonp',
jsonp: '_jsonp',
jsonpCallback: 'callback',
success: function(data) {
var template = $("#product_table_template").html();
var render = Handlebars.compile(template);
var html = render({
data: data
});
$('#product').html(html);
}
});
三个参数选项的解释:
- dataType:必须为
jsonp
,表示返回的数据类型为JSONP格式 - jsonp:表示URL中JSONP回调函数的参数名
- jsonpCallback:表示回调函数的名称,若未指定,由jQuery自动生成
中间转发层
跨域问题的核心是不同源访问。如果转换成同源请求,就不存在这个问题。因此通过搭建中间层,通过将服务端的请求进行转发,即增加dispatcher一层,那么前端请求的地址会被转发,可解决跨域问题。
当然,如果对性能有考量的产品,就需要慎重选择这个方案,因为多一层中间转发,对网络开销有一定影响。
Nginx反向代理
需要搭建一个Nginx中转服务器,用于转发请求。通过Nginx解析URL地址时进行判断,将请求转发的具体的服务器上。
当用户请求xx.720ui.com/server1的时候,Nginx会将请求转发给Server1这个服务器上的具体应用,从而达到跨域的目的。
CORS
Cross Origin Resource Sharing,跨域资源共享,参考W3C提出的CORS规范。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,用户不会有感觉。因此,实现CORS通信的关键是服务端。服务端只需添加相关响应头信息,即可实现客户端发出跨域请求。
浏览器首先会发起一个请求方法为OPTIONS的预检请求,用于确认服务器是否允许跨域,只有在得到许可后才会发出实际请求。预检请求还允许服务器通知浏览器跨域携带身份凭证(如cookie)。
但是,CORS不支持低版本的IE浏览器,如IE8以下的版本。如果想在IE8中使用jQuery发送AJAX请求时,需要配置$.support.cors = true
,才能开启CORS特性。
在Java Web开发中,解决跨域问题的方案有很多。
Filter
Tomcat内置一个org.apache.catalina.filters.CorsFilter
。
Spring Web(即maven spring-web模块)提供org.springframework.web.filter.CorsFilter
,该过滤器会先判断来自客户端的请求是不是一个跨域请求,然后根据CORS配置判断该请求是否合法:
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (isValid && !CorsUtils.isPreFlightRequest(request)) {
filterChain.doFilter(request, response);
}
}
使用spring-web模块提供的CorsFilter,可实现全局级别的跨域设置。
如果想要针对某个域名设置允许跨域请求,也可以自定义一个CrossFilter
public class CrossFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletResponse res = (HttpServletResponse) response;
res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
res.addHeader("Access-Control-Allow-Headers", "Content-Type,X-Requested-With");
res.addHeader("Access-Control-Allow-Origin", "https://blog.csdn.net");
res.addHeader("Access-Control-Max-Age", "1800");
res.addHeader("Access-Control-Allow-Credentials", "true");
}
@Override
public void destroy() {
}
}
Java Config
借助于spring-webmvc提供的CorsRegistry和WebMvcConfigurer,也可以实现如下配置类:
import jakarta.servlet.Filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
public class CrossConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://blog.csdn.net")
.allowedMethods("PUT", "DELETE")
.allowedHeaders("header1", "header2", "header3")
.exposedHeaders("header1", "header2")
.allowCredentials(false)
.maxAge(3600);
}
};
}
@Bean
public FilterRegistrationBean<Filter> corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("https://blog.csdn.net");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
FilterRegistrationBean<Filter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
bean.setOrder(0);
return bean;
}
}
@CrossOrigin
spring-web提供@CrossOrigin注解,源码如下:
// 此注解可用于方法或类
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CrossOrigin {
// 以数组形式支持配置多个origin,作用和origins方法一样,如果为空则表示origin=*,即允许全部域名,不建议设置为*
@AliasFor("origins")
String[] value() default {};
@AliasFor("value")
String[] origins() default {};
String[] originPatterns() default {};
String[] allowedHeaders() default {};
String[] exposedHeaders() default {};
RequestMethod[] methods() default {};
String allowCredentials() default "";
String allowPrivateNetwork() default "";
// 允许跨域访问的最大时间,过期后,客户端再次请求会出现跨域问题。不设置则默认值为-1,表示设置的跨域规则永远生效
long maxAge() default -1L;
}
如何使用@CrossOrigin注解,参考代码片段:
@CrossOrigin(origins = "http://domain2.com", maxAge = 3600)
@RestController
@RequestMapping("/account")
public class AccountController {
@GetMapping("/{id}")
public void retrieve(@PathVariable Long id) {
}
}
Spring Security
基于CorsFilter,在Spring Security应用开发中,支持CORS的配置非常简单,:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors(c -> {
CorsConfigurationSource source = request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("*"));
return config;
};
c.configurationSource(source);
});
}
至于具体的实现原理,参考CorsProcessor接口的唯一实现类DefaultCorsProcessor的方法handleInternal方法:
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response, CorsConfiguration config, boolean preFlightRequest) throws IOException {
// 实际请求的Origin
String requestOrigin = request.getHeaders().getOrigin();
// 配置里被允许的Origin
String allowOrigin = this.checkOrigin(config, requestOrigin);
HttpHeaders responseHeaders = response.getHeaders();
if (allowOrigin == null) {
logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
this.rejectRequest(response);
return false;
} else {
// 实际请求的方法Method
HttpMethod requestMethod = this.getMethodToUse(request, preFlightRequest);
// 配置里被允许的Method
List<HttpMethod> allowMethods = this.checkMethods(config, requestMethod);
if (allowMethods == null) {
logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
this.rejectRequest(response);
return false;
} else {
// 实际请求头
List<String> requestHeaders = this.getHeadersToUse(request, preFlightRequest);
// 配置里被允许的请求头
List<String> allowHeaders = this.checkHeaders(config, requestHeaders);
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
this.rejectRequest(response);
return false;
} else {
// 检查通过,设置响应头
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) && Boolean.parseBoolean(request.getHeaders().getFirst("Access-Control-Request-Private-Network"))) {
responseHeaders.set("Access-Control-Allow-Private-Network", Boolean.toString(true));
}
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
response.flush();
return true;
}
}
}
}
参考
- Spring Security实战
- spring跨域的几种方式