Spring Security(三) —— 加密系统

一:加密系统简介

最早我们使用类似SHA-256这样的单向Hash算法。用户注册成功后,保存在数据库中的不再是用户的明文密码,而是经过SHA-256加密计算的一个字符串,当用户进行登录时,将用户输入的明文密码用SHA-256进行加密,加密完成之后,再和存储在数据库中的密码进行比对,进而确定用户登录信息是否有效。如果系统遭遇攻击,最多也只是存储在数据库中的密文被泄漏。

这样就绝对安全了吗?当然不是的。彩虹表是一个用于加密Hash函数逆运算的表,通常用于破解加密过的Hash字符串。为了降低彩虹表对系统安全性的影响,人们又发明了密码加“盐”,之前是直接将密码作为明文进行加密,现在再添加一个随机数(即盐)和密码明文混合在一起进行加密,这样即使密码明文相同,生成的加密字符串也是不同的。当然,这个随机数也需要以明文形式和密码一起存储在数据库中。当用户需要登录时,拿到用户输入的明文密码和存储在数据库中的盐一起进行Hash运算,再将运算结果和存储在数据库中的密文进行比较,进而确定用户的登录信息是否有效。密码加盐之后,彩虹表的作用就大打折扣了,因为唯一的盐和明文密码总会生成唯一的Hash字符。

然而,随着计算机硬件的发展,每秒执行数十亿次Hash计算已经变得轻轻松松,这意味着即使给密码加密加盐也不再安全。

在Spring Security中,我们现在是用一种自适应单向函数(Adaptive One-way Functions)来处理密码问题,这种自适应单向函数在进行密码匹配时,会有意占用大量系统资源(例如CPU、内存等),这样可以增加恶意用户攻击系统的难度。在Spring Securiy中,开发者可以通过bcrypt、PBKDF2、 sCrypt 以及argon2来体验这种自适应单向函数加密。由于自适应单向函数有意占用大量系统资源,因此每个登录认证请求都会大大降低应用程序的性能,但是Spring Secuity不会采取任何措施来提高密码验证速度,因为它正是通过这种方式来增强系统的安全性。

  • BCryptPasswordEncoder:使用 BCrypt 算法对密码进行加密,为了提高密码的安全性,BCrypt算法故意降低运行速度,以增强密码破解的难度。同时 BCryptPasswordEncoder “为自己带盐”,开发者不需要额外维护一个“盐” 字段,使用 BCryptPasswordEncoder 加密后的字符串就已经“带盐”了,即使相同的明文每次生成的加密字符串都不相同。
  • Argon2PasswordEncoder:使用 Argon2 算法对密码进行加密,Argon2 曾在 Password Hashing Competition 竞赛中获胜。为了解决在定制硬件上密码容易被破解的问题,Argon2也是故意降低运算速度,同时需要大量内存,以确保系统的安全性。
  • Pbkdf2PasswordEncoder:使用PBKDF2算法对密码进行加密,和前面几种类似,PBKDF2算法也是一种故意降低运算速度的算法,当需要FIPS(Federal Information Processing Standard,美国联邦信息处理标准)认证时,PBKDF2算法是一个很好的选择。
  • SCryptPasswordEncoder:使用Scrypt算法对密码进行加密,和前面的几种类似,scrypt也是一种故意降低运算速度的算法,而且需要大量内存。

PBKDF2 已经存在很长时间了,但它有点过时了:轻松的在多核系统(GPU)上实现并行,但这对于定制系统(FPGA/ASIC)来说微不足道。

虽然在 1999 年 BCrypt 就产生了,并且在对抗 GPU/ASIC 方面要优于 PBKDF2,但还是不建议在新系统中使用它,因为它在离线破解的威胁模型分析中表现并不突出。 尽管有一些数字加密货币依赖于它(即:NUD),但它并没有因此获得较大的普及,因此,FPGA/ASIC 社区也并没有足够的兴趣来构建它的硬件实现。

SCrypt 在如今是一个更好的选择:比 BCrypt设计得更好(尤其是关于内存方面)并且已经在该领域工作了 10 年。另一方面,它也被用于许多加密货币,并且我们有一些硬件(包括 FPGA 和 ASIC)能实现它。 尽管它们专门用于采矿,也可以将其重新用于破解。

