翻譯javaworld.com上的一篇文章,希望對您有用:)

wolfmanchen發表於2004-10-12
注:本文是翻譯的javaworld.com上的一篇名為《Solving the logout problem properly and elegantly》的文章,原文請參看Solving the logout problem properly and elegantly文中所有示例程式的程式碼可以從javaworld.com中下載。由於本人水平有限,難免出現錯誤,敬請各位拍磚,當然,希望我花一個下午翻出來的文章能給您帶來幫助。

正確優雅的解決使用者退出問題――JSP和Struts解決方案

摘要
在一個有密碼保護的Web應用中,正確處理使用者退出過程並不僅僅只需呼叫HttpSession的invalidate()方法。現在大部分瀏覽器上都有後退和前進按鈕,允許使用者後退或前進到一個頁面。如果在使用者在退出一個Web應用後按了後退按鈕瀏覽器把快取中的頁面呈現給使用者,這會使使用者產生疑惑,他們會開始擔心他們的個人資料是否安全。許多Web應用強迫使用者退出時關閉整個瀏覽器,這樣,使用者就無法點選後退按鈕了。還有一些使用JavaScript,但在某些客戶端瀏覽器這卻不一定起作用。這些解決方案都很笨拙且不能保證在任一情況下100%有效,同時,它也要求使用者有一定的操作經驗。
這篇文章以示例闡述了正確解決使用者退出問題的方案。作者Kevin Le首先描述了一個密碼保護Web應用,然後以示例程式解釋問題如何產生並討論解決問題的方案。文章雖然是針對JSP頁面進行闡述,但作者所闡述的概念很容易理解切能夠為其他Web技術所採用。最後作者展示瞭如何用Jakarta Struts優雅地解決這一問題。




大部分Web應用不會包含象銀行賬戶或信用卡資料那樣機密的資訊,但一旦涉及到敏感資料,我們就需要提供一類密碼保護機制。舉例來說,一個工廠中工人透過Web訪問他們的時間安排、進入他們的訓練課程以及檢視他們的薪金等等。此時應用SSL(Secure Socket Layer)有點殺雞用牛刀的感覺,但不可否認,我們又必須為這些應用提供密碼保護,否則,工人(也就是Web應用的使用者)可以窺探到工廠中其他僱員的私人機密資訊。
與上述情形相似的還有位處圖書館、醫院等公共場所的計算機。在這些地方,許多使用者共同使用幾臺計算機,此時保護使用者的個人資料就顯得至關重要。設計良好編寫優秀的應用對使用者專業知識的要求少之又少。
我們來看一下現實世界中一個完美的Web應用是如何表現的:一個使用者透過瀏覽器訪問一個頁面。Web應用展現一個登陸頁面要求使用者輸入有效的驗證資訊。使用者輸入了使用者名稱和密碼。此時我們假設使用者提供的身份驗證資訊是正確的,經過了驗證過程,Web應用允許使用者瀏覽他有權訪問的區域。使用者想退出時,點選退出按鈕,Web應用要求使用者確認他是否則真的需要退出,如果使用者確定退出,Session結束,Web應用重新定位到登陸頁面。使用者可以放心的離開而不用擔心他的資訊會洩露。另一個使用者坐到了同一臺電腦前,他點選後退按鈕,Web應用不應該出現上一個使用者訪問過的任何一個頁面。事實上,Web應用在第二個使用者提供正確的驗證資訊之前應當一直停留在登陸頁面上。
透過示例程式,文章向您闡述瞭如何在一個Web應用中實現這一功能。

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 default
RequestDispatcher rd = request.getRequestDispatcher("home.jsp");

//Prepare connection and statement
rs = 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);
//...
<p class="indent">


本文所附示例均以關係型資料庫作為安全域,但本問所闡述的觀點對任何型別的安全域都是適用的。

Logout action
退出動作就包含了簡單的刪除使用者名稱以及對使用者的HttpSession物件呼叫invalidate()方法。清單2是從loginoutAction.jsp頁面中節選的一段程式碼以此闡述退出動作:


Listing 2 
//...
session.removeAttribute("User");
session.invalidate();
//...
<p class="indent">


阻止未經認證訪問受保護的JSP頁面
從form中獲取使用者提交的認證資訊並經過驗證後,登陸動作簡單地往 HttpSession物件中寫入一個使用者名稱,退出動作則做相反的工作,它從使用者的HttpSession物件中刪除使用者名稱並呼叫invalidate()方法銷燬HttpSession。為了使登陸和退出動作真正發揮作用,所有受保護的JSP頁面都應該首先驗證HttpSession中是否包含了使用者名稱以確認當前使用者是否已經登陸。如果HttpSession中包含了使用者名稱,也就是說使用者已經登陸,Web應用則將剩餘的JSP頁傳送給瀏覽器,否則,JSP頁將跳轉到登陸頁login.jsp。頁面home.jsp, secure1.jsp, secure2.jsp和logout.jsp均包含清單3中的程式碼段:

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
//...
<p class="indent">


