springboot + shiro 嘗試登入次數限制與併發登入人數控制

gcyml發表於2019-04-23

原始碼專案地址

嘗試登入次數控制實現

實現原理

Realm在驗證使用者身份的時候,要進行密碼匹配。最簡單的情況就是明文直接匹配,然後就是加密匹配,這裡的匹配工作則就是交給CredentialsMatcher來完成的。我們在這裡繼承這個介面,自定義一個密碼匹配器,快取入鍵值對使用者名稱以及匹配次數,若通過密碼匹配,則刪除該鍵值對,若不匹配則匹配次數自增。超過給定的次數限制則丟擲錯誤。這裡快取用的是ehcache。

shiro-ehcache配置

maven依賴

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.3.2</version>
</dependency>
複製程式碼

ehcache配置

<?xml version="1.0" encoding="UTF-8"?>
<ehcache name="es">

    <diskStore path="java.io.tmpdir"/>

    <!--
       name:快取名稱。
       maxElementsInMemory:快取最大數目
       maxElementsOnDisk:硬碟最大快取個數。
       eternal:物件是否永久有效,一但設定了,timeout將不起作用。
       overflowToDisk:是否儲存到磁碟,當系統當機時
       timeToIdleSeconds:設定物件在失效前的允許閒置時間(單位:秒)。僅當eternal=false物件不是永久有效時使用,可選屬性,預設值是0,也就是可閒置時間無窮大。
       timeToLiveSeconds:設定物件在失效前允許存活時間(單位:秒)。最大時間介於建立時間和失效時間之間。僅當eternal=false物件不是永久有效時使用,預設是0.,也就是物件存活時間無窮大。
       diskPersistent:是否快取虛擬機器重啟期資料 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
       diskSpoolBufferSizeMB:這個引數設定DiskStore(磁碟快取)的快取區大小。預設是30MB。每個Cache都應該有自己的一個緩衝區。
       diskExpiryThreadIntervalSeconds:磁碟失效執行緒執行時間間隔,預設是120秒。
       memoryStoreEvictionPolicy:當達到maxElementsInMemory限制時,Ehcache將會根據指定的策略去清理記憶體。預設策略是LRU(最近最少使用)。你可以設定為FIFO(先進先出)或是LFU(較少使用)。
        clearOnFlush:記憶體數量最大時是否清除。
         memoryStoreEvictionPolicy:
            Ehcache的三種清空策略;
            FIFO,first in first out,這個是大家最熟的,先進先出。
            LFU, Less Frequently Used,就是上面例子中使用的策略,直白一點就是講一直以來最少被使用的。如上面所講,快取的元素有一個hit屬性,hit值最小的將會被清出快取。
            LRU,Least Recently Used,最近最少使用的,快取的元素有一個時間戳,當快取容量滿了,而又需要騰出地方來快取新的元素的時候,那麼現有快取元素中時間戳離當前時間最遠的元素將被清出快取。
    -->
    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
    />

    <!-- 登入記錄快取鎖定10分鐘 -->
    <cache name="passwordRetryCache"
           maxEntriesLocalHeap="2000"
           eternal="false"
           timeToIdleSeconds="3600"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true">
    </cache>

</ehcache>
複製程式碼

#RetryLimitCredentialsMatcher

/** 
 * 驗證器,增加了登入次數校驗功能 
 * 此類不對密碼加密
 * @author wgc
 */
@Component
public class RetryLimitCredentialsMatcher extends SimpleCredentialsMatcher {  
    private static final Logger log = LoggerFactory.getLogger(RetryLimitCredentialsMatcher.class);

    private int maxRetryNum = 5;
    private EhCacheManager shiroEhcacheManager;

    public void setMaxRetryNum(int maxRetryNum) {
        this.maxRetryNum = maxRetryNum;
    }

    public RetryLimitCredentialsMatcher(EhCacheManager shiroEhcacheManager) {
    	this.shiroEhcacheManager = shiroEhcacheManager; 
    }
	
  
    @Override  
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {  
    	Cache<String, AtomicInteger> passwordRetryCache = shiroEhcacheManager.getCache("passwordRetryCache");
        String username = (String) token.getPrincipal();  
        //retry count + 1  
        AtomicInteger retryCount = passwordRetryCache.get(username);  
        if (null == retryCount) {  
            retryCount = new AtomicInteger(0);
            passwordRetryCache.put(username, retryCount);  
        }
        if (retryCount.incrementAndGet() > maxRetryNum) {
        	log.warn("使用者[{}]進行登入驗證..失敗驗證超過{}次", username, maxRetryNum);
            throw new ExcessiveAttemptsException("username: " + username + " tried to login more than 5 times in period");  
        }  
        boolean matches = super.doCredentialsMatch(token, info);  
        if (matches) {  
            //clear retry data  
        	passwordRetryCache.remove(username);  
        }  
        return matches;  
    }  
} 
複製程式碼

