最近在主导公司网站进行全站Https改造工做,本文记录在改造过程当中遇到的一个因为后端302跳转致使前端浏览器阻止访问的问题,感受这样的问题有必定通用性,因此编辑成文,但愿能给遇到相似问题的人们有所帮助。前端
通过一段时间的调研工做,终于将公司的环境改形成支持https访问模式,信心满满的打开公司测试环境主页,https://test.xxx.com。一切正常,就在我觉得改造工做就要完成的时候,问题就出现了。java
进入主页正常,输入用户名和密码登陆,页面就不动了。调出Firefox的控制台查看,发现这么一行报错。web
(图一) 后端
打开网络面板查看获得以下内容浏览器
(图二)服务器
前端发起了一个https的Ajax请求,后端返回状态码为302,location为http://开头网址,这样就形成了混合访问。本应该有Ajax自动处理的302跳转就这样被浏览器禁止了。网络
当用户访问使用HTTPS的页面时,他们与web服务器之间的链接是使用SSL加密的,从而保护链接不受嗅探器和中间人攻击。架构
若是HTTPS页面包括由普通明文HTTP链接加密的内容,那么链接只是被部分加密:非加密的内容能够被嗅探者入侵,而且能够被中间人攻击者修改,所以链接再也不受到保护。当一个网页出现这种状况时,它被称为混合内容页面。app
详情可见https://developer.mozilla.org...ide
咱们后端采用Java开发,部署与Tomcat,对于Servlet
来讲通常采用HttpServletResponse.sendRedirect(String url)
方法实现页面跳转(302跳转)。那么问题是否是出在这个方法呢?答案是否认的。sendRedirect(String url)
方法中url
参数能够传入绝对地址和相对地址。咱们使用的时候通常传入相对地址,这样由方法内部自动转换为绝对地址也就是返回给浏览器中Location
参数中的地址,sendRedirect()
方法内部会根据当前访问的scheme
来决定拼接后绝对地址的scheme
,也就是说若是访问地址是https
开头那么跳转连接的绝对地址也会是https
的,http
同理。在本次实例中咱们传入的就是相对地址,跳转连接的绝对路径地址开头是由请求地址决定的,也就是后端程序收到的HttpServletRequest
请求协议必定是http
开头的。
咱们看到(图二)中地址请求地址是由https开头的,为何到了后端程序后就成为了http请求呢?咱们接着往下说。
(图三)
为了方便说明我画了一张https配置的架构图,咱们使用Nginx做为反向代理服务器,上游服务器使用Tomcat,咱们在Nginx层进行Https配置,由Nginx负责处理Https请求。可是Nginx自身处理方式规定向上游服务器发送请求的时候是以http的方式请求的。这也就说明了为何咱们后端代码收到的请求是http协议,真想终于大白了。
问题终于明了了,接下来就是解决的时候。
既然通过Nginx代理后Tomcat服务器运行的代码都变成了http请求,而后sendRedirect
方法传入相对地址就会随着请求地址也变成http。那么咱们再也不使用相对地址而使用绝对地址。这样跳转地址就所有由咱们作主,想跳转到哪里就跳转的哪里,妈妈不再用担忧咱们跳转了。
先期改造:
/** * 从新实现sendRedirect。 * @param request * @param response * @param url * @throws IOException */ public static void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException{ if(url.startsWith("http://")||url.startsWith("https://")){ //绝对路径,直接跳转。 response.sendRedirect(url); return; } // 收集请求信息,为拼接绝对地址作准备。 String serverName = request.getServerName(); int port = request.getServerPort(); String contextPath = request.getContextPath(); String servletPath = request.getServletPath(); String queryString = request.getQueryString(); // 拼接绝对地址 StringBuilder absoluteUrl = new StringBuilder(); // 强制使用https absoluteUrl.append("https").append("://").append(serverName); //80和443位http和https默认接口,无需拼接。 if (port != 80 && port != 443) { absoluteUrl.append(":").append(port); } if (contextPath != null) { absoluteUrl.append(contextPath); } if (servletPath != null) { absoluteUrl.append(servletPath); } // 将相对地址加入。 absoluteUrl.append(url); if (queryString != null) { absoluteUrl.append(queryString); } // 跳转到绝对地址。 response.sendRedirect(absoluteUrl.toString()); }
咱们本身了一个sendRedirect()方法,可是还有一点小小的瑕疵,咱们将全部相对地址都转化成http开头的绝对地址,对于那些咱们即支持https由支持http的网站来讲,这样就不适合了,因此咱们须要和前端请求作一个预约,让前端再发相似于Ajax访问的时候,自定义一个request的header,告诉咱们是https访问仍是http访问,咱们在后端代码中判断这个自定义header,决定代码行为。
/** * 从新实现sendRedirect。 * @param request * @param response * @param url * @throws IOException */ public static void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException{ if(url.startsWith("http://")||url.startsWith("https://")){ //绝对路径,直接跳转。 response.sendRedirect(url); return; } //假设前端请求头为http_https_scheme,能够传入的值有http或https,不传默认为https。 if(("http").equals(request.getHeader("http_https_scheme"))){ //http请求,默认行为。 response.sendRedirect(url); return; } // 收集请求信息,为拼接绝对地址作准备。 String serverName = request.getServerName(); int port = request.getServerPort(); String contextPath = request.getContextPath(); String servletPath = request.getServletPath(); String queryString = request.getQueryString(); // 拼接绝对地址 StringBuilder absoluteUrl = new StringBuilder(); // 强制使用https absoluteUrl.append("https").append("://").append(serverName); //80和443位http和https默认接口,无需拼接。 if (port != 80 && port != 443) { absoluteUrl.append(":").append(port); } if (contextPath != null) { absoluteUrl.append(contextPath); } if (servletPath != null) { absoluteUrl.append(servletPath); } // 将相对地址加入。 absoluteUrl.append(url); if (queryString != null) { absoluteUrl.append(queryString); } // 跳转到绝对地址。 response.sendRedirect(absoluteUrl.toString()); }
以上为改造以后的代码,增长了请求头判断逻辑。这样咱们的方法就支持http和https混合模式了。
更进一步:
让咱们对上面的代码更进一步,其实咱们就是对sendRedirect的逻辑从新编排,只不过咱们使用的静态方法的模式,可不能够直接重写response中的sendRedirect()方法?
/** * 重写sendRedirect方法。 * */ public class HttpsServletResponseWrapper extends HttpServletResponseWrapper { private final HttpServletRequest request; public HttpsServletResponseWrapper(HttpServletRequest request,HttpServletResponse response) { super(response); this.request=request; } @Override public void sendRedirect(String location) throws IOException { if(location.startsWith("http://")||location.startsWith("https://")){ //绝对路径,直接跳转。 super.sendRedirect(location); return; } //假设前端请求头为http_https_scheme,能够传入的值有http或https,不传默认为https。 if(("http").equals(request.getHeader("http_https_scheme"))){ //http请求,默认行为。 super.sendRedirect(location); return; } // 收集请求信息,为拼接绝对地址作准备。 String serverName = request.getServerName(); int port = request.getServerPort(); String contextPath = request.getContextPath(); String servletPath = request.getServletPath(); String queryString = request.getQueryString(); // 拼接绝对地址 StringBuilder absoluteUrl = new StringBuilder(); // 强制使用https absoluteUrl.append("https").append("://").append(serverName); //80和443位http和https默认接口,无需拼接。 if (port != 80 && port != 443) { absoluteUrl.append(":").append(port); } if (contextPath != null) { absoluteUrl.append(contextPath); } if (servletPath != null) { absoluteUrl.append(servletPath); } // 将相对地址加入。 absoluteUrl.append(location); if (queryString != null) { absoluteUrl.append(queryString); } // 跳转到绝对地址。 super.sendRedirect(absoluteUrl.toString()); } }
具体逻辑同样,咱们只是继承了HttpServletResponseWrapper
这个包装类,在这里使用了一个观察者模式从新编写了sendRedirect()
方法逻辑。
咱们能够这样使用咱们自定义等HttpsServletResponseWrapper
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String location="/login"; new HttpsServletResponseWrapper(request, response).sendRedirect(location); }
再进一步:
既然咱们有了新的HttpServletResponseWrapper
,咱们在须要的地方手动包装HttpServletResponse
就显得有点多余了。咱们能够利用servlet
的filter
机制来自动包装。
public class HttpsServletResponseWrapperFilter implements Filter{ @Override public void destroy() { // TODO Auto-generated method stub } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(request, new HttpsServletResponseWrapper((HttpServletRequest)request, (HttpServletResponse)response)); } @Override public void init(FilterConfig arg0) throws ServletException { // TODO Auto-generated method stub } }
在web.xml中设置filter映射,能够直接使用HttpServletResponse
对象,无需包装,由于在请求通过HttpsServletResponseWrapperFilter
的时候response
已经被包装为HttpsServletResponseWrapper
。
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String location="/login"; response.sendRedirect(location); }
至此,咱们已经代码逻辑无缝的嵌入到咱们的后端代码中,看上去更优雅了。
在1.0版本中咱们的关注点都是Nginx上游服务中运行的后端代码,咱们经过对代码的改造达到咱们的目的。如今咱们转换一下思路,将关注点放在Nginx上,既然是Nginx代理以后,咱们的scheme丢失,那么Nginx有没有给咱们提供一种机制保留代理以后的scheme呢,答案是确定的。
location / { proxy_set_header X-Forwarded-Proto $scheme; }
一行简单的配置,就解决了咱们的问题,Nginx在代理的时候保留了scheme,这样咱们在跳转的时候能够直接使用HttpServletResponse.sendRedirect()
方法。
经过解决方案1.0的修改代码方式和2.0的修改配置方式,咱们都解决了问题。在平常开发中解决问题的方式不少,只要你了解产生问题的原理,在产生问题的任意环节均可以寻求解决方案。这篇工做记录就写到这里,固然这个问题还有其余的解决方式,若是你有其余的解决方案能够留言告诉我。