Spring Security9、登录添加验证码校验

在 spring security 中,认证和授权其实都是使用过滤器链进行的。比如登录这个操作就是在 UsernamePasswordAuthenticationFilter 这个过滤器中进行的。

一般在登录时为了防止暴力破解密码,我们一般都会进行人机验证,以此来区分是机器人还是人工操作的。这个情况下,我们就可是定义一个验证码过滤器,在登录之前进行人机校验。

这里我们使用比较原始的校验方法,这也是比较简单的方式,就是在登录时向指定邮箱发送校验码,然后登录时验证用户输入的验证码是否和系统发送的验证码一致。

一、邮件发送

在 spring boot 中如果需要发送邮件,我们只需要引入一下相关依赖:

implementation 'org.springframework.boot:spring-boot-starter-mail'

然后在配置文件中配置相关参数,我这里配置的是QQ邮箱,注意 password 要替换成你自己邮箱的授权码:

server:
  servlet:
    session:
      timeout: 30s

spring:
  # 邮件怕配置
  mail:
    host: smtp.qq.com
    port: 587
    username: ynkm.lxw@qq.com
    password: [授权码]
    protocol: smtp
    properties:
      mail:
        smtp:
          auth: true

然后我们测试一下,看看是否能正常发送:

import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.RuntimeUtil;
import cn.hutool.core.util.StrUtil;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;

import javax.annotation.Resource;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

@SpringBootTest
public class JavaMailSenderTest {

    @Resource
    JavaMailSender mailSender;

    @Test
    public void send() throws MessagingException {
        // 验证码
        String code = RandomUtil.randomStringUpper(6).toUpperCase();
        // 发送邮件
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        helper.setFrom("ynkm.lxw@qq.com");
        helper.setTo("lixingwu@aliyun.com");
        helper.setSubject("主题:校验码");
        helper.setText(StrUtil.format("校验码 <h3>{}</h3>", code), true);
        mailSender.send(message);
    }
}

结果:

校验码

现在我们把这个它写成接口【在前面我们已经把这个接口进行了忽略,不用任何权限都可访问】,每次发送我们就把生成的校验码保存在Session中,然后在校验时取出来进行和用户输入的检验码进行对比。

@PostMapping("/code")
public Dict getCode(
    @RequestParam(name = "email") String email
) throws MessagingException {
    String code = RandomUtil.randomStringUpper(6);
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true);
    helper.setFrom("ynkm.lxw@qq.com");
    helper.setTo(email);
    helper.setSubject("主题:校验码");
    helper.setText(StrUtil.format("校验码 <h3>{}</h3>", code), true);
    mailSender.send(message);
    request.getSession().setAttribute("code", code);

    Console.log("校验码发送成功[{}]", code);

    return Dict.create()
        .set("code", 0)
        .set("msg", "成功")
        .set("data", "校验码发送成功,请注意查收!");
}

二、验证码异常

如果在验证码输入错误时,我们为了便于处理,我们先定义一个验证码异常类,专门处理它。

import org.springframework.security.core.AuthenticationException;

/**
 * 验证码输入异常
 *
 * @author lixin
 */
public class ValidateCodeException extends AuthenticationException {
    public ValidateCodeException(String msg, Throwable t) {
        super(msg, t);
    }
    public ValidateCodeException(String msg) {
        super(msg);
    }
    public ValidateCodeException() {
        super("validate code check fail!");
    }
}

然后在登录失败的处理器 JsonFailureHandler 处判断是不是 ValidateCodeException ,是的话我们返回 “验证码输入有误”信息,具体配置看代码:

import cn.hutool.core.lang.Console;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.extra.servlet.ServletUtil;
import cn.hutool.http.ContentType;
import cn.hutool.json.JSONUtil;
import com.miaopasi.securitydemo.config.security.exception.ValidateCodeException;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 登录失败
 *
 * @author lixin
 */
