Spring Security系列之PasswordEncoder

概述

任何一个登录系统的密码不能明文存储,万一发生数据库泄漏事故(不管是内部人员导出数据库数据还是被黑客攻击破解数据库实例节点拿到数据库数据等,又或者是其他情况造成的),将产生巨大的损失。因此明文密码在存储到数据库之前需要加密处理。

加密算法有很多,大致有如下分类:

  • 哈希函数算法:包括消息摘要算法(MD4,MD5等),消息摘要算法是一种特殊类型的哈希函数算法,用于将任意长度的数据映射为固定长度的哈希值或摘要。摘要值通常用于验证数据的完整性、数字签名、身份验证等用途。算法包括:
    • MD5(Message Digest Algorithm 5):已经不推荐使用,存在碰撞攻击漏洞
    • SHA-1(Secure Hash Algorithm 1):也存在碰撞攻击漏洞,逐渐被淘汰
    • SHA-256、SHA-384、SHA-512:SHA-2系列,目前被广泛应用,提供更高的安全性
  • 对称加密算法使用相同的密钥进行加密和解密。常见的对称加密算法包括:
    • DES(Data Encryption Standard):已经不推荐使用,因为密钥长度较短易受到攻击
    • 3DES(Triple DES):DES增强版,使用三个密钥提高安全性
    • AES(Advanced Encryption Standard):目前广泛应用的对称加密算法,具有较高的安全性和性能
  • 非对称加密算法(公钥加密算法):
    非对称加密算法使用一对密钥,分别是公钥和私钥,公钥用于加密,私钥用于解密。常见的非对称加密算法包括:
  • RSA(Rivest-Shamir-Adleman):基于大数分解难题,被广泛用于数字签名和密钥交换
  • ECC(Elliptic Curve Cryptography):利用椭圆曲线上的离散对数问题,相比RSA,提供相同安全级别下更短的密钥长度和更高的性能

反查表、彩虹表

上文提到一些已经不推荐使用、逐渐被淘汰的算法,如MD5、SHA-1。因为不管是MD5还是SHA-1算法,对于给定的某个字符串(密码),经过哈希函数计算之后得到的结果都是固定的。比如admin经过MD5计算(有16位和32位之分,这里用的是16位)结果始终是7a57a5a743894a0eroot经过SHA-1计算后结果始终是dc76e9f0c0006e8f919e0c515c66dbba3982f785

那黑客们就可以维护一个数据库,其字段包括加密后的密文、加密算法、明文密码,意味着可以根据密文反查明文密码。这就是反查表。

基于反查表,黑客们后来发明更高级的彩虹表。

在Java Web开发中,我们会遇到各种各样的安全问题。作为最基本的,数据库密码的安全性如何得到保证呢?此时Spring Security隆重登场,可以帮助我们解决这个问题。

Spring Security

实例

加密密码的配置类(代码片段):

import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity()
public class WebSecurityConfig implements SecurityFilterChain {
	@Resource
	private UserDetailsService userDetailsService;
	
	@Autowired
	public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
		authenticationManagerBuilder
			.userDetailsService(this.userDetailsService)
			.passwordEncoder(passwordEncoder());
	}
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

PasswordEncoder

PasswordEncoder接口定义如下:

public interface PasswordEncoder {
	// 用来对明文密码进行加密
	String encode(CharSequence rawPassword);
	// 用来进行密码比对
	boolean matches(CharSequence rawPassword, String encodedPassword);
	// 用来判断当前密码是否需要升级,默认返回false表示不需要升级
	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}
}

尚未废弃的实现类,都是自适应单向函数(Adaptive One-way Functions)来处理密码问题,这种函数在进行密码匹配时,会有意占用大量系统资源(例如CPU、内存等),可以增加恶意用户攻击系统的难度。包括:bcrypt、PBKDF2、scrypt以及argon2。

