附彩蛋|Spring Security 竟然故意延長登錄時間?知道真相的我驚呆了!

2011年12月21日,有人在網絡上公開了一個包含600萬個CSDN用戶資料的數據庫,數據全部爲明文儲存,包含用戶名、密碼以及註冊郵箱。事件發生後CSDN在微博、官方網站等渠道發出了聲明,解釋說此數據庫系2009年備份所用,因不明原因泄漏,已經向警方報案,後又在官網發出了公開道歉信。在接下來的十多天裏,金山、網易、京東、噹噹、新浪等多家公司被捲入到這次事件中。整個事件中最觸目驚心的莫過於CSDN把用戶密碼明文存儲,由於很多用戶是多個網站共用一個密碼,因此一個網站密碼泄漏就會造成很大的安全隱患。由於有了這麼多前車之鑑,我們現在做系統時,密碼都要加密處理。

1.密碼加密方案進化史

最早我們使用類似SHA-256這樣的單向Hash算法。用戶註冊成功後,保存在數據庫中的不再是用戶的明文密碼,而是經過SHA-256加密計算的一個字符串,當用戶進行登錄時,將用戶輸入的明文密碼用SHA-256進行加密,加密完成之後,再和存儲在數據庫中的密碼進行比對,進而確定用戶登錄信息是否有效。如果系統遭遇攻擊,最多也只是存儲在數據庫中的密文被泄漏。

這樣就絕對安全了嗎?當然不是的。彩虹表是一個用於加密Hash函數逆運算的表,通常用於破解加密過的Hash字符串。爲了降低彩虹表對系統安全性的影響,人們又發明了密碼加“鹽”,之前是直接將密碼作爲明文進行加密,現在再添加一個隨機數(即鹽)和密碼明文混合在一起進行加密,這樣即使密碼明文相同,生成的加密字符串也是不同的。當然,這個隨機數也需要以明文形式和密碼一起存儲在數據庫中。當用戶需要登錄時,拿到用戶輸入的明文密碼和存儲在數據庫中的鹽一起進行Hash運算,再將運算結果和存儲在數據庫中的密文進行比較,進而確定用戶的登錄信息是否有效。

密碼加鹽之後,彩虹表的作用就大打折扣了,因爲唯一的鹽和明文密碼總會生成唯一的Hash字符。

然而,隨着計算機硬件的發展,每秒執行數十億次Hash計算已經變得輕輕鬆鬆,這意味着即使給密碼加密加鹽也不再安全。

在Spring Security中,我們現在是用一種自適應單向函數(Adaptive One-way Functions)來處理密碼問題,這種自適應單向函數在進行密碼匹配時,會有意佔用大量系統資源(例如CPU、內存等),這樣可以增加惡意用戶攻擊系統的難度。在Spring Security中,開發者可以通過bcrypt、PBKDF2、scrypt以及argon2來體驗這種自適應單向函數加密。

由於自適應單向函數有意佔用大量系統資源,因此每個登錄認證請求都會大大降低應用程序的性能,但是Spring Security不會採取任何措施來提高密碼驗證速度,因爲它正是通過這種方式來增強系統的安全性。當然,開發者也可以將用戶名/密碼這種長期憑證兌換爲短期憑證,如會話、OAuth2令牌等,這樣既可以快速驗證用戶憑證信息,又不會損失系統的安全性。

2.PasswordEncoder詳解

Spring Security中通過PasswordEncoder接口定義了密碼加密和比對的相關操作:

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);
    boolean matches(CharSequence rawPassword, String encodedPassword);
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

可以看到,PasswordEncoder接口中一共有三個方法:

  1. encode:該方法用來對明文密碼進行加密。
  2. matches:該方法用來進行密碼比對。
  3. upgradeEncoding:該方法用來判斷當前密碼是否需要升級,默認返回false表示不需要升級。

針對密碼的所有操作,PasswordEncoder接口中都定義好了,不同的實現類將採用不同的密碼加密方案對密碼進行處理。

2.1 PasswordEncoder常見實現類

BCryptPasswordEncoder

BCryptPasswordEncoder使用bcrypt算法對密碼進行加密,爲了提高密碼的安全性,bcrypt算法故意降低運行速度,以增強密碼破解的難度。同時BCryptPasswordEncoder “爲自己帶鹽”,開發者不需要額外維護一個“鹽”字段,使用BCryptPasswordEncoder加密後的字符串就已經“帶鹽”了,即使相同的明文每次生成的加密字符串都不相同。

BCryptPasswordEncoder的默認強度爲10,開發者可以根據自己的服務器性能進行調整,以確保密碼驗證時間約爲1秒鐘(官方建議密碼驗證時間爲1秒鐘,這樣既可以提高系統安全性,又不會過多影響系統運行性能)。

Argon2PasswordEncoder