@Component
public class JsonFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException, ServletException {
        Console.log("登录失败,{}", exception);
        Dict res = Dict.create().set("code", 1000).set("msg", "登录失败");
        if (exception instanceof UsernameNotFoundException) {
            res.set("data", "用户名不存在");
        } else if (exception instanceof LockedException) {
            res.set("data", "账号被锁定");
        } else if (exception instanceof DisabledException) {
            res.set("data", "账号被禁用");
        } else if (exception instanceof CredentialsExpiredException) {
            res.set("data", "密码过期");
        } else if (exception instanceof AccountExpiredException) {
            res.set("data", "账号过期");
        } else if (exception instanceof BadCredentialsException) {
            res.set("data", "账号密码输入有误");
        } else if (exception instanceof ValidateCodeException) {
            res.set("data", "验证码输入有误");
        } else {
            res.set("data", exception.getMessage());
        }
        String contentType = ContentType.JSON.toString(CharsetUtil.CHARSET_UTF_8);
        ServletUtil.write(response, JSONUtil.toJsonStr(res), contentType);
    }
}

三、验证码过滤器

在用户信息登录时,我们需要先校验用户输入的校验码是否正确,正确后才进行账号密码的验证。这时候我们就需要在 UsernamePasswordAuthenticationFilter 这个过滤器前面再加上一个 验证码的过滤器,这样就实现我们的验证码功能了。

(1)基于以上分析,我们先要创建一个验证码的过滤器 ValidateCodeFilter

import cn.hutool.core.lang.Console;
import cn.hutool.core.util.StrUtil;
import com.miaopasi.securitydemo.config.security.exception.ValidateCodeException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

/***
 * 验证码过滤器
 * @author lixin
 */
@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements Filter {
    private final AuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    public ValidateCodeFilter(AuthenticationFailureHandler authenticationFailureHandler) {
        this.authenticationFailureHandler = authenticationFailureHandler;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 必须是登录的post请求才能进行验证,其他的直接放行
        if (StrUtil.equals("/doLogin", request.getRequestURI()) && StrUtil.equalsIgnoreCase(request.getMethod(), "POST")) {
            Console.log("进入[自定义验证码过滤器]");
            try {
                // 1. 进行验证码的校验
                validate(request);
            } catch (AuthenticationException e) {
                // 2. 捕获步骤1中校验出现异常,交给失败处理类进行进行处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        // 校验通过,就放行
        filterChain.doFilter(request, response);
    }

    /**
     * 验证输入的验证码是否正确
     *
     * @param request 请求对象
     */
    private void validate(HttpServletRequest request) throws ServletRequestBindingException {
        String code1 = ServletRequestUtils.getStringParameter(request, "code");
        Object code2 = request.getSession().getAttribute("code");
        Console.log("输入的验证码为:{},Session中的code为:{}", code1, code2);
        if (Objects.isNull(code1) || Objects.isNull(code2) || !Objects.equals(code1, code2)) {
            throw new ValidateCodeException();
        }
        // 移除保存的验证码,防止重复使用验证码进行登录
        request.getSession().removeAttribute("code");
    }
}

这是一个一般的过滤器,在校验不通过时,会抛出异常 ValidateCodeException ,然后传递给 AuthenticationFailureHandler 这个对象,这样 JsonFailureHandler 就会收到这个异常,然后就执行处理的逻辑,最后向请求的对象抛出 “验证码输入有误” 的信息。

(2)然后我们就需要把我们创建的 ValidateCodeFilter 配置到过滤器链上,我们可以在 SecurityConfig 中进行配置,具体配置看代码:

import com.miaopasi.securitydemo.config.security.filter.ValidateCodeFilter;
import com.miaopasi.securitydemo.config.security.handler.*;
import com.miaopasi.securitydemo.config.security.impl.UrlAccessDecisionManager;
import com.miaopasi.securitydemo.config.security.impl.UrlFilterInvocationSecurityMetadataSource;
import com.miaopasi.securitydemo.config.security.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * Security配置类,会覆盖yml配置文件的内容
 *
 * @author lixin
 */
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JsonSuccessHandler successHandler;
    private final JsonFailureHandler failureHandler;
    private final JsonAccessDeniedHandler accessDeniedHandler;
    private final JsonAuthenticationEntryPoint authenticationEntryPoint;
    private final JsonLogoutSuccessHandler logoutSuccessHandler;
    private final UserDetailsServiceImpl userDetailsService;
    private final UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;
    private final UrlAccessDecisionManager accessDecisionManager;
    private final ValidateCodeFilter validateCodeFilter;

    @Autowired
    public SecurityConfig(JsonSuccessHandler successHandler, JsonFailureHandler failureHandler, JsonAccessDeniedHandler accessDeniedHandler, JsonAuthenticationEntryPoint authenticationEntryPoint, JsonLogoutSuccessHandler logoutSuccessHandler, UserDetailsServiceImpl userDetailsService, UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource, UrlAccessDecisionManager accessDecisionManager, ValidateCodeFilter validateCodeFilter) {
        this.successHandler = successHandler;
        this.failureHandler = failureHandler;
        this.accessDeniedHandler = accessDeniedHandler;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.logoutSuccessHandler = logoutSuccessHandler;
        this.userDetailsService = userDetailsService;
        this.filterInvocationSecurityMetadataSource = filterInvocationSecurityMetadataSource;
        this.accessDecisionManager = accessDecisionManager;
        this.validateCodeFilter = validateCodeFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 在校验密码前设置一层【验证码过滤器】用于校验登录时输入验证码是否正确
                .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)

                .authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
                        object.setAccessDecisionManager(accessDecisionManager);
                        return object;
                    }
                })
                .anyRequest().authenticated()
                .and().formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginProcessingUrl("/doLogin")
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                .and().logout().logoutUrl("/doLogout")
                .logoutSuccessHandler(logoutSuccessHandler)
                .and().exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(authenticationEntryPoint)
                .and().cors()
                .and().csrf().disable();
    }

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

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

