SpringSecurity5(5-自定义短信、手机验证码)

图形验证码

SpringSecurity 实现的用户名、密码登录是在 UsernamePasswordAuthenticationFilter 过滤器进行认证的,而图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在 UsernamePasswordAuthenticationFilter 过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter,用来校验用户输入的图形验证码是否正确。

实现逻辑

自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter,确保每个请求只会进入过滤器一次,避免了多次执行的情况

自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理

SpringSecurity5(5-自定义短信、手机验证码)

添加验证码配置

<!-- 图形验证码工具 kaptcha -->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>
/**
 * 图形验证码的配置类
 */

@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha captchaProducer() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        // 是否有边框
        properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
        // 边框颜色
        properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192");
        // 验证码图片的宽和高
        properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110");
        properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40");
        // 验证码颜色
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0");
        // 验证码字体大小
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32");
        // 验证码生成几个字符
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
        // 验证码随机字符库
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
        // 验证码图片默认是有线条干扰的,我们设置成没有干扰
        properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

提供验证码接口

public class CheckCode implements Serializable {

    private String code;           // 验证码字符
    private LocalDateTime expireTime;  // 过期时间

    /**
     * @param code 验证码字符
     * @param expireTime 过期时间,单位秒
     */

    public CheckCode(String code, int expireTime) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
    }

    public CheckCode(String code) {
        // 默认验证码 60 秒后过期
        this(code, 60);
    }

    // 是否过期
    public boolean isExpried() {
        returnthis.expireTime.isBefore(LocalDateTime.now());
    }

    public String getCode() {
        returnthis.code;
    }
}
@Controller
public class LoginController {

    // Session 中存储图形验证码的属性名
    public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";

    @Autowired
    private DefaultKaptcha defaultKaptcha;

    @GetMapping("/login/page")
    public String login() {
        return"login";
    }

    @GetMapping("/code/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 创建验证码文本
        String capText = defaultKaptcha.createText();
        // 创建验证码图片
        BufferedImage image = defaultKaptcha.createImage(capText);

        // 将验证码文本放进 Session 中
        CheckCode code = new CheckCode(capText);
        request.getSession().setAttribute(KAPTCHA_SESSION_KEY, code);

        // 将验证码图片返回,禁止验证码图片缓存
        response.setHeader("Cache-Control""no-store");
        response.setHeader("Pragma""no-cache");
        response.setDateHeader("Expires"0);
        response.setContentType("image/jpeg");
        ImageIO.write(image, "jpg", response.getOutputStream());
    }    
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h3>表单登录</h3>
    <form method="post" th:action="@{/login/form}">
        <input type="text" name="username" placeholder="用户名"><br>
        <input type="password" name="password" placeholder="密码"><br>
        <input name="imageCode" type="text" placeholder="验证码"><br>
        <img th:onclick="this.src='/code/image?'+Math.random()" th:src="@{/code/image}" alt="验证码"/><br>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
        </div>
        <button type="submit">登录</button>
    </form>
</body>
</html>

自定义验证码过滤器

/**
 * 自定义验证码校验错误的异常类,继承 AuthenticationException
 */

public class ValidateCodeException extends AuthenticationException {
    public ValidateCodeException(String msg, Throwable t) {
        super(msg, t);
    }

    public ValidateCodeException(String msg) {
        super(msg);
    }
}
@Component
public class ImageCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "imageCode";  // 前端输入的图形验证码参数名

    @Autowired
    private AuthenticationFailureHandlerImpl authenticationFailureHandler;  // 自定义认证失败处理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的表单提交请求不校验图形验证码
        if ("/login/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 校验图形验证码合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 手动捕获图形验证码校验过程抛出的异常,将其传给失败处理器进行处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        // 放行请求,进入下一个过滤器
        filterChain.doFilter(request, response);
    }