Argon2PasswordEncoder使用Argon2算法對密碼進行加密,Argon2曾在Password Hashing Competition競賽中獲勝。爲了解決在定製硬件上密碼容易被破解的問題,Argon2也是故意降低運算速度,同時需要大量內存,以確保系統的安全性。

Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder使用PBKDF2算法對密碼進行加密,和前面幾種類似,PBKDF2算法也是一種故意降低運算速度的算法,當需要FIPS(Federal Information Processing Standard,美國聯邦信息處理標準)認證時,PBKDF2算法是一個很好的選擇。

SCryptPasswordEncoder

SCryptPasswordEncoder使用scrypt算法對密碼進行加密,和前面的幾種類似,scrypt也是一種故意降低運算速度的算法,而且需要大量內存。

這四種就是我們前面所說的自適應單向函數加密。除了這幾種,還有一些基於消息摘要算法的加密方案,這些方案都已經不再安全,但是出於兼容性考慮,Spring Security並未移除相關類,主要有LdapShaPasswordEncoder、MessageDigestPasswordEncoder、Md4Password Encoder、StandardPasswordEncoder以及NoOpPasswordEncoder(密碼明文存儲),這五種皆已廢棄,這裏對這些類也不做過多介紹。

除了上面介紹的這幾種之外,還有一個非常重要的密碼加密工具類,那就是DelegatingPasswordEncoder。

2.2 DelegatingPasswordEncoder

根據前文的介紹,讀者可能會認爲Spring Security中默認的密碼加密方案應該是四種自適應單向加密函數中的一種,其實不然,在Spring Security 5.0之後,默認的密碼加密方案其實是DelegatingPasswordEncoder。

從名字上來看,DelegatingPasswordEncoder是一個代理類,而並非一種全新的密碼加密方案。

DelegatingPasswordEncoder主要用來代理上面介紹的不同的密碼加密方案。爲什麼採用DelegatingPasswordEncoder而不是某一個具體加密方式作爲默認的密碼加密方案呢?主要考慮瞭如下三方面的因素:

  1. 兼容性:使用DelegatingPasswordEncoder可以幫助許多使用舊密碼加密方式的系統順利遷移到Spring Security中,它允許在同一個系統中同時存在多種不同的密碼加密方案。
  2. 便捷性:密碼存儲的最佳方案不可能一直不變,如果使用DelegatingPasswordEncoder作爲默認的密碼加密方案,當需要修改加密方案時,只需要修改很小一部分代碼就可以實現。
  3. 穩定性:作爲一個框架,Spring Security不能經常進行重大更改,而使用Delegating PasswordEncoder可以方便地對密碼進行升級(自動從一個加密方案升級到另外一個加密方案)。

那麼DelegatingPasswordEncoder到底是如何代理其他密碼加密方案的?又是如何對加密方案進行升級的?我們就從PasswordEncoderFactories類開始看起,因爲正是由它裏邊的靜態方法createDelegatingPasswordEncoder提供了默認的DelegatingPasswordEncoder實例:

public class PasswordEncoderFactories {
    public static PasswordEncoder createDelegatingPasswordEncoder() {
        String encodingId = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put(encodingId, new BCryptPasswordEncoder());
        encoders.put("ldap"new org.springframework.security.crypto
                                           .password.LdapShaPasswordEncoder());
        encoders.put("MD4"new org.springframework.security.crypto
                                                .password.Md4PasswordEncoder());
        encoders.put("MD5"new org.springframework.security.crypto
                              .password.MessageDigestPasswordEncoder("MD5"));
        encoders.put("noop", org.springframework.security.crypto.password
                                           .NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2"new Pbkdf2PasswordEncoder());
        encoders.put("scrypt"new SCryptPasswordEncoder());
        encoders.put("SHA-1"new org.springframework.security.crypto
                            .password.MessageDigestPasswordEncoder("SHA-1"));
        encoders.put("SHA-256"new org.springframework.security.crypto
                         .password.MessageDigestPasswordEncoder("SHA-256"));
        encoders.put("sha256"new org.springframework.security.crypto
                                          .password.StandardPasswordEncoder());
        encoders.put("argon2"new Argon2PasswordEncoder());
        return new DelegatingPasswordEncoder(encodingId, encoders);
    }
    private PasswordEncoderFactories() {}
}

可以看到,在createDelegatingPasswordEncoder方法中,首先定義了encoders變量,encoders中存儲了每一種密碼加密方案的id和所對應的加密類,例如bcrypt對應着BcryptPassword Encoder、argon2對應着Argon2PasswordEncoder、noop對應着NoOpPasswordEncoder。

encoders創建完成後,最終新建一個DelegatingPasswordEncoder實例,並傳入encodingId和encoders變量,其中encodingId默認值爲bcrypt,相當於代理類中默認使用的加密方案是BCryptPasswordEncoder。

我們來分析一下DelegatingPasswordEncoder類的源碼,由於源碼比較長,我們就先從它的屬性開始看起:

public class DelegatingPasswordEncoder implements PasswordEncoder {
    private static final String PREFIX = "{";
    private static final String SUFFIX = "}";
    private final String idForEncode;
    private final PasswordEncoder passwordEncoderForEncode;
    private final Map<String, PasswordEncoder> idToPasswordEncoder;
    private PasswordEncoder defaultPasswordEncoderForMatches = 
                                                new UnmappedIdPasswordEncoder();
}
  1. 首先定義了前綴PREFIX和後綴SUFFIX,用來包裹將來生成的加密方案的id。
  2. idForEncode表示默認的加密方案id。
  3. passwordEncoderForEncode表示默認的加密方案(BCryptPasswordEncoder),它的值是根據idForEncode從idToPasswordEncoder集合中提取出來的。
  4. idToPasswordEncoder用來保存id和加密方案之間的映射。
  5. defaultPasswordEncoderForMatches是指默認的密碼比對器,當根據密碼加密方案的id無法找到對應的加密方案時,就會使用默認的密碼比對器。defaultPasswordEncoderForMatches的默認類型是UnmappedIdPasswordEncoder,在UnmappedIdPasswordEncoder的matches方法中並不會做任何密碼比對操作,直接拋出異常。
  6. 最後看到的DelegatingPasswordEncoder也是PasswordEncoder接口的子類,所以接下來我們就來重點分析PasswordEncoder接口中三個方法在DelegatingPasswordEncoder中的具體實現。首先來看encode方法:
@Override
public String encode(CharSequence rawPassword) {
    return PREFIX + this.idForEncode + SUFFIX 
                         + this.passwordEncoderForEncode.encode(rawPassword);
}

encode方法的實現邏輯很簡單,具體的加密工作還是由加密類來完成,只不過在密碼加密完成後,給加密後的字符串加上一個前綴{id},用來描述所採用的具體加密方案。因此,encode方法加密出來的字符串格式類似如下形式:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}123
{pbkdf2}23b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4

