設計一個可擴展的用戶登錄系統 (3)

轉 廖雪峯老師----------------

設計一個可擴展的用戶登錄系統 (3)


因爲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是有效的呢?可以按照以下步驟:

  1. 服務器把Cookie分解成用戶名"admin"和md5值"b1946...11184"

  2. 根據用戶名"admin"從數據庫中找到該用戶的記錄,並繼續找到該用戶的口令"hello"

  3. 服務器根據數據庫中存儲的口令計算md5("hello")並與客戶端Cookie的md5值對比。

如果對比一致,說明Cookie是有效的。

現在可以愉快地爲用戶創建Cookie了!

且慢!

從理論到實踐還差着一個工程的距離。上面的算法僅僅解決了基本的驗證,在實際應用中,存在如下嚴重問題:

  1. 簡單的md5值很容易被彩虹表攻擊,從而直接得到用戶原始口令;
  2. 用戶名被暴露在Cookie中,如果用email作爲用戶名,用戶的email就被泄露了;
  3. Cookie沒有設置有效期(注意瀏覽器發過來的Cookie不一定真是瀏覽器發的),導致一旦登錄,永久有效;
  4. 其他若干問題。

如何解決?方法是計算hash的時候,不僅只包含用戶口令,還包含Cookie過期時間,以及其他相關隨機數,這樣計算的hash就非常安全。

舉個栗子:

假設用戶仍以用戶名"admin",口令"hello"登錄成功,系統可以知道:

  1. 該用戶的id,例如,1230001
  2. 該用戶的口令,例如,"hello"
  3. Cookie過期時間,可由當前時間戳+固定時長計算,例如,1461288165
  4. 系統固定的一個隨機字符串,例如,"secret"

把上面4部分拼起來,得到:

"1230001:hello:1461288165:secret"

計算上述字符串的md5,得到:"d9753...004d5"

最後,按照用戶id,過期時間和最終的hash值,拼接得到Cookie如下:

"1230001:1461288165:d9753...004d5"

當瀏覽器發送Cookie回服務器時,我們就可以按照下面的方式驗證Cookie:

  1. 把Cookie分割成三部分,得到用戶id,過期時間和hash值;
  2. 如果過期時間已到,直接丟棄;
  3. 根據用戶id查找用戶,得到用戶口令;
  4. 按照生成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做一個簡單的修改就可以實現了。

這纔是真正的開閉啊!


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章