    // 判断验证码的合法性
    private void validate(HttpServletRequest request) {
        // 获取用户传入的图形验证码值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();

        // 获取 Session
        HttpSession session = request.getSession();
        // 获取存储在 Session 里的验证码值
        CheckCode savedCode = (CheckCode) session.getAttribute(LoginController.KAPTCHA_SESSION_KEY);
        if (savedCode != null) {
            // 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
            session.removeAttribute(LoginController.KAPTCHA_SESSION_KEY);
        }

        // 校验出错,抛出异常
        if (StringUtils.isBlank(requestCode)) {
            thrownew ValidateCodeException("验证码的值不能为空");
        }
        if (savedCode == null) {
            thrownew ValidateCodeException("验证码不存在");
        }
        if (savedCode.isExpried()) {
            thrownew ValidateCodeException("验证码过期");
        }
        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            thrownew ValidateCodeException("验证码输入错误");
        }
    }
}
@Component
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("root".equals(username)) {
            returnnew User(username, passwordEncoder.encode("123"), AuthorityUtils.createAuthorityList("admin"));
        } else {
            thrownew UsernameNotFoundException("用户名不存在");
        }
    }
}

设置过滤器顺序

@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandlerImpl authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandlerImpl authenticationFailureHandler;
    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
    @Autowired
    private UserDetailServiceImpl userDetailService;

    /**
     * 密码编码器,密码不能明文存储
     */

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        returnnew BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
            // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
            .loginPage("/login/page")
            // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
            .loginProcessingUrl("/login/form")
            // 使用自定义的认证成功和失败处理器
            .successHandler(authenticationSuccessHandler)
            .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
            // 以下访问不需要任何权限,任何人都可以访问
            .antMatchers("/login/page""/code/image").permitAll()
            // 其它任何请求访问都需要先通过认证
            .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();
        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);        
    }

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css""/**/*.js""/**/*.png""/**/*.jpg""/**/*.jpeg");
    }
}

手机短信验证码

验证流程

带有图形验证码的用户名、密码登录流程:

  1. 在 ImageCodeValidateFilter 过滤器中校验用户输入的图形验证码是否正确。
  2. 在 UsernamePasswordAuthenticationFilter 过滤器中将 username 和 password 生成一个用于认证的 Token(UsernamePasswordAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。
  3. AuthenticationManager 管理器寻找到一个合适的处理器 DaoAuthenticationProvider 来处理 UsernamePasswordAuthenticationToken。
  4. DaoAuthenticationProvider 通过 UserDetailsService 接口的实现类 CustomUserDetailsService 从数据库中获取指定 username 的相关信息,并校验用户输入的 password。如果校验成功,那就认证通过,用户信息类对象 Authentication 标记为已认证。
  5. 认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中。

仿照上述流程,我们分析手机短信验证码登录流程:

  1. 仿照 ImageCodeValidateFilter 过滤器设计 MobileVablidateFilter 过滤器,该过滤器用来校验用户输入手机短信验证码。
  2. 仿照 UsernamePasswordAuthenticationFilter 过滤器设计 MobileAuthenticationFilter 过滤器,该过滤器将用户输入的手机号生成一个 Token(MobileAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。
  3. AuthenticationManager 管理器寻找到一个合适的处理器 MobileAuthenticationProvider 来处理 MobileAuthenticationToken,该处理器是仿照 DaoAuthenticationProvider 进行设计的。
  4. MobileAuthenticationProvider 通过 UserDetailsService 接口的实现类 MobileUserDetailsService 从数据库中获取指定手机号对应的用户信息,此处不需要进行任何校验,直接将用户信息类对象 Authentication 标记为已认证。
  5. 认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中,此处的操作不需要我们编写。

最后通过自定义配置类 MobileAuthenticationConfig 组合上述组件,并添加到安全配置类 SpringSecurityConfig 中。

SpringSecurity5(5-自定义短信、手机验证码)

提供短信发送接口

@Controller
public class LoginController {

    // Session 中存储手机短信验证码的属性名
    public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";

    @GetMapping("/mobile/page")
    public String mobileLoginPage() {  // 跳转到手机短信验证码登录页面
        return"login-mobile";
    }

    @GetMapping("/code/mobile")
    @ResponseBody
    public Object sendMoblieCode(HttpServletRequest request) 
        // 随机生成一个 4 位的验证码
        String code = RandomStringUtils.randomNumeric(4);

        // 将手机验证码文本存储在 Session 中,设置过期时间为 10 * 60s
        CheckCode mobileCode = new CheckCode(code, 10 * 60);
        request.getSession().setAttribute(MOBILE_SESSION_KEY, mobileCode);

        return mobileCode;
    }    
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
    <script src="https://s3.pstatp.com/cdn/expire-1-M/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
    <form method="post" th:action="@{/mobile/form}">
        <input id="mobile" name="mobile" type="text" placeholder="手机号码"><br>
        <div>
            <input name="mobileCode" type="text" placeholder="验证码">
            <button type="button" id="sendCode">获取验证码</button>
        </div>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
        </div>
        <button type="submit">登录</button>
    </form>

    <script>
        // 获取手机短信验证码
        $("#sendCode").click(function ({
            var mobile = $('#mobile').val().trim();
            if(mobile == '') {
                alert("手机号不能为空");
                return;
            }
            // /code/mobile?mobile=123123123
            var url = "/code/mobile?mobile=" + mobile;
            $.get(url, function(data){
                alert(data);
            });
        });
    
</script>
</body>
</html>

自定义短信验证码校验过滤器

更改自定义失败处理器 CustomAuthenticationFailureHandler,原先的处理器在认证失败时,会直接重定向到/login/page?error 显示认证异常信息。现在我们有两种登录方式,应该进行以下处理:

  1. 带图形验证码的用户名、密码方式登录方式出现认证异常,重定向到/login/page?error
  2. 手机短信验证码方式登录出现认证异常,重定向到/mobile/page?error
/**
* 继承 SimpleUrlAuthenticationFailureHandler 处理器,该类是 failureUrl() 方法使用的认证失败处理器
*/

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        String xRequestedWith = request.getHeader("x-requested-with");
        // 判断前端的请求是否为 ajax 请求
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 认证成功,响应 JSON 数据
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("认证失败");
        }else {
            // 用户名、密码方式登录出现认证异常,需要重定向到 /login/page?error
            // 手机短信验证码方式登录出现认证异常,需要重定向到 /mobile/page?error
            // 使用 Referer 获取当前登录表单提交请求是从哪个登录页面(/login/page 或 /mobile/page)链接过来的
            String refer = request.getHeader("Referer");
            String lastUrl = StringUtils.substringBefore(refer, "?");
            // 设置默认的重定向路径
            super.setDefaultFailureUrl(lastUrl + "?error");
            // 调用父类的 onAuthenticationFailure() 方法
            super.onAuthenticationFailure(request, response, e);
        }
    }
}
/**
 * 手机短信验证码校验
 */

