Spring security中的BCryptPasswordEncoder方法对密码进行加密与密码匹配以及MD5util工具类

一般记录用户密码,我们都是通过MD5加密配置的形式。这里记录一下,MD5加密的工具类。

package com.mms.utils;

import java.security.MessageDigest;

/**
 * Created by codermen on 2017/10/26.
 */
public class MD5Util {
    public static void main(String[] args) {
        String pwd = getMD5("99991");
        System.out.println(pwd);
    }

    //生成MD5
    public static String getMD5(String message) {
        String md5 = "";
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");  // 创建一个md5算法对象
            byte[] messageByte = message.getBytes("UTF-8");
            byte[] md5Byte = md.digest(messageByte);              // 获得MD5字节数组,16*8=128位
            md5 = bytesToHex(md5Byte);                            // 转换为16进制字符串
        } catch (Exception e) {
            e.printStackTrace();
        }
        return md5;
    }

    // 二进制转十六进制
    public static String bytesToHex(byte[] bytes) {
        StringBuffer hexStr = new StringBuffer();
        int num;
        for (int i = 0; i < bytes.length; i++) {
            num = bytes[i];
            if(num < 0) {
                num += 256;
            }
            if(num < 16){
                hexStr.append("0");
            }
            hexStr.append(Integer.toHexString(num));
        }
        return hexStr.toString().toUpperCase();
    }
}

浅谈使用springsecurity中的BCryptPasswordEncoder方法对密码进行加密(encode)与密码匹配(matches)

spring security中的BCryptPasswordEncoder方法采用SHA-256 +随机盐+密钥对密码进行加密。SHA系列是Hash算法,不是加密算法,使用加密算法意味着可以解密(这个与编码/解码一样),但是采用Hash处理,其过程是不可逆的。

(1)加密(encode):注册用户时,使用SHA-256+随机盐+密钥把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中。

(2)密码匹配(matches):用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的),而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值,然后将其与从数据库中查询到的密码hash值进行比较。如果两者相同,说明用户输入的密码正确。

这正是为什么处理密码时要用hash算法,而不用加密算法。因为这样处理即使数据库泄漏,黑客也很难破解密码(破解密码只能用彩虹表)。

学习到这一块,查看了一些源码。以BCryptPasswordEncoder为例

public class BCryptPasswordEncoderTest {
    public static void main(String[] args) {
        String pass = "admin";
        BCryptPasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder();
        String hashPass = bcryptPasswordEncoder.encode(pass);
        System.out.println(hashPass);

        boolean f = bcryptPasswordEncoder.matches("admin",hashPass);
        System.out.println(f);

    }
}

可以看到,每次输出的hashPass 都不一样,但是最终的f都为 true,即匹配成功。查看代码,可以看到,其实每次的随机盐,都保存在hashPass中。在进行matchs进行比较时,调用BCrypt 的String hashpw(String password, String salt)方法。两个参数即”admin“和 hashPass

//******BCrypt.java******salt即取出要比较的DB中的密码*******
real_salt = salt.substring(off + 3, off + 25);
try {
// ***************************************************
    passwordb = (password + (minor >= 'a' ? "\000" : "")).getBytes("UTF-8");
}
catch (UnsupportedEncodingException uee) {}
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds);

假定一次hashPass为:10$AxafsyVqK51p.s9WAEYWYeIY9TKEoG83LTEOSB3KUkoLtGsBKhCwe随机盐即为 AxafsyVqK51p.s9WAEYWYe(salt = BCrypt.gensalt();中有描述)
可见,随机盐(AxafsyVqK51p.s9WAEYWYe),会在比较的时候,重新被取出。即,加密的hashPass中,前部分已经包含了盐信息。

如果只是想使用SpringSecurity + SpringBoot完成密码加密/解密操作,而不使用SpringSecurty提供的其它权证验证功能。具体步骤如下:
1 BCrypt密码加密
1.1 准备工作
任何应用考虑到安全,绝不能明文的方式保存密码。密码应该通过哈希算法进行加密。
有很多标准的算法比如SHA或者MD5,结合salt(盐)是一个不错的选择。 Spring Security
提供了BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强
哈希方法来加密密码。
BCrypt强哈希方法 每次加密的结果都不一样。
(1)tensquare_user工程的pom引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring‐boot‐starter‐security</artifactId>
</dependency>

(2)添加配置类 (资源/工具类中提供)

我们在添加了spring security依赖后,所有的地址都被spring security所控制了,我们目
前只是需要用到BCrypt密码加密的部分,所以我们要添加一个配置类,配置为所有地址
都可以匿名访问

/**
* 安全配置类
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests()
      .antMatchers("/**").permitAll()
      .anyRequest().authenticated()
      .and().csrf().disable();
  }
}

(3)修改tensquare_user工程的Application, 配置bean

@Bean
public BCryptPasswordEncoder bcryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}

之后就可以使用BCryptPasswordEncoder中的方法完成加密/解密操作:

加密:
bcryptPasswordEncoder.encoder(password)
解密:
bcrytPasswordEncoder.matches(rawPassword,encodedPassword)

在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 ,在通过正则表达式匹配“密码”是否符合格式,最后通过 BCryptcheckpw(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' ? "\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 值得到,暴力破解难度也变大了。

 

https://www.jianshu.com/p/922963106729

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章