最近在主導公司網站進行全站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的修改配置方式,咱們都解決了問題。在平常開發中解決問題的方式不少,只要你瞭解產生問題的原理,在產生問題的任意環節均可以尋求解決方案。這篇工做記錄就寫到這裏,固然這個問題還有其餘的解決方式,若是你有其餘的解決方案能夠留言告訴我。