Shiro配置修改

注入CredentialsMatcher

     /**
     * 快取管理器
     * @return cacheManager
     */
    @Bean
    public EhCacheManager ehCacheManager(){
        EhCacheManager cacheManager = new EhCacheManager();
        cacheManager.setCacheManagerConfigFile("classpath:config/ehcache-shiro.xml");
        return cacheManager;
    }
     /**
     * 限制登入次數
     * @return 匹配器
     */
    @Bean
    public CredentialsMatcher retryLimitCredentialsMatcher() {
        RetryLimitCredentialsMatcher retryLimitCredentialsMatcher = new RetryLimitCredentialsMatcher(ehCacheManager());
        retryLimitCredentialsMatcher.setMaxRetryNum(5);
        return retryLimitCredentialsMatcher;

    }
複製程式碼

realm新增認證器

myShiroRealm.setCredentialsMatcher(retryLimitCredentialsMatcher());
複製程式碼

併發線上人數控制實現

KickoutSessionControlFilter


/**
 * 併發登入人數控制
 * @author wgc
 */
public class KickoutSessionControlFilter extends AccessControlFilter {

    private static final Logger logger = LoggerFactory.getLogger(KickoutSessionControlFilter.class);
    

    /**
     * 踢出後到的地址
     */
	private String kickoutUrl;

    /**
     * 踢出之前登入的/之後登入的使用者 預設踢出之前登入的使用者
     */
	private boolean kickoutAfter = false;
    /**
     * 同一個帳號最大會話數 預設1
     */
	private int maxSession = 1;

	private String kickoutAttrName = "kickout";
	
	private SessionManager sessionManager; 
	private Cache<String, Deque<Serializable>> cache; 
	
	public void setKickoutUrl(String kickoutUrl) { 
		this.kickoutUrl = kickoutUrl; 
	}
	
	public void setKickoutAfter(boolean kickoutAfter) { 
		this.kickoutAfter = kickoutAfter;
	}
	
	public void setMaxSession(int maxSession) { 
		this.maxSession = maxSession; 
	} 
	
	public void setSessionManager(SessionManager sessionManager) { 
		this.sessionManager = sessionManager; 
	}

	/**
	 * 	設定Cache的key的字首
	 */
	public void setCacheManager(CacheManager cacheManager) { 
		this.cache = cacheManager.getCache("shiro-kickout-session");
	}
	
	@Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)
			throws Exception {
		return false;
	} 
	
	@Override
	protected boolean onAccessDenied(ServletRequest request, ServletResponse response)
			throws Exception { 
		Subject subject = getSubject(request, response); 
		if(!subject.isAuthenticated() && !subject.isRemembered())
		{ 
			//如果沒有登入,直接進行之後的流程 
			return true;
		} 
		
		Session session = subject.getSession();
		UserInfo user = (UserInfo) subject.getPrincipal(); 
		String username = user.getUsername();
		Serializable sessionId = session.getId();
		
		logger.info("進入KickoutControl, sessionId:{}", sessionId);
		//讀取快取 沒有就存入 
		Deque<Serializable> deque = cache.get(username); 
		 if(deque == null) {
			 deque = new LinkedList<Serializable>();  
		     cache.put(username, deque);  
		 }  
		 
		//如果佇列裡沒有此sessionId,且使用者沒有被踢出;放入佇列
		if(!deque.contains(sessionId) && session.getAttribute(kickoutAttrName) == null) {
			//將sessionId存入佇列 
			deque.push(sessionId); 
		} 
		logger.info("deque.size:{}",deque.size());
		//如果佇列裡的sessionId數超出最大會話數,開始踢人
		while(deque.size() > maxSession) { 
			Serializable kickoutSessionId = null; 
			if(kickoutAfter) { 
				//如果踢出後者 
				kickoutSessionId = deque.removeFirst(); 
			} else { 
				//否則踢出前者 
				kickoutSessionId = deque.removeLast(); 
			} 
			
			//踢出後再更新下快取佇列
			cache.put(username, deque); 
			try { 
				//獲取被踢出的sessionId的session物件
				Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
				if(kickoutSession != null) { 
					//設定會話的kickout屬性表示踢出了 
					kickoutSession.setAttribute(kickoutAttrName, true);
				}
			} catch (Exception e) {
				logger.error(e.getMessage());
			} 
		} 
		//如果被踢出了,直接退出,重定向到踢出後的地址
		if (session.getAttribute(kickoutAttrName) != null && (Boolean)session.getAttribute(kickoutAttrName) == true) {
			//會話被踢出了 
			try { 
				//退出登入
				subject.logout(); 
			} catch (Exception e) { 
				logger.warn(e.getMessage());
				e.printStackTrace();
			}
			saveRequest(request); 
			//重定向	
			logger.info("使用者登入人數超過限制, 重定向到{}", kickoutUrl);
			String reason = URLEncoder.encode("賬戶已超過登入人數限制", "UTF-8");
			String redirectUrl = kickoutUrl  + (kickoutUrl.contains("?") ? "&" : "?") + "shiroLoginFailure=" + reason;  
			WebUtils.issueRedirect(request, response, redirectUrl); 
			return false;
		} 
		return true; 
	} 
}
複製程式碼

