springboot + shiro之登錄人數限制、登錄判斷重定向、session時間設置
項目
前篇:spring boot + mybatis + layui + shiro後臺權限管理系統
本文是基於spring boot + mybatis + layui + shiro後臺權限管理系統開發的,新增功能:
- shiro並發登陸人數控制(超出登錄用戶最大配置數量,清理用戶)功能;
- 解決父子頁面判斷用戶未登錄之後,重定向到頁面中嵌套顯示登錄界面問題;
- 解決ajax請求,判斷用戶未登錄之後,重定向到登錄頁面問題;
- 解決完成了功能1,導致的session有效時間沖突問題。
項目源碼
項目源碼:(包含數據庫源碼)
github源碼: https://github.com/wyait/manage.git
github對應項目源碼目錄:wyait-manage-1.2.0
碼雲對應項目源碼目錄:wyait-manage-1.2.0
場景
同一個用戶,先在A電腦登錄;之後在B電腦登錄時,退出A電腦的登錄狀態;反之相同。或者限制同一個用戶在不同的設備上,同時在線的數量;
技術實現
基於shiro和ehcache實現
解決思路
spring security就直接提供了相應的功能;
Shiro的話沒有提供默認實現,不過可以在Shiro中加入這個功能。就是使用shiro強大的自定義訪問控制攔截器:AccessControlFilter,集成這個接口後要實現下面這2個方法。
/** * Returns <code>true</code> if the request is allowed to proceed through the filter normally, or <code>false</code> * if the request should be handled by the * {@link #onAccessDenied(ServletRequest,ServletResponse,Object) onAccessDenied(request,response,mappedValue)} * method instead. * * @param request the incoming <code>ServletRequest</code> * @param response the outgoing <code>ServletResponse</code> * @param mappedValue the filter-specific config value mapped to this filter in the URL rules mappings. * @return <code>true</code> if the request should proceed through the filter normally, <code>false</code> if the * request should be processed by this filter‘s * {@link #onAccessDenied(ServletRequest,ServletResponse,Object)} method instead. * @throws Exception if an error occurs during processing. */ protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; ... ... /** * Processes requests where the subject was denied access as determined by the * {@link #isAccessAllowed(javax.servlet.ServletRequest, javax.servlet.ServletResponse, Object) isAccessAllowed} * method. * * @param request the incoming <code>ServletRequest</code> * @param response the outgoing <code>ServletResponse</code> * @return <code>true</code> if the request should continue to be processed; false if the subclass will * handle/render the response directly. * @throws Exception if there is an error processing the request. */ protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
查看抽象類AccessControlFilter:
-
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。
- 思路:
a. 登陸成功時將用戶保存到了shiro提供的session中,並同時添加到ehcache緩存中;
b. KickoutSessionFilter拿到了session之後先判斷能不能通過緩存取到值,如果取得到再和服務器端session進行匹配(用戶的名字(每個用戶的名字必須不同));
c. 如果匹配,系統會為新登錄的用戶新建一個session;之前的session確認失效並踢出,老用戶就無法繼續操作而被迫下線;
shiro技術實現流程
下面就是自定義的訪問控制攔截器:KickoutSessionFilter:
自定義過濾器類KickoutSessionFilter
package com.wyait.manage.filter;
import java.io.Serializable;
import java.util.ArrayDeque;
import java.util.Deque;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import com.wyait.manage.pojo.User;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.lyd.admin.pojo.AdminUser;
/**
*
* @項目名稱:wyait-manager
* @類名稱:KickoutSessionFilter
* @類描述:自定義過濾器,進行用戶訪問控制
* @創建人:wyait
* @創建時間:2018年4月24日 下午5:18:29
* @version:
*/
public class KickoutSessionFilter extends AccessControlFilter {
private static final Logger logger = LoggerFactory
.getLogger(KickoutSessionFilter.class);
private String kickoutUrl; // 踢出後到的地址
private boolean kickoutAfter = false; // 踢出之前登錄的/之後登錄的用戶 默認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) {
//必須和ehcache緩存配置中的緩存name一致
this.cache = cacheManager.getCache("shiro-activeSessionCache");
}
@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();
logger.debug("==session時間設置:" + String.valueOf(session.getTimeout())
+ "===========");
try {
// 當前用戶
User user = (User) subject.getPrincipal();
String username = user.getUsername();
logger.debug("===當前用戶username:==" + username);
Serializable sessionId = session.getId();
logger.debug("===當前用戶sessionId:==" + sessionId);
// 讀取緩存用戶 沒有就存入
Deque<Serializable> deque = cache.get(username);
logger.debug("===當前deque:==" + deque);
if (deque == null) {
// 初始化隊列
deque = new ArrayDeque<Serializable>();
}
// 如果隊列裏沒有此sessionId,且用戶沒有被踢出;放入隊列
if (!deque.contains(sessionId)
&& session.getAttribute("kickout") == null) {
// 將sessionId存入隊列
deque.push(sessionId);
// 將用戶的sessionId隊列緩存
cache.put(username, deque);
}
// 如果隊列裏的sessionId數超出最大會話數,開始踢人
while (deque.size() > maxSession) {
logger.debug("===deque隊列長度:==" + deque.size());
Serializable kickoutSessionId = null;
// 是否踢出後來登錄的,默認是false;即後者登錄的用戶踢出前者登錄的用戶;
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
}
}
// ajax請求
// 如果被踢出了,(前者或後者)直接退出,重定向到踢出後的地址
if ((Boolean) session.getAttribute("kickout") != null
&& (Boolean) session.getAttribute("kickout") == true) {
// 會話被踢出了
try {
// 退出登錄
subject.logout();
} catch (Exception e) { // ignore
}
saveRequest(request);
logger.debug("==踢出後用戶重定向的路徑kickoutUrl:" + kickoutUrl);
// 重定向
WebUtils.issueRedirect(request, response, kickoutUrl);
return false;
}
return true;
} catch (Exception e) { // ignore
//重定向到登錄界面
WebUtils.issueRedirect(request, response, "/login");
return false;
}
}
}
設置ShiroConfig配置類
- SessionDAO 用於會話的CRUD;查看該接口源碼:
public interface SessionDAO {
/*如DefaultSessionManager在創建完session後會調用該方法;
如保存到關系數據庫/文件系統/NoSQL數據庫;即可以實現會話的持久化;
返回會話ID;主要此處返回的ID.equals(session.getId());
*/
Serializable create(Session session);
//根據會話ID獲取會話
Session readSession(Serializable sessionId) throws UnknownSessionException;
//更新會話;如更新會話最後訪問時間/停止會話/設置超時時間/設置移除屬性等會調用
void update(Session session) throws UnknownSessionException;
//刪除會話;當會話過期/會話停止(如用戶退出時)會調用
void delete(Session session);
//獲取當前所有活躍用戶,如果用戶量多此方法影響性能
Collection<Session> getActiveSessions();
}
SessionDAO實現類:
a. AbstractSessionDAO提供了SessionDAO的基礎實現,如生成會話ID等;
b. CachingSessionDAO提供了對開發者透明的會話緩存的功能,只需要設置相應的CacheManager即可;
c. MemorySessionDAO直接在內存中進行會話維護;
d. EnterpriseCacheSessionDAO提供了緩存功能的會話維護,默認情況下使用MapCache實現,內部使用ConcurrentHashMap保存緩存的會話。
- ShiroConfig配置類中EnterpriseCacheSessionDAO配置:
/** * EnterpriseCacheSessionDAO shiro sessionDao層的實現; * 提供了緩存功能的會話維護,默認情況下使用MapCache實現,內部使用ConcurrentHashMap保存緩存的會話。 */ @Bean public EnterpriseCacheSessionDAO enterCacheSessionDAO() { EnterpriseCacheSessionDAO enterCacheSessionDAO = new EnterpriseCacheSessionDAO(); //添加緩存管理器 //enterCacheSessionDAO.setCacheManager(ehCacheManager()); //添加ehcache活躍緩存名稱(必須和ehcache緩存名稱一致) enterCacheSessionDAO.setActiveSessionsCacheName("shiro-activeSessionCache"); return enterCacheSessionDAO; }
- SessionManager配置:
/**
*
* @描述:sessionManager添加session緩存操作DAO
* @創建人:wyait
* @創建時間:2018年4月24日 下午8:13:52
* @return
*/
@Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
//sessionManager.setCacheManager(ehCacheManager());
sessionManager.setSessionDAO(enterCacheSessionDAO());
return sessionManager;
}
- kickoutSessionFilter配置
/**
*
* @描述:kickoutSessionFilter同一個用戶多設備登錄限制
* @創建人:wyait
* @創建時間:2018年4月24日 下午8:14:28
* @return
*/
public KickoutSessionFilter kickoutSessionFilter(){
KickoutSessionFilter kickoutSessionFilter = new KickoutSessionFilter();
//使用cacheManager獲取相應的cache來緩存用戶登錄的會話;用於保存用戶—會話之間的關系的;
//這裏我們還是用之前shiro使用的ehcache實現的cacheManager()緩存管理
//也可以重新另寫一個,重新配置緩存時間之類的自定義緩存屬性
kickoutSessionFilter.setCacheManager(ehCacheManager());
//用於根據會話ID,獲取會話進行踢出操作的;
kickoutSessionFilter.setSessionManager(sessionManager());
//是否踢出後來登錄的,默認是false;即後者登錄的用戶踢出前者登錄的用戶;踢出順序。
kickoutSessionFilter.setKickoutAfter(false);
//同一個用戶最大的會話數,默認1;比如2的意思是同一個用戶允許最多同時兩個人登錄;
kickoutSessionFilter.setMaxSession(1);
//被踢出後重定向到的地址;
kickoutSessionFilter.setKickoutUrl("/toLogin?kickout=1");
return kickoutSessionFilter;
}
- 將SessionManager交給SecurityManager管理
/**
* shiro安全管理器設置realm認證、ehcache緩存管理、session管理器、Cookie記住我管理器
* @return
*/
@Bean public org.apache.shiro.mgt.SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 設置realm.
securityManager.setRealm(shiroRealm());
// //註入ehcache緩存管理器;
securityManager.setCacheManager(ehCacheManager());
// //註入session管理器;
securityManager.setSessionManager(sessionManager());
//註入Cookie記住我管理器
securityManager.setRememberMeManager(rememberMeManager());
return securityManager;
}
- 配置filterChainDefinitionMap
...
//添加kickout認證
HashMap<String,Filter> hashMap=new HashMap<String,Filter>();
hashMap.put("kickout",kickoutSessionFilter());
shiroFilterFactoryBean.setFilters(hashMap);
...
filterChainDefinitionMap.put("/**", "kickout,authc");
...
解決子頁面,重定向之後,出現頁面嵌套的問題
新增登錄中轉頁面toLogin.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!--head部分-->
<head th:include="layout :: htmlhead" th:with="title=‘利易達貸款後臺‘">
</head>
<script type="text/javascript">
var href=window.location.href;
if(href.indexOf("kickout")>0){
setTimeout("top.location.href=‘/login?kickout‘;", 0);
}else{
setTimeout("top.location.href=‘/login‘;", 0);
}
</script>
</html>
更改shiro中filterChainDefinitionMap配置
// 指定要求登錄時的鏈接
shiroFilterFactoryBean.setLoginUrl("/toLogin");
...
// 配置不會被攔截的鏈接 從上向下順序判斷
filterChainDefinitionMap.put("/login", "anon");
上面兩個配置,即可解決頁面重定向後,嵌套問題。
ajax請求問題
如果對用戶在線數量進行限制,踢出了之前登錄的用戶A;這時候用戶A在系統中,發送了一個ajax請求,會出現彈框空白等問題;
解決方案
- 自定義ShiroFilterUtils工具類判斷請求是否為ajax
package com.wyait.manage.utils;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @項目名稱:wyait-manager
* @類名稱:ShiroFilterUtils
* @類描述:shiro工具類
* @創建人:wyait
* @創建時間:2018年4月24日 下午5:12:04
* @version:
*/
public class ShiroFilterUtils {
private static final Logger logger = LoggerFactory
.getLogger(ShiroFilterUtils.class);
/**
*
* @描述:判斷請求是否是ajax
* @創建人:wyait
* @創建時間:2018年4月24日 下午5:00:22
* @param request
* @return
*/
public static boolean isAjax(ServletRequest request){
String header = ((HttpServletRequest) request).getHeader("X-Requested-With");
if("XMLHttpRequest".equalsIgnoreCase(header)){
logger.debug("shiro工具類【wyait-manager-->ShiroFilterUtils.isAjax】當前請求,為Ajax請求");
return Boolean.TRUE;
}
logger.debug("shiro工具類【wyait-manager-->ShiroFilterUtils.isAjax】當前請求,非Ajax請求");
return Boolean.FALSE;
}
}
- 調整KickoutSessionFilter過濾器,新增ajax請求判斷和響應
private final static ObjectMapper objectMapper = new ObjectMapper();
...
// ajax請求
/**
* 判斷是否已經踢出
* 1.如果是Ajax 訪問,那麽給予json返回值提示。
* 2.如果是普通請求,直接跳轉到登錄頁
*/
//判斷是不是Ajax請求
ResponseResult responseResult = new ResponseResult();
if (ShiroFilterUtils.isAjax(request) ) {
logger.debug(getClass().getName()+ "當前用戶已經在其他地方登錄,並且是Ajax請求!");
responseResult.setCode(IStatusMessage.SystemStatus.MANY_LOGINS.getCode());
responseResult.setMessage("您已在別處登錄,請您修改密碼或重新登錄");
out(response, responseResult);
}else{
// 重定向
WebUtils.issueRedirect(request, response, kickoutUrl);
}
...
/**
*
* @描述:response輸出json
* @創建人:wyait
* @創建時間:2018年4月24日 下午5:14:22
* @param response
* @param result
*/
public static void out(ServletResponse response, ResponseResult result){
PrintWriter out = null;
try {
response.setCharacterEncoding("UTF-8");//設置編碼
response.setContentType("application/json");//設置返回類型
out = response.getWriter();
out.println(objectMapper.writeValueAsString(result));//輸出
logger.error("用戶在線數量限制【wyait-manager-->KickoutSessionFilter.out】響應json信息成功");
} catch (Exception e) {
logger.error("用戶在線數量限制【wyait-manager-->KickoutSessionFilter.out】響應json信息出錯", e);
}finally{
if(null != out){
out.flush();
out.close();
}
}
}
- 前端編寫公共判斷用戶是否登錄方法isLogin
/**
* 判斷是否登錄,沒登錄刷新當前頁,促使Shiro攔截後跳轉登錄頁
* @param result ajax請求返回的值
* @returns {如果沒登錄,刷新當前頁}
*/
function isLogin(result){
if(result && result.code && result.code == ‘1101‘){
window.location.reload(true);//刷新當前頁
}
return true;//返回true
}
- js中ajax調用isLogin方法
$.post("/user/delUser",{"id":id},function(data){
//判斷用戶是否登錄
if(isLogin(data)){
if(data=="ok"){
//回調彈框
layer.alert("刪除成功!",function(){
layer.closeAll();
//加載load方法
load(obj);//自定義
});
}else{
layer.alert(data);//彈出錯誤提示
}
}
});
只改動了userList.js用戶列表界面,其他界面//TODO
- 測試
同一個用戶在線沖突測試,然後點擊先登錄用戶界面中其中一個ajax方法,如果後臺用戶已退出,前臺isLogin刷新頁面,重新請求重定向到/toLogin?kickout頁面,最終跳轉到登錄界面。
session有效時間設置
session默認有效時間:30分鐘(1800s)
- spring boot session時間配置:
# 會話超時(秒)1天 server.session.timeout=86400
- session有效時間問題
使用shiro進行用戶在線數量限制功能中,securityManager配置sessionManager之後,springboot中配置的session有效時間無效(sessionManager管理器覆蓋了springboot中session有效時間的配置)。
session過期問題
使用shiro進行用戶在線數量限制功能;用戶登錄後,2分鐘不操作,之後session失效。
原因
- spring boot整合shiro,在使用shiro進行用戶在線數量限制時,重新配置了SessionManger,
// //註入session管理器;
securityManager.setSessionManager(sessionManager());
SessionManager,配置EnterpriseCacheSessionDAO:
sessionManager.setSessionDAO(enterCacheSessionDAO());
EnterpriseCacheSessionDAO類,存取session的時候,是通過ehcache緩存中操作的。
這裏如果配置有緩存的話需要給其配置一個cache的鍵類似於:
shiro默認了一個默認值為:shiro-activeSessionCache,如果不相同(cache文件中的鍵值) 需要進行替換,最終進行session存取的類為CachingSessionDAO
緩存管理器使用的是org.apache.shiro.cache.ehcache.EhCacheManager,那麽最終shiro在找session的時候也會調用getCache。
Ehcache.xml配置
<!-- shiro-activeSessionCache活躍用戶session緩存策略 -->
<cache name="shiro-activeSessionCache"
maxElementsInMemory="10000"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
maxElementsOnDisk="10000000"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU">
</cache>
這裏配置了session緩存時間為2分鐘,故會出現登錄2分鐘無操作後,session失效問題。
- shiro拿到ehcache緩存中的session後,和服務器中的session校驗匹配,這時,如果服務器的session失效,也會出現問題;
假設設置服務器端當前用戶的session為30s【SecurityUtils.getSubject().getSession().setTimeout(30000);//毫秒
】,ehcache中session有效時間120s不變;在無操作30s後,請求後臺,報錯如下:
org.apache.shiro.session.ExpiredSessionException: Session with id [8aac0daf-c432-44b6-86cc-a618095ad2bd] has expired. Last access time: 18-4-24 上午11:32. Current time: 18-4-24 上午11:33. Session timeout is set to 30 seconds (0 minutes)
at org.apache.shiro.session.mgt.SimpleSession.validate(SimpleSession.java:292) ~[shiro-core-1.3.1.jar:1.3.1]
at org.apache.shiro.session.mgt.AbstractValidatingSessionManager.doValidate(AbstractValidatingSessionManager.java:186) ~[shiro-core-1.3.1.jar:1.3.1]
... ...
故ehcache緩存中session的有效時間和服務器端session有效時間必須配置一致。
解決方案
- 服務端session時間設置:
//session有效時間1天(毫秒)
SecurityUtils.getSubject().getSession().setTimeout(86400000);
- 設置的最大時間,正負都可以,為負數時表示永不超時。
SecurityUtils.getSubject().getSession().setTimeout(-1000l);
註意:這裏設置的時間單位是:ms,但是Shiro會把這個時間轉成:s,而且是會舍掉小數部分,這樣設置的是-1ms,轉成s後就是0s,馬上就過期了。所有要是除以1000以後還是負數,必須設置小於-1000
- 將Ehcache.xml時間配置和服務器設置的session有效時間保持一致。
<!-- shiro-activeSessionCache活躍用戶session緩存策略(秒) --> <cache name="shiro-activeSessionCache" maxElementsInMemory="10000" timeToIdleSeconds="86400" timeToLiveSeconds="86400" maxElementsOnDisk="10000000" diskExpiryThreadIntervalSeconds="120" memoryStoreEvictionPolicy="LRU"> </cache>
通過代碼中查看session有效時間:
logger.debug("session設置的有效時間:"+request.getSession().getMaxInactiveInterval());
logger.debug("shiro中session設置的有效時間:"+SecurityUtils.getSubject().getSession().getTimeout());
//86400(秒)
//86400000(毫秒)
前篇:spring boot + mybatis + layui + shiro後臺權限管理系統
總結
具體實現可以根據具體需求做調整;近期提供redis實現版本。
springboot + shiro之登錄人數限制、登錄判斷重定向、session時間設置