1. 程式人生 > >SpringBoot+Shiro學習之自定義攔截器管理線上使用者(踢出使用者)

SpringBoot+Shiro學習之自定義攔截器管理線上使用者(踢出使用者)

應用場景

  1. 我們經常會有用到,當A 使用者在北京登入 ,然後A使用者在天津再登入 ,要踢出北京登入的狀態。如果使用者在北京重新登入,那麼又要踢出天津的使用者,這樣反覆。又或是需要限制同一使用者的同時線上數量,超出限制後,踢出最先登入的或是踢出最後登入的。

  2. 第一個場景踢出使用者是由使用者觸發的,有時候需要手動將某個線上使用者踢出,也就是對當前線上使用者的列表進行管理。

·························································································································································
個人部落格:

http://z77z.oschina.io/

此專案下載地址:https://git.oschina.net/z77z/springboot_mybatisplus
························································································································································

實現思路

spring security就直接提供了相應的功能;Shiro的話沒有提供預設實現,不過可以很容易的在Shiro中加入這個功能。那就是使用shiro強大的自定義訪問控制攔截器:AccessControlFilter,整合這個介面後要實現下面這三個方法。

abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;  

boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; 

abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws
Exception;

isAccessAllowed:表示是否允許訪問;mappedValue就是[urls]配置中攔截器引數部分,如果允許訪問返回true,否則false;

onAccessDenied:表示當訪問拒絕時是否已經處理了;如果返回true表示需要繼續處理;如果返回false表示該攔截器例項已經處理了,將直接返回即可。

onPreHandle:會自動呼叫這兩個方法決定是否繼續處理;

另外AccessControlFilter還提供瞭如下方法用於處理如登入成功後/重定向到上一個請求:

void setLoginUrl(String loginUrl) //身份驗證時使用,預設/login.jsp  
String getLoginUrl()  
Subject getSubject(ServletRequest request, ServletResponse response) //獲取Subject例項  
boolean isLoginRequest(ServletRequest request, ServletResponse response)//當前請求是否是登入請求  
void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //將當前請求儲存起來並重定向到登入頁面  
void saveRequest(ServletRequest request) //將請求儲存起來,如登入成功後再重定向回該請求  
void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登入頁面   

比如基於表單的身份驗證就需要使用這些功能。

到此基本的攔截器就完事了,如果我們想進行訪問的控制就可以繼承AccessControlFilter;如果我們要新增一些通用資料我們可以直接繼承PathMatchingFilter。

下面就是我實現的訪問控制攔截器:KickoutSessionControlFilter:

/**
 * @author 作者 z77z
 * @date 建立時間:2017年3月5日 下午1:16:38
 * 思路:
 * 1.讀取當前登入使用者名稱,獲取在快取中的sessionId佇列
 * 2.判斷佇列的長度,大於最大登入限制的時候,按踢出規則
 *  將之前的sessionId中的session域中存入kickout:true,並更新佇列快取
 * 3.判斷當前登入的session域中的kickout如果為true,
 * 想將其做退出登入處理,然後再重定向到踢出登入提示頁面
 */
public class KickoutSessionControlFilter extends AccessControlFilter {

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

    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_redis_cache");
    }

    @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();
        SysUser user = (SysUser) subject.getPrincipal();
        String username = user.getNickname();
        Serializable sessionId = session.getId();

        //讀取快取   沒有就存入
        Deque<Serializable> deque = cache.get(username);

        //如果佇列裡沒有此sessionId,且使用者沒有被踢出;放入佇列
        if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
            //將sessionId存入佇列
            deque.push(sessionId);
            //將使用者的sessionId佇列快取
            cache.put(username, deque);
        }

        //如果佇列裡的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("kickout", true);
                }
            } catch (Exception e) {//ignore exception
            }
        }

        //如果被踢出了,直接退出,重定向到踢出後的地址
        if ((Boolean)session.getAttribute("kickout")!=null&&(Boolean)session.getAttribute("kickout") == true) {
            //會話被踢出了
            try {
                //退出登入
                subject.logout();
            } catch (Exception e) { //ignore
            }
            saveRequest(request);
            //重定向
            WebUtils.issueRedirect(request, response, kickoutUrl);
            return false;
        }
        return true;
    }
}

將這個自定義的攔截器配置在ShiroConfig.java檔案中:

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

將這個kickoutSessionControlFilter()注入到shiroFilterFactoryBean中:

//自定義攔截器
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
//限制同一帳號同時線上的個數。
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);

由於我們連結許可權的控制是動態存在資料庫中的,這個可以去看我之前動態許可權控制的博文,所以我們還要在資料庫中修改連結的許可權,將kickout這個自定義的許可權配置在對應的連結上。如下圖:

許可權表

還要編寫對應的被踢出的跳轉頁面:

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
    String path = request.getContextPath();
    String basePath = request.getScheme() + "://"
            + request.getServerName() + ":" + request.getServerPort()
            + path;
%>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script type="text/javascript"
    src="<%=basePath%>/static/js/jquery-1.11.3.js"></script>
<title>被踢出</title>
</head>
<body>
被踢出 或則在另一地方登入,或已經達到此賬號登入上限被擠掉。
<input type="button" id="login" value="重新登入" />
</body>
<script type="text/javascript">
$("#login").click(function(){
    window.open("<%=basePath%>/login"); 
});
</script>
</html>

到此,第一個場景就實現了,寫到這裡實際第二個場景的實現思路已經就很明顯了,可以通過sessionDAO獲取到全部的shiro會話List,然後顯示在前端頁面,踢出對應使用者就可以使用在對應sessionId的session域中設定key為kickout的值為true,上面的KickoutSessionControlFilter就會判斷session域中的kickout值,做響應的處理。這裡我就先不上程式碼了,大家可以自己試一試。之後再把程式碼同步到我的碼雲上,供大家學習交流。

處理了這個需求後,我發現一個問題,這裡有一個前提,我們知道Ajax不能做頁面redirect和forward跳轉,所以Ajax請求假如沒登入,那麼這個請求給使用者的感覺就是沒有任何反應,而使用者又不知道使用者已經退出了。這個就要對ajax請求做相應的優化,我已經有解決思路了,大家也可以思考下,我也會在下一博提供程式碼。

還有我接下來會對之前的前端頁面進行完善,比如下面是我更新的登入頁面:

登入頁面

已經更新到我的碼雲上面。