新書出版——松哥的!
好朋友松哥出版了一本新書,作為老鐵,在公眾號必須宣傳一把。簡單寫了一點宣傳文案,大家可以看一看。迫不及待的話,可以直接拉到文末領(lǐng)取贈書。
2011年12月21日,有人在網(wǎng)絡(luò)上公開了一個包含600萬個CSDN用戶資料的數(shù)據(jù)庫,數(shù)據(jù)全部為明文儲存,包含用戶名、密碼以及注冊郵箱。事件發(fā)生后CSDN在微博、官方網(wǎng)站等渠道發(fā)出了聲明,解釋說此數(shù)據(jù)庫系2009年備份所用,因不明原因泄漏,已經(jīng)向警方報案,后又在官網(wǎng)發(fā)出了公開道歉信。在接下來的十多天里,金山、網(wǎng)易、京東、當當、新浪等多家公司被卷入到這次事件中。整個事件中最觸目驚心的莫過于CSDN把用戶密碼明文存儲,由于很多用戶是多個網(wǎng)站共用一個密碼,因此一個網(wǎng)站密碼泄漏就會造成很大的安全隱患。由于有了這么多前車之鑒,我們現(xiàn)在做系統(tǒng)時,密碼都要加密處理。
1.密碼加密方案進化史
最早我們使用類似SHA-256這樣的單向Hash算法。用戶注冊成功后,保存在數(shù)據(jù)庫中的不再是用戶的明文密碼,而是經(jīng)過SHA-256加密計算的一個字符串,當用戶進行登錄時,將用戶輸入的明文密碼用SHA-256進行加密,加密完成之后,再和存儲在數(shù)據(jù)庫中的密碼進行比對,進而確定用戶登錄信息是否有效。如果系統(tǒng)遭遇攻擊,最多也只是存儲在數(shù)據(jù)庫中的密文被泄漏。
這樣就絕對安全了嗎?當然不是的。彩虹表是一個用于加密Hash函數(shù)逆運算的表,通常用于破解加密過的Hash字符串。為了降低彩虹表對系統(tǒng)安全性的影響,人們又發(fā)明了密碼加“鹽”,之前是直接將密碼作為明文進行加密,現(xiàn)在再添加一個隨機數(shù)(即鹽)和密碼明文混合在一起進行加密,這樣即使密碼明文相同,生成的加密字符串也是不同的。當然,這個隨機數(shù)也需要以明文形式和密碼一起存儲在數(shù)據(jù)庫中。當用戶需要登錄時,拿到用戶輸入的明文密碼和存儲在數(shù)據(jù)庫中的鹽一起進行Hash運算,再將運算結(jié)果和存儲在數(shù)據(jù)庫中的密文進行比較,進而確定用戶的登錄信息是否有效。
密碼加鹽之后,彩虹表的作用就大打折扣了,因為唯一的鹽和明文密碼總會生成唯一的Hash字符。
然而,隨著計算機硬件的發(fā)展,每秒執(zhí)行數(shù)十億次Hash計算已經(jīng)變得輕輕松松,這意味著即使給密碼加密加鹽也不再安全。
在Spring Security中,我們現(xiàn)在是用一種自適應(yīng)單向函數(shù)(Adaptive One-way Functions)來處理密碼問題,這種自適應(yīng)單向函數(shù)在進行密碼匹配時,會有意占用大量系統(tǒng)資源(例如CPU、內(nèi)存等),這樣可以增加惡意用戶攻擊系統(tǒng)的難度。在Spring Security中,開發(fā)者可以通過bcrypt、PBKDF2、scrypt以及argon2來體驗這種自適應(yīng)單向函數(shù)加密。
由于自適應(yīng)單向函數(shù)有意占用大量系統(tǒng)資源,因此每個登錄認證請求都會大大降低應(yīng)用程序的性能,但是Spring Security不會采取任何措施來提高密碼驗證速度,因為它正是通過這種方式來增強系統(tǒng)的安全性。當然,開發(fā)者也可以將用戶名/密碼這種長期憑證兌換為短期憑證,如會話、OAuth2令牌等,這樣既可以快速驗證用戶憑證信息,又不會損失系統(tǒng)的安全性。
2.PasswordEncoder詳解
Spring Security中通過PasswordEncoder接口定義了密碼加密和比對的相關(guān)操作:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
可以看到,PasswordEncoder接口中一共有三個方法:
encode:該方法用來對明文密碼進行加密。 matches:該方法用來進行密碼比對。 upgradeEncoding:該方法用來判斷當前密碼是否需要升級,默認返回false表示不需要升級。
針對密碼的所有操作,PasswordEncoder接口中都定義好了,不同的實現(xiàn)類將采用不同的密碼加密方案對密碼進行處理。
2.1 PasswordEncoder常見實現(xiàn)類
BCryptPasswordEncoder
BCryptPasswordEncoder使用bcrypt算法對密碼進行加密,為了提高密碼的安全性,bcrypt算法故意降低運行速度,以增強密碼破解的難度。同時BCryptPasswordEncoder “為自己帶鹽”,開發(fā)者不需要額外維護一個“鹽”字段,使用BCryptPasswordEncoder加密后的字符串就已經(jīng)“帶鹽”了,即使相同的明文每次生成的加密字符串都不相同。
BCryptPasswordEncoder的默認強度為10,開發(fā)者可以根據(jù)自己的服務(wù)器性能進行調(diào)整,以確保密碼驗證時間約為1秒鐘(官方建議密碼驗證時間為1秒鐘,這樣既可以提高系統(tǒng)安全性,又不會過多影響系統(tǒng)運行性能)。
Argon2PasswordEncoder
Argon2PasswordEncoder使用Argon2算法對密碼進行加密,Argon2曾在Password Hashing Competition競賽中獲勝。為了解決在定制硬件上密碼容易被破解的問題,Argon2也是故意降低運算速度,同時需要大量內(nèi)存,以確保系統(tǒng)的安全性。
Pbkdf2PasswordEncoder
Pbkdf2PasswordEncoder使用PBKDF2算法對密碼進行加密,和前面幾種類似,PBKDF2算法也是一種故意降低運算速度的算法,當需要FIPS(Federal Information Processing Standard,美國聯(lián)邦信息處理標準)認證時,PBKDF2算法是一個很好的選擇。
SCryptPasswordEncoder
SCryptPasswordEncoder使用scrypt算法對密碼進行加密,和前面的幾種類似,scrypt也是一種故意降低運算速度的算法,而且需要大量內(nèi)存。
這四種就是我們前面所說的自適應(yīng)單向函數(shù)加密。除了這幾種,還有一些基于消息摘要算法的加密方案,這些方案都已經(jīng)不再安全,但是出于兼容性考慮,Spring Security并未移除相關(guān)類,主要有LdapShaPasswordEncoder、MessageDigestPasswordEncoder、Md4Password Encoder、StandardPasswordEncoder以及NoOpPasswordEncoder(密碼明文存儲),這五種皆已廢棄,這里對這些類也不做過多介紹。
除了上面介紹的這幾種之外,還有一個非常重要的密碼加密工具類,那就是DelegatingPasswordEncoder。
2.2 DelegatingPasswordEncoder
根據(jù)前文的介紹,讀者可能會認為Spring Security中默認的密碼加密方案應(yīng)該是四種自適應(yīng)單向加密函數(shù)中的一種,其實不然,在Spring Security 5.0之后,默認的密碼加密方案其實是DelegatingPasswordEncoder。
從名字上來看,DelegatingPasswordEncoder是一個代理類,而并非一種全新的密碼加密方案。
DelegatingPasswordEncoder主要用來代理上面介紹的不同的密碼加密方案。為什么采用DelegatingPasswordEncoder而不是某一個具體加密方式作為默認的密碼加密方案呢?主要考慮了如下三方面的因素:
兼容性:使用DelegatingPasswordEncoder可以幫助許多使用舊密碼加密方式的系統(tǒng)順利遷移到Spring Security中,它允許在同一個系統(tǒng)中同時存在多種不同的密碼加密方案。 便捷性:密碼存儲的最佳方案不可能一直不變,如果使用DelegatingPasswordEncoder作為默認的密碼加密方案,當需要修改加密方案時,只需要修改很小一部分代碼就可以實現(xiàn)。 穩(wěn)定性:作為一個框架,Spring Security不能經(jīng)常進行重大更改,而使用Delegating PasswordEncoder可以方便地對密碼進行升級(自動從一個加密方案升級到另外一個加密方案)。
那么DelegatingPasswordEncoder到底是如何代理其他密碼加密方案的?又是如何對加密方案進行升級的?我們就從PasswordEncoderFactories類開始看起,因為正是由它里邊的靜態(tài)方法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和所對應(yīng)的加密類,例如bcrypt對應(yīng)著BcryptPassword Encoder、argon2對應(yīng)著Argon2PasswordEncoder、noop對應(yīng)著NoOpPasswordEncoder。
encoders創(chuàng)建完成后,最終新建一個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();
}
首先定義了前綴PREFIX和后綴SUFFIX,用來包裹將來生成的加密方案的id。 idForEncode表示默認的加密方案id。 passwordEncoderForEncode表示默認的加密方案(BCryptPasswordEncoder),它的值是根據(jù)idForEncode從idToPasswordEncoder集合中提取出來的。 idToPasswordEncoder用來保存id和加密方案之間的映射。 defaultPasswordEncoderForMatches是指默認的密碼比對器,當根據(jù)密碼加密方案的id無法找到對應(yīng)的加密方案時,就會使用默認的密碼比對器。defaultPasswordEncoderForMatches的默認類型是UnmappedIdPasswordEncoder,在UnmappedIdPasswordEncoder的matches方法中并不會做任何密碼比對操作,直接拋出異常。 最后看到的DelegatingPasswordEncoder也是PasswordEncoder接口的子類,所以接下來我們就來重點分析PasswordEncoder接口中三個方法在DelegatingPasswordEncoder中的具體實現(xiàn)。首先來看encode方法:
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX
+ this.passwordEncoderForEncode.encode(rawPassword);
}
encode方法的實現(xiàn)邏輯很簡單,具體的加密工作還是由加密類來完成,只不過在密碼加密完成后,給加密后的字符串加上一個前綴{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方法中,首先調(diào)用extractId方法從加密字符串中提取出具體的加密方案id,也就是{}中的字符,具體的提取方式就是字符串截取。拿到id之后,再去idToPasswordEncoder集合中獲取對應(yīng)的加密方案,如果獲取到的為null,說明不存在對應(yīng)的加密實例,那么就會采用默認的密碼匹配器defaultPasswordEncoderForMatches;如果根據(jù)id獲取到了對應(yīng)的加密實例,則調(diào)用其matches方法完成密碼校驗。
可以看到,這里的matches方法非常靈活,可以根據(jù)加密字符串的前綴,去查找到不同的加密方案,進而完成密碼校驗。同一個系統(tǒng)中,加密字符串可以使用不同的前綴而互不影響。
最后,我們再來看一下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),就會自動進行密碼升級,否則就調(diào)用默認加密方案的upgradeEncoding方法判斷密碼是否需要升級。至此,我們將Spring Security中的整個加密體系向讀者簡單介紹了一遍,接下來我們通過幾個實際的案例來看一下加密方案要怎么用。
以上內(nèi)容節(jié)選自《深入淺出 Spring Security》一書,作者江南一點雨,也就是松哥,我的好朋友,鐵哥們,他是華為云MVP,華為云云享專家,之前出版過另外一本書《Spring Boot+Vue全棧開發(fā)實戰(zhàn)》,對 Spring 全家桶有深入研究。想要直接購買的,可以點擊下面的鏈接。
松哥在新書出版的第一時間就給我送了一本簽名版。好像字寫得和我一個水平,都不咋滴哈。

我給公號的讀者申請了五本,后臺回復關(guān)鍵字「松哥」就行了,按照順序,第 1、50、100、150、200 各送一本,完全憑運氣了哈,我會在下次發(fā)文的時候人工數(shù)一數(shù)的,到時候會告訴中獎的小伙伴,記得給我發(fā)郵寄信息喲~
