概述
随着前后端分离这种开发模式的普及,前台和后台分开部署,可能部署在一台主机上不同的端口下,也有可能部署在多个主机上,前后台通过ajax或者axios等方式调用restful接口进行交互。由于浏览器的“同源策略”,协议、域名、端口号但凡有一个不同,势必会产生跨域问题。
如果发生跨域的话,浏览器中每次请求的session都是一个新的,即sessionId肯定不相同。
我们知道 ,服务器可以为每个用户浏览器创建一个session对象。默认情况下一个浏览器中独占一个session.
http请求是无状态的,那服务器是如何知道多次浏览器的请求是同一个会话呢?
事实上服务器创建session出来后,会将session的id,以cookie的形式回写给客户机,这样,只要浏览器不关,再去访问服务器时,都会带着session的id号去,服务器发现客户端浏览器携带session id过来了,就会使用内存中与之对应的session为之服务。 下文配合代码和浏览器一起来看下。
浏览器同源策略
参考阮一峰老师的文章:浏览器同源政策及其规避方法
后台搭建
为了简单,我们使用Spring Boot 快速搭建个后台服务,提供restful接口。 我这里加上了interceptor,其实验证这个问题,没必要加。 加上一方面是熟悉下拦截器的使用,二来也可以看下request中请求的URI
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.3.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <groupId>com.artisan</groupId> <artifactId>CrossDomain</artifactId> <version>0.0.1-SNAPSHOT</version> <name>CrossDomainByNginxBackground</name> <description>Artisan </description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> <scope>true</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
interceptor 配置
不多说了,MyInterceptor.java 参考 Spring Boot2.x-12 Spring Boot2.1.2中Filter和Interceptor 的使用
按照工程中restful的设计,注意下 WebConfig中的拦截路径即可。
Controller
package com.artisan.controller; import javax.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/artisan") public class ArtisanController { @GetMapping("/getValueFromSession") public String getSession(HttpServletRequest request) { // 获取当前request的session,将属性设置到session里 request.getSession().setAttribute("artisan", "artisanTest"); return "sessionId:" + request.getSession().getId() + ", artisans的属性值:" + request.getSession().getAttribute("artisan"); } @GetMapping("/checkCrossDomain") public String checkCrossDomain(HttpServletRequest request) { return "sessionId:" + request.getSession().getId() + ", artisans的属性值:" + request.getSession().getAttribute("artisan"); } }
启动测试
没在application.yml中指定server.port ,使用了默认的8080端口,启动项目,确保可以访问
http://localhost:8080/artisan/getValueFromSession
不要关闭浏览器,继续访问
http://localhost:8080/artisan/checkCrossDomain
注意下这两个sessionId是一样的,说明是同一个session
浏览器和session
刚才概述中
再细化点
用户向服务器发送请求,比如登录操作发送用户名和密码
服务器验证通过后,通过HttpServletRequest#getSession()#setAttribute等方法保存相关数据
服务器向用户返回一个 session_id,浏览器set-cookie Cookie 即Cookie = session_id
用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
当然了单节点的情况下还好,如果是集群环境,或者是跨域的服务请求,那么久需要实现session 数据共享,使集群中的每台服务器都能够读取 session。
总的来说【集群环境下】我目前所了解的有三种思路
session复制,比如Tomcat支持的Session复制. 优点:tomcat内置支持 缺点:如果集群过大,session 复制为all to all占用带宽,效率不高
session 数据持久化,写入redis或者数据库等。优点架构清晰,缺点是工程量大。而且也需要考虑session数据的持久层的高可用,否则单点登录就会失败。
服务端不保存 session ,所有数据都保存在客户端,比如 JWT (JSON WEB TOKEN)
我们清空浏览器的缓存(包括cookie)
结合上面建好的工程来演示下上面的描述。
重新访问 http://localhost:8080/artisan/getValueFromSession
上面的截图就是: 服务器创建session出来后,会将session的id,以cookie的形式回写给客户机
不要关闭浏览器,新开个窗口访问
http://localhost:8080/artisan/checkCrossDomain
上面的截图就是: 只要浏览器不关,再去访问服务器时,都会带着session的id号去,服务器发现客户端浏览器携带session id过来了,就会使用内存中与之对应的session为之服务
后端工程发布到服务器上
把刚才的spring boot 服务端,达成了可执行的jar 【sts 工程右键-- Run As --Maven build , 输入clean package (清除、打包)】 ,放到192.168.31.34服务器上 , 为了创造一个不同的ip地址。 顺便我把端口号也通过启动脚本设置成了9000
启动脚本如下:
#!/bin/bash nohup java -jar CrossDomain-0.0.1-SNAPSHOT.jar --server.port=9000 > log.txt & tail -f log.txt
问题复现
为了模拟【协议、域名、端口号但凡有一个不同,势必会产生跨域问题 】,那就让ip地址+端口号不同吧。
正好前几天折腾axis , 搭建axis环境的时候,正好需要用tomcat去验证下是否搭建成功(把axis拷贝到tomcat的webapps下),那顺便借用下这里的index.html ,修改后的index.html如下
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <title>Cross Domain Test</title> </head> <body> <h2>Artisan</h2> <button type="submit" id="btn">跨域请求</button> <p id="crossDomainRequest1"></p> <p id="crossDomainRequest2"></p> </body> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script> $("#btn").click(function(event){ $.ajax({ url: 'http://192.168.31.34:9000/artisan/getValueFromSession', type: "GET", success: function (data) { $("#crossDomainRequest1").html("跨域访问成功->getValueFromSession方法返回:" + data); $.ajax({ url: 'http://192.168.31.34:9000/artisan/checkCrossDomain', type: "GET", success: function (data) { $("#crossDomainRequest2").html("跨域访问成功->checkCrossDomain方法返回:" + data); } }); }, error: function (data) { $("#crossDomainRequest1").html("发生跨域错误!!"); } }); }); </script> </html>
启动tomcat ,访问 http://localhost:8080/axis/index.html ,点击按钮,观察开发者工具中的Network和Console
点击 getValueFromSession 查看,
服务端其实是返回了,也从侧面说明了跨域问题是浏览器的“同源策略”导致,和服务端不相干。
再继续看下报错
Access to XMLHttpRequest at ‘http://192.168.31.34:9000/artisan/getValueFromSession’ from origin ‘http://localhost:8080’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
如上 发生了跨域问题。
通过Nginx反向代理解决跨域问题
原理: Nginx的反向代理“欺诈”浏览器,使得浏览器和服务器是同源访问。
安装Nginx
因为要测试跨域 ,为了方便,服务端放到了服务器上,使用Nginx部署的前台我们就放到本地吧,所以使用了windows版本的Nginx 。
Nginx 下载地址: http://nginx.org/en/download.html
修改配置文件
worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; #前端页面服务器信息 server { #启动的端口和域名 listen 8888; server_name localhost; #添加头部信息,proxy_set_header用来重定义发往后端服务器的请求头。 #语法 proxy_set_header Field Value proxy_set_header Cookie $http_cookie; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; #代理地址 及映射的服务端的地址 # 最重要的配置 location /frontend/ { proxy_pass http://192.168.31.34:9000/; #使用代理地址时末尾加上斜杠"/" # 如下 proxy_set_header 和 add_header 不加经过验证也是OK的。 # 使用add_header指令来设置response header if ($request_method = 'OPTIONS') { 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,Content-Range,Range,Token'; 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,Content-Range,Range,Token'; add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Token'; } 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,Content-Range,Range,Token'; add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Token'; } } #添加拦截路径和根目录 location / { root html/artisan; # 根目录 index index.html index.htm; #首页 } } }
最重要的是 proxy_pass配置
关于add_header ,比如 GET 增加了 add_header ,在浏览器中GET请求的方法可以在response header查看到相关信息
add_header ‘Access-Control-Expose-Headers’ 必须要加上你请求时所带的header,比如我们经常用的Token
参考: https://enable-cors.org/server_nginx.html
下面的浏览器返回截图,是没有增加add_header的,故特意贴一张截图如上,增加上也是OK的,更细粒度的控制,请知悉。
修改前台页面访问地址
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <title>Nginx Cross Domain Test</title> </head> <body> <h2>Artisan</h2> <button type="submit" id="btn">跨域请求</button> <p id="crossDomainRequest1"></p> <p id="crossDomainRequest2"></p> </body> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script> $("#btn").click(function(event){ $.ajax({ url: 'http://localhost:8888/frontend/artisan/getValueFromSession', type: "GET", success: function (data) { $("#crossDomainRequest1").html("跨域访问成功->getValueFromSession方法返回:" + data); $.ajax({ url: 'http://localhost:8888/frontend/artisan/checkCrossDomain', type: "GET", success: function (data) { $("#crossDomainRequest2").html("跨域访问成功->checkCrossDomain方法返回:" + data); } }); }, error: function (data) { $("#crossDomainRequest1").html("发生跨域错误!!"); } }); }); </script> </html>
原因分析
先看index.html的存放位置
与 nginx的配置文件中如下配置保持一致
同时配置的启动端口和域名,对应配置文件中的
所以通过访问 http://localhost:8888/index.html 就找到了 html/artisan目录下的index.html文件
再看下 index.html中修改的请求地址,由原先的直接请求后台,改为请求Nginx,让Nginx去转发请求
localhost:8888上面说了,下面来看下这个frontend是个啥东西呢? 是自定义的,叫啥都行,只要能对应上就行。
意思是让Nginx代理该请求
html中的两个地址经过Nginx后,发生如下变化
请求URL:http://localhost:8888/frontend/artisan/getValueFromSession
代理后的URL:http://192.168.31.34:9000/artisan/getValueFromSession
请求URL:http://localhost:8888/frontend/artisan/checkCrossDomain
代理后的URL:http://192.168.31.34:9000/artisan/checkCrossDomain
代理后的地址也是192.168.31.34:9000端口了,和服务端 192.168.31.34:9000一致,也就不存在跨域问题了。
跨域操作实际上是由Nginx的proxy_pass进行完成.
这个可以从控制台中得到确认
启动Nginx 测试
双击nginx.exe 启动Nginx , 访问 http://localhost:8888/index.html
访问正常,且是通过一个session , 跨域问题使用Nginx得到解决。
小结
通过Nginx去解决跨域问题本质上是间接跨域,因为使用反向代理欺骗浏览器,所以浏览器任务客户端和服务端在相同的域名中,可以认为是同源访问,所以session不会丢失。上面的实验结论也证明了这一点
如果使用CORS实现了直接跨域,主要是在服务端通过给response设置header属性,帮助服务器资源进行跨域授权。 因为发生跨域访问,服务器会每次都创建新的Session,会导致session丢失,安全性和灵活性更高,但需要开发人员去解决跨域session丢失的问题。