首先我們先來看看一個創(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)工廠方法可以看出,默認是創(chuàng)建并返回一個 BCryptPasswordEncoder,同時該 BCryptPasswordEncoder( PasswordEncoder 子類)也是 Spring Security 推薦的默認密碼編碼器,其中 noop 就是不做處理默認保存原密碼。
一般我們代碼中 @Autowired 注入并使用 PasswordEncoder 接口的實例,然后調(diào)用其 matches 方法去匹配原密碼和數(shù)據(jù)庫中保存的“密碼”;密碼的校驗方式有多種,從 PasswordEncoder 接口實現(xiàn)的類是可以知道。
業(yè)務代碼中注入 PasswordEncoder
@Autowiredprivate PasswordEncoder passwordEncoder;
BCrypt 密碼散列函數(shù)的概念介紹可以看一下維基百科或百度百科的內(nèi)容 (因為只是使用,暫時不需要對算法的了解和實現(xiàn))
維基百科:BCrypt
百度百科:BCrypt
加密/解密 與 Hash 這兩個概念不能混淆,比如:SHA 系列是 Hash 算法,不是加密算法,加密意味著可以解密,但是 Hash 是不可逆的(無法通過 Hash 值還原得到密碼,只能比對 Hash 值看看是否相等)。
目前很大一部分存在安全問題的系統(tǒng)一般僅僅使用密碼的 MD5 值進行保存,可以通過 MD5 查詢庫去匹配對大部分的密碼(可以直接從彩虹表里反推出來),而且 MD5 計算 Hash 值碰撞容易構(gòu)造,安全性大大降低。MD5 加鹽在本地計算速度也是很快,也是密碼短也是極其容易破解;更好的選擇是 SHA-256、BCrypt 等等等
本文簡單說一下 BCryptPasswordEncoder 密碼匹配的一個簡單流程或者過程。
如果是使用 BCryptPasswordEncoder 調(diào)用 encode() 方法編碼輸入密碼的話,其實這個編碼后的“密碼”并不是我們平時輸入的真正密碼,而是密碼加鹽后的通過單向 Hash 算法(BCrypt)得到值。
這里面細心的同學可能會發(fā)現(xiàn)一些問題:
同一個密碼計算 Hash 不應該是一樣的嗎?每次使用 BCryptPasswordEncoder 編碼同一個密碼都是不一樣的?
BCryptPasswordEncoder 編碼同一個密碼后結(jié)果都不一樣,怎么進行匹配?
下面通過源碼簡單說一下這個匹配的流程:matches(CharSequence rawPassword, String encodedPassword)
方法根據(jù)兩個參數(shù)都可以知道
PasswordEncoder
調(diào)用 encode(CharSequence rawPassword)
編碼過后保存在數(shù)據(jù)庫的密碼。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ù)庫保存的“密碼”(后面簡稱:“密碼”)是否為空或者 null
,在通過正則表達式匹配“密碼”是否符合格式,最后通過 BCrypt
的 checkpw(String plaintext, String hashed)
方法進行密碼匹配
再詳細看看 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));}
第二個參數(shù) hashed
表明其實數(shù)據(jù)庫查詢出來的“密碼”也就是 Hash 值;equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed))
代碼中通過調(diào)用 hashpw
計算輸入密碼的 Hash 值(參數(shù)分別是輸入的密碼和保存在數(shù)據(jù)庫的“密碼”)
再繼續(xù)看 hashpw
里面的部分代碼(內(nèi)容過長,省略部分代碼,看看代碼中的中文注釋):
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)鍵點:上面***一大堆就是校驗是否符合相應格式,然后下面這行就是取出密碼的鹽,real_salt就是 Hash 計算前的密碼鹽(關(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ù)據(jù)庫得到的“密碼”(參數(shù): salt )進行一系列校驗(長度校驗等)并截取“密碼”中相應的密碼鹽,利用這個密碼鹽進行同樣的一系列計算 Hash 操作和 Base64 編碼拼接一些標識符 生成所謂的“密碼”,最后 equalsNoEarlyReturn
方法對同一個密碼鹽生成的兩個“密碼”進行匹配。
上述大致就是密碼匹配流程了,對于問題“ BCryptPasswordEncoder 編碼同一個密碼后結(jié)果都不一樣,怎么進行匹配”
的簡單解答:
因為密碼鹽是隨機生成的,但是可以根據(jù)數(shù)據(jù)庫查詢出來的“密碼”拿到密碼鹽,同一個密碼鹽+原密碼計算 Hash 結(jié)果值是能匹配的。
看看加密的一個過程,
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
public String encode(CharSequence rawPassword) { String salt; if (strength > 0) { if (random != null) { // 生成隨機密碼鹽 salt = BCrypt.gensalt(strength, random); } else { // 生成隨機密碼鹽 salt = BCrypt.gensalt(strength); } } else { // 生成隨機密碼鹽 salt = BCrypt.gensalt(); } return BCrypt.hashpw(rawPassword.toString(), salt);}
encode 方法傳入是原密碼,其中 int strength, SecureRandom random
這兩個構(gòu)造參數(shù)是 BCryptPasswordEncoder(int strength, SecureRandom random)
構(gòu)造方法按需傳入,如果不指定strength和random,默認執(zhí)行 BCrypt.gensalt()
這行代碼生成也相應密碼隨機鹽。
先看看 gensalt(int log_rounds, SecureRandom random)
方法的代碼(可以看看中文注釋):
org/springframework/security/crypto/bcrypt/BCrypt.java
public static String gensalt(int log_rounds, SecureRandom random) { // 一些檢驗 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]; // 生成隨機字節(jié)并將其置于rnd字節(jié)數(shù)組 random.nextBytes(rnd); rs.append("$2a$"); if (log_rounds < 10) { // 不夠長度補夠 rs.append("0"); } // 拼接字符串得到相應的格式 rs.append(log_rounds); rs.append("$"); encode_base64(rnd, rnd.length, rs); return rs.toString();}
最終上面的 gensalt
方法得到一個 隨機密碼鹽+無用字符串(這個字符串可以理解為你輸入的密碼) 計算 Hash 操作和 Base64 編碼拼接一些標識符 生成假“密碼”(這個假“密碼”為了兼容方便調(diào)用 hashpw
方法),最后關(guān)鍵點就是調(diào)用 BCrypt.hashpw
方法取到密碼鹽生成相應的真實“密碼”(這個得到的密碼可以用于保存在數(shù)據(jù)庫中了)。
對于問題“同一個密碼計算 Hash 不應該是一樣的嗎?每次使用 BCryptPasswordEncoder 編碼同一個密碼都是不一樣的?”
的簡單解答:
因為用到的隨機密碼鹽每次都是不一樣的,同一個密碼和不同的密碼鹽組合計算出來的 Hash 值肯定不一樣啦,所以編碼同一個密碼得到的結(jié)果都是不一樣。
本文主要講解一些安全性防護的思想,學習的過程思想很重要。
登錄注冊是每個系統(tǒng)都具備的功能,開發(fā)的同學記住一定不能保存明文密碼,否則被脫庫就會造成嚴重的后果。如果是通過上述的方法進行密碼保存,即便拿到“密碼”也非常難還原密碼。
上述在密碼編碼的過程中的思想還是需要掌握:
只是保存散列碼是不安全的,但是我們可以為密碼加鹽再通過一些 Hash 值 低概率碰撞且計算速度慢 的散列算法計算 Hash 值保存。
Spring Security 每次 Hash 之前用的鹽都是隨機,鹽可以保存在最終生成的“密碼”中,這樣每個密碼都是用了相應不同的隨機鹽+原密碼計算 Hash 值得到,暴力破解難度也變大了。