因此实现类包括:

  • BCryptPasswordEncoder:使用bcrypt强散列算法对密码进行加密,为提高密码的安全性,bcrypt算法故意降低运行速度,以增强密码破解的难度。BCryptPasswordEncoder自带salt加盐机制,即使相同的明文每次生成的加密字符串都不相同。默认强度为10(参考源码里的strength字段),开发者可以根据自己的服务器性能进行调整,以确保密码验证时间约为1秒钟(官方建议密码验证时间为1秒钟,既可以提高系统安全性,又不会过多影响系统运行性能)
  • Argon2PasswordEncoder:使用Argon2算法对密码进行加密,Argon2曾在Password Hashing Competition竞赛中获胜。为了解决在定制硬件上密码容易被破解的问题,Argon2也是故意降低运算速度,同时需要大量内存
  • Pbkdf2PasswordEncoder:使用PBKDF2算法对密码进行加密,可用于FIPS(Federal Information Processing Standard,美国联邦信息处理标准)认证
  • SCryptPasswordEncoder:使用scrypt算法对密码进行加密

几个已经被废弃的基于消息摘要算法的实现类:

  • NoOpPasswordEncoder:密码明文存储,不可用于生产环境
  • Md4PasswordEncoder:使用Md4算法加密密码
  • LdapShaPasswordEncoder:使用SHA算法
  • StandardPasswordEncoder:使用SHA-256算法
  • MessageDigestPasswordEncoder:使用MD5算法

BCryptPasswordEncoder

public String encode(CharSequence rawPassword) {
	if (rawPassword == null) {
		throw new IllegalArgumentException("rawPassword cannot be null");
	}
	String salt = getSalt();
	return BCrypt.hashpw(rawPassword.toString(), salt);
}

private String getSalt() {
	if (this.random != null) {
		return BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
	}
	return BCrypt.gensalt(this.version.getVersion(), this.strength);
}

使用Spring Security提供的BCrypt工具类生成盐(salt);然后,根据盐和明文密码生成最终的密文。所谓加盐,就是在初始化明文数据时,由系统自动向该明文里添加一些附加数据,然后散列。引入加盐机制的目的是进一步提高加密数据的安全性,单向散列加密及加盐思想广泛应用于系统登录过程中的密码生成和校验。

构造方法:

public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) {
	if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
		throw new IllegalArgumentException("Bad strength");
	}
	this.version = version;
	this.strength = (strength == -1) ? 10 : strength;
	this.random = random;
}

从构造函数可知,strength长度默认为10,最小值为BCrypt.MIN_LOG_ROUNDS=4,最大值为BCrypt.MAX_LOG_ROUNDS=31。显而易见,长度越长,加密算法越复杂,被恶意破解攻击的难度越大,但是也会增加系统负载,增加加密计算时长和存储空间。因此需要取得权衡,默认情况下使用Spring Security建议的长度10即可。

PasswordEncoderFactories

浏览一下spring-security-crypto-6.2.3源码结构:
在这里插入图片描述
不难发现PasswordEncoderFactories这个类,采用工厂方法模式,源码:

public static PasswordEncoder createDelegatingPasswordEncoder() {
	String encodingId = "bcrypt";
	Map<String, PasswordEncoder> encoders = new HashMap();
	encoders.put(encodingId, new BCryptPasswordEncoder());
	encoders.put("ldap", new LdapShaPasswordEncoder());
	encoders.put("MD4", new Md4PasswordEncoder());
	encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
	encoders.put("noop", NoOpPasswordEncoder.getInstance());
	encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
	encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
	encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
	encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
	encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
	encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
	encoders.put("sha256", new StandardPasswordEncoder());
	encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
	encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
	return new DelegatingPasswordEncoder(encodingId, encoders);
}

静态方法createDelegatingPasswordEncoder,encoders中存储每一种密码加密方案的id和所对应的加密类,如bcrypt对应BcryptPassword。最后,返回代理类DelegatingPasswordEncoder实例,并且默认使用的加密方案是BCryptPasswordEncoder。

DelegatingPasswordEncoder