ehcache配置

ehcache-shiro.xml加入

    <!-- 使用者佇列快取10分鐘 -->
    <cache name="shiro-kickout-session"
           maxEntriesLocalHeap="2000"
           eternal="false"
           timeToIdleSeconds="3600"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true">
    </cache>
複製程式碼

shiro配置

ShiroConfig.java中注入相關物件

     /**
     * 會話管理器
     * @return sessionManager
     */
    @Bean
    public DefaultWebSessionManager configWebSessionManager(){
        DefaultWebSessionManager manager = new DefaultWebSessionManager();
        // 加入快取管理器
        manager.setCacheManager(ehCacheManager());
        // 刪除過期的session
        manager.setDeleteInvalidSessions(true);

        // 設定全域性session超時時間
        manager.setGlobalSessionTimeout(1 * 60 *1000);

        // 是否定時檢查session
        manager.setSessionValidationSchedulerEnabled(true);
        manager.setSessionValidationScheduler(configSessionValidationScheduler());
        manager.setSessionIdUrlRewritingEnabled(false);
        manager.setSessionIdCookieEnabled(true);
        return manager;
    }

    /**
     * session會話驗證排程器
     * @return session會話驗證排程器
     */
    @Bean
    public ExecutorServiceSessionValidationScheduler configSessionValidationScheduler() {
    	ExecutorServiceSessionValidationScheduler sessionValidationScheduler = new ExecutorServiceSessionValidationScheduler();
    	//設定session的失效掃描間隔,單位為毫秒
    	sessionValidationScheduler.setInterval(300*1000);
    	return sessionValidationScheduler;
    }

    /**
     * 限制同一賬號登入同時登入人數控制
     * @return 過濾器
     */
    @Bean
    public KickoutSessionControlFilter kickoutSessionControlFilter() {
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        //使用cacheManager獲取相應的cache來快取使用者登入的會話;用於儲存使用者—會話之間的關係的;
        //這裡我們還是用之前shiro使用的redisManager()實現的cacheManager()快取管理
        //也可以重新另寫一個,重新配置快取時間之類的自定義快取屬性
        kickoutSessionControlFilter.setCacheManager(ehCacheManager());
        //用於根據會話ID,獲取會話進行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(configWebSessionManager());
        //是否踢出後來登入的,預設是false;即後者登入的使用者踢出前者登入的使用者;踢出順序。
        kickoutSessionControlFilter.setKickoutAfter(false);
        //同一個使用者最大的會話數,預設1;比如2的意思是同一個使用者允許最多同時兩個人登入;
        kickoutSessionControlFilter.setMaxSession(1);

        //被踢出後重定向到的地址;
        kickoutSessionControlFilter.setKickoutUrl("/login");
        return kickoutSessionControlFilter;
    }
複製程式碼

shiro過濾鏈中加入併發登入人數過濾器

filterChainDefinitionMap.put("/**", "kickout,user");
複製程式碼

訪問任意連結均需要認證通過以及限制併發登入次數

相關文章