项目对接中的跨域,同源,nginx转发等问题
1.造成跨域的原因
浏览器的同源策略是浏览器上为安全性考虑实施的非常重要的安全策略。
从一个域上加载的脚本不允许访问另外一个域的文档属性。
同源策略(Same origin policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。
它的核心就在于它认为自任何站点装载的信赖内容是不安全的。当被浏览器半信半疑的脚本运行在沙箱时,它们应该只被允许访问来自同一站点的资源,而不是那些来自其它站点可能怀有恶意的资源。
所谓同源是指:域名、协议、端口都相同。当前端浏览器所在的源与访问的后端服务器源不同时,就会出现跨域问题。
2.跨域问题的解决
只需要在后端代码中允许跨域访问即可。代码如下,在WebMvcConfig配置类中加上如下方法即可,在以下几个域名下,允许跨域访问所有路径资源,任意请求头,所有请求方法。
@Configuration
@SuppressWarnings("ALL")
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedHeaders("*")
.allowedMethods("*")
.maxAge(1800)
//如果需要把Cookie发到服务端,需要指定Access-Control-Allow-Credentials字段为true;
.allowCredentials(true) .allowedOrigins("https://www.baidu.com","https://10.31.109.15","https://localhost:4200");
}
}
如果需要包含 cookie 信息,服务器需要设置响应头部 Access-Control-Allow-Credentials: true
将跨域cookie响应给前端,但此时响应的跨域cookie不会写入到浏览器,如果跨域cookie要写入到浏览器,ajax 请求需要设置 xhr 的属性 withCredentials 为 true。
3.新版本浏览器跨域cookie的问题
Chrome升级到80版本后,默认限制了跨域携带cookie给后端,我在项目对接时,遇到无法传递cookie的问题,需要设置SameSite属性为None(同时需要设置Secure属性才能生效)来确保线上服务正常,我的项目后端使用的权限框架是shiro,由于项目本来使用的shiro-spring版本为1.4.1,无法设置cookie中的sameSite属性,因此我将shiro的版本升级到了1.5.2,成功设置了sameSite属性为none,最终问题解决。需要注意的是sameSite属性设置为none后,cookie只能通过https协议传输(不能通过http)。
4.项目对接中遇到的问题
在与别的项目对接时,为了允许别的项目可以跨域访问我的项目,我将上面第二步设置完后,发现了一个问题:
我自己访问不了自己的项目了,当我把WebMvcConfig中的跨域配置时,又可以访问了。
当时的我特别奇怪,我项目前端是打包成文件后直接放入到后端文件夹中一起部署的,按道理肯定是同源的,怎么会出现跨域问题呢。
于是我不停的debug,发现org.springframework.web.util.WebUtils中判断是否为同源的方法isSameOrigin
;如下,是将请求头中的origin与request中获取的scheme,host,port一一比较,完全相同则为同源。因为项目中访问时是通过了nginx的反向代理,访问时是通过https协议访问的,而项目本身是http协议的,所以判断协议是否相同时就没能通过,直接就被判断为不同源的了。
解决:直接将nginx中暴露出来的访问地址加入允许跨域的源数组中就行。
public static boolean isSameOrigin(HttpRequest request) {
HttpHeaders headers = request.getHeaders();
String origin = headers.getOrigin();
if (origin == null) {
return true;
}
String scheme;
String host;
int port;
if (request instanceof ServletServerHttpRequest) {
// Build more efficiently if we can: we only need scheme, host, port for origin comparison
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
scheme = servletRequest.getScheme();
host = servletRequest.getServerName();
port = servletRequest.getServerPort();
}
else {
URI uri = request.getURI();
scheme = uri.getScheme();
host = uri.getHost();
port = uri.getPort();
}
UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build();
return (ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) &&
ObjectUtils.nullSafeEquals(host, originUrl.getHost()) &&
getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort()));
}
5.问题思考
1.如何让服务器认为nginx中暴露出去的访问地址是与服务器同源的呢(在自己项目中访问,本身应该算是同源)?
首先在4中我们可以看到被判断为不同源的原因是request中获取的协议、主机、端口与浏览器传入的请求头中的origin不一致,浏览器传入的origin即为当前项目的访问地址,request则是由servlet容器创建的,springboot中内嵌的tomcat 创建的scheme默认为http,但是我们可以通过配置来修改。在application.yml中添加如下配置 use-forward-headers: true,表示使用请求中传入的X-Forwarded-* 请求头。
server:
port: 8001
tomcat:
uri-encoding: UTF-8
max-threads: 1000
max-connections: 20000
# Name of the HTTP header used to override the original port value.
# port-header: X-Forwarded-Port
# Header that holds the incoming protocol, usually named "X-Forwarded-Proto".
# protocol-header:
# Value of the protocol header that indicates that the incoming request uses SSL.
# protocol-header-https-value: https
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain,text/javascript,text/css,application/javascript,image/png
min-response-size: 1024
#Whether X-Forwarded-* headers should be applied to the HttpRequest.
use-forward-headers: true
再在nginx的ssl转发配置中添加
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
两个配置,将访问的项目的协议、端口传给servlet容器。此时servlet容器创建request时便能使用到nginx中传入的协议,端口了。如此便能访问自己项目时便会被认定为同源了。
server {
listen 443 ssl;
server_name 127.0.0.1;ssl_certificate ssl/nginx.crt;
ssl_certificate_key ssl/nginx.key;
ssl_protocols TLSv1.2;
#access_log logs/iwork.log;
error_log logs/iwork_error.log;
location / {
if ( $request_uri ~ \/$) {
add_header Cache-Control no-store;
}
proxy_redirect off;
proxy_buffering on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
proxy_pass http://10.31.109.15:8001;
}
2.为什么最初在没有添加跨域配置时,前后端联调没有出现跨域问题(联调时,前后端分别运行)?
我们先看org.springframework.web.cors.DefaultCorsProcessor类中processRequest方法对请求的处理。如下
private static final Log logger = LogFactory.getLog(DefaultCorsProcessor.class);
@Override
@SuppressWarnings("resource")
public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
HttpServletResponse response) throws IOException {
//判断请求头中是否有origin,没有则直接通过
if (!CorsUtils.isCorsRequest(request)) {
return true;
}
ServletServerHttpResponse serverResponse = new ServletServerHttpResponse(response);
// 判断response中是否已经允许跨域
if (responseHasCors(serverResponse)) {
logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
return true;
}
ServletServerHttpRequest serverRequest = new ServletServerHttpRequest(request);
// 判断是否为同源请求
if (WebUtils.isSameOrigin(serverRequest)) {
logger.trace("Skip: request is from same origin");
return true;
}
// 判断是否为预检请求
boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
// 如果没有跨域配置,config即为null
if (config == null) {
if (preFlightRequest) {
rejectRequest(serverResponse);
return false;
}
else {
return true;
}
}
return handleInternal(serverRequest, serverResponse, config, preFlightRequest);
}
public abstract class CorsUtils {
/**
* Returns {@code true} if the request is a valid CORS one.
*/
public static boolean isCorsRequest(HttpServletRequest request) {
return (request.getHeader(HttpHeaders.ORIGIN) != null);
}
/**
* Returns {@code true} if the request is a valid CORS pre-flight one.
*/
public static boolean isPreFlightRequest(HttpServletRequest request) {
return (isCorsRequest(request) && HttpMethod.OPTIONS.matches(request.getMethod()) &&
request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD) != null);
}
}
联调时,浏览器经过前端服务器后,再访问后端,请求头中的origin为前端服务器的地址,与后端web容器的request中的scheme,host,port显然是不一致的,别判定为不同源。此时便会由CorsUtils中的isPreFlightRequest来判断是否为预检请求(后面会解释什么是预检请求)。由于浏览器访问前端时显然是同源的,此时浏览器在请求前是不会发送预检请求的,因此preFlightRequest为false,而没有跨域配置是config为null,便会返回true,请求通过,于是便没有出现跨域问题。
什么是预检请求
对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET
以外的 HTTP 请求,或者搭配某些 MIME 类型的POST请求),浏览器必须首先使用 OPTION方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括Cookies和 HTTP 认证相关数据)。
CORS请求失败会产生错误,但是为了安全,在JavaScript代码层面是无法获知到底具体是哪里出了问题。你只能查看浏览器的控制台以得知具体是哪里出现了错误。详情请到预检请求。