Argon2 在 2015 年 7 月赢得了密码哈希竞赛。该竞赛于 2012 年秋季启动,2013 年第一季度,竞赛委员会发布了征集参赛作品的通知,截止日期为 2014 年 3 月底。作为比赛的一部分,小组成员对提交的参赛作品进行了全面审核,并发布了一份初步的简短报告,其中描述了他们的选择标准和理由。

  • 介绍:Argon2 有两个主要的版本:Argon2i 是对抗侧信道攻击的最安全选择,而 Argon2d 是抵抗 GPU 破解攻击的最安全选择。
  • 基于 AES 实现:Argon2 基于 AES 实现,现代的 x64 和 ARM 处理器已经在指令集扩展中实现了它,从而大大缩小了普通系统和攻击者系统之间的性能差距
  • 参数调整:两个版本的算法都可以实现参数化,这意味着你可以分别调整这些参数,并根据你的用例、威胁模型和硬件规范来量身定制安全约束。
    • 时间开销,它定义了执行的时间
    • 内存开销,它定义了内存的使用情况
    • 并行程度,它定义了线程的数量
  • 权衡攻击:除此之外,Argon2 尤其能抵挡排名权衡攻击,这使得在现场可编程逻辑门阵列上进行低成本攻击变得更加困难:虽然,最近的现场可编程逻辑门阵列已经嵌入 RAM 区块,但是,内存带宽仍然是一个限制,并且为了减少内存带宽要求,攻击者必须为了 Argon2 使用更多的计算资源。

综上,一般建议不要使用 PBKDF2 或 BCrypt,而是建议将 Argon2(最好是 Argon2id)用于最新系统。而Scrypt是当 Argon2 不可用时的不二选择,但要记住,它在侧信道泄露方面也存在相同的问题。

二:PasswordEncoder与DelegatingPasswordEncoder

我们可以先分析一下Spring Security的密码比较流程,经过前面的两篇博客,我们已经知道,认证是在ProviderManager的authenticate方法内实现的,如果想了解完整过程,可以看看我的前面两篇博客,这里就不赘述了:

  • Spring Security(一) —— 整体架构与入门案例分析
  • Spring Security(二) —— 自定义配置

provider.authenticate()方法处打断点,我们开启debug模式追踪认证的流程,并试图从中找到比较密码的逻辑:

Spring Security(三) —— 加密系统_第1张图片
我们可以看到additionalAuthenticationChecks()方法传入了数据库中的用户信息,和客户端传的用户信息,因此这个方法很有可能是做密码比对
Spring Security(三) —— 加密系统_第2张图片

进入additionalAuthenticationChecks()方法:

	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
			
		// 如果凭证信息(例如密码)为空,则打印日志抛出异常
		if (authentication.getCredentials() == null) {
			logger.debug("Authentication failed: no credentials provided");

			throw new BadCredentialsException(messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.badCredentials",
					"Bad credentials"));
		}

		// 获取客户端传来的密码
		String presentedPassword = authentication.getCredentials().toString();

		// 可以看到,在这里进行了密码比对,如果密码错误,则抛出BadCredentialsException异常,即密码错误异常
		if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			logger.debug("Authentication failed: password does not match stored value");

			throw new BadCredentialsException(messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.badCredentials",
					"Bad credentials"));
		}
	}

通过源码我们知道,密码比对是通过passwordEncoder.matches()方法实现的,我们进入该方法:

    public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
        // 如果原始密码和数据库密码都是null,则认证通过
        if (rawPassword == null && prefixEncodedPassword == null) {
            return true;
        } else {
        	// 从密码中获取{}内的值
        	// 因为此时数据库中的密码是{noop}123,{noop}不是密码组成部分,而是表示密码是明文
        	// 此时id就是代表密码存储方式,这里的值为noop
            String id = this.extractId(prefixEncodedPassword);
            // 根据密码存储方式获取相应的加密策略
            PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id);
            if (delegate == null) {
            	// 如果没有相应的加密策略,则采用默认比较策略
                return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
            } else {
            	// 提取数据库密码的密码部分,即除去{}的部分
                String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
                // 如果有相应的加密策略,则用该策略比较
                return delegate.matches(rawPassword, encodedPassword);
            }
        }
    }