@Component
public class MobileCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "mobileCode";  // 前端输入的手机短信验证码参数名

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的手机短信验证码提交请求不进行校验
        if("/mobile/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 检验手机验证码的合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 将异常交给自定义失败处理器进行处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        // 放行,进入下一个过滤器
        filterChain.doFilter(request, response);
    }

    /**
     * 检验用户输入的手机验证码的合法性
     */

    private void validate(HttpServletRequest request) {
        // 获取用户传入的手机验证码值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();

        // 获取 Session
        HttpSession session = request.getSession();
        // 获取 Session 中存储的手机短信验证码
        CheckCode savedCode = (CheckCode) session.getAttribute(LoginController.MOBILE_SESSION_KEY);

        if (savedCode != null) {
            // 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
            session.removeAttribute(LoginController.MOBILE_SESSION_KEY);
        }

        // 校验出错,抛出异常
        if (StringUtils.isBlank(requestCode)) {
            thrownew ValidateCodeException("验证码的值不能为空");
        }
        if (savedCode == null) {
            thrownew ValidateCodeException("验证码不存在");
        }
        if (savedCode.isExpried()) {
            thrownew ValidateCodeException("验证码过期");
        }
        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            thrownew ValidateCodeException("验证码输入错误");
        }
    }
}

自定义短信验证码认证过滤器

  1. 仿照 UsernamePasswordAuthenticationToken 类进行编写
  2. 仿照 UsernamePasswordAuthenticationFilter 过滤器进行编写
