Spring RESTful + Redis全註解實現惡意登陸保護機制

很久沒更博了...
最近看了個真正全註解實現的 SpringMVC 博客,感受很不錯,終於能夠完全丟棄 web.xml 了。其實這玩意也是老東西了,丟棄 web.xml,是基於 五、6年前發佈的 Servlet 3.0 規範,只不過少有人玩而已...如今4.0都快正式發佈了...Spring對註解的支持也從09年末就開始支持了...
基礎部分我就不仔細講了,能夠先看一下這篇 以及其中提到的另外兩篇文章,這三篇文章講的很不錯。
下面開始舊東西新玩~~~html

構建

項目是基於 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 同樣,依次添加 filterservletjava

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 基本上啓動起來了。git

編寫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 是方法名。github

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 web

若超過最大限制,再也不對用戶名和密碼進行認證,直接返回認證失敗提示信息,也即帳戶已被鎖定的提示信息。ajax

不然,進行用戶認證。redis

若認證失敗,將其添加到 Redis 緩存中,並設置過時默認爲 MAX_DISABLED_SECONDS,表示今後刻起,MAX_DISABLED_SECONDS 秒內,該用戶已登陸失敗 count 次。spring

若Redis緩存中已存在該用戶認證失敗的計數信息,則刷新 count 值,並將舊值的剩餘存活時間設置到新值上,而後返回認證失敗提示信息。json

不然,返回認證成功提示信息。

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

相關文章
相關標籤/搜索