Spring Security PasswordEncoder 密码校验和密码加密流程

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;

科普一些基本知识

BCrypt 密码散列函数的概念介绍可以看一下维基百科或百度百科的内容 (因为只是使用,暂时不需要对算法的了解和实现)

维基百科: BCrypt

百度百科: BCrypt

知识混淆点

加密/解密 与 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 ,在通过正则表达式匹配“密码”是否符合格式,最后通过 BCryptcheckpw(String plaintext, String hashed) 方法进行密码匹配


再详细看看 BCryptcheckpw(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' ? "\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();
}

其实上面代码就是从数据库得到的“密码”(参数: 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 值肯定不一样啦,所以编码同一个密码得到的结果都是不一样。

建议和想法

本文主要讲解一些安全性防护的思想,学习的过程思想很重要。

登录注册是每个系统都具备的功能,开发的同学记住一定不能保存明文密码,否则被脱库就会造成严重的后果。如果是通过上述的方法进行密码保存,即便拿到“密码”也非常难还原密码。

上述在密码编码的过程中的思想还是需要掌握:

  1. 只是保存散列码是不安全的,但是我们可以为密码加盐再通过一些 Hash 值 低概率碰撞且计算速度慢 的散列算法计算 Hash 值保存。

  2. Spring Security 每次 Hash 之前用的盐都是随机,盐可以保存在最终生成的“密码”中,这样每个密码都是用了相应不同的随机盐+原密码计算 Hash 值得到,暴力破解难度也变大了。

推荐阅读 更多精彩内容