天天看点

SpringSecurity 是怎样升级用户密码加密方式的?

作者:橘子泡茶

用户密码加密方式升级这种需求,一般在老项目翻版重做或者是迁移了其他第三方的用户时可能会用到。

以老项目密码采用MD5,需要转换为SM3加密方式为例,实现的效果是用户在登录时,如果加密方式是MD5而不是SM3,且密码对比成功就升级为SM3。

SpringSecurity 的认证部分是通过 AuthenticationManager 实现的,不考虑我们自定义直接实现AuthenticationManager 进行认证,那么在框架中实际的认证工作就是AuthenticationProvider 的实现类来完成的。(这里没看过源码的朋友可能不理解,但是并不影响我们下面学习加密方式升级的思路)

SpringSecurity 是怎样升级用户密码加密方式的?

在密码升级的过程中,数据库中必然同时存在2种甚至2种以上加密方式存储的密码,我们先来看看Security是如何处里多种加密方式并存的。

1 数据库密码的兼容

1.1 DaoAuthenticationProvider

我们首先来看下 DaoAuthenticationProvider 的源码,它包含以下方法

SpringSecurity 是怎样升级用户密码加密方式的?

方法 retrieveUser() 的作用是根据用户输入的账号或其他可以唯一标识用户身份的入参,从数据库中查询出整个用户对象的信息,一般通过userDetailService实现,这里不是我们关注的重点。

在获取到数据库中的用户信息后,接下来就是使用用户输入的密码与查询到的密码做对比了。通过源码可以发现对比的工作是通过 passwordEncoder.matches()方法实现的。我们知道从数据库中查询到的密码的加密方式有两种可能,MD5和SM3,显然matches()方法必须同时满足这两种加密方式的校验。

@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        this.logger.debug("Authentication failed: no credentials provided");
        throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    } else {
        String presentedPassword = authentication.getCredentials().toString();
        //前台传的密码进行加密和数据库存的密码做对比
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            this.logger.debug("Authentication failed: password does not match stored value");
            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
}           

我们可以通过DaoAuthenticationProvider 中的 setPasswordEncoder(PasswordEncoder passwordEncoder)方法来设置需要的密码验证逻辑,这个逻辑的实现必须同时满足这MD5和SM3两种方式的校验。

也就是说,我们需要一个PasswordEncoder的实现类,能够同时验证MD5和SM3。

SpringSecurity 是怎样升级用户密码加密方式的?

实际上,security框架在DaoAuthenticationProvider构造器中已经默认为我们设置了一个PasswordEncoder对象,而这个对象就满足多种加密方式的校验。

public DaoAuthenticationProvider() {
    this.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}           

1.2 PasswordEncoderFactories

通过 PasswordEncoderFactories 的实现我们可以看到,它是以bcrypt作为默认的加密方式,然后将其他加密方式的实现放到了一个map中。

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);
    }           

尽管这并不满足我们默认SM3 的需求,但从这里基本可以推测出,最终肯定是从DelegatingPasswordEncoder对象管理的众多加密实现中选择和数据库加密方式匹配的进行验证了。如果我们能将默认的 bcrypt 修改为 SM3 并在map中添加对应的实现,也就完成了新注册账号的采用SM3加密的工作。

1.3 DelegatingPasswordEncoder

我们回到matches()方法,去看DelegatingPasswordEncoder对象的实现,第一个参数是明文密码,第二个参数是数据库中查询到的密码,可以看到这段代码先是从数据库中查询到的密码中提取了一个id,然后又使用这个id从DelegatingPasswordEncoder 对象管理的 map 中提取了对应的加密实现,用这个具体的实现去做密码匹配校验,这是典型的代理模式应用。

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
    if (rawPassword == null && prefixEncodedPassword == null) {
        return true;
    }
    //从数据库中查询到的密码中提取id
    String id = extractId(prefixEncodedPassword);
    //这里获取真正用来验证密码的加密实现,注意是根据前缀从列表中获取的,不是默认方式
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    if (delegate == null) {
        return this.defaultPasswordEncoderForMatches
            .matches(rawPassword, prefixEncodedPassword);
    }
    String encodedPassword = extractEncodedPassword(prefixEncodedPassword);//去掉前缀
    return delegate.matches(rawPassword, encodedPassword);
}           

不难知道,这个id就是map的key值。跟踪extractId()的实现就可以知道数据库中的密码前面应该拼接了一个{MD5}或{SM3},这就需要我们迁移原始数据时做一下加密标识 。eg:{MD5}e10adc3949ba59abbe56e057f20f883e

通过分析上面的代码,我们只需要自己new一个DelegatingPasswordEncoder对象,按照需求自定义 new DelegatingPasswordEncoder(encodingId, encoders)的两个参数,也就是设置默认的id和加密方式是SM3,在encoders中加入SM3加密方式实现。(这里需要我们新增PasswordEncoder接口的SM3加密实现)

最后,在初始化DaoAuthenticationProvider对象时(一般我们会自定义它的子类实现),需要通过setPasswordEncoder()设置自定义的DelegatingPasswordEncoder对象。到这里我们就实现了对数据库中密码存在多种加密方式的兼容,但只是验证的兼容,接下来还需要将数据库的密码改为新加密方式存储。

2 升级数据库密码

回到 DaoAuthenticationProvider来看下面的方法。

SpringSecurity 是怎样升级用户密码加密方式的?
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
    //令upgradeEncoding 为true就可以直接更新密码了
    boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword());
    if (upgradeEncoding) {
        //客户端传的密码-(这里是明文)
        String presentedPassword = authentication.getCredentials().toString();
        //使用默认加密方式对密码进行加密处理
        String newPassword = this.passwordEncoder.encode(presentedPassword);
        //更新密码
        user = this.userDetailsPasswordService.updatePassword(user, newPassword);
    }

    return super.createSuccessAuthentication(principal, authentication, user);
}           

分析这段代码,只要 upgradeEncoding 为 true,就会触发密码加密方式的升级。第一个条件需要设置自己的userDetailsPasswordService 实现;第二个条件 upgradeEncoding()方法是判断当前数据库中的密码方式标识是否为默认加密方式,如果不同则返回true,就是判断密码前缀是不是{SM3}

这里的encode(presentedPassword)方法返回值newPassword就是使用的默认加密方式SM3的加密结果。

现在,我们只要实现UserDetailsPasswordService接口,逻辑就是操作数据库修改密码。同时不用忘记在初始化 DaoAuthenticationProvider对象时,调用setUserDetailsPasswordService()方法把这个service设置一下。

public interface UserDetailsPasswordService {
    UserDetails updatePassword(UserDetails user, String newPassword);
}           

最后总结一下密码升级的思路,最核心的就是在数据库密码的前面加上一个前缀,例如{MD5},{SM3}...,做密码校验时,先从数据库中查询出密码,根据密码的前缀对用户输入的明文密码采用对应的加密方式进行加密,然后对比两个密码是否相同。

如果相同,说明用户口令正确,接下来判断数据库中的密码前缀是不是{SM3},如果不是,就把用户输入的明文密码用SM3加密一下,替换掉数据库里的密码字段。

有了这个思路,是否采用Security架构就不重要了。

继续阅读