public class MobileAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;
    private final Object principal;

    /**
     * 认证前,使用该构造器进行封装信息
     */

    public MobileAuthenticationToken(Object principal) {
        super(null);     // 用户权限为 null
        this.principal = principal;   // 前端传入的手机号
        this.setAuthenticated(false); // 标记为未认证
    }

    /**
     * 认证成功后,使用该构造器封装用户信息
     */

    public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);          // 用户权限集合
        this.principal = principal;  // 封装认证用户信息的 UserDetails 对象,不再是手机号
        super.setAuthenticated(true); // 标记认证成功
    }

    @Override
    public Object getCredentials() {
        // 由于使用手机短信验证码登录不需要密码,所以直接返回 null
        returnnull;
    }

    @Override
    public Object getPrincipal() {
        returnthis.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            thrownew IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

    @Override
    public void eraseCredentials() {
        // 手机短信验证码认证方式不必去除额外的敏感信息,所以直接调用父类方法
        super.eraseCredentials();
    }
}
/**
 * 手机短信验证码认证过滤器,仿照 UsernamePasswordAuthenticationFilter 过滤器编写
 */

public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private String mobileParamter = "mobile";  // 默认手机号参数名为 mobile
    private boolean postOnly = true;    // 默认请求方式只能为 POST

    protected MobileAuthenticationFilter() {
        // 默认登录表单提交路径为 /mobile/form,POST 方式请求
        super(new AntPathRequestMatcher("/mobile/form""POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //(1) 默认情况下,如果请求方式不是 POST,会抛出异常
        if(postOnly && !request.getMethod().equals("POST")) {
            thrownew AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }else {
            //(2) 获取请求携带的 mobile
            String mobile = request.getParameter(mobileParamter);
            if(mobile == null) {
                mobile = "";
            }
            mobile = mobile.trim();

            //(3) 使用前端传入的 mobile 构造 Authentication 对象,标记该对象未认证
            // MobileAuthenticationToken 是我们自定义的 Authentication 类,后续介绍
            MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);
            //(4) 将请求中的一些属性信息设置到 Authentication 对象中,如:remoteAddress,sessionId
            this.setDetails(request, authRequest);
            //(5) 调用 ProviderManager 类的 authenticate() 方法进行身份认证
            returnthis.getAuthenticationManager().authenticate(authRequest);
        }
    }

    @Nullable
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(this.mobileParamter);
    }

    protected void setDetails(HttpServletRequest request, MobileAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setMobileParameter(String mobileParamter) {
        Assert.hasText(mobileParamter, "Mobile par ameter must not be empty or null");
        this.mobileParamter = mobileParamter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public String getMobileParameter() {
        return mobileParamter;
    }
}

自定义短信验证码认证处理器

  1. 仿照 DaoAuthenticationProvider 处理器进行编写
  2. MobileAuthenticationProvider 处理器传入的 UserDetailsService 对象的类型需要我们自定义
public class MobileAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks();

    /**
     * 处理认证
     */

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //(1) 如果入参的 Authentication 类型不是 MobileAuthenticationToken,抛出异常
        Assert.isInstanceOf(MobileAuthenticationToken.classauthentication, () -> {
            returnthis.messages.getMessage("MobileAuthenticationProvider.onlySupports""Only MobileAuthenticationToken is supported");
        });

        // 获取手机号
        String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
        //(2) 根据手机号从数据库中查询用户信息
        UserDetails user = this.userDetailsService.loadUserByUsername(mobile);
        if (user == null) {
            //(3) 未查询到用户信息,抛出异常
            thrownew AuthenticationServiceException("该手机号未注册");
        }

        //(4) 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
        this.authenticationChecks.check(user);

        //(5) 查询到了用户信息,则认证通过,构建标记认证成功用户信息类对象 AuthenticationToken
        MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());
        // 需要把认证前 Authentication 对象中的 details 信息加入认证后的 Authentication
        result.setDetails(authentication.getDetails());
        return result;
    }

    /**
     * ProviderManager 管理器通过此方法来判断是否采用此 AuthenticationProvider 类
     * 来处理由 AuthenticationFilter 过滤器传入的 Authentication 对象
     */

    @Override
    public boolean supports(Class<?> authentication) {
        // isAssignableFrom 返回 true 当且仅当调用者为父类.class,参数为本身或者其子类.class
        // ProviderManager 会获取 MobileAuthenticationFilter 过滤器传入的 Authentication 类型
        // 所以当且仅当 authentication 的类型为 MobileAuthenticationToken 才返回 true
        return MobileAuthenticationToken.class.isAssignableFrom(authentication);
    }

    /**
     * 此处传入自定义的 MobileUserDetailsSevice 对象
     */

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    /**
     * 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
     */

    private class DefaultAuthenticationChecks implements UserDetailsChecker {
        private DefaultAuthenticationChecks() {
        }

        @Override
        public void check(UserDetails user) {
            if (!user.isAccountNonLocked()) {
                thrownew LockedException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked""User account is locked"));
            } elseif (!user.isEnabled()) {
                thrownew DisabledException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled""User is disabled"));
            } elseif (!user.isAccountNonExpired()) {
                thrownew AccountExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired""User account has expired"));
            } elseif (!user.isCredentialsNonExpired()) {
                thrownew CredentialsExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired""User credentials have expired"));
            }
        }
    }
}
@Service
public class MobileUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        //(1) 从数据库尝试读取该用户
        User user = userMapper.selectByMobile(mobile);
        // 用户不存在,抛出异常
        if (user == null) {
            thrownew UsernameNotFoundException("用户不存在");
        }

        //(2) 将数据库形式的 roles 解析为 UserDetails 的权限集合
        // AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用于将逗号隔开的权限集字符串切割为可用权限对象列表
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));

        //(3) 返回 UserDetails 对象
        return user;
    }
}