在這個程式碼段中,程式從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”為應用程式伺服器提供了一個控制瀏覽器和代理伺服器上快取的機制。HTTP頭資訊Expires告訴代理伺服器它的快取頁面何時將過期。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 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
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);
}
//...
<p class="indent">


透過設定頭資訊和檢查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);
//...
<p class="indent">


為了實現上述方法,你必須記錄每個使用者的最後登陸時間。對於採用關係型資料庫安全域來說,這點可以可以透過在某個表中加上lastLogin欄位輕鬆實現。LDAP以及其他的安全域需要稍微動下腦筋,但很顯然是可以實現的。
表示最後登陸時間的方法有很多。示例logoutSampleJSP3利用了自1970年1月1日以來的毫秒數。這個方法在許多人在不同瀏覽器中用一個使用者賬號登陸時也是可行的。
執行logoutSampleJSP3
執行示例logoutSampleJSP3將展示如何正確處理退出問題。一旦使用者退出,點選瀏覽器上的後退按鈕在任何情況下都不會是受保護的頁面在瀏覽器上顯示出來。這個示例展示瞭如何正確處理退出問題而不需要額外的培訓。
為了使程式碼更簡練有效,一些冗餘的程式碼可以剔除掉。一種途徑就是把清單4中的程式碼寫到一個單獨的JSP頁中,透過標籤<jsp:include>其他頁面也可以引用。
Struts框架下的退出實現
與直接使用JSP或JSP/servlets相比,另一個可選的方案是使用Struts。為一個基於Struts的Web應用新增一個處理退出問題的框架可以優雅地不費氣力的實現。這部分歸功於Struts是採用MVC設計模式的因此將模型和檢視清晰的分開。另外,Java是一個物件導向的語言,其支援繼承,可以比JSP中的指令碼更為容易地實現程式碼重用。在Struts中,清單4中的程式碼可以從JSP頁面中移植到Action類的execute()方法中。
此外,我們還可以定義一個繼承Struts Action類的基本類,其execute()方法中包含了清單4中的程式碼。透過使用類繼承機制,其他類可以繼承基本類中的通用邏輯來設定HTTP頭資訊以及檢索HttpSession物件中的username字串。這個基本類是一個抽象類並定義了一個抽象方法executeAction()。所有繼承自基類的子類都應實現exectuteAction()方法而不是覆蓋它。清單6是基類的部分程式碼:


清單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;
   }
}
<p class="indent">


清單6中的程式碼與清單4中的很相像,僅僅只是用ActionMapping findForward替代了RequestDispatcher forward。清單6中,如果在HttpSession中未找到username字串,ActionMapping物件將找到名為sessionEnded的forward元素並跳轉到對應的path。如果找到了,子類將執行其實現了executeAction()方法的業務邏輯。因此,在配置檔案struts-web.xml中為所有子類宣告個一名為sessionEnded的forward元素是必須的。清單7以secure1 action闡明瞭這樣一個宣告:

清單7
<action path="/secure1" 
   type="com.kevinhle.logoutSampleStruts.Secure1Action"           
   scope="request">
   <forward name="success" path="/WEB-INF/jsps/secure1.jsp"/>
   <forward name="sessionEnded" path="/login.jsp"/>
</action>

<p class="indent">


繼承自BaseAction類的子類Secure1Action實現了executeAction()方法而不是覆蓋它。Secure1Action類不執行任何退出程式碼,如清單8:


清單8
public class Secure1Action extends BaseAction {
   public ActionForward executeAction(ActionMapping mapping, ActionForm form,
      HttpServletRequest request, HttpServletResponse response)
      throws IOException, ServletException {
      
      HttpSession session = request.getSession();            
      return (mapping.findForward("success"));
   }
}
<p class="indent">


只需要定義一個基類而不需要額外的程式碼工作,上述解決方案是優雅而有效的。不管怎樣,將通用的行為方法寫成一個繼承StrutsAction的基類是許多Struts專案的共同經驗,值得推薦。
侷限性
上述解決方案對JSP或基於Struts的Web應用都是非常簡單而實用的,但它還是有某些侷限。在我看來,這些侷限並不是至關緊要的。
結論
本文闡述瞭解決退出問題的方案,儘管方案簡單的令人驚訝,但卻在所有情況下都能有效地工作。無論是對JSP還是Struts,所要做的不過是寫一段不超過50行的程式碼以及一個記錄使用者最後登陸時間的方法。在Web應用中混合使用這些方案能夠使擁護的私人資料不致洩露,同時,也能增加使用者的經驗。

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.

相關文章