其中,idToPasswordEncoder.get(id)是获取加密策略,其实这idToPasswordEncoder是一个map,我们可以对其进行计算,可以看到map里默认是有11种加密策略,以后我们自定义加密策略,也要把策略放进这个map
Spring Security(三) —— 加密系统_第3张图片

到这里我们就明白Spring Security比对密码的方式了,即密码比较是由PasswordEncoder完成的,因此只需要使用PasswordEncoder不同实现就可以实现不同方式加密。

public interface PasswordEncoder {

	/**
	 * 对原始密码进行加密。
	 * 通常,一个好的加密算法应该用SHA-1或更大的哈希值与8字节或更大的随机生成的salt相结合。
	 */
	String encode(CharSequence rawPassword);

	/**
	 * 验证从数据源获得的加密密码是否与提交的原始密码加密后匹配
	 * 如果密码匹配,则返回true;如果密码不匹配,则返回false。
	 * 存储的密码本身永远不会被解码。
	 * @param rawPassword
	 * @param encodedPassword
	 */
	boolean matches(CharSequence rawPassword, String encodedPassword);

	/**
	 * 如果应再次加密已加密的密码以提高安全性,则返回true,否则返回false
	 * 默认实现总是返回false。
	 * @param encodedPassword 
	 */
	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}
}

我们可以看到,该接口有大量的实现类,但有很多都已废弃,那些废弃的策略都是因为不安全,所以Spring Security并不推荐
Spring Security(三) —— 加密系统_第4张图片

我们可以更换一下加密策略,比如说,现在我们希望用BCryptPasswordEncoder对密码进行加密,就可以这么做,我们创建一个李四用户,密码为123,那么先获取123经过加密后的值为:$2a$10$LXrffAUpj/YgVqAMpAUwteRn1uBLYAZfxbUOGj6vrJjUNoNUb1D5m

    @Test
    public void test() {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        System.out.println(bCryptPasswordEncoder.encode("123"));
    }

然后我们就可以设置数据源:

    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(
        User.withUsername("李四")
        	.password("{bcrypt}$2a$10$LXrffAUpj/YgVqAMpAUwteRn1uBLYAZfxbUOGj6vrJjUNoNUb1D5m")
        	.roles("admin")
        	.build());
        return userDetailsManager;
    }

    // 自定义AuthenticationManager
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }

然后测试,可见成功登录

Spring Security(三) —— 加密系统_第5张图片

三:自定义加密

一般来说,即使Spring Security提供的加密算法很安全,但我们仍然有自己定义加密算法的需求,此时我们就需要自定义PasswordEncoder的实现类了

