設計一個可擴展的用戶登錄系統 (3)
如何生成一個可信的Cookie
因爲Cookie都是服務器端創建的,所以,生成一個可信Cookie的關鍵在於,客戶端無法僞造出Cookie。
用什麼方法可以防止僞造?數學理論告訴我們,單向函數就可以防僞造。
例如,計算md5就是一個單向函數。假設寫好了函數md5(String s)
,根據輸入可以很容易地計算結果:
md5("hello") => "b1946ac92492d2347c6235b4d2611184"
但是,根據結果"b1946...11184"
反推輸入卻非常困難。
利用單向函數,我們可以生成一個防僞造的Cookie。
例如,用戶以用戶名"admin",口令"hello"登錄成功後,要生成Cookie,我們就可以用md5計算:
md5("hello") => "b1946ac92492d2347c6235b4d2611184"
然後,把md5值和用戶名"admin"
串起來構成一個Cookie發送給客戶端:
"admin:b1946ac92492d2347c6235b4d2611184"
當客戶端把上面的Cookie發給服務器時,服務器如何驗證該Cookie是有效的呢?可以按照以下步驟:
-
服務器把Cookie分解成用戶名
"admin"
和md5值"b1946...11184"
; -
根據用戶名
"admin"
從數據庫中找到該用戶的記錄,並繼續找到該用戶的口令"hello"
; -
服務器根據數據庫中存儲的口令計算
md5("hello")
並與客戶端Cookie的md5值對比。
如果對比一致,說明Cookie是有效的。
現在可以愉快地爲用戶創建Cookie了!
且慢!
從理論到實踐還差着一個工程的距離。上面的算法僅僅解決了基本的驗證,在實際應用中,存在如下嚴重問題:
- 簡單的md5值很容易被彩虹表攻擊,從而直接得到用戶原始口令;
- 用戶名被暴露在Cookie中,如果用email作爲用戶名,用戶的email就被泄露了;
- Cookie沒有設置有效期(注意瀏覽器發過來的Cookie不一定真是瀏覽器發的),導致一旦登錄,永久有效;
- 其他若干問題。
如何解決?方法是計算hash的時候,不僅只包含用戶口令,還包含Cookie過期時間,以及其他相關隨機數,這樣計算的hash就非常安全。
舉個栗子:
假設用戶仍以用戶名"admin"
,口令"hello"
登錄成功,系統可以知道:
- 該用戶的id,例如,
1230001
; - 該用戶的口令,例如,
"hello"
; - Cookie過期時間,可由當前時間戳+固定時長計算,例如,
1461288165
; - 系統固定的一個隨機字符串,例如,
"secret"
。
把上面4部分拼起來,得到:
"1230001:hello:1461288165:secret"
計算上述字符串的md5,得到:"d9753...004d5"
。
最後,按照用戶id,過期時間和最終的hash值,拼接得到Cookie如下:
"1230001:1461288165:d9753...004d5"
當瀏覽器發送Cookie回服務器時,我們就可以按照下面的方式驗證Cookie:
- 把Cookie分割成三部分,得到用戶id,過期時間和hash值;
- 如果過期時間已到,直接丟棄;
- 根據用戶id查找用戶,得到用戶口令;
- 按照生成Cookie時的算法計算md5,與Cookie自帶的hash值對比。
如果用戶自己對Cookie進行修改,無論改用戶id、過期時間,還是hash值,都會導致最終計算結果不一致。
即使用戶知道自己的id和口令,也知道服務器的生成算法,他也無法自己構造出有效的Cookie,原因就在於計算hash時的“系統固定的隨機字符串”他不知道。
這個“系統固定的隨機字符串”還有一個用途,就是編寫代碼的開發人員不知道生產環境服務器配置的隨機字符串,他也無法僞造Cookie。
md5算法還可以換成更安全的sha1/sha256。
現在我們就解決了如何生成一個可信Cookie的問題。
如果用戶通過第三方OAuth登錄,服務器如何生成Cookie呢?
方法和上面一樣,具體算法自己想去。
如何綁定用戶
如果用戶被認證了,系統實際上就認爲從數據庫讀取的一個User對象是有效的當前用戶,現在的問題是,如何讓業務層代碼獲知當前用戶。
方法一:每個業務方法新增一個User參數。
該方法太弱智,故不在此處討論。
方法二:把User綁定到request
中。
該方法太幼稚,導致編寫業務的時候需要這麼寫:
User user = (User) request.getAttribute("USER");
問題一大堆:
- Key值
"USER"
需要定義到常量中,但不排除很多開發人員偷懶直接寫死了,這樣編譯器根本檢測不到錯誤; - 某個零經驗的開發人員在某處放置了
request.setAttribute("USER", true)
的代碼,導致後續操作直接崩潰; - request對象怎麼拿?再寫一個
SpringHelper.getContext().getCurrentRequest()
? - 強制轉型看着就不爽。
正確做法:把User用ThreadLocal
綁定到當前處理線程:
public class UserContext {
public static final ThreadLocal<User> current = new ThreadLocal<User>();
}
在統一的入口,例如Filter
處理:
public class MyFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
User user = tryGetAuthenticatedUser(request, response);
UserContext.current.set(user);
chain.doFilter(request, response);
UserContext.current.remove(user);
}
}
這樣就可以在業務邏輯的任何地方獲得當前User:
User user = UserContext.current.get();
上述代碼是零經驗工程師寫的,大家不要學。
有經驗的工程師會指出,沒有try...finally
邏輯就不對,但這只是知道Java語法後的生搬硬套,也不對。
這段代碼的真正問題是缺少封裝,沒有把實現細節隱藏起來。大家熟知的開閉原則“對擴展開放,對修改關閉”,說起來容易,實現起來困難。
讓我們用開閉原則重寫上面的代碼:
public class UserContext implements AutoCloseable { static final ThreadLocal<User> current = new ThreadLocal<User>(); public UserContext(User user) { current.set(user); } public static User getCurrentUser() { return current.get(); } public void close() { current.remove(); } }
是不是簡單多了?代碼量大了,難道還更簡單了?
是的,簡單與否不看代碼量本身,而是看調用起來是不是簡單。在Filter中調用起來就非常簡單:
public class MyFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
User user = tryGetAuthenticatedUser(request, response);
try (UserContext context = new UserContext(user)) {
chain.doFilter(request, response);
}
}
}
finally
哪去了?與時俱進是我們的原則之一,搜索一下AutoCloseable
吧!
在業務邏輯中調用更簡單:
User user = UserContext.getCurrentUser();
最後我們來演示一下很多場景需要的用法:
try (UserContext context = new UserContext(user)) {
// 當前用戶是user:
processProfile(UserContext.getCurrentUser());
// 需要更高權限的admin才能執行的操作怎麼辦?
// 方法是獲取一個admin用戶:
try (UserContext context = new UserContext(getAdmin())) {
// 現在的當前用戶是admin:
processAdminJob(UserContext.getCurrentUser());
}
// 現在當前用戶又自動變回了普通user:
processProfile(UserContext.getCurrentUser());
}
實現上述邏輯只需要對UserContext
做一個簡單的修改就可以實現了。
這纔是真正的開閉啊!