同源
说起跨域,首先不得不说同源,这是浏览器的一个策略,规定一个页面只能请求当前源的资源。
举个例子,访问www.baidu.com返回的页面里的js,只能访问www.baidu.com域名下的资源,不能访问其他比如www.qq.com的资源。这么做是出于安全的考虑。如果没有同源策略,那么某些不正当页面可能会访问用户打开的其他页面的资源,访问其他资源时,根据浏览器的规范,cookie等信息会被自动带上,这是不安全的。
如何认定为同源?
协议+域名+端口,三元组。三元组想用才是同源。
跨域
有了同源策略,那么再看下跨域。跨域本质上就是为了绕过同源。
举个例子,一家公司的api服务器域名一般都会不止一个,如果都是自己公司的产品,a域名的页面,无法访问b域名的api接口,这确实很蛋疼。比如,a域名可能是一个h5的前端域名,a域名返回的页面里js代码会调用域名b下的api取数据。
可以看下具体的例子:
前端h5的url:
http://localhost.charlesproxy.com:8081/springmvcxm/hello
直接返回一个h5的页面:
<html>
<body>
<h2>cos test</h2>
</body>
<script>
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost.charlesproxy.com:8081/springmvcxml1/api/get');
xhr.send();
</script>
</html>
该页面使用ajax向api服务器发起请求。api地址:
http://localhost.charlesproxy.com:8081/springmvcxml1/api/get
由于两个地址是不同的源,如果不做特殊处理,会被同源策略拦截:
但是,这里不同的源其实是互相信任的,应该允许绕过同源策略的。
浏览器当然也考虑到了这种情况,所以设计了跨域的一些方式,比如说jsonp,还有今天重点介绍的cors协议。
cors
其全称是cross-origin-resource-share,跨域资源共享。浏览器会在跨域请求的header中加入一个origin信息,来表明该请求是一个跨域请求,origin的值就是源。服务器在接收到这个请求后,根据origin信息判定是否允许该跨域请求,如果允许,那么服务器需要在返回的header中设置特殊的值来告诉浏览器,然后浏览器就知道跨域请求成功了。否则,浏览器就会让请求失败。
我们使用chales抓包看下之前那个跨域请求的header:
可以看到,确实有origin字段。
那么服务器如果确定需要处理这个跨域请求,该如何返回?
Access-Control-Allow-Origin: 允许关于的源,可以是请求header里的origin,也可以是*,表示任何origin都可以跨域。浏览器拿到这个header字段时,就知道服务器允许该跨域请求。
下面我们写一个简单的java Filter过滤器来处理cors跨域返回
public class CorsFilter implements Filter {
private List<String> origins;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
origins = new ArrayList<>();
origins.add("localhost");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String origin = httpServletRequest.getHeader("Origin");
System.out.println("origin: " + origin);
if (origin != null && origins.stream().anyMatch(o -> origin.contains(o))) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.addHeader("Access-Control-Allow-Origin", origin);
} else {
System.out.println("no permit");
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
<filter>
<filter-name>cors</filter-name>
<filter-class>com.liyao.filter.CorsFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>cors</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
配置好跨域的filter即可。
这里只是做了简单的判定,如果判定通过,就设置Access-Control-Allow-Origin为当前的origin。
再重试之前的请求,可以看到跨域成功:
另外看下response:
有对应的header。
我们还可以试一下设置为*的情况:
跨域cookie
如果想在跨域请求中带上cookie,那么需要额外设置。比如a域跨域访问b域,即使浏览器已经有了b域的cookie,但是如果不做特殊处理,几百年跨域成功,也无法带上b域的cookie。需要服务端和客户端做处理:
服务端需要多设置一个Access-Control-Allow-Credentials的header为true,并且之前的allow-origin不能为*,只能为具体的某一个域。
另外,客户端ajax请求必须也设置withCredentials属性为true,跨域请求时,浏览器才会带上cookie。
上面的代码做一下修改:
<html>
<body>
<h2>cos test</h2>
</body>
<script>
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost.charlesproxy.com:8081/springmvcxml1/api/get');
xhr.withCredentials = true;
xhr.send();
</script>
</html>
filter:
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.addHeader("Access-Control-Allow-Origin", origin);
httpServletResponse.addHeader("Access-Control-Allow-Credentials", "true");
api:
@RequestMapping("/api/get")
public String get(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
System.out.println("cookies: " + Arrays.stream(cookies).map(c -> c.getName() + ":" +c.getValue()).collect(Collectors.joining(", ")));
} else {
Cookie cookie = new Cookie("cname1", "test2");
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
response.addCookie(cookie);
System.out.println("add cookie succeed");
}
return "succeed1";
}
如果有跨域请求带上了cookie,就打印,否则就下发cookie。
可以通过抓包看到,cookie已经被带上了。
简单与非简单
cors协议针对简单跨域与非简单跨域的处理其实是不同的,上面的例子都是简单跨域请求。
先看下定义:
简单请求必须满足以下两个条件
1)方法是get post head之一;
2)header字段只能如下几个
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
只要不满足任意一个条件,就是非简单请求。
简单请求,之请求一次,入前面的例子。浏览器第一次发送某一个非简单请求时,会在真正请求之前先发送一个预请求,是一个option方法。
服务端先要先响应这个请求,真正的请求才能被浏览器发出来。并且在有效期内,多不在需要发送预请求了。
下面看一个例子。
我们在跨域请求的header里加入一个新的字段:
X-MY-HEADER。
<html>
<body>
<h2>cos test</h2>
</body>
<script>
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost.charlesproxy.com:8081/springmvcxml1/api/get');
xhr.withCredentials = true;
xhr.setRequestHeader("X-MY-HEADER", "ly");
xhr.send();
</script>
</html>
此时该跨域请求变为了非简单请求。
服务端处理额外的header字段,需要多下发一个字段:
Access-Control-Allow-Headers,值为允许的额外的请求header名。显然,我们需要下发:Access-Control-Allow-Headers:X-MY-HEADER。
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String origin = httpServletRequest.getHeader("Origin");
System.out.println("origin: " + origin);
if (origin != null) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.addHeader("Access-Control-Allow-Origin", origin);
httpServletResponse.addHeader("Access-Control-Allow-Credentials", "true");
httpServletResponse.addHeader("Access-Control-Allow-Headers", "X-MY-HEADER");
httpServletResponse.addHeader("Access-Control-Max-Age", "60");
} else {
System.out.println("no permit");
}
chain.doFilter(request, response);
}
另外,Access-Control-Max-Age字段表明了预请求的有效期,在这个时间段内,非简单请求不需要再发起预请求。
看个例子:
抓包看下:
可以看到,在我们get请求前多了option的预请求。
在预请求中,多了一个Access-Control-Request-Header字段,值就是多出的额外的header。
响应中,下发了Access-Control-Allow-Headers字段,告知浏览器额外的header是允许的。
后续的get跨域请求中,确实加上了我们的header。
紧接着,再发一次get跨域请求,就不再有option预请求了。
直到过了预请求的有效期,之前我们设置的是一分钟。之后会再次出现预请求。