不同的前綴代表了後面的字符串採用了不同的加密方案。

再來看密碼比對方法matches:

@Override
public boolean matches(CharSequence rawPassword, 
                            String prefixEncodedPassword)
 
{
    if (rawPassword == null && prefixEncodedPassword == null) {
        return true;
    }
    String id = extractId(prefixEncodedPassword);
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    if (delegate == null) {
        return this.defaultPasswordEncoderForMatches
            .matches(rawPassword, prefixEncodedPassword);
    }
    String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
    return delegate.matches(rawPassword, encodedPassword);
}
private String extractId(String prefixEncodedPassword) {
    if (prefixEncodedPassword == null) {
        return null;
    }
    int start = prefixEncodedPassword.indexOf(PREFIX);
    if (start != 0) {
        return null;
    }
    int end = prefixEncodedPassword.indexOf(SUFFIX, start);
    if (end < 0) {
        return null;
    }
    return prefixEncodedPassword.substring(start + 1, end);
}

在matches方法中,首先調用extractId方法從加密字符串中提取出具體的加密方案id,也就是{}中的字符,具體的提取方式就是字符串截取。拿到id之後,再去idToPasswordEncoder集合中獲取對應的加密方案,如果獲取到的爲null,說明不存在對應的加密實例,那麼就會採用默認的密碼匹配器defaultPasswordEncoderForMatches;如果根據id獲取到了對應的加密實例,則調用其matches方法完成密碼校驗。

可以看到,這裏的matches方法非常靈活,可以根據加密字符串的前綴,去查找到不同的加密方案,進而完成密碼校驗。同一個系統中,加密字符串可以使用不同的前綴而互不影響。

最後,我們再來看一下DelegatingPasswordEncoder中的密碼升級方法upgradeEncoding:

@Override
public boolean upgradeEncoding(String prefixEncodedPassword) {
    String id = extractId(prefixEncodedPassword);
    if (!this.idForEncode.equalsIgnoreCase(id)) {
        return true;
    }
    else {
        String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
        return this.idToPasswordEncoder.get(id)
                                              .upgradeEncoding(encodedPassword);
    }
}

可以看到,如果當前加密字符串所採用的加密方案不是默認的加密方案(BcryptPassword Encoder),就會自動進行密碼升級,否則就調用默認加密方案的upgradeEncoding方法判斷密碼是否需要升級。至此,我們將Spring Security中的整個加密體系向讀者簡單介紹了一遍,接下來我們通過幾個實際的案例來看一下加密方案要怎麼用。


以上內容節選自松哥的新書《深入淺出 Spring Security》,他和磊哥是老鄉,也是認識很久的朋友了,同時他也是《Spring Boot+Vue全棧開發實戰》一書的作者,非常低調和務實的技術大佬 ,最後推薦一波他的新書,非常值得一讀。

彩蛋

爲了感謝各位讀者朋友的長期支持,此評論區下留言,磊哥送 5 本松哥的新書《深入淺出 Spring Security》,需要的小夥伴趕緊留言吧,當然,臉熟和經常留言的朋友中獎機率更大。

本文分享自微信公衆號 - Java中文社羣(javacn666)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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