• Spring RESTful + Redis全注解实现恶意登录保护机制


    好久没更博了...
    最近看了个真正全注解实现的 SpringMVC 博客,感觉很不错,终于可以彻底丢弃 web.xml 了。其实这玩意也是老东西了,丢弃 web.xml,是基于 5、6年前发布的 Servlet 3.0 规范,只不过少有人玩而已...现在4.0都快正式发布了...Spring对注解的支持也从09年底就开始支持了...
    基础部分我就不仔细讲了,可以先看一下这篇 以及其中提到的另外两篇文章,这三篇文章讲的很不错。
    下面开始旧东西新玩~~~

    构建

    项目是基于 gradle 3.1构建的,这是项目依赖:

    dependencies {
      def springVersion = '4.3.2.RELEASE'
      
      compile "org.springframework:spring-web:$springVersion"
      compile "org.springframework:spring-webmvc:$springVersion"
      compile "redis.clients:jedis:2.9.0"
      compile "javax.servlet:javax.servlet-api:3.1.0"
      compile "org.json:json:20160810"
    }
    

    编写Java版的web.xml

    想要让请求经过Java,少不了配置 web.xml,不过现在我们来写个Java版的~
    这里和传统的 web.xml 一样,依次添加 filterservlet

    package org.xueliang.loginsecuritybyredis.commons;
    
    import javax.servlet.FilterRegistration;
    import javax.servlet.Servlet;
    import javax.servlet.ServletContext;
    import javax.servlet.ServletException;
    import javax.servlet.ServletRegistration;
    
    import org.springframework.web.WebApplicationInitializer;
    import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
    import org.springframework.web.filter.CharacterEncodingFilter;
    import org.springframework.web.servlet.DispatcherServlet;
    
    /**
     * 基于注解的/WEB-INF/web.xml
     * 依赖 servlet 3.0
     * @author XueLiang
     * @date 2016年10月24日 下午5:58:45
     * @version 1.0
     */
    public class CommonInitializer implements WebApplicationInitializer {
    
      @Override
      public void onStartup(ServletContext servletContext) throws ServletException {
        
        // 基于注解配置的Web容器上下文
        AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
        context.register(WebAppConfig.class);
        
        // 添加编码过滤器并进行映射
        CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter("UTF-8", true);
        FilterRegistration.Dynamic dynamicFilter = servletContext.addFilter("characterEncodingFilter", characterEncodingFilter);
        dynamicFilter.addMappingForUrlPatterns(null, true, "/*");
        
        // 添加静态资源映射
        ServletRegistration defaultServletRegistration = servletContext.getServletRegistration("default");
        defaultServletRegistration.addMapping("*.html");
        
        Servlet dispatcherServlet = new DispatcherServlet(context);
        ServletRegistration.Dynamic dynamicServlet = servletContext.addServlet("dispatcher", dispatcherServlet);
        dynamicServlet.addMapping("/");
      }
    }
    

    这一步走完,Spring 基本上启动起来了。

    编写Java版的Spring配置

    现在Spring已经可以正常启动了,但我们还要给 Spring 做一些配置,以便让它按我们需要的方式工作~
    这里因为后端只负责提供数据,而不负责页面渲染,所以只需要配置返回 json 视图即可,个人比较偏爱采用内容协商,所以这里我使用了 ContentNegotiationManagerFactoryBean,但只配置了一个 JSON 格式的视图。
    为了避免中文乱码,这里设置了 StringHttpMessageConverter 默认编码格式为 UTF-8,然后将其设置为 RequestMappingHandlerAdapter 的消息转换器。
    最后还需要再配置一个欢迎页,类似于 web.xmlwelcome-file-list - welcome-file,因为 Servlet 3.0 规范没有针对欢迎页的Java配置方案,所以目前只能在Java中这样配置,其效果类似于在XML版中配置 <mvc:redirect-view-controller path="/" redirect-url="/index.html"/>
    最后注意这里的 @Bean 注解,默认的 name 是方法名。

    package org.xueliang.loginsecuritybyredis.commons;
    
    import java.nio.charset.Charset;
    import java.util.Collections;
    import java.util.Properties;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.PropertySource;
    import org.springframework.http.MediaType;
    import org.springframework.http.converter.StringHttpMessageConverter;
    import org.springframework.web.accept.ContentNegotiationManager;
    import org.springframework.web.accept.ContentNegotiationManagerFactoryBean;
    import org.springframework.web.servlet.config.annotation.EnableWebMvc;
    import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
    import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
    import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
    
    @Configuration
    @EnableWebMvc
    @ComponentScan(basePackages = "org.xueliang.loginsecuritybyredis")
    @PropertySource({"classpath:loginsecuritybyredis.properties"})
    public class WebAppConfig extends WebMvcConfigurerAdapter {
    	
    	/**
    	 * 内容协商
    	 * @return
    	 */
    	@Bean
    	public ContentNegotiationManager mvcContentNegotiationManager() {
    	    ContentNegotiationManagerFactoryBean contentNegotiationManagerFactoryBean = new ContentNegotiationManagerFactoryBean();
    	    contentNegotiationManagerFactoryBean.setFavorParameter(true);
    	    contentNegotiationManagerFactoryBean.setIgnoreAcceptHeader(true);
    	    contentNegotiationManagerFactoryBean.setDefaultContentType(MediaType.APPLICATION_JSON_UTF8);
    	    Properties mediaTypesProperties = new Properties();
    	    mediaTypesProperties.setProperty("json", MediaType.APPLICATION_JSON_UTF8_VALUE);
    	    contentNegotiationManagerFactoryBean.setMediaTypes(mediaTypesProperties);
    	    contentNegotiationManagerFactoryBean.afterPropertiesSet();
    		return contentNegotiationManagerFactoryBean.getObject();
    	}
    	
    	@Bean
    	public ContentNegotiatingViewResolver contentNegotiatingViewResolver(@Autowired ContentNegotiationManager mvcContentNegotiationManager) {
    		ContentNegotiatingViewResolver contentNegotiatingViewResolver = new ContentNegotiatingViewResolver();
    		contentNegotiatingViewResolver.setOrder(1);
    		contentNegotiatingViewResolver.setContentNegotiationManager(mvcContentNegotiationManager);
    		return contentNegotiatingViewResolver;
    	}
    	
    	/**
    	 * 采用UTF-8编码,防止中文乱码
    	 * @return
    	 */
    	@Bean
    	public StringHttpMessageConverter stringHttpMessageConverter() {
    		return new StringHttpMessageConverter(Charset.forName("UTF-8"));
    	}
    	
    	@Bean
    	public RequestMappingHandlerAdapter requestMappingHandlerAdapter(@Autowired StringHttpMessageConverter stringHttpMessageConverter) {
    		RequestMappingHandlerAdapter requestMappingHandlerAdapter = new RequestMappingHandlerAdapter();
    		requestMappingHandlerAdapter.setMessageConverters(Collections.singletonList(stringHttpMessageConverter));
    		return requestMappingHandlerAdapter;
    	}
    	
    	/**
    	 * 设置欢迎页
    	 * 相当于web.xml中的 welcome-file-list > welcome-file
    	 */
    	@Override
    	public void addViewControllers(ViewControllerRegistry registry) {
    		registry.addRedirectViewController("/", "/index.html");
    	}
    }
    

    编写登录认证Api

    这里在 init 方法中初始化几个用户,放入 USER_DATA 集合,用于后续模拟登录。然后初始化 jedis 连接信息。init 方法被 @PostConstruct 注解,因此 Spring 创建该类的对象后,将自动执行其 init 方法,进行初始化操作。
    然后看 login 方法,首先根据用户名获取最近 MAX_DISABLED_SECONDS 秒内失败的次数,是否超过最大限制 MAX_TRY_COUNT

    若超过最大限制,不再对用户名和密码进行认证,直接返回认证失败提示信息,也即账户已被锁定的提示信息。

    否则,进行用户认证。

    若认证失败,将其添加到 Redis 缓存中,并设置过期默认为 MAX_DISABLED_SECONDS,表示从此刻起,MAX_DISABLED_SECONDS 秒内,该用户已登录失败 count 次。

    若Redis缓存中已存在该用户认证失败的计数信息,则刷新 count 值,并将旧值的剩余存活时间设置到新值上,然后返回认证失败提示信息。

    否则,返回认证成功提示信息。

    package org.xueliang.loginsecuritybyredis.web.controller.api;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import javax.annotation.PostConstruct;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    import org.xueliang.loginsecuritybyredis.web.model.JSONResponse;
    import org.xueliang.loginsecuritybyredis.web.model.User;
    
    import redis.clients.jedis.Jedis;
    
    /**
     * 认证类
     * @author XueLiang
     * @date 2016年11月1日 下午4:11:59
     * @version 1.0
     */
    @RestController
    @RequestMapping("/api/auth/")
    public class AuthApi {
    
      private static final Map<String, User> USER_DATA = new HashMap<String, User>();
      @Value("${auth.max_try_count}")
      private int MAX_TRY_COUNT = 0;
      @Value("${auth.max_disabled_seconds}")
      private int MAX_DISABLED_SECONDS = 0;
      
      @Value("${redis.host}")
      private String host;
      @Value("${redis.port}")
      private int port;
      private Jedis jedis;
      
      @PostConstruct
      public void init() {
        for (int i = 0; i < 3; i++) {
          String username = "username" + 0;
          String password = "password" + 0;
          USER_DATA.put(username + "_" + password, new User(username, "nickname" + i));
        }
        jedis = new Jedis(host, port);
      }
      
      @RequestMapping(value = {"login"}, method = RequestMethod.POST)
      public String login(@RequestParam("username") String username, @RequestParam("password") String password) {
        JSONResponse jsonResponse = new JSONResponse();
        String key = username;
        String countString = jedis.get(key);
        boolean exists = countString != null;
        int count = exists ? Integer.parseInt(countString) : 0;
        if (count >= MAX_TRY_COUNT) {
          checkoutMessage(key, count, jsonResponse);
          return jsonResponse.toString();
        }
        User user = USER_DATA.get(username + "_" + password);
        if (user == null) {
          count++;
          int secondsRemain = MAX_DISABLED_SECONDS;
          if (exists && count < 5) {
            secondsRemain = (int)(jedis.pttl(key) / 1000);
          }
          jedis.set(key, count + "");
          jedis.expire(key, secondsRemain);
          checkoutMessage(key, count, jsonResponse);
          return jsonResponse.toString();
        }
        count = 0;
        if (exists) {
          jedis.del(key);
        }
        checkoutMessage(key, count, jsonResponse);
        return jsonResponse.toString();
      }
      
      /**
       * 
       * @param key
       * @param count 尝试次数,也可以改为从redis里直接读
       * @param jsonResponse
       * @return
       */
      private void checkoutMessage(String key, int count, JSONResponse jsonResponse) {
        if (count == 0) {
          jsonResponse.setCode(0);
          jsonResponse.addMsg("success", "恭喜,登录成功!");
          return;
        }
        jsonResponse.setCode(1);
        if (count >= MAX_TRY_COUNT) {
          long pttlSeconds = jedis.pttl(key) / 1000;
          long hours = pttlSeconds / 3600;
          long sencondsRemain = pttlSeconds - hours * 3600;
          long minutes = sencondsRemain / 60;
          long seconds = sencondsRemain - minutes * 60;
          jsonResponse.addError("login_disabled", "登录超过" + MAX_TRY_COUNT + "次,请" + hours + "小时" + minutes + "分" + seconds + "秒后再试!");
          return;
        }
        jsonResponse.addError("username_or_password_is_wrong", "密码错误,您还有 " + (MAX_TRY_COUNT - count) + " 次机会!");
      }
    }
    

    编写前端页面

    页面很简单,监听表单提交事件,用 ajax 提交表单数据,然后将认证结果显示到 div 中。

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8">
    <title>登录</title>
    <style>
      span.error {
        color: red;
      }
      span.msg {
        color: green;
      }
    </style>
    </head>
    <body>
    <form action="" method="post">
      <label>用户名</label><input type="text" name="username">
      <label>密码</label><input type="text" name="password">
      <button type="submit">登录</button>
      <div></div>
    </form>
    
    <script>
      (function($) {
        var $ = (selector) => document.querySelector(selector);
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function() {
          if (this.readyState == 4 && this.status == 200) {
            var response = JSON.parse(this.responseText);
            var html = '';
            var msgNode = '';
            if (response.code != 0) {
              msgNode = 'error';
            } else {
              msgNode = 'msg';
            }
            for (var key in response[msgNode]) {
              html += '<span class="' + msgNode + '">' + response[msgNode][key] + '</span>';
            }
            $('div').innerHTML = html;
          }
        }
        
        var ajax = function(formData) {
          xhr.open('POST', '/api/auth/login.json', true);
          xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); // 将请求头设置为表单方式提交
          xhr.send(formData);
        }
        $('form').addEventListener('submit', function(event) {
          event.preventDefault();
          var formData = '';
          for (var elem of ['username', 'password']) {
            var value = $('input[name="' + elem + '"]').value;
            formData += (elem + '=' + value + '&');
          }
          ajax(formData);
        });
      })();
    </script>
    </body>
    </html>
    

    源码

    最后上下源码地址:https://github.com/liangzai-cool/loginsecuritybyredis

    更新

    2016年11月29日 更新,代码优化,增加原子操作,org.xueliang.loginsecuritybyredis.web.controller.api.AuthApi#login 函数作如下优化:

        @RequestMapping(value = {"login"}, method = RequestMethod.POST)
        public String login(@RequestParam("username") String username, @RequestParam("password") String password) {
            JSONResponse jsonResponse = new JSONResponse();
            String key = username;
            String countString = jedis.get(key);
            boolean exists = countString != null;
            int count = exists ? Integer.parseInt(countString) : 0;
            if (count >= MAX_TRY_COUNT) {
                checkoutMessage(key, count, jsonResponse);
                return jsonResponse.toString();
            }
            User user = USER_DATA.get(username + "_" + password);
            if (user == null) {
                count++;
    //            int secondsRemain = MAX_DISABLED_SECONDS;
    //            if (exists && count < 5) {
    //                secondsRemain = (int)(jedis.pttl(key) / 1000);
    //            }
    //            jedis.set(key, count + "");
    //            jedis.expire(key, secondsRemain);
                if (exists) {
                    jedis.incr(key);
                    if (count >= MAX_TRY_COUNT) {
                        jedis.expire(key, MAX_DISABLED_SECONDS);
                    }
                } else {
                    jedis.set(key, count + "");
                    jedis.expire(key, MAX_DISABLED_SECONDS);
                }
                checkoutMessage(key, count, jsonResponse);
                return jsonResponse.toString();
            }
            count = 0;
            if (exists) {
                jedis.del(key);
            }
            checkoutMessage(key, count, jsonResponse);
            return jsonResponse.toString();
        }
    

    原文链接http://xueliang.org/article/detail/20161102173458963

  • 相关阅读:
    Flutter 路由管理
    SpringMVC 集成 MyBatis
    关于windows下安装mysql数据库出现中文乱码的问题
    md5.digest()与md5.hexdigest()之间的区别及转换
    MongoDB基础命令及操作
    redis相关操作&基本命令使用
    python中mysql主从同步配置的方法
    shell入门基础&常见命令及用法
    ORM总结
    多任务:进程、线程、协程总结及关系
  • 原文地址:https://www.cnblogs.com/liangzai-cool/p/6132917.html
Copyright © 2020-2023  润新知