在WebSecurityConfigurerAdapter中有这样两个静态内部类DefaultPasswordEncoderAuthenticationManagerBuilderLazyPasswordEncoder,其源码如下:

	static class DefaultPasswordEncoderAuthenticationManagerBuilder extends AuthenticationManagerBuilder {
		private PasswordEncoder defaultPasswordEncoder;

		/**
		 * 构造方法,默认创建一个defaultPasswordEncoder
		 * 而defaultPasswordEncoder是由LazyPasswordEncoder设置的,可以往下看
		 */
		DefaultPasswordEncoderAuthenticationManagerBuilder(
			ObjectPostProcessor<Object> objectPostProcessor, PasswordEncoder defaultPasswordEncoder) {
			super(objectPostProcessor);
			this.defaultPasswordEncoder = defaultPasswordEncoder;
		}
		
		// 基于内存的UserDetailsManager的实现类的密码认证策略默认配置
		@Override
		public InMemoryUserDetailsManagerConfigurer<AuthenticationManagerBuilder> inMemoryAuthentication()
			throws Exception {
			return super.inMemoryAuthentication()
				.passwordEncoder(this.defaultPasswordEncoder);
		}
		
		// 基于数据库的UserDetailsManager的实现类的密码认证策略默认配置
		@Override
		public JdbcUserDetailsManagerConfigurer<AuthenticationManagerBuilder> jdbcAuthentication()
			throws Exception {
			return super.jdbcAuthentication()
				.passwordEncoder(this.defaultPasswordEncoder);
		}

		@Override
		public <T extends UserDetailsService> DaoAuthenticationConfigurer<AuthenticationManagerBuilder, T> userDetailsService(
			T userDetailsService) throws Exception {
			return super.userDetailsService(userDetailsService)
				.passwordEncoder(this.defaultPasswordEncoder);
		}
	}

	static class LazyPasswordEncoder implements PasswordEncoder {
		private ApplicationContext applicationContext;
		private PasswordEncoder passwordEncoder;

		LazyPasswordEncoder(ApplicationContext applicationContext) {
			this.applicationContext = applicationContext;
		}

		@Override
		public String encode(CharSequence rawPassword) {
			return getPasswordEncoder().encode(rawPassword);
		}

		@Override
		public boolean matches(CharSequence rawPassword,
			String encodedPassword) {
			return getPasswordEncoder().matches(rawPassword, encodedPassword);
		}

		@Override
		public boolean upgradeEncoding(String encodedPassword) {
			return getPasswordEncoder().upgradeEncoding(encodedPassword);
		}

		private PasswordEncoder getPasswordEncoder() {
			if (this.passwordEncoder != null) {
				return this.passwordEncoder;
			}
			// 获取PasswordEncoder的Bean
			PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
			if (passwordEncoder == null) { // 没有获取到
				// 在这通过工厂类创建DelegatingPasswordEncoder,也就是默认的PasswordEncoder
				passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
			}
			this.passwordEncoder = passwordEncoder;
			return passwordEncoder;
		}

		private <T> T getBeanOrNull(Class<T> type) {
			try {
				return this.applicationContext.getBean(type);
			} catch(NoSuchBeanDefinitionException notFound) {
				return null;
			}
		}

		@Override
		public String toString() {
			return getPasswordEncoder().toString();
		}
	}

通过源码可以知道,默认的passwordEncoder是通过PasswordEncoderFactories的工厂类的createDelegatingPasswordEncoder()方法创建的,我们可以进去看看,很明显,这就是上面所说的11种加密策略的由来,也是Spring Security默认的PasswordEncoder

	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());
		encoders.put("argon2", new Argon2PasswordEncoder());

		return new DelegatingPasswordEncoder(encodingId, encoders);
	}

到这里,我们就可以总结出来,springboot项目启动后,我们自定义的配置类会被加载,而我们自定义的配置类因为继承了WebSecurityConfigurerAdapter,因此也会创建AuthenticationManager,而AuthenticationManager需要PasswordEncoder,而PasswordEncoder的默认实现是DelegatingPasswordEncoder,而DelegatingPasswordEncoder是由PasswordEncoderFactories创建的。

那么我们要怎么使用自己的自定义的PasswordEncoder呢,我在上面源码中已经写了,可以看到,如果没获取到PasswordEncoder的Bean,才会调用工厂类去创建DelegatingPasswordEncoder,因此我们只要自己创建PasswordEncoder的实现类,然后交给spring管理,就可以了。
Spring Security(三) —— 加密系统_第6张图片

因此我们可以如下配置,注意,此时密码前面已经不需要加{bcrtpt}了

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(User.withUsername("李四").password("$2a$10$LXrffAUpj/YgVqAMpAUwteRn1uBLYAZfxbUOGj6vrJjUNoNUb1D5m").roles("admin").build());
        return userDetailsManager;
    }

    // 自定义AuthenticationManager
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }

