Spring Security学习(六)——配置多个Provider(存在两种认证规则)

前言

《Spring Security学习(五)——账号密码的存取》一文已经能满足一般应用的情况。但实际商业应用也会存在如下的情况:用户提交的账号密码,能在本地的保存的账号密码匹配上,或者能在远端服务认证中匹配上,就算认证通过。这种通常类似于单点登录,或者认证中心之类的。本文不去探索这种架构,只是单纯讨论如果存在两种认证规则下如何处理。

多种认证方式使用Spring Security时有好几种处理方式。根据不同的情况,架构师考虑不同的解决方案。本文先从比较简单的方案说起。

假设能匹配数据库保存的账号密码,或者能匹配内存中保存的账号密码两者之一,就算认证通过。附加条件:

  1. 这两种认证方式是通过同一方式提交用户密码;
  2. 这两种认证方式是一条链式的处理方式。比如先判断第一种方式是否通过认证,不通过则判断第二种。

上述情况,我们考虑使用多个provider去处理。

架构

前面的文章讲过ProviderManager是AuthenticationManager的实现。其架构图如下:

Spring Security学习(六)——配置多个Provider(存在两种认证规则)_第1张图片

在《Spring Security学习(五)——账号密码的存取》 中,我们说过DaoAuthenticationProvider撬动UserDetailsService和PasswordEncoder的“杠杆”。DaoAuthenticationProvider则是AuthenticationProvider的默认实现。Spring Security通过AuthenticationProvider调用获取UserDetails,然后进行匹配,最后返回UsernamePasswordAuthenticationToken(Authentication接口的具体)。

对于Spring Security的当存在多个AuthenticationProvider的实现时,用户提交的账号密码满足其中任意一个AuthenticationProvider,返回正确的Authentication即可。

自定义AuthenticationProvider

我们做个简单的自定义AuthenticationProvider。自定义MyProvider类:

@Data
public class MyProvider extends AbstractUserDetailsAuthenticationProvider{

	private UserDetailsServiceImpl userDetailsServiceImpl;
	
	private PasswordEncoder passwordEncoder;
	
	private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
	
	private volatile String userNotFoundEncodedPassword;

	@Override
	protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = userDetailsServiceImpl.loadUserByMemory(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

	@Override
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
		String presentedPassword = authentication.getCredentials().toString();
		if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}
	
	private void prepareTimingAttackProtection() {
		if (this.userNotFoundEncodedPassword == null) {
			this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
		}
	}

	private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
		if (authentication.getCredentials() != null) {
			String presentedPassword = authentication.getCredentials().toString();
			this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
		}
	}
}

自定义Provider应该实现AuthenticationProvider接口,并实现authenticate方法。这里我们参考DaoAuthenticationProvider来写,重写retrieveUser方法调用我们自定义的userDetailsServiceImpl的loadUserByMemory方法从内存获取。additionalAuthenticationChecks方法的作用是对提交的密码和系统中保存的密码进行匹配,这里直接参考DaoAuthenticationProvider类的写法。prepareTimingAttackProtection和mitigateAgainstTimingAttack都是用来防御计时攻击的,非认证必要。

在《Spring Security学习(二)——使用数据库保存密码》中我们定义了SysUserServiceImpl,这里进行一些修改:

@Component
public class UserDetailsServiceImpl implements UserDetailsService{

	@Autowired
	private SysUserService userService;
	
	private static Map userMap;
	
	static {
		userMap = new HashMap();
		SysUserEntity sysUser = new SysUserEntity();
		sysUser.setUsername("test");
		sysUser.setPassword("test###");
		userMap.put("test", sysUser);
	}
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		QueryWrapper queryWrapper = new QueryWrapper();
		queryWrapper.eq("username", username);
		queryWrapper.last("limit 1");
		SysUserEntity user = userService.getOne(queryWrapper);
		if(user == null) {
			throw new UsernameNotFoundException("username not found");
		}
		return (new LoginUser(user));
	}
	
	public UserDetails loadUserByMemory(String username) throws UsernameNotFoundException {
		if(StrUtil.isNotEmpty(username)) {
			SysUserEntity user = userMap.get(username);
			if(user == null) {
				throw new UsernameNotFoundException("username not found");
			}
			return (new LoginUser(user));
		}
		return null;
	}
}

首先我们增加一个loadUserByUsername方法,用于读取map中保存的测试账号。另外之前当我们查询找不到对象的时候,抛出了RuntimeException,按照Spring Security的设计思想来说是不对的,应该抛出UsernameNotFoundException让上层接住这个异常。抛出RuntimeException会直接终止后面provider的匹配,不符合Spring Security的设计理念。

自定义密码加密器还是使用之前文章的版本MyPasswordEncoder,为原密码后加3个#:

public class MyPasswordEncoder implements PasswordEncoder {

	@Override
	public String encode(CharSequence rawPassword) {
		return rawPassword + "###";
	}

	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		if (encodedPassword.equals(rawPassword + "###")) {
			return true;
		}
		return false;
	}
}

另外由于自定义加密方式,所以数据库密码也要改一下:

Spring Security学习(六)——配置多个Provider(存在两种认证规则)_第2张图片

最后修改一下WebSecurityConfig的配置,把自定义的MyProvider加进去:

@EnableWebSecurity
public class WebSecurityConfig{
	
	@Bean
	public MyPasswordEncoder PasswordEncoder() {
		return new MyPasswordEncoder();
	}

	@Bean
	public SecurityFilterChain formLoginFilterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests(authorize -> authorize
				.anyRequest().authenticated()
			)
			.formLogin(Customizer.withDefaults());
		MyProvider myProvider = new MyProvider();
		myProvider.setPasswordEncoder(PasswordEncoder());
		UserDetailsServiceImpl UserDetailsServiceImpl = SpringUtils.getBean(UserDetailsServiceImpl.class);
		myProvider.setUserDetailsServiceImpl(UserDetailsServiceImpl);
		http.authenticationProvider(myProvider);

		DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
		daoAuthenticationProvider.setPasswordEncoder(PasswordEncoder());
		daoAuthenticationProvider.setUserDetailsService(UserDetailsServiceImpl);
		http.authenticationProvider(daoAuthenticationProvider);
		return http.build();
	}
}

16行新建MyProvider,17行设置自定义的加密器,18-19行设置UserDetailsServiceImpl。20行加到HttpSecurity中。22行设置DaoAuthenticationProvider到HttpSecurity中。

之后我们同样是访问/hello路径,然后在默认登录页分别尝试使用jake/123,test/test两个账号登陆。会发现通过两种认证方式都可以登陆成功。

小结

本文使用自定义Provider的方式让用户匹配多种认证方式。这个只是最简单的方式。从系列文章的本文章开始,就涉及很多需要了解Spring Security架构设计的情况,读者最好还是阅读源码去理解。否则生搬硬套很难达到实际开发想要达到的目的。本文主要涉及ProviderManager、DaoAuthenticationProvider、AbstractUserDetailsAuthenticationProvider的源码。读者最好阅读一下以了解本文实现的思路。

其实还有个小疑问,WebSecurityConfig似乎我们去掉DaoAuthenticationProvider的配置,一样能达到同样的效果,那我们的配置是不是有点多余了?其实这涉及另一个概念,留到下一篇文章再讲。

你可能感兴趣的:(Spring,Security,spring,security)