JSP samples
為了更為有效地闡述實現方案,本文將從展示一個樣本應用logoutSampleJSP1中碰到的問題開始。這個樣本代表了許多沒有正確解決退出過程的 Web應用。logoutSampleJSP1包含了下述jsp頁面:login.jsp, home.jsp, secure1.jsp, secure2.jsp, logout.jsp, loginAction.jsp, and logoutAction.jsp。其中頁面home.jsp, secure1.jsp, secure2.jsp, 和logout.jsp是不允許未經認證的使用者訪問的,也就是說,這些頁麵包含了重要訊息,在使用者登陸之前或者退出之後都不應該出現在瀏覽器中。 login.jsp包含了用於使用者輸入使用者名稱和密碼的form。logout.jsp頁包含了要求使用者確認是否退出的form。 loginAction.jsp和logoutAction.jsp作為控制器分別包含了登陸和結束代碼。
第二個樣本應用logoutSampleJSP2展示了如何解決樣本logoutSampleJSP1中的問題。然而,第二個應用自身也是有疑問的。在特定的情況下,退出問題還是會出現。
第三個樣本應用logoutSampleJSP3在第二個樣本上進行了改進,比較完善地解決了退出問題。
最後一個樣本logoutSampleStruts展示了Struts如何優美地解決登陸問題。
注意:本文所附樣本在最新版本的Microsoft Internet Explorer (IE), Netscape Navigator, Mozilla, FireFox和Avant瀏覽器上測試通過。
Login action
Brian Pontarelli的經典文章《J2EE Security: Container Versus Custom》討論了不同的J2EE認證途徑。文章同時指出,HTTP協議和基於form的認證並未提供處理使用者退出的機制。因此,解決途徑便是引入自訂的安全實現機制。
自訂的安全認證機制普遍採用的方法是從form中獲得使用者輸入的認證資訊,然後到諸如LDAP (lightweight directory access protocol)或關聯式資料庫的安全域中進行認證。如果使用者提供的認證資訊是有效,登陸動作往HttpSession對象中注入某個對象。 HttpSession存在著注入的對象則表示使用者已經登陸。為了方便讀者理解,本文所附的樣本只往HttpSession中寫入一個使用者名稱以表明使用者已經登陸。清單1是從loginAction.jsp頁面中節選的一段代碼以此闡述登陸動作:
Listing 1 //...//initialize RequestDispatcher object; set forward to home page by defaultRequestDispatcher rd = request.getRequestDispatcher("home.jsp");//Prepare connection and statementrs = stmt.executeQuery("select password from USER where userName = '" + userName + "'");if (rs.next()) { //Query only returns 1 record in the result set; only 1 password per userName which is also the primary key if (rs.getString("password").equals(password)) { //If valid password session.setAttribute("User", userName); //Saves username string in the session object } else { //Password does not match, i.e., invalid user password request.setAttribute("Error", "Invalid password."); rd = request.getRequestDispatcher("login.jsp"); }} //No record in the result set, i.e., invalid username else { request.setAttribute("Error", "Invalid user name."); rd = request.getRequestDispatcher("login.jsp"); }}//As a controller, loginAction.jsp finally either forwards to "login.jsp" or "home.jsp"rd.forward(request, response);//...
Listing 3 //...String userName = (String) session.getAttribute("User");if (null == userName) { request.setAttribute("Error", "Session has ended. Please login."); RequestDispatcher rd = request.getRequestDispatcher("login.jsp"); rd.forward(request, response);}//...//Allow the rest of the dynamic content in this JSP to be served to the browser//...
在這個程式碼片段中,程式從HttpSession中減縮username字串。如果字串為空白,Web應用則自動中止執行當前頁面並跳轉到登陸頁,同時給出 Session has ended. Please log in.的提示;如果不為空白,Web應用則繼續執行,也就是把剩餘的頁面提供給使用者。
運行logoutSampleJSP1
運行logoutSampleJSP1將會出現如下幾種情形:
• 如果使用者沒有登陸,Web應用將會正確中止受保護頁面home.jsp, secure1.jsp, secure2.jsp和logout.jsp的執行,也就是說,假如使用者在瀏覽器地址欄中直接敲入受保護JSP頁的地址試圖訪問,Web應用將自動跳轉到登陸頁並提示Session has ended.Please log in.
• 同樣的,當一個使用者已經退出,Web應用也會正確中止受保護頁面home.jsp, secure1.jsp, secure2.jsp和logout.jsp的執行
• 使用者退出後,如果點擊瀏覽器上的後退按鈕,Web應用將不能正確保護受保護的頁面——在Session銷毀後(使用者退出)受保護的JSP頁重新在瀏覽器中顯示出來。然而,如果使用者點擊返回頁面上的任何連結,Web應用將會跳轉到登陸頁面並提示Session has ended.Please log in.
阻止瀏覽器緩衝
上述問題的根源在於大部分瀏覽器都有一個後退按鈕。當點擊後退按鈕時,預設情況下瀏覽器不是從Web伺服器上重新擷取頁面,而是從瀏覽器緩衝中載入頁面。基於Java的Web應用並未限制這一功能,在基於PHP、ASP和.NET的Web應用中也同樣存在這一問題。
在使用者點擊後退按鈕後,瀏覽器到伺服器再從伺服器到瀏覽器這樣通常意思上的HTTP迴路並沒有建立,僅僅只是使用者,瀏覽器和緩衝進行了互動。所以,即使包含了清單3上的代碼來保護JSP頁面,當點擊後退按鈕時,這些代碼是不會執行的。
緩衝的好壞,真是仁者見仁智者見智。緩衝的確提供了一些便利,但通常只在使用靜態HTML頁面或基於圖形或影響的頁面你才能感受到。而另一方面,Web應用通常是基於資料的,資料通常是頻繁更改的。與從緩衝中讀取並顯示到期的資料相比,提供最新的資料才是更重要的!
幸運的是,HTTP頭資訊“Expires”和“Cache-Control”為應用程式伺服器提供了一個控制瀏覽器和Proxy 伺服器上緩衝的機制。HTTP頭資訊Expires告訴Proxy 伺服器它的快取頁面面何時將到期。HTTP1.1規範中新定義的頭資訊Cache-Control可以通知瀏覽器不緩衝任何頁面。當點擊後退按鈕時,瀏覽器重新訪問伺服器已擷取頁面。如下是使用Cache-Control的基本方法:
• no-cache:強制緩衝從伺服器上擷取新的頁面
• no-store: 在任何環境下緩衝不儲存任何頁面
HTTP1.0規範中的Pragma:no-cache等同於HTTP1.1規範中的Cache-Control:no-cache,同樣可以包含在頭資訊中。
通過使用HTTP頭資訊的cache控制,第二個樣本應用logoutSampleJSP2解決了logoutSampleJSP1的問題。 logoutSampleJSP2與logoutSampleJSP1不同表現在如下程式碼片段中,這一程式碼片段加入進所有受保護的頁面中:
//...response.setHeader("Cache-Control","no-cache"); //Forces caches to obtain a new copy of the page from the origin serverresponse.setHeader("Cache-Control","no-store"); //Directs caches not to store the page under any circumstanceresponse.setDateHeader("Expires", 0); //Causes the proxy cache to see the page as "stale"response.setHeader("Pragma","no-cache"); //HTTP 1.0 backward compatibilityString userName = (String) session.getAttribute("User");if (null == userName) { request.setAttribute("Error", "Session has ended. Please login."); RequestDispatcher rd = request.getRequestDispatcher("login.jsp"); rd.forward(request, response);}//...
通過設定頭資訊和檢查HttpSession中的使用者名稱確保了瀏覽器不快取頁面面,同時,如果使用者未登陸,受保護的JSP頁面將不會發送到瀏覽器,取而代之的將是登陸頁面login.jsp。
運行logoutSampleJSP2
運行logoutSampleJSP2後將回看到如下結果:
• 當使用者退出後試圖點擊後退按鈕,瀏覽器並不會顯示受保護的頁面,它只會現實登陸頁login.jsp同時給出提示資訊Session has ended. Please log in.
• 然而,當按了後退按鈕返回的頁是處理使用者提交資料的頁面時,IE和Avant瀏覽器將彈出如下資訊提示:
警告:頁面已到期……(你肯定見過)
選擇重新整理後前一個JSP頁面將重新顯示在瀏覽器中。很顯然,這不是我們所想看到的因為它違背了logout動作的目的。發生這一現象時,很可能是一個惡意使用者在嘗試擷取其他使用者的資料。然而,這個問題僅僅出現在後退按鈕對應的是一個處理POST請求的頁面。
記錄最後登陸時間
上述問題之所以出現是因為瀏覽器將其緩衝中的資料重新提交了。這本文的例子中,資料包含了使用者名稱和密碼。無論是否給出安全警告資訊,瀏覽器此時起到了負面作用。
為瞭解決logoutSampleJSP2中出現的問題,logoutSampleJSP3的login.jsp在包含username和 password的基礎上還包含了一個稱作lastLogon的隱藏表單域,此表單域動態用一個long型值初始化。這個long型值是調用 System.currentTimeMillis()擷取到的自1970年1月1日以來的毫秒數。當login.jsp中的form提交時, loginAction.jsp首先將隱藏欄位中的值與使用者資料庫中的值進行比較。只有當lastLogon表單域中的值大於資料庫中的值時Web應用才認為這是個有效登陸。
為了驗證登陸,資料庫中lastLogon欄位必須以表單中的lastLogon值進行更新。上例中,當瀏覽器重複提交資料時,表單中的lastLogon值不比資料庫中的lastLogon值大,因此,loginAction轉到login.jsp頁面,並提示 Session has ended.Please log in.清單5是loginAction中節選的程式碼片段:
清單5//...RequestDispatcher rd = request.getRequestDispatcher("home.jsp"); //Forward to homepage by default//...if (rs.getString("password").equals(password)) { //If valid password long lastLogonDB = rs.getLong("lastLogon"); if (lastLogonForm > lastLogonDB) { session.setAttribute("User", userName); //Saves username string in the session object stmt.executeUpdate("update USER set lastLogon= " + lastLogonForm + " where userName = '" + userName + "'"); } else { request.setAttribute("Error", "Session has ended. Please login."); rd = request.getRequestDispatcher("login.jsp"); }}else { //Password does not match, i.e., invalid user password request.setAttribute("Error", "Invalid password."); rd = request.getRequestDispatcher("login.jsp"); }//...rd.forward(request, response);//...
清單6 public abstract class BaseAction extends Action { public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { response.setHeader("Cache-Control","no-cache"); //Forces caches to obtain a new copy of the page from the origin server response.setHeader("Cache-Control","no-store"); //Directs caches not to store the page under any circumstance response.setDateHeader("Expires", 0); //Causes the proxy cache to see the page as "stale" response.setHeader("Pragma","no-cache"); //HTTP 1.0 backward compatibility if (!this.userIsLoggedIn(request)) { ActionErrors errors = new ActionErrors(); errors.add("error", new ActionError("logon.sessionEnded")); this.saveErrors(request, errors); return mapping.findForward("sessionEnded"); } return executeAction(mapping, form, request, response); } protected abstract ActionForward executeAction(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException; private boolean userIsLoggedIn(HttpServletRequest request) { if (request.getSession().getAttribute("User") == null) { return false; } return true; }}
About the author
Kevin H. Le has more than 12 years of experience in software development. In the first half of his career, his programming language of choice was C++. In 1997, he shifted his focus to Java. He has engaged in and successfully completed several J2EE and EAI projects as both developer and architect. In addition to J2EE, his current interests now include Web services and SOA. More information on Kevin can be found on his Website http://kevinhle.com.