摘要
在一個(gè)有密碼保護的Web應用中,正確處理用戶(hù)退出過(guò)程并不僅僅只需調用HttpSession的invalidate()方法?,F在大部分瀏覽器上都有后退和前進(jìn)按鈕,允許用戶(hù)后退或前進(jìn)到一個(gè)頁(yè)面。如果在用戶(hù)在退出一個(gè)Web應用后按了后退按鈕瀏覽器把緩存中的頁(yè)面呈現給用戶(hù),這會(huì )使用戶(hù)產(chǎn)生疑惑,他們會(huì )開(kāi)始擔心他們的個(gè)人數據是否安全。許多Web應用強迫用戶(hù)退出時(shí)關(guān)閉整個(gè)瀏覽器,這樣,用戶(hù)就無(wú)法點(diǎn)擊后退按鈕了。還有一些使用 JavaScript,但在某些客戶(hù)端瀏覽器這卻不一定起作用。這些解決方案都很笨拙且不能保證在任一情況下100%有效,同時(shí),它也要求用戶(hù)有一定的操作經(jīng)驗。
這篇文章以示例闡述了正確解決用戶(hù)退出問(wèn)題的方案。作者Kevin Le首先描述了一個(gè)密碼保護Web應用,然后以示例程序解釋問(wèn)題如何產(chǎn)生并討論解決問(wèn)題的方案。文章雖然是針對JSP頁(yè)面進(jìn)行闡述,但作者所闡述的概念很容易理解切能夠為其他Web技術(shù)所采用。最后作者展示了如何用Jakarta Struts優(yōu)雅地解決這一問(wèn)題。
大部分Web應用不會(huì )包含象銀行賬戶(hù)或信用卡資料那樣機密的信息,但一旦涉及到敏感數據,我們就需要提供一類(lèi)密碼保護機制。舉例來(lái)說(shuō),一個(gè)工廠(chǎng)中工人通過(guò) Web訪(fǎng)問(wèn)他們的時(shí)間安排、進(jìn)入他們的訓練課程以及查看他們的薪金等等。此時(shí)應用SSL(Secure Socket Layer)有點(diǎn)殺雞用牛刀的感覺(jué),但不可否認,我們又必須為這些應用提供密碼保護,否則,工人(也就是Web應用的使用者)可以窺探到工廠(chǎng)中其他雇員的私人機密信息。
與上述情形相似的還有位處圖書(shū)館、醫院等公共場(chǎng)所的計算機。在這些地方,許多用戶(hù)共同使用幾臺計算機,此時(shí)保護用戶(hù)的個(gè)人數據就顯得至關(guān)重要。設計良好編寫(xiě)優(yōu)秀的應用對用戶(hù)專(zhuān)業(yè)知識的要求少之又少。
我們來(lái)看一下現實(shí)世界中一個(gè)完美的Web應用是如何表現的:一個(gè)用戶(hù)通過(guò)瀏覽器訪(fǎng)問(wèn)一個(gè)頁(yè)面。Web應用展現一個(gè)登陸頁(yè)面要求用戶(hù)輸入有效的驗證信息。用戶(hù)輸入了用戶(hù)名和密碼。此時(shí)我們假設用戶(hù)提供的身份驗證信息是正確的,經(jīng)過(guò)了驗證過(guò)程,Web應用允許用戶(hù)瀏覽他有權訪(fǎng)問(wèn)的區域。用戶(hù)想退出時(shí),點(diǎn)擊退出按鈕,Web應用要求用戶(hù)確認他是否則真的需要退出,如果用戶(hù)確定退出,Session結束,Web應用重新定位到登陸頁(yè)面。用戶(hù)可以放心的離開(kāi)而不用擔心他的信息會(huì )泄露。另一個(gè)用戶(hù)坐到了同一臺電腦前,他點(diǎn)擊后退按鈕,Web應用不應該出現上一個(gè)用戶(hù)訪(fǎng)問(wèn)過(guò)的任何一個(gè)頁(yè)面。事實(shí)上,Web應用在第二個(gè)用戶(hù)提供正確的驗證信息之前應當一直停留在登陸頁(yè)面上。
通過(guò)示例程序,文章向您闡述了如何在一個(gè)Web應用中實(shí)現這一功能。
JSP samples
為了更為有效地闡述實(shí)現方案,本文將從展示一個(gè)示例應用logoutSampleJSP1中碰到的問(wèn)題開(kāi)始。這個(gè)示例代表了許多沒(méi)有正確解決退出過(guò)程的 Web應用。logoutSampleJSP1包含了下述jsp頁(yè)面:login.jsp, home.jsp, secure1.jsp, secure2.jsp, logout.jsp, loginAction.jsp, and logoutAction.jsp。其中頁(yè)面home.jsp, secure1.jsp, secure2.jsp, 和logout.jsp是不允許未經(jīng)認證的用戶(hù)訪(fǎng)問(wèn)的,也就是說(shuō),這些頁(yè)面包含了重要信息,在用戶(hù)登陸之前或者退出之后都不應該出現在瀏覽器中。 login.jsp包含了用于用戶(hù)輸入用戶(hù)名和密碼的form。logout.jsp頁(yè)包含了要求用戶(hù)確認是否退出的form。 loginAction.jsp和logoutAction.jsp作為控制器分別包含了登陸和退出代碼。
第二個(gè)示例應用logoutSampleJSP2展示了如何解決示例logoutSampleJSP1中的問(wèn)題。然而,第二個(gè)應用自身也是有疑問(wèn)的。在特定的情況下,退出問(wèn)題還是會(huì )出現。
第三個(gè)示例應用logoutSampleJSP3在第二個(gè)示例上進(jìn)行了改進(jìn),比較完善地解決了退出問(wèn)題。
最后一個(gè)示例logoutSampleStruts展示了Struts如何優(yōu)美地解決登陸問(wèn)題。
注意:本文所附示例在最新版本的Microsoft Internet Explorer (IE), Netscape Navigator, Mozilla, FireFox和Avant瀏覽器上測試通過(guò)。
Login action
Brian Pontarelli的經(jīng)典文章《J2EE Security: Container Versus Custom》討論了不同的J2EE認證途徑。文章同時(shí)指出,HTTP協(xié)議和基于form的認證并未提供處理用戶(hù)退出的機制。因此,解決途徑便是引入自定義的安全實(shí)現機制。
自定義的安全認證機制普遍采用的方法是從form中獲得用戶(hù)輸入的認證信息,然后到諸如LDAP (lightweight directory access protocol)或關(guān)系數據庫的安全域中進(jìn)行認證。如果用戶(hù)提供的認證信息是有效的,登陸動(dòng)作往HttpSession對象中注入某個(gè)對象。 HttpSession存在著(zhù)注入的對象則表示用戶(hù)已經(jīng)登陸。為了方便讀者理解,本文所附的示例只往HttpSession中寫(xiě)入一個(gè)用戶(hù)名以表明用戶(hù)已經(jīng)登陸。清單1是從loginAction.jsp頁(yè)面中節選的一段代碼以此闡述登陸動(dòng)作:
[code] 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); //... [/code]
本文所附示例均以關(guān)系型數據庫作為安全域,但本問(wèn)所闡述的觀(guān)點(diǎn)對任何類(lèi)型的安全域都是適用的。
Logout action
退出動(dòng)作就包含了簡(jiǎn)單的刪除用戶(hù)名以及對用戶(hù)的HttpSession對象調用invalidate()方法。清單2是從loginoutAction.jsp頁(yè)面中節選的一段代碼以此闡述退出動(dòng)作:
[code] Listing 2 //... session.removeAttribute("User"); session.invalidate(); //... [/code]
阻止未經(jīng)認證訪(fǎng)問(wèn)受保護的JSP頁(yè)面
從form 中獲取用戶(hù)提交的認證信息并經(jīng)過(guò)驗證后,登陸動(dòng)作簡(jiǎn)單地往 HttpSession對象中寫(xiě)入一個(gè)用戶(hù)名,退出動(dòng)作則做相反的工作,它從用戶(hù)的HttpSession對象中刪除用戶(hù)名并調用invalidate ()方法銷(xiāo)毀HttpSession。為了使登陸和退出動(dòng)作真正發(fā)揮作用,所有受保護的JSP頁(yè)面都應該首先驗證HttpSession中是否包含了用戶(hù)名以確認當前用戶(hù)是否已經(jīng)登陸。如果HttpSession中包含了用戶(hù)名,也就是說(shuō)用戶(hù)已經(jīng)登陸,Web應用則將剩余的JSP頁(yè)發(fā)送給瀏覽器,否則, JSP頁(yè)將跳轉到登陸頁(yè)login.jsp。頁(yè)面home.jsp, secure1.jsp, secure2.jsp和logout.jsp均包含清單3中的代碼段:
[code] 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 //... [/code]
在這個(gè)代碼段中,程序從HttpSession中減縮username字符串。如果字符串為空,Web應用則自動(dòng)中止執行當前頁(yè)面并跳轉到登陸頁(yè),同時(shí)給出 Session has ended. Please log in.的提示;如果不為空,Web應用則繼續執行,也就是把剩余的頁(yè)面提供給用戶(hù)。
運行logoutSampleJSP1
運行logoutSampleJSP1將會(huì )出現如下幾種情形:
• 如果用戶(hù)沒(méi)有登陸,Web應用將會(huì )正確中止受保護頁(yè)面home.jsp, secure1.jsp, secure2.jsp和logout.jsp的執行,也就是說(shuō),假如用戶(hù)在瀏覽器地址欄中直接敲入受保護JSP頁(yè)的地址試圖訪(fǎng)問(wèn),Web應用將自動(dòng)跳轉到登陸頁(yè)并提示Session has ended.Please log in.
• 同樣的,當一個(gè)用戶(hù)已經(jīng)退出,Web應用也會(huì )正確中止受保護頁(yè)面home.jsp, secure1.jsp, secure2.jsp和logout.jsp的執行
• 用戶(hù)退出后,如果點(diǎn)擊瀏覽器上的后退按鈕,Web應用將不能正確保護受保護的頁(yè)面——在Session銷(xiāo)毀后(用戶(hù)退出)受保護的JSP頁(yè)重新在瀏覽器中顯示出來(lái)。然而,如果用戶(hù)點(diǎn)擊返回頁(yè)面上的任何鏈接,Web應用將會(huì )跳轉到登陸頁(yè)面并提示Session has ended.Please log in.
阻止瀏覽器緩存
上述問(wèn)題的根源在于大部分瀏覽器都有一個(gè)后退按鈕。當點(diǎn)擊后退按鈕時(shí),默認情況下瀏覽器不是從Web服務(wù)器上重新獲取頁(yè)面,而是從瀏覽器緩存中載入頁(yè)面?;贘ava的Web應用并未限制這一功能,在基于PHP、ASP和.NET的Web應用中也同樣存在這一問(wèn)題。
在用戶(hù)點(diǎn)擊后退按鈕后,瀏覽器到服務(wù)器再從服務(wù)器到瀏覽器這樣通常意思上的HTTP回路并沒(méi)有建立,僅僅只是用戶(hù),瀏覽器和緩存進(jìn)行了交互。所以,即使包含了清單3上的代碼來(lái)保護JSP頁(yè)面,當點(diǎn)擊后退按鈕時(shí),這些代碼是不會(huì )執行的。
緩存的好壞,真是仁者見(jiàn)仁智者見(jiàn)智。緩存的確提供了一些便利,但通常只在使用靜態(tài)的HTML頁(yè)面或基于圖形或影響的頁(yè)面你才能感受到。而另一方面,Web應用通常是基于數據的,數據通常是頻繁更改的。與從緩存中讀取并顯示過(guò)期的數據相比,提供最新的數據才是更重要的!
幸運的是,HTTP頭信息“Expires”和“Cache-Control”為應用程序服務(wù)器提供了一個(gè)控制瀏覽器和代理服務(wù)器上緩存的機制。HTTP頭信息Expires告訴代理服務(wù)器它的緩存頁(yè)面何時(shí)將過(guò)期。HTTP1.1規范中新定義的頭信息Cache-Control可以通知瀏覽器不緩存任何頁(yè)面。當點(diǎn)擊后退按鈕時(shí),瀏覽器重新訪(fǎng)問(wèn)服務(wù)器已獲取頁(yè)面。如下是使用Cache-Control的基本方法:
• no-cache:強制緩存從服務(wù)器上獲取新的頁(yè)面
• no-store: 在任何環(huán)境下緩存不保存任何頁(yè)面
HTTP1.0規范中的Pragma:no-cache等同于HTTP1.1規范中的Cache-Control:no-cache,同樣可以包含在頭信息中。
通過(guò)使用HTTP頭信息的cache控制,第二個(gè)示例應用logoutSampleJSP2解決了logoutSampleJSP1的問(wèn)題。 logoutSampleJSP2與logoutSampleJSP1不同表現在如下代碼段中,這一代碼段加入進(jìn)所有受保護的頁(yè)面中:
[code] //... 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); } //... [/code]
通過(guò)設置頭信息和檢查HttpSession中的用戶(hù)名確保了瀏覽器不緩存頁(yè)面,同時(shí),如果用戶(hù)未登陸,受保護的JSP頁(yè)面將不會(huì )發(fā)送到瀏覽器,取而代之的將是登陸頁(yè)面login.jsp。
運行logoutSampleJSP2
運行logoutSampleJSP2后將回看到如下結果:
• 當用戶(hù)退出后試圖點(diǎn)擊后退按鈕,瀏覽器并不會(huì )顯示受保護的頁(yè)面,它只會(huì )現實(shí)登陸頁(yè)login.jsp同時(shí)給出提示信息Session has ended. Please log in.
• 然而,當按了后退按鈕返回的頁(yè)是處理用戶(hù)提交數據的頁(yè)面時(shí),IE和Avant瀏覽器將彈出如下信息提示:
警告:頁(yè)面已過(guò)期……(你肯定見(jiàn)過(guò))
選擇刷新后前一個(gè)JSP頁(yè)面將重新顯示在瀏覽器中。很顯然,這不是我們所想看到的因為它違背了logout動(dòng)作的目的。發(fā)生這一現象時(shí),很可能是一個(gè)惡意用戶(hù)在嘗試獲取其他用戶(hù)的數據。然而,這個(gè)問(wèn)題僅僅出現在后退按鈕對應的是一個(gè)處理POST請求的頁(yè)面。
記錄最后登陸時(shí)間
上述問(wèn)題之所以出現是因為瀏覽器將其緩存中的數據重新提交了。這本文的例子中,數據包含了用戶(hù)名和密碼。無(wú)論是否給出安全警告信息,瀏覽器此時(shí)起到了負面作用。
為了解決logoutSampleJSP2中出現的問(wèn)題,logoutSampleJSP3的login.jsp在包含username和 password的基礎上還包含了一個(gè)稱(chēng)作lastLogon的隱藏表單域,此表單域動(dòng)態(tài)的用一個(gè)long型值初始化。這個(gè)long型值是調用 System.currentTimeMillis()獲取到的自1970年1月1日以來(lái)的毫秒數。當login.jsp中的form提交時(shí), loginAction.jsp首先將隱藏域中的值與用戶(hù)數據庫中的值進(jìn)行比較。只有當lastLogon表單域中的值大于數據庫中的值時(shí)Web應用才認為這是個(gè)有效的登陸。
為了驗證登陸,數據庫中lastLogon字段必須以表單中的lastLogon值進(jìn)行更新。上例中,當瀏覽器重復提交數據時(shí),表單中的lastLogon值不比數據庫中的lastLogon值大,因此,loginAction轉到login.jsp頁(yè)面,并提示 Session has ended.Please log in.清單5是loginAction中節選的代碼段:
[code] 清單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); //... [/code]
為了實(shí)現上述方法,你必須記錄每個(gè)用戶(hù)的最后登陸時(shí)間。對于采用關(guān)系型數據庫安全域來(lái)說(shuō),這點(diǎn)可以可以通過(guò)在某個(gè)表中加上lastLogin字段輕松實(shí)現。LDAP以及其他的安全域需要稍微動(dòng)下腦筋,但很顯然是可以實(shí)現的。
表示最后登陸時(shí)間的方法有很多。示例logoutSampleJSP3利用了自1970年1月1日以來(lái)的毫秒數。這個(gè)方法在許多人在不同瀏覽器中用一個(gè)用戶(hù)賬號登陸時(shí)也是可行的。
運行logoutSampleJSP3
運行示例logoutSampleJSP3將展示如何正確處理退出問(wèn)題。一旦用戶(hù)退出,點(diǎn)擊瀏覽器上的后退按鈕在任何情況下都不會(huì )是受保護的頁(yè)面在瀏覽器上顯示出來(lái)。這個(gè)示例展示了如何正確處理退出問(wèn)題而不需要額外的培訓。
為了使代碼更簡(jiǎn)練有效,一些冗余的代碼可以剔除掉。一種途徑就是把清單4中的代碼寫(xiě)到一個(gè)單獨的JSP頁(yè)中,通過(guò)標簽其他頁(yè)面也可以引用。
Struts框架下的退出實(shí)現
與直接使用JSP或JSP/servlets相比,另一個(gè)可選的方案是使用Struts。為一個(gè)基于Struts的Web應用添加一個(gè)處理退出問(wèn)題的框架可以?xún)?yōu)雅地不費氣力的實(shí)現。這部分歸功于Struts是采用MVC設計模式的因此將模型和視圖清晰的分開(kāi)。另外,Java是一個(gè)面向對象的語(yǔ)言,其支持繼承,可以比JSP中的腳本更為容易地實(shí)現代碼重用。在Struts中,清單4中的代碼可以從JSP頁(yè)面中移植到Action類(lèi)的execute()方法中。
此外,我們還可以定義一個(gè)繼承Struts Action類(lèi)的基本類(lèi),其execute()方法中包含了清單4中的代碼。通過(guò)使用類(lèi)繼承機制,其他類(lèi)可以繼承基本類(lèi)中的通用邏輯來(lái)設置HTTP頭信息以及檢索HttpSession對象中的username字符串。這個(gè)基本類(lèi)是一個(gè)抽象類(lèi)并定義了一個(gè)抽象方法executeAction()。所有繼承自基類(lèi)的子類(lèi)都應實(shí)現exectuteAction()方法而不是覆蓋它。清單6是基類(lèi)的部分代碼:
[code] 清單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; } } [/code]
清單6中的代碼與清單4中的很相像,僅僅只是用ActionMapping findForward替代了RequestDispatcher forward。清單6中,如果在HttpSession中未找到username字符串,ActionMapping對象將找到名為 sessionEnded的forward元素并跳轉到對應的path。如果找到了,子類(lèi)將執行其實(shí)現了executeAction()方法的業(yè)務(wù)邏輯。因此,在配置文件struts-web.xml中為所有子類(lèi)聲明個(gè)一名為sessionEnded的forward元素是必須的。清單7以secure1 action闡明了這樣一個(gè)聲明:
[code] 清單7 [/code]
繼承自BaseAction類(lèi)的子類(lèi)Secure1Action實(shí)現了executeAction()方法而不是覆蓋它。Secure1Action類(lèi)不執行任何退出代碼,如清單8:
[code] 清單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")); } } [/code]
只需要定義一個(gè)基類(lèi)而不需要額外的代碼工作,上述解決方案是優(yōu)雅而有效的。不管怎樣,將通用的行為方法寫(xiě)成一個(gè)繼承StrutsAction的基類(lèi)是許多Struts項目的共同經(jīng)驗,值得推薦。
局限性
上述解決方案對JSP或基于Struts的Web應用都是非常簡(jiǎn)單而實(shí)用的,但它還是有某些局限。在我看來(lái),這些局限并不是至關(guān)緊要的。
結論
本文闡述了解決退出問(wèn)題的方案,盡管方案簡(jiǎn)單的令人驚訝,但卻在所有情況下都能有效地工作。無(wú)論是對JSP還是Struts,所要做的不過(guò)是寫(xiě)一段不超過(guò) 50行的代碼以及一個(gè)記錄用戶(hù)最后登陸時(shí)間的方法。在Web應用中混合使用這些方案能夠使擁護的私人數據不致泄露,同時(shí),也能增加用戶(hù)的經(jīng)驗。
聯(lián)系客服