上面只是一个示例,如果是小项目,密码只需要一种加密方式,可以这么做,但是如果是一个长期的大项目,密码的加密方式可能会修改,比如说第一个版本使用BCrypt加密,然后用着用着就因为某些需求需要更换加密加密算法,那么可以老用户还是使用BCrypt算法,新用户采用新算法,又或者说对于不同身份的用户有不同的加密需求,高级用户价值更大,而且数量相对比较少,此时就可以针对高级用户采用另一套更复杂的算法,因为数量少,因此换用更复杂的算法也不会明显增加服务器压力。针对以上类似的需求,直接固定加密算法明显是不妥的,因此为了项目的灵活性,我们自定义PasswordEncoder最好模仿DelegatingPasswordEncoder,即"代理PasswordEncoder",使用代理PasswordEncoder成为默认PasswordEncoder,然后在代理PasswordEncoder内引入Map encoders = new HashMap<>();,这样就可以实现自定义多套加密算法的PasswordEncoder了。

四:密码自动升级

实际开发中,还有这样一种业务场景,比如说目前系统中密码加密方式是BCrypt,然后突然曝出BCrypt算法已经被破解了,这时候我们就要更换密码,而数据库中的密码都是单向加密的,因此我们是无法从数据库中解密出原始密码然后再用新算法加密再重新存储的。但是,我们可以等用户再次登录,认证成功后,将用户输入的密码用新算法加密然后替换数据库密码,这就是密码的自动升级。

因为密码升级是在认证成功后执行的,因此我们可以先看一下认证的源码,并从中寻找Spring Security对密码升级的处理,我们可以看到,在认证成功后会执行这个createSuccessAuthentication()方法
Spring Security(三) —— 加密系统_第7张图片
进入createSuccessAuthentication()方法:

	@Override
	protected Authentication createSuccessAuthentication(Object principal,
			Authentication authentication, UserDetails user) {
		// 判断是否需要更新密码
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String presentedPassword = authentication.getCredentials().toString();
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			// 利用userDetailsPasswordService更新密码
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}

因此如果我们需要更新旧密码,就需要实现UserDetailsPasswordService接口,该接口只有一个方法:

public interface UserDetailsPasswordService {

	/**
	 * 修改指定用户的密码。
	 * 这会更改持久用户存储库(数据库、LDAP等)中的用户密码。
	 */
	UserDetails updatePassword(UserDetails user, String newPassword);
}

为了演示密码升级的操作,可以先准备一下数据库,数据库sql还有实体类我都已经在前一篇博客就已经贴上去了,如果想亲自试一试的话可以去Spring Security(二) —— 自定义配置这篇博客复制。

我们需要实现一个mapper,即更新数据库密码的sql:

    /**
     * 根据用户名更新密码
     * @param username
     * @param password
     * @return
     */
    @Update("update user set password=#{password} where username=#{username}")
    Integer updatePassword(String username, String password);

因为是实现接口,所以我们可以让之前已经实现了UserDetailsService接口的类再去实现UserDetailsPasswordService:

@Component
public class CustomUserDetailService implements UserDetailsService, UserDetailsPasswordService {

    private final UserMapper userMapper;

    public CustomUserDetailService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userMapper.loadUserByUsername(username);
        if(ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名或账号错误");
        List<RoleEntity> roles = userMapper.getRolesByUid(user.getId());
        user.setRoles(roles);
        return user;
    }

    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        Integer result = userMapper.updatePassword(user.getUsername(), newPassword);
        if(result == 1) {
            ((UserEntity)user).setPassword(newPassword);
        }
        return user;
    }
}

然后再设置一下数据源:

    private final CustomUserDetailService customUserDetailService;

    public WebSecurityConfig(CustomUserDetailService customUserDetailService) {
        this.customUserDetailService = customUserDetailService;
    }

//    @Bean
//    public PasswordEncoder passwordEncoder() {
//        return new BCryptPasswordEncoder();
//    }

    public UserDetailsService userDetailsService() {
        return customUserDetailService;
    }

    // 自定义AuthenticationManager
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }

就可以尝试登录了:
Spring Security(三) —— 加密系统_第8张图片
查看数据库,可见已经成功升级了,当然我们没有去设置升级算法,而Spring Security默认采用DelegatingPasswordEncoder的BCryptPasswordEncoder算法升级
在这里插入图片描述

如果有兴趣了解更多相关内容,欢迎来我的个人网站看看:瞳孔的个人空间

你可能感兴趣的:(java,spring,java,spring,security,加密)