--- title: 'Cookie、Session - 機制、管理' disqus: kyleAlien --- Cookie、Session - 機制、管理 === ## Overview of Content 如有引用參考請詳註出處,感謝 :cat: [TOC] ## 會話管理 Session Management Http 通訊之所有需要有管理,是因為 Http 的特性!**Http 本身是無狀態通訊**,所以伺服器、客戶端都不會知道上一次的通訊情況 **++基於 Http 這個 ==無狀態== 特性++,造就了 `Cookie`、`Session` 管理** > 這種記得上一次請求、這次請求的關聯關係也稱為 `Session Management`; > > eg. 記得問卷的上下頁關聯就需要會話管理 ## Web 應用管理 `Hidden Field`、`Cookie`、`URL Rewriting` 三種方案,這些方案都是將資訊保存在遊覽器中,並且以下這些操作都由 Web 應用開法 ### 隱藏欄位 - Hidden Field * 既然伺服器不會記得客戶端的通訊,那就由客戶端(瀏覽器),來傳送一個隱藏欄位(類似一個 TAG),來讓伺服器判斷這個欄位 > 藉由多次請求間,帶入必要的資訊,來讓伺服器認得… 這種手法就稱為 **隱藏欄位 `Hidden Field`** 1. 第一次訪問,客戶端傳進隱藏欄位,伺服器照樣回傳 ```mermaid graph LR; 客戶端_瀏覽器 --> |1. 參數 apple=red| 服務端_伺服器 服務端_伺服器 -.-> |2. 參數 apple=red| 客戶端_瀏覽器 ``` 2. 第二次訪問,客戶端將隱藏參數繼續帶給伺服器 ```mermaid graph LR; 客戶端_瀏覽器 --> |3. 參數 apple=red&banana=yellow | 服務端_伺服器 ``` * 這種隱藏欄位的方式有以下特點 * 透過 HTML 的「**隱藏欄位**」(`type='hidden'`)來達到數值的傳遞 ```xml= <input type='hidden' name='" + 參數Key + "' value='" + 參數Value + "'><br>" ``` * 這種方式需要客戶端的遊覽器與伺服器協作,才能達到參數傳遞的功能 :::warning * 當客戶遊覽器關閉時,這種機制就會失效 * **「隱藏欄位」++並非++ Servlet 實際的會話管理機制** ::: * 隱藏欄位 範例如下 ```java= @WebServlet( "/hidden.field" ) public class HiddenFieldServlet extends HttpServlet { private static final String QUESTION = "color_question"; private static final String APPLE_PARAM = "apple"; private static final String BANANA_PARAM = "banana"; private static final String BANANA_QUESTION = "next_banana"; private static final String FINISH_QUESTION = "question_finish"; private void handleHiddenField(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF8"); // setting MIME(Header) resp.setContentType("text/html; charset=big5"); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hidden Field</title>"); out.println("</head>"); String question = req.getParameter(QUESTION); out.println("<body>"); // 由於是導向相同頁面,所以 action 就是這頁的 URI out.println("<form action='hidden.field' method='post'>"); if (question == null) { out.println("Apple color:<br>"); out.println("<input type='text' name='" + APPLE_PARAM + "'><br>"); out.println("<input type='submit' name='" + QUESTION + "' value='"+ BANANA_QUESTION +"'>"); } else if(question.equals(BANANA_QUESTION)) { String appleColor = req.getParameter(APPLE_PARAM); // 透過參數的傳遞 out.println("<input type='hidden' name='" + APPLE_PARAM + "' value='" + appleColor + "'><br>"); out.println("Banana color:<br>"); out.println("<input type='text' name='" + BANANA_PARAM + "'><br>"); out.println("<input type='submit' name='" + QUESTION + "' value='"+ FINISH_QUESTION +"'>"); } else if (question.equals(FINISH_QUESTION)) { String appleColor = req.getParameter(APPLE_PARAM); String bananaColor = req.getParameter(BANANA_PARAM); out.println("Apple color: " + appleColor + "<br>"); out.println("Banana color: " + bananaColor + "<br>"); } out.println("</form>"); out.println("</body>"); out.println("</html>"); out.close(); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { handleHiddenField(req, resp); } } ``` > ![image](https://hackmd.io/_uploads/Hkl5tbhra.png) ### Cookie 機制 - addCookie 自動登入 * **`Cookie` 是在遊覽器儲存訊息的一種機制**,伺服器可以回應 **客戶端遊覽器的 ==`set-cookie` 標頭==**;這個機制的 **特性** 如下 > `set-cookie` 是後來拓展的標頭,並非原始的表準標頭 1. 會將 **儲存在客戶端的一個「檔案」** 回傳給伺服器做紀錄,這個 **檔案就稱之為 Cookie** 2. 伺服器可以將一些資訊保存在 Cookie 中 3. 伺服器可以設定 Cookie 存活時間(**透過 `Expires`設定**) :::info 伺服器不會直接設定(它是間接設定),而是透過 Header 指定,並由客戶端瀏覽器來設置 Cookie 存活時間 ::: ```mermaid graph LR; 服務端_伺服器 --> |set-cookie| 客戶端_瀏覽器 subgraph 客戶端_瀏覽器 檔案 end 檔案 -.-> |cookie 檔案資訊| 服務端_伺服器 ``` * Cookie 機制簡單的自動登入範例如下(分為幾個部分,分開來解釋) 1. 入口,**使用者首先進入 Cookie 機制**: 判斷客戶端傳入的 Cookie 是否有需要(自動登入)的資訊 * 如果客戶端沒有 Cookie,則導向到純登入頁(`login.html`) * 判斷 Cookie 的正確性,其中要存有 `user` 作為 Key 、`cache` 作為 Value;如果都有,則導向使用者頁面(`user.view`) ```java= @WebServlet( "/cookie.use" ) public class CookieServlet extends HttpServlet { public static final String COOKIE_KEY = "user"; public static final String COOKIE_VALUE = "cache"; private void handleCookie(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { Cookie[] cookies = req.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { String name = cookie.getName(); String value = cookie.getValue(); // 檢查 Cookie if (COOKIE_KEY.equals(name) && COOKIE_VALUE.equals(value)) { RequestDispatcher dispatcher = req.getRequestDispatcher("user.view"); dispatcher.forward(req, resp); return; } } } // 沒有 Cookie 代表首次登入 resp.sendRedirect("login.html"); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handleCookie(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handleCookie(req, resp); } } ``` 2. 純 HTML 登入頁面 * 兩個輸入框、一個送出按鈕 * 點擊按鈕後轉向 `cookie.use`(第一步),先去確認使用者 Cookie 是否已經存伺服器要求的訊息! ```xml= <!DOCTYPE html> <html lang="en"> <head> <meta content = "text/html; charset=BIG5"> <title>Title</title> </head> <body> <form method="post" action="login.do"> <label> User name: <input type="text" name="user.name"> </label><br> <label> Password: <input type="password" name="user.password"> </label><br> <button>submit</button> </form> </body> </html> ``` > ![image](https://hackmd.io/_uploads/By_9GIhr6.png) 3. **登入請求判別**: 登入請求會判斷使用者是否具有 Cookie,並執行對應行為 * Mock 帳密檢查的行為,如果檢查失敗則導向到純登入頁(`login.html`) * 帳密檢查成功,則設定 Cookie 存活時間、並添加 Cookie Header,並導向使用者頁面(`user.view`) > 為了讓 `cookie.use` 頁面可以判斷 ```java= @WebServlet( "/login.do" ) public class LoginServlet extends HttpServlet { private static final String INPUT_USER = "user.name"; private static final String INPUT_PASSWD = "user.password"; private void handleLoginLogic(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { String user = req.getParameter(INPUT_USER); String password = req.getParameter(INPUT_PASSWD); // 假裝當成帳號密碼確認 if (!"Alien".equals(user) || !"123456".equals(password)) { resp.sendRedirect("login.html"); return; } Cookie cookie = new Cookie(CookieServlet.COOKIE_KEY, CookieServlet.COOKIE_VALUE); // 設定 expires 時間(7 天),單位秒 cookie.setMaxAge(7 * 24 * 60 * 60); // second is unit // 設定 setCookie Header resp.addCookie(cookie); // like set cookie // 轉發前,將使用者名放入 Attribute req.setAttribute(CookieServlet.COOKIE_KEY, user); // 轉發到登入頁 RequestDispatcher dispatcher = req.getRequestDispatcher("user.view"); dispatcher.forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { handleLoginLogic(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handleLoginLogic(req, resp); } } ``` > ![image](https://hackmd.io/_uploads/HJmKMUhHT.png) :::info * 如果想測試,也可以手動刪除 Cookie ::: 4. **使用者頁面**: 單純顯示使用者資訊 ```java= @WebServlet( "/user.view" ) public class UserServlet extends HttpServlet { private void handleUser(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html; charset=big5"); Object user = req.getAttribute(CookieServlet.COOKIE_KEY); if (user == null) { user = "Auto login."; } PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>User information</title>"); out.println("</head>"); out.println("<body>"); out.println("<h1>User: " + user +" already login</h1>"); out.println("</body>"); out.println("</html>"); out.close(); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { handleUser(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { handleUser(req, resp); } } ``` :::danger * **為什麼 `doGet` 也要寫,明明就是 post**? 因為瀏覽器在 URL 載入時,是使用 GET 請求;假設使用者是自動登入的狀況之下,就會使用到 Get 請求(**重新導向時就用 Get**) > ![image](https://hackmd.io/_uploads/SyCReUnBa.png) ::: 首次登入 > ![image](https://hackmd.io/_uploads/rk9ce8hST.png) 重新從第一步進入頁面,就會直接進入 `user.view` 頁面 > ![image](https://hackmd.io/_uploads/r1ldhBnr6.png) ### URL 重寫 - URL Rewriting * URL 重寫(`URL Rewriting`)其實是 **GET 請求參數的應用**;當伺服器回應瀏覽器的 GET 請求時,將相關資訊以 HTML 的「**超連結**」方式回應給瀏覽器 > 並且超連接中會包括請求參數資訊 ```java= @WebServlet( "/url-rewriting.search" ) public class URLRewriting extends HttpServlet { private static final String CUR_PAGE = "curPage"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html; charset=big5; charset=UTF-8"); String start = req.getParameter(CUR_PAGE); if (start == null || start.isBlank()) { start = "1"; } PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Url rewriting</title>"); out.println("</head>"); out.println("<body>"); int count = Integer.parseInt(start); int begin = (10 * count) - 9; int end = (10 * count); out.println("The result: " + begin + " ~ " + end + "<br>"); out.println("<ul>"); for (int i = 1; i < 10; i++) { out.println("<li>Search result: " + i + "</li>"); } out.println("</ul>"); for (int i = 1; i < 10; i++) { if (i == count) { out.println(i); continue; } out.println("<a href='url-rewriting.search?" + CUR_PAGE + "=" + i + "'>" + i + "</a>"); } out.println("</body>"); out.println("</html>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/BJBGaUeUp.png) ## Web 容器管理 伺服器(遠端)管理會話的機制,是將會話期間必須「共用的資料」儲存為「屬性」,並且這些資料由 Web 容器管理 ### Servelt Session 使用 - getSession * 在 Servlet/JSP 中,如果想要進行 Session 會話管理,需使用 `HttpServletRequest`#`getSession()` 方法取得 HttpSession 物件,常用方法如下 1. Servlet 中的 `getSession()` 的參數如果設置為 `true`(該函數有被重載),就會在沒有 Session 時自動創建; > 如果沒有設置參數… 則可能返回 Null > > ![image](https://hackmd.io/_uploads/ryyUjLBDp.png) 2. 如果想在瀏覽器、Web 應用程式的會話(通訊)期間保留訊息,可以使用 HttpSession 類的 `setAttribute`、`getAttribute` 方法 > 將與 Client 端通訊的資料設定在 Web 應用中 :::warning * 如果 Session 已經被設置為 `invalidated`… 那就不能再設定 Attribute,否則伺服器會出錯 > ![image](https://hackmd.io/_uploads/SkOZ6LSPp.png) ::: 3. HttpSession 也可以透過 `setMaxInactiveInterval` 設定存活區間(秒為單位),如果使用者在幾秒內沒有請求應用的話,就讓該 Session 失效 :::success * Session 特點: * **可以無視 Http 的請求週期** * 預設在遊覽器關閉之前,HttpSession 物件中儲存的資料都會是相同實例 > 如果像提前在這次會話中清除 Session,可以使用 HttpSession#`invalidate` 方法 > > ![image](https://hackmd.io/_uploads/r10VcUEIa.png) ::: :::danger * **`HttpSession` 並非執行緒安全**,所以在使用時請注意同步的問題 ::: ### Session 使用 - 改寫隱藏欄位 * 小試 HttpSession 的使用,範例如下 * 改寫隱藏欄位的範例 ```java= @WebServlet( "/session.base.usage" ) public class SessionUsageServlet extends HttpServlet { private static final String QUESTION = "color_question"; private static final String APPLE_PARAM = "apple"; private static final String BANANA_PARAM = "banana"; private static final String BANANA_QUESTION = "next_banana"; private static final String FINISH_QUESTION = "question_finish"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF8"); // setting MIME(Header) resp.setContentType("text/html; charset=big5"); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hidden Field</title>"); out.println("</head>"); String question = req.getParameter(QUESTION); out.println("<body>"); // 由於是導向相同頁面,所以 action 就是這頁的 URI out.println("<form action='hidden.field' method='post'>"); if (question == null) { out.println("Apple color:<br>"); out.println("<input type='text' name='" + APPLE_PARAM + "'><br>"); out.println("<input type='submit' name='" + QUESTION + "' value='"+ BANANA_QUESTION +"'>"); } else if(question.equals(BANANA_QUESTION)) { String appleColor = req.getParameter(APPLE_PARAM); // use session HttpSession session = req.getSession(true); session.setAttribute(APPLE_PARAM, appleColor); out.println("Banana color:<br>"); out.println("<input type='text' name='" + BANANA_PARAM + "'><br>"); out.println("<input type='submit' name='" + QUESTION + "' value='"+ FINISH_QUESTION +"'>"); } else if (question.equals(FINISH_QUESTION)) { HttpSession session = req.getSession(); String appleColor = (String) session.getAttribute(APPLE_PARAM); String bananaColor = req.getParameter(BANANA_PARAM); out.println("Apple color: " + appleColor + "<br>"); out.println("Banana color: " + bananaColor + "<br>"); } out.println("</form>"); out.println("</body>"); out.println("</html>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/HyR0s8NI6.png) ### Session 使用 - 登入、登出功能 * 改寫 Login 頁面的範例,並在 user 頁面新增登出功能(並且這些功能都用 Session 完成) 1. 入口,**登入頁面**: 純 `HTML`,這部分沒有修改很多,只修改以下兩點 * `form`#`action` 導向 `session.login.do` * 並且入口不再是 Cookie 判斷,是直接從這個頁面進入(拿掉自動登入功能) ```xml= <!DOCTYPE html> <html lang="en"> <head> <meta content = "text/html; charset=BIG5"> <title>Title</title> </head> <body> <form method="post" action="login.do"> <label> User name: <input type="text" name="user.name"> </label><br> <label> Password: <input type="password" name="user.password"> </label><br> <button>submit</button> </form> </body> </html> ``` > ![image](https://hackmd.io/_uploads/SyQAX5LLT.png) 2. **Login 參數處理**: * 這裡會透過 `getSession()` 方法 **創建 HttpSession 物件** * 並使用 HttpSession#`setAttribute` 方法將使用者的名稱儲存進 Session ```java= @WebServlet( "/session.login.do" ) public class LoginServlet extends HttpServlet { public static final String INPUT_USER2 = "user.name"; private static final String INPUT_PASSWD2 = "user.password"; private void handleLoginLogic(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { String user = req.getParameter(INPUT_USER2); String password = req.getParameter(INPUT_PASSWD2); // 假裝檢查帳密 if (!"Alien".equals(user) || !"123456".equals(password)) { resp.sendRedirect("login_with_session.html"); return; } // 創建 Session HttpSession session = req.getSession(true); // 名稱儲存 session.setAttribute(INPUT_USER2, user); // 請求轉發 RequestDispatcher dispatcher = req.getRequestDispatcher("session.user.view"); dispatcher.forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { handleLoginLogic(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handleLoginLogic(req, resp); } } ``` 3. **使用者頁面**: * 透過 HttpSession#`getAttribute` 取得使用者名稱 * **新增 Logout 頁面** 的連結導向 ```java= @WebServlet( "/session.user.view" ) public class UserServlet extends HttpServlet { private void handleUser(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html; charset=big5"); // 取得 Session HttpSession session = req.getSession(true); // 取得使用者名稱 String user = (String) session.getAttribute(LoginServlet.INPUT_USER2); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>User information</title>"); out.println("</head>"); out.println("<body>"); out.println("<h1>User: " + user +" login</h1>"); out.println("<a href='session.logout.view'>Logout</a>"); out.println("</body>"); out.println("</html>"); out.close(); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { handleUser(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { handleUser(req, resp); } } ``` > ![image](https://hackmd.io/_uploads/SJ5E45I8T.png) 4. **登出機制** * 使用 HttpSession#`invalidate` 清除儲存在伺服器端的使用者訊息 ```java= @WebServlet( "/session.logout.view" ) public class LogoutServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html; charset=big5"); HttpSession session = req.getSession(true); Object user = session.getAttribute(LoginServlet.INPUT_USER2); // 呼叫 invalidate,告知 Web 容器 這個 Session 失效 session.invalidate(); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>User Logout</title>"); out.println("</head>"); out.println("<body>"); out.println("<h1>User: " + user +" already logout</h1>"); // 導回 HTML out.println("<a href='login_with_session.html'>Login</a>"); out.println("</body>"); out.println("</html>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/rJk8N5IUp.png) ### Web 容器 - Session 機制原理 * Session 機制原理其實是透過「**Web 容器**(也就是伺服器)」來達成(Web 應用開發者並不用處理),當我們在執行 HttpSession 時,Web 容器 會幫我們處理如下 1. 建立 HttpSession 物件 ```mermaid graph TB; subgraph 伺服器_Web容器 HttpSession_物件 subgraph Web應用 create_session end end create_session --> |創建| HttpSession_物件 ``` 2. HttpSession 物件與 `Session ID` 綁定 > 可以透過 HttpSession#`getId` 函數來獲取這個特殊 ID ```mermaid graph TB; subgraph 伺服器_Web容器 HttpSession_物件 <-.-> |綁定| Session_ID subgraph Web應用 create_session end end create_session --> HttpSession_物件 ``` 3. 這個 Session ID 預設會使用 Cookie 存放至客戶端的瀏覽器之中… 也就是說,**其實 ++Session 仍是使用了 Cookie 機制++** :::success Web 容器幫我們操作 Cookie 機制,並將 Web 容器綁定的 ID 透過 `JSESSIONID` 存到客戶端的 Cookie 中 ::: ```mermaid graph LR; subgraph 伺服器_Web容器 HttpSession_物件 --> Session_ID subgraph Web應用 create_session end end subgraph 客戶端_瀏覽器 Cookie_文件 end create_session --> HttpSession_物件 Session_ID --> Cookie_文件 ``` Web 伺服器會替 Web 應用創建 Session,並回傳到使用者空間存處起來,如下圖所見,它會放置在 Cookie 的位置 > ![image](https://hackmd.io/_uploads/rkjvvq8Ia.png) * Web 容器(網頁伺服器),其實本身也是 JVM 之下的一個應用程式,這個應用程式會一直存在伺服器端,而 **Session 機制則是將 Session ID 交給 Web 容器來管理** * 伺服器端設定 Session 的概念圖 伺服器將這個 Session ID 通過 HTTP 回應的 Set-Cookie Header 傳送到客戶端,讓客戶端將其儲存在 Cookie 中 ```mermaid graph LR; subgraph 伺服器_Web容器 HttpSession_物件A --> Session_ID_A HttpSession_物件B --> Session_ID_B subgraph Web應用 create_session end end subgraph 客戶端A_瀏覽器 Cookie_文件A end subgraph 客戶端B_瀏覽器 Cookie_文件B end create_session --> HttpSession_物件A create_session --> HttpSession_物件B Session_ID_A --> Cookie_文件A Session_ID_B --> Cookie_文件B ``` * 客戶端回傳資訊時自動會帶入 Session 在後續的每次 HTTP 請求中,客戶端會自動將這個 Session ID 以 Cookie 的形式放在 HTTP Header 中傳送給伺服器 ```mermaid graph LR; subgraph 伺服器_Web容器 Session_ID_A --> HttpSession_物件A Session_ID_B --> HttpSession_物件B subgraph Web應用 create_session end end subgraph 客戶端A_瀏覽器 Cookie_文件A end subgraph 客戶端B_瀏覽器 Cookie_文件B end HttpSession_物件A --> create_session HttpSession_物件B --> create_session Cookie_文件A --> Session_ID_A Cookie_文件B --> Session_ID_B ``` :::danger * Web 容器儲存的 Session ID 的 Cookie 預設為關閉瀏覽器時失效 > 客戶端重開遊覽器後,HttpSession 取得的 Session 就會是新的物件 * 由於 Session 是存在 Web 容器的「記憶體中」,所以不適合儲存過大的資料(像是音頻、視頻... 等等資料就不合適,因為瀏覽器自身也會限制存儲空間) ::: ### Cookie 禁用 - 自動產生 URL 重寫 * 我們知道其實 Session 是基於遊覽器的 Cookie 機制後,那我們就可以發現一個問題,使用者可以禁用 Cookie 的存取,這時 Session 機制就會失效 * 在使用者禁用 Cookie 的存取後,我們可以搭配「URL 重寫」機制,向遊覽器回應的 HTML 中,插入需要的超連結 這時可以運用 JavaEE Servlet 的 **HttpServletResponse#`encodeURL` 方法**,這個方法在不同狀況之下有不同的反應 ```java= @WebServlet( "/url-rewriting.refresh.count" ) public class URLRewriting extends HttpServlet { private static final String COUNT_KEY = "count_key"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html; charset=big5; charset=UTF-8"); int count = 0; // 產生 Session HttpSession session = req.getSession(true); // 嘗試取得 Attribute Object curPage = session.getAttribute(COUNT_KEY); if (curPage != null) { // 取的到,代表使用者沒有禁用 Cookie // 將 Cookie 的值 + 1 count = Integer.parseInt(((String) curPage)) + 1; } // 刷新 Session Attribute 的設置 session.setAttribute(COUNT_KEY, count); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Url count: " + count + "</title>"); out.println("</head>"); out.println("<body>"); out.println("<h1>Url count: " + count + "</h1>"); // 自動產生對應的 URL!不同情況會產生不同 URL String url = resp.encodeURL("url-rewriting.refresh.count"); out.println("<h2>Url: " + url + "</h2>"); out.println("<a href='" + url + "'>refresh</a>"); out.println("</body>"); out.println("</html>"); out.close(); } } ``` * **Cookie 沒有被禁用的情況下** 代表 **Session 可以正常取得遊覽器端的 Cookie**,`encodeURL` 方法會將傳入的 URL 原封不動的返回(你寫什麼它就返回什麼) > 未禁用,刷新多次的情況 > > ![image](https://hackmd.io/_uploads/HyuXR38I6.png) :::info * **伺服器如何知道客戶端的遊覽器是否禁用 Cookie**? 伺服器實際上是無從得知客戶端遊覽器是否禁用 Cookie,所以在 **第一次設定 Session 時會 ++寫在 URL 中++、++存在 Cookie++ 兩個方法都做** 直到第一次通訊後,確認客戶端的遊覽器沒有禁用 Cookie 後(Web 容器會嘗試取得 Session),再改用純 Cookie 機制的 Session > 以下使用「**無痕模式**」訪問 1. 第一次訪問:可以看到伺服器訪問時,會將 Cookie 帶入 Get 參數中(`jsessionid` 參數) > ![image](https://hackmd.io/_uploads/r1cxy6UUp.png) 2. 第二、三… 次訪問之後的訪問,發現客戶端沒有禁用 Cookie 機制,Web 容器就會使用純 Session 機制(不將 Session ID 保存在參數中) > ![image](https://hackmd.io/_uploads/SytIkp88T.png) ::: * **Cookie 被禁用的狀況下** **Session 無法取得 Cookie**,`encodeURL` 方法會自動產生帶有 Session ID 得 URL,並且 > 在之後的請求,Web 容器就會從回應中取得對應的的 Session ID :::warning * 本地開發似乎無法禁用 localhost Cookie ::: ### Session ID 的安全性 * 在伺服器 Web 容器中,會儲存 `Session ID`,而伺服器其實並不會區分是哪個客戶端擁有這個 `Session ID`,也就是說… 只要另一個人使用同一個 `Session ID` 就可以訪問到不同客戶的相同資源 ```mermaid graph LR; subgraph 伺服器_Web容器 Session_ID_A --> HttpSession_物件A subgraph Web應用 create_session end end subgraph 客戶端A_瀏覽器 Cookie_文件A end subgraph 客戶端B_瀏覽器 Cookie_文件B end HttpSession_物件A --> create_session Cookie_文件A --> Session_ID_A Cookie_文件B -.-> |與 A 相同的 ID | Session_ID_A ``` 所以建議 1. 在同 Session 下,加密取得的敏感資訊(使他人取得也不知如何破解) 2. 在不使用 `Session ID` 時,執行 `invlidate`,使該 ID 失效 3. 在會話的重要階段,務必重新做一次身份確認 ### Cookie 與 Session 的差異 * 前面小節我們說到 Cookie、Session 都是瀏覽器保存資料的手段,並且保存的資料都是放置在客戶端(使用者個瀏覽器端),那它們兩者的差異在哪呢? 我們已幾個面向來討論 1. **儲存位置**: * **Cookie**:**資料儲存在客戶端的瀏覽器中**,每當客戶端向伺服器發出請求時,相關的 Cookie 資料會被自動傳送到伺服器,而這些資訊往往是明碼可識別的有用資訊 * **Session**:真正有意義的資料主要儲存在伺服器端,而不是客戶端,客戶端只保留 ID,但它並不會知道 ID 對應的資料的含義; 而伺服器根據客戶端的 Session ID 來識別和存取儲存在伺服器的 Session 資料 2. **安全性**: * Cookie:由於儲存在客戶端,Cookie 容易受到攻擊,例如 XSS(跨站腳本攻擊)或竊取 Cookie 資料。除非加密,否則不建議在 Cookie 中存儲敏感資訊 * Session:儲存在伺服器端的資料相對更安全,因為客戶端無法直接存取 Session 資料… 客戶端僅傳送 Session ID,伺服器會根據此 ID 存取相關資料。 3. **生命週期**: * Cookie:可以設置過期時間,並且即使 **瀏覽器關閉後,Cookie 仍可保留**,直到過期時間到達或被手動刪除 * Session:通常與瀏覽器會話相聯繫,當瀏覽器關閉時,Session 通常會自動失效;當然,也可以設置特定的失效時間,但默認情況下,Session 會比 Cookie 更短暫 4. **用途**: * Cookie:常用於儲存客戶端的一些持久性資料,例如用戶偏好設置、追蹤 ID、廣告相關資料等 * Session:常用於儲存與用戶會話相關的臨時資料,例如登入狀態、購物車內容等,這些資料只需在特定的會話期間內有效 ## Session 監聽器 JavaEE Servlet 與 HttpSession 相關的監聽器有 4 個:^1.^ `HttpSessionListener`、^2.^ `HttpSessionAttributeListener`、^3.^ `HttpSessionBindingListener`、^4.^ `HttpSessionActivationListener` 這些 Listener 都 **須使用 `@WebListener` 註解** 告知容器,否則容器不會通知 ### 監聽 Session 生命週期 * HttpSessionListener 是 Session 生命週期監聽器 > ![image](https://hackmd.io/_uploads/ByR6YIrP6.png) :::info 可以用這個機制來實現當前服務有多少使用者在訪問 ::: * HttpSessionListener 使用範例: 在 Session 被移除時抓取,並將特殊屬性設定給 ServletContext ```java= @WebListener public class HttpSessionListenerImpl implements HttpSessionListener { public static final String SESSION_CATCHER = "session catcher"; // Session 創建 @Override public void sessionCreated(HttpSessionEvent se) { HttpSessionListener.super.sessionCreated(se); } // Session 失效 @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); ServletContext context = session.getServletContext(); Object attrVal = session.getAttribute(SessionTesterServlet.SESSION_CREATE); if (attrVal == null) { context.setAttribute(SESSION_CATCHER, "Get nothing ?"); } else { context.setAttribute(SESSION_CATCHER, attrVal + ", i got you."); } } } ``` 測試 HttpSessionListenerImpl ```java= @WebServlet("/session.tester") public class SessionTesterServlet extends HttpServlet { public static final String SESSION_CREATE = "session create"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { PrintWriter out = resp.getWriter(); HttpSession session = req.getSession(true); session.setAttribute(SESSION_CREATE, "Session hello :D"); session.invalidate(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); out.println("<h3> Catcher : " + getServletContext().getAttribute(HttpSessionListenerImpl.SESSION_CATCHER) + "</h3>"); out.println("</body>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/BJ_0ALHPa.png) ### 監聽 Session 屬性 * HttpSessionAttributeListener 是 Session 屬性的監聽器(包括添加、移除、替換) > ![image](https://hackmd.io/_uploads/ryf-fwrwp.png) ```java= @WebListener public class HttpSessionAttrsListenerImpl implements HttpSessionAttributeListener { public static final String SESSION_ATTRS_CATCHER = "session attrs catcher"; private final List<HttpSession> sessionCather = new ArrayList<>(); @Override public void attributeAdded(HttpSessionBindingEvent event) { HttpSession session = event.getSession(); if (session.getAttribute(SESSION_ATTRS_CATCHER) == null) { session.setAttribute(SESSION_ATTRS_CATCHER, session.getId() + " added"); sessionCather.add(session); } } @Override public void attributeRemoved(HttpSessionBindingEvent event) { HttpSession session = event.getSession(); sessionCather.remove(session); } } ``` ### 監聽 Session 物件綁定狀態 * 當有一個物件要賦予給 Session 作為屬性時(或移除),你希望可以知道該物件是否在 Session 之中,就可以使用 HttpSessionBindingListener > ![image](https://hackmd.io/_uploads/S1D5rvSPa.png) ```java= @WebListener public class HttpSessionBindingListenerImpl implements HttpSessionBindingListener { private String webSessionId = ""; @Override public void valueBound(HttpSessionBindingEvent event) { webSessionId = event.getSession().getId(); // 可以在這裡取得資料庫檔案 } @Override public void valueUnbound(HttpSessionBindingEvent event) { webSessionId = ""; // 可在這裡移除資料庫檔案 } } ``` ### 監聽 Session 啟用 * HttpSessionActivationListener 不是很常使用,它是「**物件遷移監聽器**」;通常會在用「**分散式**」開發環境中,因為 **分散式開發會將物件傳遞到不同的 JVM 中**(這就需要 **序列化物件**) 所以其實該監聽器就是在監聽物件的「序列化」(`sessionWillPassivate`)、「反序列化」(`sessionDidActivate`) > ![image](https://hackmd.io/_uploads/HkMCDPSwa.png) :::info * 觸發時機是 Web 容器對於該物件的序列化、反序列化… 其中並不包括自己手動序列化、反序列化 * 並且序列化物件也需要標示(實做)`Serializable` 界面 ::: 範例:在序列化、反序列化時反轉密碼 ```java= @WebListener public class HttpSessionActivationListenerImpl implements HttpSessionActivationListener { public static class UserInfo { public final String userName; public String password; public UserInfo(String userName, String password) { this.userName = userName; this.password = password; } } // 序列化 @Override public void sessionWillPassivate(HttpSessionEvent se) { Object userInfo = se.getSession().getAttribute("UserInfo"); if (userInfo instanceof UserInfo) { revertPassword((UserInfo) userInfo); } } // 反序列化 @Override public void sessionDidActivate(HttpSessionEvent se) { Object userInfo = se.getSession().getAttribute("UserInfo"); if (userInfo instanceof UserInfo) { revertPassword((UserInfo) userInfo); } } private void revertPassword(UserInfo userInfo) { byte[] bytes = userInfo.password.getBytes(StandardCharsets.UTF_8); byte[] revertBytes = new byte[bytes.length]; for (int i = bytes.length - 1, j = 0; i >= 0; i--, j++) { revertBytes[j] = bytes[i]; } userInfo.password = new String(revertBytes, StandardCharsets.UTF_8); } } ``` ## Appendix & FAQ :::info ::: ###### tags: `Web`