自定义短信验证码认证方式配置类

  1. 将上述组件进行管理,仿照 SecurityConfigurerAdapter类进行编写
  2. 绑定到最终的安全配置类 SpringSecurityConfig 中
@Component
publicclass MobileAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChainHttpSecurity{

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;  // 自定义认证成功处理器
    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;  // 自定义认证失败处理器
    @Autowired
    private MobileCodeValidateFilter mobileCodeValidaterFilter;  // 手机短信验证码校验过滤器
    @Autowired
    private MobileUserDetailsService userDetailsService;  // 手机短信验证方式的 UserDetail

    @Override
    public void configure(HttpSecurity http) throws Exception {
        //(1) 将短信验证码认证的自定义过滤器绑定到 HttpSecurity 中
        //(1.1) 创建手机短信验证码认证过滤器的实例 filer
        MobileAuthenticationFilter filter = new MobileAuthenticationFilter();

        //(1.2) 设置 filter 使用 AuthenticationManager(ProviderManager 接口实现类) 认证管理器
        // 多种登录方式应该使用同一个认证管理器实例,所以获取 Spring 容器中已经存在的 AuthenticationManager 实例
        AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
        filter.setAuthenticationManager(authenticationManager);

        //(1.3) 设置 filter 使用自定义成功和失败处理器
        filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        //(1.4) 设置 filter 使用 SessionAuthenticationStrategy 会话管理器
        // 多种登录方式应该使用同一个会话管理器实例,获取 Spring 容器已经存在的 SessionAuthenticationStrategy 实例
        SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
        filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);

        //(1.5) 在 UsernamePasswordAuthenticationFilter 过滤器之前添加 MobileCodeValidateFilter 过滤器
        // 在 UsernamePasswordAuthenticationFilter 过滤器之后添加 MobileAuthenticationFilter 过滤器
        http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);

        //(2) 将自定义的 MobileAuthenticationProvider 处理器绑定到 HttpSecurity 中
        //(2.1) 创建手机短信验证码认证过滤器的 AuthenticationProvider 实例,并指定所使用的 UserDetailsService
        MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);

        //(2.2) 将该 AuthenticationProvider 实例绑定到 HttpSecurity 中
        http.authenticationProvider(provider);
    }
}
@Configuration
publicclass SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailsService;
    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器
    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类

    /**
     * 密码编码器,密码不能明文存储
     */

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        returnnew BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {       
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
            // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
            .loginPage("/login/page")
            // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
            .loginProcessingUrl("/login/form")
            // 使用自定义的认证成功和失败处理器
            .successHandler(authenticationSuccessHandler)
            .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
            // 以下访问不需要任何权限,任何人都可以访问
            .antMatchers("/login/page""/code/image","/mobile/page""/code/mobile").permitAll()
            // 其它任何请求访问都需要先通过认证
            .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();

        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);

        // 将手机短信验证码认证的配置与当前的配置绑定
        http.apply(mobileAuthenticationConfig);
    }

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css""/**/*.js""/**/*.png""/**/*.jpg""/**/*.jpeg");
    }
}

原文始发于微信公众号(爱编程的小生):SpringSecurity5(5-自定义短信、手机验证码)

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/314521.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!