四、测试

(1)获取校验码,调用接口 /code ,返回如下JSON字符串:

{
  "code": 0,
  "msg": "成功",
  "data": "校验码发送成功,请注意查收!"
}

然后在邮箱中发现邮件

验证码

(2)输入账号密码 不输入校验码 进行登录,返回JSON字符串:

{
  "msg": "登录失败",
  "code": 1000,
  "data": "验证码输入有误"
}

(3)输入账号密码和 正确校验码 进行登录,返回JSON字符串:

{
  "msg": "登录成功",
  "code": 0,
  "data": {
    "authenticated": true,
    "authorities": [
      {}
    ],
    "principal": {
      "isDelete": false,
      "sort": 0,
      "gmtCreate": 1594918566938,
      "operator": "管理员",
      "authorities": [
        {}
      ],
      "id": 1,
      "remarks": "测试用户1",
      "username": "user1",
      "status": 0
    },
    "details": {
      "sessionId": "947F45CAFC0DE62BC317BE0A99005803",
      "remoteAddress": "127.0.0.1"
    }
  }
}

(4)输入账号密码和 错误校验码 进行登录,返回JSON字符串:

{
  "msg": "登录失败",
  "code": 1000,
  "data": "验证码输入有误"
}

五、说明一下

其实这篇文章主要是想说明我们如何在默认的过滤器链中插入自己的过滤器,实现在即想要的功能。我在这里使用了邮箱验证码这个功能来进行说明,实例中比较依赖session,实际情况可以根据项目情况进行处理。

spring security系列文章请 点击这里 查看。
这是代码 码云地址
注意注意!!!项目是使用分支的方式来提交每次测试的代码的,请根据章节来我切换分支。

原文地址:https://www.cnblogs.com/lixingwu/p/13326829.html