博主在之前已经介绍了Spring Security
的用户UserDetails
与用户服务UserDetailsService
,本篇博客介绍Spring Security
的密码编码器PasswordEncoder
,它们是相互联系的,博主会带大家一步步深入理解Spring Security
的实现原理,也会带来Spring Security
的实战分享。
为什么是介绍而不是源码分析?博主虽然在研一上过密码学的课,但毕竟没有细致研究过密码学领域,因此不敢管中窥豹。再者,这些加密算法的实现原理、是否能抵御攻击、明文与密文(明文经过加密得到)的匹配方法以及时间成本等因素都不是学习Spring Security
框架的核心内容,因此本篇博客只会简单介绍Spring Security
的密码编码器PasswordEncoder
及其实现类,以及密码编码器在Spring Security
中的使用时机。
PasswordEncoder
PasswordEncoder
接口有很多实现类,也有被标记了@Deprecated
注解的实现类,一般是该类表示的密码编码器(加密算法)不安全,比如可以在能接受的时间内被破解,比如彩虹表攻击。
这里不去分析每个密码编码器的实现原理,因为密码编码器的种类太多了,而且没有必要,密码编码器的主要作用无非就是对密码进行编码(加密),以及原始密码(客户端登录验证时输入的密码)与编码密码(正确原始密码通过密码编码器编码的结果)的正确匹配,因此密码编码器必定需要实现PasswordEncoder
接口的两个方法,而其他方法的实现是服务于这两个方法。
package org.springframework.security.crypto.password;
// 首选实现是BCryptPasswordEncoder
public interface PasswordEncoder {
/**
* 对原始密码进行编码
*/
String encode(CharSequence rawPassword);
/**
* 验证从存储(比如数据库或者内存等)中获取的编码密码是否与需要验证的密码匹配
* 如果密码匹配,则返回 true,否则返回 false
* 存储的编码密码永远不会被解码
* 因此会将需要验证的密码进行编码,然后与编码密码进行匹配
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* 如果为了更好的安全性需要再次对编码的密码进行编码,则返回 true,否则返回 false
* 默认实现始终返回 false
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
很显然密码编码器的主要作用是为了编码与匹配,而有些加密算法需要经过多次迭代加密,因此也需要实现upgradeEncoding
方法,比如BCryptPasswordEncoder
类的实现(strength
属性越大,需要做更多的工作来加密密码,默认值为10
):
@Override
public boolean upgradeEncoding(String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
Matcher matcher = BCRYPT_PATTERN.matcher(encodedPassword);
if (!matcher.matches()) {
throw new IllegalArgumentException("Encoded password does not look like BCrypt: " + encodedPassword);
}
else {
int strength = Integer.parseInt(matcher.group(2));
return strength < this.strength;
}
}
PasswordEncoderFactories
PasswordEncoderFactories
类源码:
package org.springframework.security.crypto.factory;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import java.util.HashMap;
import java.util.Map;
/**
* 用于创建PasswordEncoder实例
*/
public class PasswordEncoderFactories {
/**
* 使用默认映射创建一个DelegatingPasswordEncoder
* 可能会添加其他映射,并且将更新编码以符合最佳实践
* 但是,由于DelegatingPasswordEncoder的性质,更新不应影响用户
*/
@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
// 默认bcrypt
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());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
private PasswordEncoderFactories() {}
}
PasswordEncoderFactories
类可以看作密码编码器工厂,它将已有的密码编码器存储在HashMap
中,通过静态方法createDelegatingPasswordEncoder
即可获取,该方法的返回值是一个DelegatingPasswordEncoder
实例。
DelegatingPasswordEncoder
DelegatingPasswordEncoder
类源码:
public class DelegatingPasswordEncoder implements PasswordEncoder {
private static final String PREFIX = "{";
private static final String SUFFIX = "}";
private final String idForEncode;
private final PasswordEncoder passwordEncoderForEncode;
private final Map<String, PasswordEncoder> idToPasswordEncoder;
private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
/**
* 创建一个新实例
* idForEncode:用于查找应使用哪个PasswordEncoder进行encode
* idToPasswordEncoder:id到PasswordEncoder的映射,用于确定应使用哪个PasswordEncoder进行matches
*/
public DelegatingPasswordEncoder(String idForEncode,
Map<String, PasswordEncoder> idToPasswordEncoder) {
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for (String id : idToPasswordEncoder.keySet()) {
if (id == null) {
continue;
}
// 如果id有'{'或者'}'字符则会出现问题,比如:
// 基于prefixEncodedPassword获取id,是根据'{'和'}'字符对第一次出现的位置来截取
// 以及去除{id}得到encodedPassword,是根据'}'字符第一次出现的位置来截取
if (id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if (id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
// 初始化
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}
/**
* 设置defaultPasswordEncoderForMatches,默认为UnmappedIdPasswordEncoder实例
*/
public void setDefaultPasswordEncoderForMatches(
PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
}
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}
// 编码,{id}前缀拼接委托的PasswordEncoder的编码结果
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}
// 匹配
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
// 根据prefixEncodedPassword提取id
String id = extractId(prefixEncodedPassword);
// 根据id获取PasswordEncoder
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
// 是否有对应的PasswordEncoder
if (delegate == null) {
// 没有对应的PasswordEncoder
// 则使用defaultPasswordEncoderForMatches进行匹配
return this.defaultPasswordEncoderForMatches
.matches(rawPassword, prefixEncodedPassword);
}
// 有对应的PasswordEncoder
// 提取encodedPassword,即去掉{id}前缀
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
// 返回匹配结果
return delegate.matches(rawPassword, encodedPassword);
}
// 提取id
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
}
// 第一个'{'字符的位置
int start = prefixEncodedPassword.indexOf(PREFIX);
if (start != 0) {
return null;
}
// 从start开始的第一个'}'字符的位置
int end = prefixEncodedPassword.indexOf(SUFFIX, start);
if (end < 0) {
return null;
}
// 截取得到id
return prefixEncodedPassword.substring(start + 1, end);
}
@Override
public boolean upgradeEncoding(String prefixEncodedPassword) {
// 提取id
String id = extractId(prefixEncodedPassword);
// id与idForEncode属性不匹配,则返回true
if (!this.idForEncode.equalsIgnoreCase(id)) {
return true;
}
else {
// 提取encodedPassword
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
// 根据id获取PasswordEncoder
// 返回该PasswordEncoder的upgradeEncoding方法基于encodedPassword的返回值
return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword);
}
}
// 提取encodedPassword
private String extractEncodedPassword(String prefixEncodedPassword) {
// 第一个'}'字符的位置
int start = prefixEncodedPassword.indexOf(SUFFIX);
// 截取得到encodedPassword
return prefixEncodedPassword.substring(start + 1);
}
/**
* 引发异常的默认PasswordEncoder
*/
private class UnmappedIdPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}
@Override
public boolean matches(CharSequence rawPassword,
String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
}
}
}
DelegatingPasswordEncoder
是基于前缀标识符并委托给另一个PasswordEncoder
的密码编码器,可以使用PasswordEncoderFactories
类创建一个DelegatingPasswordEncoder
实例,也可以创建自定义的DelegatingPasswordEncoder
实例。
密码存储格式为 {id}encodedPassword
(prefixEncodedPassword
),id
是用于查找应该使用哪个PasswordEncoder
的标识符,encodedPassword
是使用PasswordEncoder
对原始密码进行编码的结果,id
必须在密码的开头,以{
开头,}
结尾。 如果找不到id
,则id
将为空。 例如,以下可能是使用不同id
(PasswordEncoder
)编码的密码列表:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
第一个密码的id
为bcrypt
,encodePassword
为$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
,匹配时,将委托给BCryptPasswordEncoder
。第二个密码的id
为noop
,encodePassword
为password
。匹配时,将委托给NoOpPasswordEncoder
。以此类推。
传递给构造函数的idForEncode
确定将使用哪个PasswordEncoder
来编码原始密码。匹配是基于id
和构造函数中提供的idToPasswordEncoder
来完成的。matches
方法可能使用带有未映射id
(包括空id
)的密码,调用matches
方法将抛出IllegalArgumentException
异常, 可以使用setDefaultPasswordEncoderForMatches
方法自定义此行为,即设置defaultPasswordEncoderForMatches
属性,当根据id
获取不到PasswordEncoder
时使用。
Debug分析
依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kaven</groupId>
<artifactId>security</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
</parent>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
接口:
@RestController
public class MessageController {
@GetMapping("/message")
public String getMessage() {
return "hello kaven, this is security";
}
}
启动类:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
Debug
启动应用,访问接口,会被重定向到默认登录页。
使用Spring Security
自动创建的用户(用户名为user
,密码在启动日志中)进行登录验证。
用户验证时Spring Security
会使用DelegatingPasswordEncoder
类的matches
方法进行密码匹配,提取的id
为noop
(prefixEncodedPassword
有{noop}
前缀,应用启动时,如果没有用户与用户源的相关配置,Spring Security
会创建一个默认用户,即一个UserDetails
实例,该实例的密码就是prefixEncodedPassword
),因此委托给NoOpPasswordEncoder
进行密码匹配。
NoOpPasswordEncoder
类的matches
方法只是简单的字符串匹配,上图的rawPassword
和encodedPassword
很显然是匹配的。
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
验证成功。
配置PasswordEncoder
增加配置:
package com.kaven.security.config;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 重写验证处理的配置
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(new UserDetailsServiceImpl()).passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
// 自定义的用户服务
public static class UserDetailsServiceImpl implements UserDetailsService {
// 使用PasswordEncoderFactories工厂创建DelegatingPasswordEncoder实例作为该用户服务的密码编码器
private static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder();
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO 查找数据库
// 使用密码编码器对原始密码进行编码
String encodedPassword = PASSWORD_ENCODER.encode("itkaven");
// 默认存在该用户名的用户,并且原始密码都为itkaven,角色都为USER和ADMIN
return User.withUsername(username).password(encodedPassword).roles("USER", "ADMIN").build();
}
}
}
Debug
启动应用,访问接口,然后进行验证登录,由于自定义的用户服务默认任意用户名的用户都存在,并且原始密码都为itkaven
,角色都为USER
和ADMIN
,因此登录时用户名可任意,但密码必须为itkaven
才能通过验证。
客户端进行验证登录时,Spring Security
通过用户服务加载匹配用户名的UserDetails
实例,而博主自定义的用户服务直接默认该UserDetails
实例存在,并且设置默认的密码(编码后的密码,使用UserDetailsServiceImpl
类中的PASSWORD_ENCODER
进行编码)与角色(权限)。密码匹配使用在重写验证处理的配置时指定的密码编码器来完成(passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder())
)。
id
为bcrypt
,使用BCryptPasswordEncoder
进行密码匹配,因为通过自定义的用户服务加载的UserDetails
实例的密码就是DelegatingPasswordEncoder
的{bcrypt}
前缀与BCryptPasswordEncoder
对原始密码(itkaven
)的编码的拼接,最后会返回true
。
所以,密码编码器在用户验证时用于密码的匹配,以及创建UserDetails
实例时对密码进行编码(可选,如果是基于用户服务加载的UserDetails
实例创建的新实例,新实例一般不更改该UserDetails
实例的密码,因此,通过用户服务加载的UserDetails
实例的密码应该是编码后的密码),因此密码的编码与匹配过程需要使用相同的密码编码器,不然一样的原始密码也有可能匹配不成功。
Spring Security
的密码编码器PasswordEncoder
的介绍与Debug
分析就到这里,如果博主有说错的地方或者大家有不同的见解,欢迎大家评论补充。