什么是跨域
跨域的概念很简单,即当一个请求URL的协议、域名、端口三者之间任意一个与当前页面URL不同则视为跨域,而跨域问题产生的原因主要是由浏览器的“同源策略”限制导致的,是浏览器对JavaScript 施加的安全限制。
什么是同源策略
所谓同源是指协议、域名以及端口要相同。我们举例说明:假如有这么一个网站:http://www.example.com/zw/index.html,很容易知道,它的协议是http://,域名是www.example.com,端口号是80(默认端口可以省略),它的同源情况如下:
①、http://www.example.com/zwxk/manager.html 同源
②、https://www.example.com/zw/index.html 不同源(协议不同)
③、http://examle.com/zw/index.html 不同源(域名不同)
④、http://www.example.com:81zw/index.html 不同源(端口号不同)
那什么是同源策略呢?
官方解释是,同源策略限制了从同一个源加载的文档或脚本
同源策略是由Netscape提出的一个著名的安全策略,它是浏览器最核心也最基本的安全功能,现在所有支持JavaScript的浏览器都会使用这个策略。
同源策略的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。假设你成功登录了某个银行网站,自己的账户余额在你登录后都可以随意操作,大家都知道很多网站系统在你登录成功后都会给你的浏览器发送Cookie,Cookie中一般会记录你的部分信息甚至是登录状态,而当你在未退出银行网站的情况下,接着又去浏览了其他网站,如果其他网站可以读取银行网站的 Cookie,会发生什么?很显然,你的Cookie中信息会全部泄露,甚至其他网站还会使用你的Cookie来登录你的账号冒充你来操作你的账户,由于浏览器同时还规定,提交表单不受同源策略的限制,从而你的钱就会不翼而飞。这就是所谓的CSRF攻击,中文名称为:跨站请求伪造攻击,即攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账……造成的问题包括:个人隐私泄露以及财产安全。
同源策略是基于安全方面的考虑提出来的,这个策略本身没问题,但是我们在实际开发中,由于各种原因又经常有跨域的需求。传统的跨域方案是JSONP,JSONP虽然能解决跨域问题,但是它有一个很大的局限性,那就是只支持GET请求,不支持其他类型的请求。而今天我们说的CORS(跨域源资源共享)(CORS,Cross-origin resource sharing)是一个W3C标准,它是一份浏览器技术的规范,提供了Web服务从不同网域传来沙盒脚本的方法,以避开浏览器的同源策略,这是JSONP模式的现代版。
为什么出现跨域问题
非跨域请求:在请求头中会只包含请求的主机名。如下图:
跨域请求:在请求头中会既包含要请求的主机名还包括当前的源主机名,如果这两者不一致,那就是跨域请求。如下图:
当前绝大多数浏览器出于安全考虑,都实现了同源策略,浏览器会根据同源策略来判断一个请求是不是跨域请求,因此当在这些浏览器中进行跨域请求时,就会出现所谓的跨域问题。说白了就是浏览器发出跨域请求后,无法正常的获得请求结果。
非同源限制范围
思考个问题,是不是在浏览器上无法访问任何非同源的资源呢?答案是否定的。浏览器只是禁止某些跨域请求:
- 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB
- 无法接触非同源网页的 DOM
- 无法向非同源地址发送 AJAX 请求
在浏览器中,< script>、< img>、< iframe>、< link>等标签都可以加载跨域资源,而不受同源限制,但浏览器会限制脚本中发起的跨域请求。比如,使用 XMLHttpRequest 对象和Fetch发起 HTTP 请求就必须遵守同源策略。
Web 应用程序通过 XMLHttpRequest 对象或Fetch能且只能向同域名的资源发起 HTTP 请求,而不能向任何其它域名发起请求。
但是这里需要特别注意,跨域请求限制是浏览器出于安全考虑限制的,并不是应用服务器限制的。这里很多人有个误区:认为浏览器会直接禁止跨域请求,也就是说认为浏览器根本不会发送跨域请求,这是不对的。浏览器不但会发送跨域请求,而且接收请求的服务器还可以正常的响应请求,只是浏览器会对跨域请求的响应结果进行拦截,并不会正常的处理响应结果,而是报错处理。
最好的例子是CSRF跨站攻击原理,请求是发送到了后端服务器,无论设置是否允许跨域。但有些浏览器不允许从HTTPS跨域访问HTTP,比如Chrome和Firefox,这些浏览器在请求还未发出的时候就会拦截请求,这是特例。
常见的跨域场景
前后端分离出现之前,最常见的跨域场景是:我们有一个jsp文件在A服务器上,正常此jsp文件访问A服务器上的接口是没有问题的,但是如果此jsp文件对B服务器的接口发起请求,这就是最典型的跨域访问了。
前后端分离出现之后,最常见的跨域场景是:前端代码部署在A服务器上,而后端代码部署在B服务器上,这样前端调用B服务器的接口就是天然的跨域请求了。
当然解决跨域请求的方案也有很多,我们下面介绍。
浏览器请求类型
预检请求是在发送实际的请求之前,客户端会先发送一个 OPTIONS 方法的请求向服务器确认,如果通过之后,浏览器才会发起真正的请求,这样可以避免跨域请求对服务器的用户数据造成影响。
示例:
<script>
fetch('http://127.0.0.1:3011/api/data', {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
'Test-Cors': 'abc'
}
});
</script>
上述代码在浏览器执行时会发现是一个非简单请求,就会先发送一个预检请求,Request Headers 会有如下信息:
OPTIONS /api/data HTTP/1.1
Host: 127.0.0.1:3011
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,test-cors
Origin: http://127.0.0.1:3010
Sec-Fetch-Mode: cors
可以看到有一个 OPTIONS 类型的请求,它是预检请求使用的方法,该方法是在 HTTP/1.1 协议中所定义的。还有一个重要的字段 Origin 表示请求来自哪个源,服务端则可以根据这个字段判断是否是合法的请求源,例如 Websocket 中因为没有了同源策略限制,服务端可以根据这个字段来判断。
Access-Control-Request-Method 告诉服务器,实际请求将使用 PUT 方法。
Access-Control-Request-Headers 告诉服务器,实际请求将使用两个头部字段 content-type,test-cors。这里如果 content-type 指定的为简单请求中的几个值,Access-Control-Request-Headers 在告诉服务器时,实际请求将只有 test-cors 这一个头部字段。
CORS 将请求分为了两类:简单请求和非简单请求。
- 简单请求:浏览器先发送(执行)请求然后再判断是否跨域,不会触发 CORS 预检请求。
- 非简单请求:浏览器先发送预检命令(OPTIONS方法),检查通过后才发送真正的数据请求。
在HTTP1.1 协议中的,请求方法分为GET、POST、PUT、DELETE、HEAD、TRACE、OPTIONS、CONNECT 八种。浏览器根据这些请求方法和请求类型将请求划分为简单请求和非简单请求。
简单请求
请求满足下述任何情况,则认为该请求为简单请求:
情况一: 使用以下方法(意思就是以下请求以外的都是非简单请求)
GET、HEAD、POST
情况二: 人为设置以下集合外的请求头
AcceptAccept-LanguageContent-LanguageContent-Type (需要注意额外的限制)DPRDownlinkSave-DataViewport-WidthWidth
情况三:Content-Type的值仅限于下列三者之一:(例如 application/json 为非简单请求)
text/plainmultipart/form-dataapplication/x-www-form-urlencoded
情况四:
请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
情况五:
请求中没有使用 ReadableStream 对象。
非简单请求
除了符合简单请求情况外的请求都是非简单请求。
CORS 与认证
对于跨域的 XMLHttpRequest 或 Fetch 请求,浏览器是不会发送身份凭证信息的。如果我们想要在跨域请求中发送 Cookie 信息,除了浏览器发送实际请求时要向服务器发送 Cookies,同时服务器也需要在响应中设置 Access-Control-Allow-Credentials 响应头。
跨域问题的解决方案
很多人对跨域有一种误解,以为跨域是由于前端请求了不同源的资源导致的,所以理应前端来解决,其实这是不对的。
必须明确的是,跨域问题即可以在前端解决,也可以在后端解决。前端常用的解决方案是通过 JSONP 来解决跨域问题,但JSONP 只可以发送 GET 请求,无法发送其他类型的请求,在 RESTful 风格的应用中,就显得非常鸡肋。因此,从代码规范性、安全性、复杂度来讲,我们推荐在后端通过 (CORS,Cross-origin resource sharing) 来解决跨域问题。
解决跨域问题的方案非常多,特别是现在前端框架很多,都有自己解决跨域问题的方式,对于后端而言也分为不同的解决方案,主要区别在于在请求的什么阶段解决跨域问题,下面总结了一些解决跨域问题的方案:
- 通过jsonp解决跨域问题
- 在应用程序中解决跨域问题,如在Zuul搭建的网关中
- 使用代理服务器(如Nginx)解决跨域问题
- 通过修改document.domain来跨域
- 使用window.name来进行跨域
- 使用HTML5中新引进的window.postMessage方法来跨域传送数据
这里我们只讲前三种方案,至于其他的方案或者其他框架(如node.js,vue)的跨域解决方案大家可以网上搜索,这并不难。
通过jsonp解决跨域跨域
先了解一下什么是JSONP?
JSONP(JSON with Padding)是一个非官方的协议,它允许在服务器端集成Script tags返回至客户端,通过javascript callback的形式实现跨域访问(这仅仅是JSONP简单的实现形式)。
JSONP的基本思想:浏览器会将网页中的jsonp脚本编译成一个< script >元素,动态的插入网页中,并将jsonp请求的url放到< script >元素的src属性中(需要注意的是url中的参数中必须要带一个自定义函数名,要不然后台无法返回数据),然后向服务器请求,这种做法不受同源政策限制,服务器处理完请求后,将响应数据放在一个指定名字的回调函数里传回。
原生JS中使用JSONP
原生JS中通过JSONP解决跨域问题是比较常见的方式,这种方式的优点是实现非常简单,缺点也很明显,它只支持get请求,不支持post,并且需要前端和后端都要做出修改才能完成。
第一种写法:
<script type="text/javascript">
function jsonpCallback(result) {
alert(result);
}
var JSONP=document.createElement("script");
JSONP.type="text/javascript";
JSONP.src="http://crossdomain.com/services.php?callback=jsonpCallback";
document.getElementsByTagName("head")[0].appendChild(JSONP);
</script>
第二种写法:
<script type="text/javascript">
function jsonpCallback(result) {
alert(result.a);
alert(result.b);
alert(result.c);
for(var i in result) {
alert(i+":"+result[i]);//循环输出a:1,b:2,etc.
}
}
</script>
<script type="text/javascript" src="http://crossdomain.com/services.php?callback=jsonpCallback"></script>
Jquery中使用JSONP解决跨域问题
Jquery封装的jsonp,使用起来相对简单。
使用起来跟使用ajax类似,只是dataType变成了jsonp,且增加了jsonp参数,参数就是上述的callback参数,不需要管他是啥值,因为jq自动给你起了个名字传到后台,并自动帮你生成回调函数并把数据取出来供success属性方法来调用
第一种写法:
<script type="text/javascript" src="jquery.js"></script>
<script type="text/javascript">
$.getJSON("http://crossdomain.com/services?callback=?",
function(result) {
for(var i in result) {
alert(i+":"+result[i]);//循环输出a:1,b:2,etc.
}
});
</script>
第二种写法:
$.ajax({
type: 'get',
url: "http://192.168.100.150:8081/zhxZone/webmana/dict/jsonp",
dataType: 'jsonp',
jsonp:"callback",
async:true,
data:{
},
success: function(ret){
console.log(ret)
},
error:function(data) {
},
});
第三种写法:
<script type="text/javascript" src="jquery.js"></script>
<script type="text/javascript">
$.get('http://crossdomain.com/services?callback=?', {name: encodeURIComponent('tester')}, function (json) {
for(var i in json){
alert(i+":"+json[i]);
}, 'jsonp');
</script>
这里针对Ajax与JSONP的异同再做一些补充说明:
- ajax和jsonp这两种技术在调用方式上”看起来”很像,目的也一样,都是请求一个url,然后把服务器返回的数据进行处理,因此jquery框架把jsonp作为ajax的一种形式进行了封装。
- 但ajax和jsonp其实本质上是不同的东西。ajax的核心是通过XmlHttpRequest获取非本页内容,而XHR请求的类型是json类型,而jsonp的核心则是动态添加< script>标签,请求类型是JavaScript脚本。
需要注意的是,无论是使用原生方式还是jquery方式,都需要后端的配合,后端java程序实现实例如下:
@ResponseBody
@RequestMapping(value = "jsonp", produces = "text/plain;charset=UTF-8")
public void jsonp(String callback, HttpServletRequest req, HttpServletResponse res) {
List<Map<String,Object>> list = new ArrayList<Map<String,Object>>();
RetBase ret=new RetBase();
String callback=req.getParameter("callback");
try {
res.setContentType("text/plain");
res.setHeader("Pragma", "No-cache");
res.setHeader("Cache-Control", "no-cache");
res.setDateHeader("Expires", 0);
Map<String,Object> params = new HashMap<String,Object>();
list = dictService.getDictList(params);
ret.setData(list);
ret.setSuccess(true);
ret.setMsg("获取成功");
PrintWriter out = res.getWriter();
//JSONObject resultJSON = JSONObject.fromObject(ret); //根据需要拼装json
//返回jsonp格式数据,即:回调函数名称(返回数据)
out.println(callback+"("+JSON.toJSONString(ret)+")");
} catch (Exception e) {
e.printStackTrace();
}final{
out.flush();
out.close();
}
}
后端解决跨域请求
其实后端解决跨域请求也有很多种情况。
场景一:如前端代码和A应用部署在同一个服务器上,而前端需要请求B服务器上资源的场景。
解决方案:这时可通过先请求A上的接口,再由A通过http的方式请求B服务器的资源,最后再返回给浏览器,这就避免了跨域的请求。
场景二:和场景一类似,如前端代码和A应用部署在同一个服务器上,而前端有请求B服务器上资源的需求,而我们又不想通过A服务器进行。
解决方案:这时就需要我们在B服务器上进行跨域处理了,只需要在特定的接口进行跨域处理就可以了。需要注意的是,此时并不需要前端做跨域处理,也就是说前端此时和请求非跨域资源是一样的。
场景三:前后端分离的场景。
解决方案:此时前端请求后端所有的接口都面临着跨域问题,如果在每个接口里都进行跨域处理,这显然是不现实的,这就需要我们找到一个能够对所有请求都能统一进行跨域处理的方案,大家很容易想到使用拦截器或者过滤器。其实场景三的解决方案比场景二的解决方案更具有一般性、更通用,所以我们这里主要将这种解决方案。
CORS解决方案
CORS是Cross-Origin Resource Sharing的缩写,是指跨源资源分享,它是W3C标准,是跨源AJAX请求的根本解决方法。相比JSONP只能发GET请求,CORS允许任何类型的请求。
**跨域资源共享(CORS)**是一种网络浏览器的技术规范,它为Web服务器定义了一种方式,允许网页从不同的域访问其资源,而这种访问是被同源策略所禁止的。CORS系统定义了一种浏览器和服务器交互的方式来确定是否允许跨域请求。 它是一个妥协,有更大的灵活性,但比起简单地允许所有这些的要求来说更加安全。
在具体讲解后端解决跨域问题的方案之前,我们先说一下,后端解决跨域问题的思想,其实很简单,就是让浏览器和服务器之间建立信任,这也是CORS背后的基本思想。浏览器之所以不接受跨域请求返回的结果,是出于安全考虑,那如果服务器告诉浏览器,你刚才发过来的请求虽然和我非同源,但是我是允许它请求的,那这样浏览器是不是就没有必要再对响应的结果进行拦截了!
那服务器是如何告知浏览器它是接收这个源的请求的呢?就是通过响应头中的参数,主要是:Access-Control-Allow-Origin、Access-Control-Allow-Methods和Access-Control-Request-Headers。
- Access-Control-Allow-Origin:指定授权访问的源
- Access-Control-Allow-Methods:授权请求的方法(GET, POST, PUT, DELETE,OPTIONS等)
- Access-Control-Request-Headers: 告诉服务器,实际请求将使用两个头部字段 content-type,test-cors。这里如果 content-type 指定的为简单请求中的几个值,Access-Control-Request-Headers 在告诉服务器时,实际请求将只有 test-cors 这一个头部字段。
知道了这个,其实我们就很清楚解决方案了!虽然过滤器和拦截器都可以实现,但是很显然过滤器更适合干这种事。
前端实现逻辑
这种方案中前端的发送跨域请求的代码和发送非跨域请求的代码基本一致,只是在写请求url时必须是完整的url路径,不能是相对的url,这一点很好理解,如果你想跨域请求,你总得告诉我,你要跨到哪个域名或哪个服务器上吧。这里不多介绍,我们重点看后端是如何实现的。
后端实现逻辑
直接定义一个过滤器,拦截所以请求,然后再响应头中添加上述参数,代码如下:
@Order(2)
@WebFilter(filterName="secondFilter", urlPatterns="/*")
@Component
public class CorsFilter implements Filter{
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request=(HttpServletRequest)servletRequest;
HttpServletResponse response=(HttpServletResponse)servletResponse;
String origin = request.getHeader("Origin");
if (StringUtils.isNotBlank(origin)) {
//表示允许所有源的请求访问,和直接设置"*"效果一样
response.addHeader("Access-Control-Allow-Origin",origin);
}
//response.addHeader("Access-Control-Allow-Origin","*");
String header = request.getHeader("Access-Control-Allow-Headers");
if (StringUtils.isNotBlank(header)) {
//表示允许所有源的请求访问,和直接设置"*"效果一样
response.addHeader("Access-Control-Allow-Headers",header);
}
response.addHeader("Access-Control-Max-Age","3600");
//带Cookie的跨域请求头字段设置,且此时Access-Control-Allow-Origin字段必须是全匹配,不能使用*
response.addHeader("Access-Control-Allow-Credentials","true");
filterChain.doFilter(request,response);
}
}
这种写法在Spring mvc和Spring boot中都是适用的,只是要注意在两者中使自定义Filter生效的配置,实例中是Springmvc中的写法。但是这种方案,要注意项目中是否有检测用户是否登录的过滤器。
提示:如果是SpringBoot项目可以直接在Controller类上加上如下注解实现跨域,其中origins 是一个数组,存的是允许跨域的请求主机地址,另外在controller方法中获取参数时,需要使用@RequestBody注解,否则可能无法获取请求参数。
@CrossOrigin(origins = {"http://localhost:8081"},allowCredentials="true")
通过Nginx反向代理解决跨域问题
上个方法跨域是借助了浏览器对 Access-Control-Allow-Origin 的支持。但有些浏览器是不支持的,所以这并非是最佳方案,现在我们来利用nginx 通过反向代理满足浏览器的同源策略实现跨域,因为同源限制是浏览器实现的,如果请求不是从浏览器发起的,就不存在跨域问题了。
nginx作为反向代理服务器,就是把http请求转发到另一个或者一些服务器上。通过把本地一个url前缀映射到要跨域访问的web服务器上,就可以实现跨域访问。对于浏览器来说,访问的就是同源服务器上的一个url。而nginx通过检测url前缀,把http请求转发到上游服务器,并通过rewrite命令把前缀再去掉,这样真实的服务器就可以正确处理请求。
假设我们有个前后端分离项目,前端项目部署在本机localhost:8888端口的服务器上,后端项目部署在本机localhost的8080端口的服务器上,后端提供了一个获取用户信息的API,请求如下:localhost:8080/api/user/listUser,而前端在浏览器上如果直接请求此api会因为端口不同导致不同源而存在跨域问题,那么我们可以使用Nginx反向代理配置来实现,Nginx的conf配置文件配置如下:
server {
listen 8888;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
location /api/ {
//rewrite ^.+api/?(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8080;
}
}
这样,我们访问localhost:8888/api/user/listUser这个不跨域的端口就会被服务器反向代理到localhost:8080/api/user/listUser,那么跨域问题就解决了。
注意,这里的前提是前端资源和Nginx部署在同一台服务器上。
下面是另外两种nginx中的配置示例:
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
#
# Custom headers and headers various browsers *should* be OK with but aren't
#
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
#
# Tell client that this pre-flight info is valid for 20 days
#
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
}
使用 Nginx 代理服务器之后,请求不会直接到达我们的上游服务器,请求会先经过 Nginx 在设置一些跨域等信息之后再由 Nginx 转发到我们的上游服务端,所以这个时候我们的 Nginx 服务器监听的是 3011 端口,然后在把请求转发到端口为30011的上游服务,简单配置如下所示:
server {
listen 3011;
server_name localhost;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'http://127.0.0.1:3011';
add_header 'Access-Control-Allow-Methods' 'PUT,DELETE';
add_header 'Access-Control-Allow-Headers' 'Test-CORS, Content-Type';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'http://127.0.0.1:3011';
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://127.0.0.1:30011;
proxy_set_header Host $host;
}
}