Spring Security PasswordEncoder 密码校验和密码加密流程
PasswordEncoder 使用
首先我们先来看看一个创建密码编码器工厂方法
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
上面静态工厂方法可以看出,默认是创建并返回一个 BCryptPasswordEncoder,同时该 BCryptPasswordEncoder( PasswordEncoder 子类)也是 Spring Security 推荐的默认密码编码器,其中 noop 就是不做处理默认保存原密码。
一般我们代码中 @Autowired 注入并使用 PasswordEncoder 接口的实例,然后调用其 matches 方法去匹配原密码和数据库中保存的“密码”;密码的校验方式有多种,从 PasswordEncoder 接口实现的类是可以知道。
业务代码中注入 PasswordEncoder
@Autowired private PasswordEncoder passwordEncoder;
知识混淆点
加密/解密 与 Hash 这两个概念不能混淆,比如:SHA 系列是 Hash 算法,不是加密算法,加密意味着可以解密,但是 Hash 是不可逆的(无法通过 Hash 值还原得到密码,只能比对 Hash 值看看是否相等)。
安全性问题
目前很大一部分存在安全问题的系统一般仅仅使用密码的 MD5 值进行保存,可以通过 MD5 查询库去匹配对大部分的密码(可以直接从彩虹表里反推出来),而且 MD5 计算 Hash 值碰撞容易构造,安全性大大降低。MD5 加盐在本地计算速度也是很快,也是密码短也是极其容易破解;更好的选择是 SHA-256、BCrypt 等等等
密码匹配流程的源码解释
本文简单说一下 BCryptPasswordEncoder 密码匹配的一个简单流程或者过程。
重点
如果是使用 BCryptPasswordEncoder 调用 encode() 方法编码输入密码的话,其实这个编码后的“密码”并不是我们平时输入的真正密码,而是密码加盐后的通过单向 Hash 算法(BCrypt)得到值。
这里面细心的同学可能会发现一些问题:
-
同一个密码计算 Hash 不应该是一样的吗?每次使用 BCryptPasswordEncoder 编码同一个密码都是不一样的?
-
BCryptPasswordEncoder 编码同一个密码后结果都不一样,怎么进行匹配?
下面通过源码简单说一下这个匹配的流程:
matches(CharSequence rawPassword, String encodedPassword)
方法根据两个参数都可以知道
- 第一个参数是原密码
- 第二个参数就是用
PasswordEncoder
调用encode(CharSequence rawPassword)
编码过后保存在数据库的密码。
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); }
上述代码解读:首先判断是否数据库保存的“密码”(后面简称:“密码”)是否为空或者 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)); }
第二个参数 hashed
表明其实数据库查询出来的“密码”也就是 Hash 值;equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed))
代码中通过调用 hashpw
计算输入密码的 Hash 值(参数分别是输入的密码和保存在数据库的“密码”)
再继续看 hashpw
里面的部分代码(内容过长,省略部分代码,看看代码中的中文注释):
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)); // 关键点:上面***一大堆就是校验是否符合相应格式,然后下面这行就是取出密码的盐,real_salt就是 Hash 计算前的密码盐(关于盐的介绍: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' ? " 00" : "")).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(); }
其实上面代码就是从数据库得到的“密码”(参数: salt )进行一系列校验(长度校验等)并截取“密码”中相应的密码盐,利用这个密码盐进行同样的一系列计算 Hash 操作和 Base64 编码拼接一些标识符 生成所谓的“密码”,最后 equalsNoEarlyReturn
方法对同一个密码盐生成的两个“密码”进行匹配。
上述大致就是密码匹配流程了,对于问题“ BCryptPasswordEncoder 编码同一个密码后结果都不一样,怎么进行匹配”
的简单解答:
因为密码盐是随机生成的,但是可以根据数据库查询出来的“密码”拿到密码盐,同一个密码盐+原密码计算 Hash 结果值是能匹配的。
密码“加密”保存源码解释
看看加密的一个过程,
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
这两个构造参数是 BCryptPasswordEncoder(int strength, SecureRandom random)
构造方法按需传入,如果不指定strength和random,默认执行 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]; // 生成随机字节并将其置于rnd字节数组 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 编码拼接一些标识符 生成假“密码”(这个假“密码”为了兼容方便调用 hashpw
方法),最后关键点就是调用 BCrypt.hashpw
方法取到密码盐生成相应的真实“密码”(这个得到的密码可以用于保存在数据库中了)。
对于问题“同一个密码计算 Hash 不应该是一样的吗?每次使用 BCryptPasswordEncoder 编码同一个密码都是不一样的?”
的简单解答:
因为用到的随机密码盐每次都是不一样的,同一个密码和不同的密码盐组合计算出来的 Hash 值肯定不一样啦,所以编码同一个密码得到的结果都是不一样。
建议和想法
本文主要讲解一些安全性防护的思想,学习的过程思想很重要。
登录注册是每个系统都具备的功能,开发的同学记住一定不能保存明文密码,否则被脱库就会造成严重的后果。如果是通过上述的方法进行密码保存,即便拿到“密码”也非常难还原密码。
上述在密码编码的过程中的思想还是需要掌握:
-
只是保存散列码是不安全的,但是我们可以为密码加盐再通过一些 Hash 值 低概率碰撞且计算速度慢 的散列算法计算 Hash 值保存。
-
Spring Security 每次 Hash 之前用的盐都是随机,盐可以保存在最终生成的“密码”中,这样每个密码都是用了相应不同的随机盐+原密码计算 Hash 值得到,暴力破解难度也变大了。
原链接:https://www.jianshu.com/p/922963106729