自定义认证授权逻辑
查看源码可知【AuthenticationProvider】定义了认证逻辑

  1. public interface AuthenticationProvider {
  2. Authentication authenticate(Authentication authentication) throws AuthenticationException;
  3. boolean supports(Class<?> authentication);
  4. }
  • authenticate 方法用来做验证,就是验证用户身份。
  • supports 则用来判断当前的 AuthenticationProvider 是否支持对应的 Authentication。

认证逻辑分析

在 Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息,Authentication 本身是一个接口,它实际上对 java.security.Principal 做的进一步封装,我们来看下 Authentication 的定义

  1. public interface Authentication extends Principal, Serializable {
  2. Collection<? extends GrantedAuthority> getAuthorities();
  3. Object getCredentials();
  4. Object getDetails();
  5. Object getPrincipal();
  6. boolean isAuthenticated();
  7. void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
  8. }
  1. getAuthorities 方法用来获取用户的权限。
  2. getCredentials 方法用来获取用户凭证,一般来说就是密码。
  3. getDetails 方法用来获取用户携带的详细信息,可能是当前请求之类的东西。
  4. getPrincipal 方法用来获取当前用户,可能是一个用户名,也可能是一个用户对象。
  5. isAuthenticated 当前用户是否认证成功。

image.png

对于Authentication接口的实现类中,我们通常用的最多的就是UsernamePasswordAuthenticationToken
而每一个 Authentication 都有适合它的 AuthenticationProvider 去处理校验。
例如处理 UsernamePasswordAuthenticationToken 的 AuthenticationProvider 是 DaoAuthenticationProvider。

所以大家在 AuthenticationProvider 中看到一个 supports 方法,就是用来判断 AuthenticationProvider 是否支持当前 Authentication。
在一次完整的认证中,可能包含多个 AuthenticationProvider,而这多个 AuthenticationProvider 则由 ProviderManager 进行统一管理

处理器中主要有两个方法

authenticate 方法

  1. public Authentication authenticate(Authentication authentication) throws AuthenticationException {
  2. Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
  3. return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
  4. });
  5. String username = this.determineUsername(authentication);
  6. boolean cacheWasUsed = true;
  7. UserDetails user = this.userCache.getUserFromCache(username);
  8. if (user == null) {
  9. cacheWasUsed = false;
  10. try {
  11. user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
  12. } catch (UsernameNotFoundException var6) {
  13. this.logger.debug("Failed to find user '" + username + "'");
  14. if (!this.hideUserNotFoundExceptions) {
  15. throw var6;
  16. }
  17. throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
  18. }
  19. Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
  20. }
  21. try {
  22. this.preAuthenticationChecks.check(user);
  23. this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
  24. } catch (AuthenticationException var7) {
  25. if (!cacheWasUsed) {
  26. throw var7;
  27. }
  28. cacheWasUsed = false;
  29. user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
  30. this.preAuthenticationChecks.check(user);
  31. this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
  32. }
  33. this.postAuthenticationChecks.check(user);
  34. if (!cacheWasUsed) {
  35. this.userCache.putUserInCache(user);
  36. }
  37. Object principalToReturn = user;
  38. if (this.forcePrincipalAsString) {
  39. principalToReturn = user.getUsername();
  40. }
  41. return this.createSuccessAuthentication(principalToReturn, authentication, user);
  42. }

authenticate 方法就是用来做认证的方法,我们来简单看下方法流程:

  1. 首先从 Authentication 提取出登录用户名。

image.png

  1. 然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象,该方法是一个抽象方法,这一步会调用我们自己在登录时候的写的 loadUserByUsername 方法,所以这里返回的 user 其实就是你的登录对象
    image.png

image.png

image.png
我们的配置类中配置的实现类,具体的实现根据业务进行编写
image.png

  1. 接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。

image.png

image.png

  1. 再然后调用additionalAuthenticationChecks 方法做密码比对

additionalAuthenticationChecks 方法是一个抽象方法,
具体的实现是在 AbstractUserDetailsAuthenticationProvider 的子类中实现的,也就是 DaoAuthenticationProvider。
这个其实很好理解,因为 AbstractUserDetailsAuthenticationProvider 作为一个较通用的父类,处理一些通用的行为,
我们在登录的时候,有的登录方式并不需要密码,
所以 additionalAuthenticationChecks 方法一般交给它的子类去实现,
在 DaoAuthenticationProvider 类中,additionalAuthenticationChecks 方法就是做密码比对的,
在其他的 AuthenticationProvider 中,additionalAuthenticationChecks 方法的作用就不一定了。
image.png

  1. 最后在 postAuthenticationChecks.check 方法中检查密码是否过期。

image.png

image.png

  1. 接下来有一个 forcePrincipalAsString 属性,

这个属性代表是否强制将 Authentication 中的 principal 属性设置为字符串
这个属性我们一开始在 UsernamePasswordAuthenticationFilter 类中其实就是设置为字符串的(即 username),
但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。
之所以会这样,就是因为 forcePrincipalAsString 默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。

  1. 最后,通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken。
    protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
         UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
         result.setDetails(authentication.getDetails());
         this.logger.debug("Authenticated user");
         return result;
     }
    
    由于 AbstractUserDetailsAuthenticationProvider 已经把 authenticate 和 supports 方法实现了,
    所以在 DaoAuthenticationProvider 中,我们主要关注 additionalAuthenticationChecks 方法即可:

additionalAuthenticationChecks 方法主要用来做密码比对的,逻辑也比较简单,就是调用 PasswordEncoder 的 matches 方法做比对,如果密码不对则直接抛出异常即可。
「正常情况下,我们使用用户名/密码登录,最终都会走到这一步。」
而 AuthenticationProvider 都是通过 ProviderManager#authenticate 方法来调用的。
由于我们的一次认证可能会存在多个 AuthenticationProvider,
所以,在 ProviderManager#authenticate 方法中会逐个遍历 AuthenticationProvider,并调用他们的 authenticate 方法做认证,


public Authentication authenticate(Authentication authentication)
  throws AuthenticationException {
 for (AuthenticationProvider provider : getProviders()) {
  result = provider.authenticate(authentication);
  if (result != null) {
   copyDetails(authentication, result);
   break;
  }
 }
    ...
    ...
}

可以看到,在这个方法中,会遍历所有的 AuthenticationProvider,并调用它的 authenticate 方法进行认证。

supports方法

自定义认证逻辑

之前我们都是配置filter,并将其添加至security的执行filter链中,进而实现添加登录验证码的功能,
但是这种方式是有弊端的,就是破坏了原有的过滤器链,请求每次都要走一遍验证码过滤器,这样不合理。

登录请求是调用
AbstractUserDetailsAuthenticationProvider#authenticate 方法进行认证的,
在该方法中,又会调用到
DaoAuthenticationProvider#additionalAuthenticationChecks 方法做进一步的校验,去校验用户登录密码。
我们可以自定义一个 AuthenticationProvider 代替 DaoAuthenticationProvider,
并重写它里边的 additionalAuthenticationChecks 方法,在重写的过程中,加入验证码的校验逻辑即可。

实现代码

依赖导入

 <dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>${kaptcha.version}</version>
</dependency>

验证码配置类

我们为生成验证码配置一些基础配置

package com.zukxu.config;

import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

/**
 * @author xupu
 * @Description
 * @Date 2021-09-26 15:15
 */
@Configuration
public class CommonConfig {
    @Bean
    Producer verifyCode() {
        Properties properties = new Properties();
        properties.setProperty("kaptcha.image.width", "150");
        properties.setProperty("kaptcha.image.height", "50");
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

我们就是提供了验证码图片的宽高、字符库以及生成的验证码字符长度。

验证码接口

接下来提供一个返回验证码图片的接口:

@RestController
public class CaptchaController {
    @Autowired
    Producer producer;

    @GetMapping("/captcha")
    public void getCaptcha(HttpServletResponse resp, HttpSession session) throws IOException {
        resp.setContentType("image/jpeg");
        String text = producer.createText();
        session.setAttribute("verify_code", text);
        BufferedImage image = producer.createImage(text);
        try(ServletOutputStream out = resp.getOutputStream()) {
            ImageIO.write(image, "jpg", out);
        }
    }
}

这里我们生成验证码图片,并将生成的验证码字符存入 HttpSession 中。注意这里我用到了 try-with-resources ,可以自动关闭流

自定义认证逻辑

继承自 DaoAuthenticationProvider,并重写 additionalAuthenticationChecks 方法:

/**
 * @author xupu
 * @Description 自定义认证逻辑
 * @Date 2021-09-26 15:13
 */
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
                                                  UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        HttpServletRequest req = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String code = req.getParameter("code");
        String verify_code = (String) req.getSession().getAttribute("verify_code");
        if(code == null || !code.equals(verify_code)) {
            throw new AuthenticationServiceException("验证码错误");
        }
        super.additionalAuthenticationChecks(userDetails, authentication);
    }
}

实现逻辑如下:

  1. 首先获取当前请求,注意这种获取方式,在基于 Spring 的 web 项目中,我们可以随时随地获取到当前请求,获取方式就是我上面给出的代码。

    HttpServletRequest req = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

  2. 从当前请求中拿到 code 参数,也就是用户传来的验证码。

  3. 从 session 中获取生成的验证码字符串。
  4. 两者进行比较,如果验证码输入错误,则直接抛出异常。
  5. 最后通过 super 调用父类方法,也就是 DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法,该方法中主要做密码的校验。

使用自定义认证类

将自定义的认证类纳入security的管理,
前面我们说,所有的 AuthenticationProvider 都是放在 ProviderManager 中统一管理
所以接下来我们就要自己提供 ProviderManager,然后注入自定义的 MyAuthenticationProvider

     @Bean
    MyAuthenticationProvider myAuthenticationProvider() {
        MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
        myAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        myAuthenticationProvider.setUserDetailsService(userDetailsService());
        return myAuthenticationProvider;
    }

    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return new ProviderManager(Collections.singletonList(myAuthenticationProvider()));
    }

     @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/captcha").permitAll()//放行验证码图片接口
        }

实现逻辑如下:

  1. 我们需要提供一个 MyAuthenticationProvider 的实例,创建该实例时,需要提供 UserDetailService 和 PasswordEncoder 实例。
  2. 通过重写 authenticationManager 方法来提供一个自己的 AuthenticationManager,实际上就是 ProviderManager,在创建 ProviderManager 时,加入自己的 myAuthenticationProvider。

结果:

获取验证码 image.png
错误验证码登录 image.png
正确验证码登录 image.png