最全SpringBoot下SpringSecurity账号和手机验证码登录多重认证实践

前言: 坚持不是件容易的事. 距离上次发言已是半载. 最近更新了个人项目的登录权限模块;增加了手机号认证,到此实现了spring security 双重认证方式.目前可以自行注册体验.

一: Spring Boot 引入Security 的 pom依赖

1.1: 首先引入pom jar 包

spring boot使用security 只需引入以下配置,会自动拉取当前boot版本下的security依赖模块:

<dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-security</artifactId>
</dependency>

当前项目SpringBoot 版本 2.0.4.RELEASE; 引入后可以看到 当前引入了 以下模块版本;spring-security-config;
spring-security-core;spring-security-web;


<dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>5.0.7.RELEASE</version>
 </dependency>
 <dependency>
             <groupId>org.springframework.security</groupId>
             <artifactId>spring-security-core</artifactId>
             <version>5.0.7.RELEASE</version>
  </dependency>
  <dependency>
              <groupId>org.springframework.security</groupId>
              <artifactId>spring-security-web</artifactId>
              <version>5.0.7.RELEASE</version>
   </dependency>

1.2: 配置 WebSecurityConfig

@EnableWebSecurity 作用 :
  • 加载了WebSecurityConfiguration配置类, 配置安全认证策略。
    在这个配置类中,注入了一个非常重要的bean, bean的name为: springSecurityFilterChain,这是Spring Secuity的核心过滤器, 这是请求的认证入口。

  • 加载了AuthenticationConfiguration, 配置了认证信息。
    这个类是来配置认证相关的核心类, 这个类的主要作用是,
    向spring容器中注入AuthenticationManagerBuilder, AuthenticationManagerBuilder其实是使用了建造者模式,
    他能建造AuthenticationManager, 这个类前面提过,是身份认证的入口。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private MyAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Autowired
    private MyAccessDeniedHandler myAccessDeniedHandler;

    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Autowired
    private MyLogoutSuccessHandler myLogoutSuccessHandler;

    @Resource
    private ApplicationEventPublisher applicationEventPublisher;

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 自定义 账号登录身份认证组件
        auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
        // 自定义 短信登录身份认证组件
        auth.authenticationProvider(new SmsCodeAuthenticationProvider());
    }


 //各类错误异常处理 以下针对于访问资源路径 认证异常捕获 和 无权限处理
     /**
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        项目中用到iframe嵌入网页,然后用到springsecurity就被拦截了 浏览器报错  x-frame-options deny
//        原因是因为springSecurty使用X-Frame-Options防止网页被Frame
        http.headers().frameOptions().disable();
        // 禁用 csrf(Cross-site request forgery)跨站请求伪造, 由于使用的是JWT,我们这里不需要csrf
        //https://blog.csdn.net/yjclsx/article/details/80349906
        //处理来自浏览器的请求需要是CSRF保护,如果后台服务是提供API调用那么可能就要禁用CSRF保护
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // 跨域预检请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()     
                ..... 等配置
        http.exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint).accessDeniedHandler(myAccessDeniedHandler);
        // token 校验
        http.addFilterBefore(new JwtTokenAuthenticationFilter(),AbstractPreAuthenticatedProcessingFilter.class);
        // 开启短信登录认证过滤器
        http.addFilterBefore(new SmsCodeLoginFilter(authenticationManager(),myAuthenticationSuccessHandler,myAuthenticationFailureHandler,applicationEventPublisher),UsernamePasswordAuthenticationFilter.class);
        // 开启账号登录认证流程过滤器
        http.addFilterBefore(new JwtLoginFilter(authenticationManager(),myAuthenticationSuccessHandler,myAuthenticationFailureHandler,applicationEventPublisher), UsernamePasswordAuthenticationFilter.class);
        // 退出登录处理器 清除redis 中token GET请求
        http.logout().logoutUrl("/logout").logoutSuccessHandler(myLogoutSuccessHandler);
        // token 不保存session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  }

    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
在这个配置类中,我们主要做了以下几个配置:
  • 1.访问路径URL的授权策略,如登录、静态资源、回调接口、Swagger访问免登录认证等

  • 2.指定了账号登录认证流程过滤器 JwtLoginFilter,由它来触发账号登录认证

    • 指定了账号登录自定义身份认证组件 JwtAuthenticationProvider,并注入 UserDetailsService
  • 3.指定了短信登录认证流程过滤器 SmsCodeLoginFilter,由它来触发短信登录认证

    • 指定了短信登录自定义身份认证组件 SmsCodeAuthenticationProvider
  • 4.指定了token访问控制过滤器 JwtTokenAuthenticationFilter,在授权时解析令牌和设置登录状态

  • 5.自定义认证成功处理器MyAuthenticationSuccessHandler;

    • 认证失败处理器MyAuthenticationFailureHandler;
    • 退出成功处理器MyLogoutSuccessHandler
  • 6.自定义认证身份验证处理器MyAuthenticationEntryPoint;

    • 访问拒绝处理器 MyAccessDeniedHandler
  • 7.注入事件监听器 监听登录 退出等事件

二: 账号权限登录流程

账号登录认证流程过滤器 JwtLoginFilter

覆写认证方法,修改用户名、密码的获取方式 覆写认证成功后的操作,移除后台跳转,添加生成令牌并返回给客户端

public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {


    public JwtLoginFilter(AuthenticationManager authManager,
                          AuthenticationSuccessHandler successHandler,
                          AuthenticationFailureHandler failureHandler,
                          ApplicationEventPublisher eventPublisher) {
        setAuthenticationManager(authManager);
        setAuthenticationSuccessHandler(successHandler);
        setAuthenticationFailureHandler(failureHandler);
        setApplicationEventPublisher(eventPublisher);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // POST 请求 /login 登录时拦截, 由此方法触发执行登录认证流程,可以在此覆写整个登录认证逻辑
        super.doFilter(req, res, chain);
    }


    /**
     * 此过滤器的用户名密码默认从request.getParameter()获取,但是这种
     * 读取方式不能读取到如 application/json 等 post 请求数据,需要把
     * 用户名密码的读取逻辑修改为到流中读取request.getInputStream()
     * 在此做验证码的验证
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //获取请求实体
        String body = HttpRequestUtil.getBody(request);
        JSONObject jsonObject = JSON.parseObject(body);

        String uuid = jsonObject.getString("uuid");
        String code = jsonObject.getString("imgCode");
          RedissonObject redissonObject = (RedissonObject)SpringContextUtils.getBeanByClass(RedissonObject.class);

        // 查询验证码
        String redisCode = redissonObject.getValue(uuid);
        // 清除验证码
        redissonObject.delete(uuid);
        if (StringUtils.isBlank(redisCode)) {
            logger.error("验证码不存在或已过期");
            throw new MyAuthenticationException("验证码不存在或已过期");
        }
        if (StringUtils.isBlank(code) || !code.equalsIgnoreCase(redisCode)) {
            logger.error("验证码错误");
            throw new MyAuthenticationException("验证码错误");
        }

        //账户和密码
        String username = jsonObject.getString("username");
        String password = jsonObject.getString("password");

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();
        JwtAuthenticatioToken authRequest = new JwtAuthenticatioToken(username, password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);

    }
  • 1: attemptAuthentication方法 把用户名密码的读取逻辑修改为到流中读取request.getInputStream() 校验验证码;

  • 2:this.getAuthenticationManager().authenticate(authRequest) 账号和密码 传递到认证管理ProviderManager

  • 3:ProviderManager 下循环遍历多个认证器provider;if (!provider.supports(toTest)) 判断当前 Authentication 是否支持
    当前是账号登录;此处 JwtAuthenticatioToken.class.isAssignableFrom(aClass) 为true 进行用户名登录认证;

  • 4: 找到自定义的 JwtAuthenticationProvider 进行认证;此处可以复写部分功能扩展认证;再调用 父类AbstractUserDetailsAuthenticationProvider的super.authenticate(authentication)方法;

  • 5:AbstractUserDetailsAuthenticationProvider 调用子类DaoAuthenticationProvider的retrieveUser()方法;

  • 6:DaoAuthenticationProvider执行实际获取用户 UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username)获取用户;

  • 7:找到我们自定义实现 UserDetailsService的类 UserDetailsServiceImpl;通过用户名查询用户判断是否存在;查询权限封装;

  • 8:AbstractUserDetailsAuthenticationProvider 再调用DefaultPreAuthenticationChecks检查当前用户preAuthenticationChecks.check(user)判断当前用户isAccountNonLocked()、isEnabled()、isAccountNonExpired()、isCredentialsNonExpired();

  • 9:AbstractUserDetailsAuthenticationProvider再调用 DaoAuthenticationProvider的additionalAuthenticationChecks(user,
    (UsernamePasswordAuthenticationToken) authentication)验证密码;

  • 10: 如果登录过程出现失败:MyAuthenticationFailureHandler将处理失败信息json回写;

    • 登录成功触发:MyAuthenticationSuccessHandler onAuthenticationSuccess() 将登录信息记录到redis
  • 11: 交互认证成功 监听器AuthenticationEventLogger 监听到事件 InteractiveAuthenticationSuccessEvent 记录登录日志;

三: 手机号权限登录流程

手机号登录认证流程过滤器 SmsCodeLoginFilter

手机号登录采用自己的认证器,对于认证结果成功和失败处理采用公共的处理器;

public class SmsCodeLoginFilter extends AbstractAuthenticationProcessingFilter {


    public static final String SPRING_SECURITY_MOBILE_KEY = "mobile";
    public static final String SPRING_SECURITY_CODE_KEY = "code";
    private String mobileParameter = SPRING_SECURITY_MOBILE_KEY;
    private String codeParameter = SPRING_SECURITY_CODE_KEY;
    private boolean postOnly = true;

    public SmsCodeLoginFilter(AuthenticationManager authManager,
                              AuthenticationSuccessHandler successHandler,
                              AuthenticationFailureHandler failureHandler,
                              ApplicationEventPublisher eventPublisher) {
        super(new AntPathRequestMatcher("/mobile/login", "POST"));
        setAuthenticationManager(authManager);
        setAuthenticationSuccessHandler(successHandler);
        setAuthenticationFailureHandler(failureHandler);
        setApplicationEventPublisher(eventPublisher);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {



        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String body = HttpRequestUtil.getBody(request);
            JSONObject jsonObject = JSON.parseObject(body);
            String mobile = jsonObject.getString(mobileParameter);
            String code = jsonObject.getString(codeParameter);

            //校验code
            RedissonObject redissonObject = (RedissonObject)SpringContextUtils.getBeanByClass(RedissonObject.class);
            String msgCode = redissonObject.getValue(mobile);

            // 清除短信验证码
            redissonObject.delete(mobile);
            if (StringUtils.isBlank(msgCode)) {
                logger.error("验证码不存在或已过期");
                throw new MyAuthenticationException("验证码不存在或已过期");
            }
            if (StringUtils.isBlank(code) || !code.equalsIgnoreCase(msgCode)) {
                logger.error("验证码错误");
                throw new MyAuthenticationException("验证码错误");
            }

            if (mobile == null) {
                mobile = "";
            }

            if (code == null) {
                code = "";
            }

            mobile = mobile.trim();
            SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile,code);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }


    protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
  • 1: attemptAuthentication方法,同样把手机号和验证码获取到,并此处校验验证码;

  • 2: this.getAuthenticationManager().authenticate(authRequest)手机号和验证码 传递到认证管理ProviderManager

  • 3 :ProviderManager 下循环遍历多个认证器provider;if (!provider.supports(toTest)) 判断当前 Authentication 是否支持
    当前是账号登录;此处 SmsCodeAuthenticationToken.class.isAssignableFrom(aClass); 为true 进行短信登录认证;

  • 4: 找到自定义的 SmsCodeAuthenticationProvider 进行认证;此处相比用户名登录简化了没有用户密码判断;用户状态判断;直接查询用户权限

  • 5: 如果登录过程出现失败:MyAuthenticationFailureHandler将处理失败信息json回写;

    • 登录成功触发:MyAuthenticationSuccessHandler onAuthenticationSuccess() 将登录信息记录到redis
  • 6: 交互认证成功 监听器AuthenticationEventLogger 监听到事件 InteractiveAuthenticationSuccessEvent 记录登录日志;

四: token 过滤认证校验

token 认证过滤器 JwtTokenAuthenticationFilter

由于前后分离的权限 无状态化 无session 我们将登录用户信息放入缓存 监控登录用户时效管理

public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        //获取登录接口
        String requestUrl = request.getRequestURI();
        // 如果不为登录接口 则处理token
        if (!requestUrl.endsWith("/login")) {

            // 获取token, 并检查登录状态
            String token = request.getHeader("token");

            // token未过期
            if (token != null && !JwtTokenUtils.isTokenExpired(token)) {

                RedissonObject redissonObject = (RedissonObject) SpringContextUtils.getBeanByClass(RedissonObject.class);
                //缓存中拿取数据
                JwtUserRedis jwtUserRedis = redissonObject.getValue("user:"+token);

                //没有获取到值
                if (jwtUserRedis == null) {
                    chain.doFilter(request,response);
                    return;
                }
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(jwtUserRedis.getUsername(),null,jwtUserRedis.getAuthorities());
                // 认证放入线程
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
            chain.doFilter(request,response);
        }else{
            chain.doFilter(request,response);
        }

    }

}

  • 对于非登录请求判断 拿取请求头中的 token判断是否过期;过期直接跳过后续会进行权限返回无法访问;

  • 未过期则从redis 获取信息;如果为空直接跳过去执行后面过滤器;不为空则封装用户权限到当前线程认证;

  • 当前线程存在登录用户信息后,后续可以进行校验通过操作;

完整项目代码以及体验地址:

项目代码: https://gitee.com/sinaC/youliao
体验地址: http://132.232.43.102

  • 4
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值