SpringSecurity系列学习(二):密码验证
系列导航
SpringSecurity系列
- SpringSecurity系列学习(一):初识SpringSecurity
- SpringSecurity系列学习(二):密码验证
- SpringSecurity系列学习(三):认证流程和源码解析
- SpringSecurity系列学习(四):基于JWT的认证
- SpringSecurity系列学习(四-番外):多因子验证和TOTP
- SpringSecurity系列学习(五):授权流程和源码分析
- SpringSecurity系列学习(六):基于RBAC的授权
SpringSecurityOauth2系列
- SpringSecurityOauth2系列学习(一):初认Oauth2
- SpringSecurityOauth2系列学习(二):授权服务
- SpringSecurityOauth2系列学习(三):资源服务
- SpringSecurityOauth2系列学习(四):自定义登陆登出接口
- SpringSecurityOauth2系列学习(五):授权服务自定义异常处理
密码存储安全进化史
这一节我们还是积累一些知识,迫不及待想编码的小伙伴再忍忍,打好基础才能避免一些坑,一步一脚印。
最开始的时候,密码是明文存储的。黑客只需要攻破你的数据库,就能拿到所有的账号与密码了
为了防止密码泄露,后来在存储密码的时候对密码进行了哈希加密,这种加密是不可逆的。这样黑客即使攻破数据库拿到了账号和密码保存的记录,也不能获得真正的密码,因为存储在数据库里的密码是经过哈希加密算法之后的数据。
黑客为了解决这种情况,使用了彩虹表:预先将一些字符串进行哈希加密,然后存储起来。因为现代硬件设备的升级,他们使用性能高的设备,每秒进行多次哈希计算,将结果存储在一个巨大的库中,也就是彩虹表中。如果拿到了你数据库中的密码哈希值,就将其与彩虹表中预先计算好了的哈希值作比较,一旦相等,则说明你密码的明文就是彩虹表中对应的字符串。
为了应付彩虹表的情况,采用了一种叫加盐的措施,就是在进行哈希计算的时候,并不完全依赖密码。而是先生成一个随机数,存储在系统的某个地方,然后再使用这个盐值和密码做哈希,这样就加大了黑客破解的难度,彩虹表也就没什么作用了。
再往后发展就变成了自适应,随着硬件设备的发展,我们密码的加密也变得越来越高级,这里所谓的高级,其实就是越来越多次的哈希。具体来讲就是:
可以配置迭代次数(md5(md5("password")))
可以配置的随机的盐值
迭代次数和盐值存储在数据库中
更多的编码格式算法选择:Bcrypt,Scrypt,pdkdf2等等
这种方式除了增加密码存储的复杂度,还能人为的降低认证的响应速度,这样如果黑客使用代码进行暴力破解,没有用这种复杂加密的方式,黑客可能一秒钟进行几千次暴力破解的尝试。采用这种复杂加密的方式,黑客一秒钟只能做几十次甚至一次的程度。
未来的发展趋势有好几种:比如多因子认证,认证不只是依赖于密码,还依赖于三方因素,比如短信验证码,邮箱验证码等等。
又比如指纹,人脸识别,但是这种方式需要硬件支持。
但是现在来说,密码还是互联网的主流。
密码编码器
SpringSecurity对于密码的存储也做了安全设定,保存在数据库的密码都是通过编码之后的,通常我们会采用BCryptPasswordEncoder
编码器进行编码,这个编码器采用SHA-256 +随机盐+密钥
对密码进行加密。SHA
系列是Hash
算法,不是加密算法,使用加密算法意味着可以解密(这个与编码/解码一样),但是采用Hash
处理,其过程是不可逆的。
在注册用户的时候,使用SHA-256+随机盐+密钥
把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中。
用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的),而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值,然后将其与从数据库中查询到的密码hash值进行比较。如果两者相同,说明用户输入的密码正确。
我们写一个简单的demo,配置一下密码的编码器
密码编码器demo
定义一个密码编码器的bean
@Bean
public PasswordEncoder passwordEncoder(){
//默认编码算法的Id,新的密码编码都会使用这个id对应的编码器
String idForEncode = "bcrypt";
//要支持的多种编码器
Map encoders = new HashMap();
encoders.put(idForEncode,new BCryptPasswordEncoder());
//可以使用多种编码器,密码匹配时只要有一种编码器匹配上即可
//举例:历史原因,之前用的SHA-1编码,现在我们希望新的密码使用bcrypt编码
//老用户使用SHA-1这种老的编码格式,新用户使用bcrypt这种编码格式,登录过程无缝切换
//encoders.put("SHA-1",new MessageDigestPasswordEncoder("SHA-1"));
return new DelegatingPasswordEncoder(idForEncode,encoders);
}
这里的DelegatingPasswordEncoder
,允许以不同的格式验证密码,提供升级的可能性。这个东西是为了解决老数据库中,密码使用的编码系统方法较老,但是随着着计算能力的发展,如果不迁移,老的编码系统很容易就会被破解,所以迁移到更安全的编码标准之上是一个必要的过程。
将这个密码编码器配置进安全配置中
/**
* `@EnableWebSecurity` 注解 deug参数为true时,开启调试模式,会有更多的debug输出
*
* @author 硝酸铜
* @date 2021/6/2
*/
@EnableWebSecurity(debug = true)
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Resource
private PasswordEncoder passwordEncoder;
...
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//配置从数据库中读取用户信息
auth.authenticationProvider(daoAuthenticationProvider());
}
/**
* 配置 DaoAuthenticationProvider
* @return DaoAuthenticationProvider
*/
private DaoAuthenticationProvider daoAuthenticationProvider(){
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
...
// 密码编码器
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
...
return daoAuthenticationProvider;
}
}
测试一下密码加密的方法:passwordEncoder.encode()
:
加密后的密码格式:{编码id}xxxxx
那么问题来了,要是我们不想使用旧的编码格式了,就要使用新的,可以将老的密码迁移到新的编码格式下面吗?
答案肯定是可以的,使用UserDetailsPasswordService
中提供的updatePassword
方法,这个后面会说明,这里不深入。
其实除了使用SpringSecurity提供的,我们自己也能够实现,具体思路就是在登录的时候,我们会拿到明文的密码,如果认证成功,则我们按照{id}encodedPassword
这种格式,自行进行加密组装,存储即可。
密码的验证规则
密码的验证规则非常复杂,比如要求密码有大小写,长度不能为多少等等,这些规则对于使用者和规则的制定者来说都很痛苦,好在我们可以使用Passay
框架进行验证,其已经封装好了一些规则。
我们将验证的逻辑封装在注解中,有效的剥离验证逻辑和业务逻辑
对于2个以上属性的复合验证,可以写一个应用于类的注解
自定义密码验证demo
引入Passay
依赖和SpringValication
依赖,我们采用SpringValication
的方式来验证密码
关于SpringValication
的内容,请自行了解,这是常用的参数检验的依赖
<properties>
...
<passay.verion>1.6.0</passay.verion>
</properties>
...
<dependency>
<groupId>org.passay</groupId>
<artifactId>passay</artifactId>
<version>${passay.verion}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
编写密码注解
/**
* 密码验证注解
* @author 硝酸铜
* @date 2021/6/7
*/
@Target({ElementType.FIELD,ElementType.TYPE,ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
@Documented
public @interface ValidPassword {
String message() default "Invalid Password";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
然后实现ConstraintValidator
接口,实现验证的逻辑,编写密码验证器
import org.passay.*;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;
/**
* 密码验证器
* @author 硝酸铜
* @date 2021/6/7
*/
public class PasswordConstraintValidator implements ConstraintValidator<ValidPassword,String> {
@Override
public void initialize(ValidPassword constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
PasswordValidator validator = new PasswordValidator(Arrays.asList(
//长度规则,8-30
new LengthRule(8,30),
//字符规则 至少有一个大写字母
new CharacterRule(EnglishCharacterData.UpperCase,1),
//字符规则 至少有一个小写字母
new CharacterRule(EnglishCharacterData.LowerCase,1),
//字符规则 至少有一个特殊字符
new CharacterRule(EnglishCharacterData.Special,1),
//非法顺序规则 不允许有5个连续字母表顺序的字母,比如不允许abcde
new IllegalSequenceRule(EnglishSequenceData.Alphabetical,5,false),
//非法顺序规则 不允许有5个连续数字顺序的数字 比如不允许12345
new IllegalSequenceRule(EnglishSequenceData.Numerical,5,false),
//非法顺序规则 不允许有5个连续键盘顺序的字母 比如不允许asdfg
new IllegalSequenceRule(EnglishSequenceData.USQwerty,5,false),
//空格规则,不能有空格
new WhitespaceRule()
));
return validator.validate(new PasswordData(s)).isValid();
}
}
在入参Dto的字段上面,写上注解
@NotNull
@ValidPassword
private String password;
@NotNull
@ValidPassword
private String matchingPassword;
启动,调用注册接口(这个接口时怎么实现的这里先不关心,主要看一下在传参的时候,密码验证器时怎么起作用的)
这个时候12345678
就不能通过验证了,因为有5个以上数字顺序的数字,还没有大写字母,小写字母,和特殊字符。
没有通过则会返回HTTP 400的错误,这个时候并没有进入到接口方法当中
只有完全满足设定的规则,才能进入接口中。