DelegatingPasswordEncoder,采用代理模式,Spring Security 5.0版本后默认的密码加密方案,主要考虑如下三方面的因素:

  • 兼容性:使用DelegatingPasswordEncoder可以帮助许多使用旧密码加密方式的系统顺利迁移到Spring Security中,它允许在同一个系统中同时存在多种不同的密码加密方案
  • 便捷性:密码存储的最佳方案不可能一直不变,使用DelegatingPasswordEncoder作为默认的密码加密方案,当需要修改加密方案时,只需要修改很小一部分代码即可实现
  • 稳定性:作为一个框架,Spring Security不能经常进行重大更改,使用Delegating PasswordEncoder可以方便地对密码进行升级(自动从一个加密方案升级到另外一个加密方案)

属性如下:

// 默认的前缀和后缀,用于包裹将来生成的加密方案的id
private static final String DEFAULT_ID_PREFIX = "{";
private static final String DEFAULT_ID_SUFFIX = "}";
// 构造方法里支持传入用户自定义的前缀和后缀
private final String idPrefix;
private final String idSuffix;
// 默认的加密方案id
private final String idForEncode;
// 根据idForEncode从idToPasswordEncoder map中提取出来的
private final PasswordEncoder passwordEncoderForEncode;
// 保存id和加密方案之间的映射
private final Map<String, PasswordEncoder> idToPasswordEncoder;
// 默认的密码比对器,当根据密码加密方案的id无法找到对应的加密方案时,就会使用默认的密码比对器。默认类型是UnmappedIdPasswordEncoder
private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();

UnmappedIdPasswordEncoder是一个内部私有类:

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 + "\"");
	}
}

核心方法encode

public String encode(CharSequence rawPassword) {
	return this.idPrefix + this.idForEncode + this.idSuffix + this.passwordEncoderForEncode.encode(rawPassword);
}

作为一个代理类,不负责具体的加密工作,由加密类来完成,最后加上类似于{bcrypt}这样的前缀,不同的前缀表示使用不同的加密算法,即不同的PasswordEncoder实现类,当然也包括自定义的加密类。

核心方法matches

public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
	if (rawPassword == null && prefixEncodedPassword == null) {
		return true;
	}
	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);
}

extractId方法用于从加密字符串中提取出具体的加密方案id,也就是前缀和后缀包裹的字符串,如bcrypt,此方法就不贴出来了。根据加密方案id从map集合查找对应的加密算法实现类,查找失败则使用默认的加密类,即UnmappedIdPasswordEncoder,然后就会抛出异常。

核心方法upgradeEncoding

public boolean upgradeEncoding(String prefixEncodedPassword) {
	String id = extractId(prefixEncodedPassword);
	if (!this.idForEncode.equalsIgnoreCase(id)) {
		return true;
	} else {
		String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
		return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword);
	}
}

如果当前加密字符串所采用的加密方案不是默认的BcryptPasswordEncoder ,就会自动进行密码升级,否则就调用默认加密方案的upgradeEncoding方法判断密码是否需要升级。

自定义加密方案

业务开发中,如果Spring Security自带的几个加密类都不能满足需求,或者业务场景比较复杂,需要兼容数据库历史未加密字段或加密算法不够好的字段,则可能需要自定义加密类。

具体来说,实现PasswordEncoder接口类,并重写3个方法。比如自定义一个使用SHA-512加密算法的加密类:

public class Sha512PasswordEncoder implements PasswordEncoder {
	@Override
	public String encode(CharSequence rawPassword) {
		return hashWithSha512(rawPassword.toString());
	}
	
	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		String hashedPassword = encode(rawPassword);
		return encodedPassword.equals(hashedPassword);
	}

	@Override
	public boolean upgradeEncoding(String prefixEncodedPassword) {
		// 不需要升级
		return false;
	}
	
	private String hashWithSha512(String input) {
		StringBuilder result = new StringBuilder();
		try {
			MessageDigest md = MessageDigest.getInstance("SHA-512");
			byte [] digested = md.digest(input.getBytes());
			for (int i = 0; i < digested.length; i++) {
				result.append(Integer.toHexString(0xFF & digested[i]));
			}
		} catch (NoSuchAlgorithmException e) {
			throw new RuntimeException("Bad algorithm");
		}
		return result.toString();
	}
}

最后需要配置一下使用此自定义类,使其生效。

参考

  • 深入浅出Spring Security
posted @ 2024-08-20 10:06  johnny233  阅读(5)  评论(0编辑  收藏  举报