Spring Security PasswordEncoder 密碼校驗(yàn)和密碼加密流程
- 本文使用的源碼是 Spring Security 5.1.2
http://central.maven.org/maven2/org/springframework/security/spring-security-core/5.1.2.RELEASE/spring-security-core-5.1.2.RELEASE-sources.jar
PasswordEncoder 使用
首先我們先來看看一個(gè)創(chuàng)建密碼編碼器工廠方法
org/springframework/security/crypto/factory/PasswordEncoderFactories.java
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());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
上述代碼 encoders 的 Map 包含了很多種密碼編碼器镣丑,有 ldap 旺遮、MD4 疙描、 MD5 、noop 、pbkdf2 沮协、scrypt 谦铃、SHA-1 、SHA-256
上面靜態(tài)工廠方法可以看出赞厕,默認(rèn)是創(chuàng)建并返回一個(gè) BCryptPasswordEncoder艳狐,同時(shí)該 BCryptPasswordEncoder( PasswordEncoder 子類)也是 Spring Security 推薦的默認(rèn)密碼編碼器,其中 noop 就是不做處理默認(rèn)保存原密碼皿桑。
一般我們代碼中 @Autowired 注入并使用 PasswordEncoder 接口的實(shí)例毫目,然后調(diào)用其 matches 方法去匹配原密碼和數(shù)據(jù)庫(kù)中保存的“密碼”蔬啡;密碼的校驗(yàn)方式有多種,從 PasswordEncoder 接口實(shí)現(xiàn)的類是可以知道镀虐。
業(yè)務(wù)代碼中注入 PasswordEncoder
@Autowired
private PasswordEncoder passwordEncoder;
科普一些基本知識(shí)
BCrypt 密碼散列函數(shù)的概念介紹可以看一下維基百科或百度百科的內(nèi)容 (因?yàn)橹皇鞘褂孟潴。瑫簳r(shí)不需要對(duì)算法的了解和實(shí)現(xiàn))
維基百科:BCrypt
百度百科:BCrypt
知識(shí)混淆點(diǎn)
加密/解密 與 Hash 這兩個(gè)概念不能混淆,比如:SHA 系列是 Hash 算法刮便,不是加密算法顽腾,加密意味著可以解密,但是 Hash 是不可逆的(無法通過 Hash 值還原得到密碼诺核,只能比對(duì) Hash 值看看是否相等)抄肖。
安全性問題
目前很大一部分存在安全問題的系統(tǒng)一般僅僅使用密碼的 MD5 值進(jìn)行保存,可以通過 MD5 查詢庫(kù)去匹配對(duì)大部分的密碼(可以直接從彩虹表里反推出來)窖杀,而且 MD5 計(jì)算 Hash 值碰撞容易構(gòu)造漓摩,安全性大大降低。MD5 加鹽在本地計(jì)算速度也是很快入客,也是密碼短也是極其容易破解管毙;更好的選擇是 SHA-256、BCrypt 等等等
密碼匹配流程的源碼解釋
本文簡(jiǎn)單說一下 BCryptPasswordEncoder 密碼匹配的一個(gè)簡(jiǎn)單流程或者過程桌硫。
重點(diǎn)
如果是使用 BCryptPasswordEncoder 調(diào)用 encode() 方法編碼輸入密碼的話夭咬,其實(shí)這個(gè)編碼后的“密碼”并不是我們平時(shí)輸入的真正密碼,而是密碼加鹽后的通過單向 Hash 算法(BCrypt)得到值铆隘。
這里面細(xì)心的同學(xué)可能會(huì)發(fā)現(xiàn)一些問題:
同一個(gè)密碼計(jì)算 Hash 不應(yīng)該是一樣的嗎卓舵?每次使用 BCryptPasswordEncoder 編碼同一個(gè)密碼都是不一樣的?
BCryptPasswordEncoder 編碼同一個(gè)密碼后結(jié)果都不一樣膀钠,怎么進(jìn)行匹配掏湾?
下面通過源碼簡(jiǎn)單說一下這個(gè)匹配的流程:
matches(CharSequence rawPassword, String encodedPassword)
方法根據(jù)兩個(gè)參數(shù)都可以知道
- 第一個(gè)參數(shù)是原密碼
- 第二個(gè)參數(shù)就是用
PasswordEncoder
調(diào)用encode(CharSequence rawPassword)
編碼過后保存在數(shù)據(jù)庫(kù)的密碼。
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
上述代碼解讀:首先判斷是否數(shù)據(jù)庫(kù)保存的“密碼”(后面簡(jiǎn)稱:“密碼”)是否為空或者 null
肿嘲,在通過正則表達(dá)式匹配“密碼”是否符合格式融击,最后通過 BCrypt
的 checkpw(String plaintext, String hashed)
方法進(jìn)行密碼匹配
再詳細(xì)看看 BCrypt
的 checkpw(String plaintext, String hashed)
方法:
org/springframework/security/crypto/bcrypt/BCrypt.java
public static boolean checkpw(String plaintext, String hashed) {
return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}
第二個(gè)參數(shù) hashed
表明其實(shí)數(shù)據(jù)庫(kù)查詢出來的“密碼”也就是 Hash 值;equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed))
代碼中通過調(diào)用 hashpw
計(jì)算輸入密碼的 Hash 值(參數(shù)分別是輸入的密碼和保存在數(shù)據(jù)庫(kù)的“密碼”)
再繼續(xù)看 hashpw
里面的部分代碼(內(nèi)容過長(zhǎng)雳窟,省略部分代碼尊浪,看看代碼中的中文注釋):
org/springframework/security/crypto/bcrypt/BCrypt.java
public static String hashpw(String password, String salt) throws IllegalArgumentException {
BCrypt B;
String real_salt;
byte passwordb[], saltb[], hashed[];
char minor = (char) 0;
int rounds, off = 0;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
}
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
}
if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
if (salt.charAt(2) == '$') {
off = 3;
}
else {
minor = salt.charAt(2);
if (minor != 'a' || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
if (saltLength - off < 25) {
throw new IllegalArgumentException("Invalid salt");
}
// Extract number of rounds
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
}
rounds = Integer.parseInt(salt.substring(off, off + 2));
// 關(guān)鍵點(diǎn):上面***一大堆就是校驗(yàn)是否符合相應(yīng)格式,然后下面這行就是取出密碼的鹽封救,real_salt就是 Hash 計(jì)算前的密碼鹽(關(guān)于鹽的介紹:https://zh.wikipedia.org/wiki/%E7%9B%90_(%E5%AF%86%E7%A0%81%E5%AD%A6))
real_salt = salt.substring(off + 3, off + 25);
try {
passwordb = (password + (minor >= 'a' ? "\000" : "")).getBytes("UTF-8");
}
catch (UnsupportedEncodingException uee) {
throw new AssertionError("UTF-8 is not supported");
}
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
其實(shí)上面代碼就是從數(shù)據(jù)庫(kù)得到的“密碼”(參數(shù): salt )進(jìn)行一系列校驗(yàn)(長(zhǎng)度校驗(yàn)等)并截取“密碼”中相應(yīng)的密碼鹽拇涤,利用這個(gè)密碼鹽進(jìn)行同樣的一系列計(jì)算 Hash 操作和 Base64 編碼拼接一些標(biāo)識(shí)符 生成所謂的“密碼”,最后 equalsNoEarlyReturn
方法對(duì)同一個(gè)密碼鹽生成的兩個(gè)“密碼”進(jìn)行匹配兴泥。
上述大致就是密碼匹配流程了工育,對(duì)于問題“ BCryptPasswordEncoder 編碼同一個(gè)密碼后結(jié)果都不一樣,怎么進(jìn)行匹配”
的簡(jiǎn)單解答:
因?yàn)槊艽a鹽是隨機(jī)生成的搓彻,但是可以根據(jù)數(shù)據(jù)庫(kù)查詢出來的“密碼”拿到密碼鹽如绸,同一個(gè)密碼鹽+原密碼計(jì)算 Hash 結(jié)果值是能匹配的嘱朽。
密碼“加密”保存源碼解釋
看看加密的一個(gè)過程,
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
public String encode(CharSequence rawPassword) {
String salt;
if (strength > 0) {
if (random != null) {
// 生成隨機(jī)密碼鹽
salt = BCrypt.gensalt(strength, random);
}
else {
// 生成隨機(jī)密碼鹽
salt = BCrypt.gensalt(strength);
}
}
else {
// 生成隨機(jī)密碼鹽
salt = BCrypt.gensalt();
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
encode 方法傳入是原密碼怔接,其中 int strength, SecureRandom random
這兩個(gè)構(gòu)造參數(shù)是 BCryptPasswordEncoder(int strength, SecureRandom random)
構(gòu)造方法按需傳入搪泳,如果不指定strength和random,默認(rèn)執(zhí)行 BCrypt.gensalt()
這行代碼生成也相應(yīng)密碼隨機(jī)鹽扼脐。
先看看 gensalt(int log_rounds, SecureRandom random)
方法的代碼(可以看看中文注釋):
org/springframework/security/crypto/bcrypt/BCrypt.java
public static String gensalt(int log_rounds, SecureRandom random) {
// 一些檢驗(yàn)
if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
throw new IllegalArgumentException("Bad number of rounds");
}
StringBuilder rs = new StringBuilder();
byte rnd[] = new byte[BCRYPT_SALT_LEN];
// 生成隨機(jī)字節(jié)并將其置于rnd字節(jié)數(shù)組
random.nextBytes(rnd);
rs.append("$2a$");
if (log_rounds < 10) {
// 不夠長(zhǎng)度補(bǔ)夠
rs.append("0");
}
// 拼接字符串得到相應(yīng)的格式
rs.append(log_rounds);
rs.append("$");
encode_base64(rnd, rnd.length, rs);
return rs.toString();
}
最終上面的 gensalt
方法得到一個(gè) 隨機(jī)密碼鹽+無用字符串(這個(gè)字符串可以理解為你輸入的密碼) 計(jì)算 Hash 操作和 Base64 編碼拼接一些標(biāo)識(shí)符 生成假“密碼”(這個(gè)假“密碼”為了兼容方便調(diào)用 hashpw
方法)岸军,最后關(guān)鍵點(diǎn)就是調(diào)用 BCrypt.hashpw
方法取到密碼鹽生成相應(yīng)的真實(shí)“密碼”(這個(gè)得到的密碼可以用于保存在數(shù)據(jù)庫(kù)中了)。
對(duì)于問題“同一個(gè)密碼計(jì)算 Hash 不應(yīng)該是一樣的嗎瓦侮?每次使用 BCryptPasswordEncoder 編碼同一個(gè)密碼都是不一樣的艰赞?”
的簡(jiǎn)單解答:
因?yàn)橛玫降碾S機(jī)密碼鹽每次都是不一樣的,同一個(gè)密碼和不同的密碼鹽組合計(jì)算出來的 Hash 值肯定不一樣啦肚吏,所以編碼同一個(gè)密碼得到的結(jié)果都是不一樣方妖。
建議和想法
本文主要講解一些安全性防護(hù)的思想,學(xué)習(xí)的過程思想很重要罚攀。
登錄注冊(cè)是每個(gè)系統(tǒng)都具備的功能党觅,開發(fā)的同學(xué)記住一定不能保存明文密碼,否則被脫庫(kù)就會(huì)造成嚴(yán)重的后果斋泄。如果是通過上述的方法進(jìn)行密碼保存杯瞻,即便拿到“密碼”也非常難還原密碼。
上述在密碼編碼的過程中的思想還是需要掌握:
只是保存散列碼是不安全的炫掐,但是我們可以為密碼加鹽再通過一些 Hash 值 低概率碰撞且計(jì)算速度慢 的散列算法計(jì)算 Hash 值保存魁莉。
Spring Security 每次 Hash 之前用的鹽都是隨機(jī),鹽可以保存在最終生成的“密碼”中卒废,這樣每個(gè)密碼都是用了相應(yīng)不同的隨機(jī)鹽+原密碼計(jì)算 Hash 值得到沛厨,暴力破解難度也變大了宙地。