前言
《Spring Security学习(五)——账号密码的存取》一文已经能满足一般应用的情况。但实际商业应用也会存在如下的情况:用户提交的账号密码,能在本地的保存的账号密码匹配上,或者能在远端服务认证中匹配上,就算认证通过。这种通常类似于单点登录,或者认证中心之类的。本文不去探索这种架构,只是单纯讨论如果存在两种认证规则下如何处理。
多种认证方式使用Spring Security时有好几种处理方式。根据不同的情况,架构师考虑不同的解决方案。本文先从比较简单的方案说起。
假设能匹配数据库保存的账号密码,或者能匹配内存中保存的账号密码两者之一,就算认证通过。附加条件:
- 这两种认证方式是通过同一方式提交用户密码;
- 这两种认证方式是一条链式的处理方式。比如先判断第一种方式是否通过认证,不通过则判断第二种。
上述情况,我们考虑使用多个provider去处理。
架构
前面的文章讲过ProviderManager是AuthenticationManager的实现。其架构图如下:
在《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<String, SysUserEntity> userMap;
static {
userMap = new HashMap<String, SysUserEntity>();
SysUserEntity sysUser = new SysUserEntity();
sysUser.setUsername("test");
sysUser.setPassword("test###");
userMap.put("test", sysUser);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<SysUserEntity>();
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;
}
}
另外由于自定义加密方式,所以数据库密码也要改一下:
最后修改一下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的源码。读者最好阅读一下以了解本文实现的思路。