通常系統(tǒng)都會限制同一個(gè)賬號的登錄人數(shù),多人登錄要么限制后者登錄塘幅,要么踢出前者,Spring Security 提供了這樣的功能,本文講解一下在沒有使用Security的時(shí)候如何手動(dòng)實(shí)現(xiàn)這個(gè)功能
本文借鑒了 https://jinnianshilongnian.iteye.com/blog/2039760, 如果你是使用 Shiro + Session 的模式吩翻,推薦閱讀此文
demo 技術(shù)選型
- SpringBoot
- JWT
- Filter
- Redis + Redisson
JWT(token)存儲在Redis中,類似 JSessionId-Session的關(guān)系锥咸,用戶登錄后每次請求在Header中攜帶jwt
如果你是使用session的話狭瞎,也完全可以借鑒本文的思路,只是代碼上需要加些改動(dòng)
兩種實(shí)現(xiàn)思路
比較時(shí)間戳
維護(hù)一個(gè) username: jwtToken
這樣的一個(gè) key-value
在Reids中, Filter邏輯如下
public class CompareKickOutFilter extends KickOutFilter {
@Autowired
private UserService userService;
@Override
public boolean isAccessAllowed(HttpServletRequest request, HttpServletResponse response) {
String token = request.getHeader("Authorization");
String username = JWTUtil.getUsername(token);
String userKey = PREFIX + username;
RBucket<String> bucket = redissonClient.getBucket(userKey);
String redisToken = bucket.get();
if (token.equals(redisToken)) {
return true;
} else if (StringUtils.isBlank(redisToken)) {
bucket.set(token);
} else {
Long redisTokenUnixTime = JWTUtil.getClaim(redisToken, "createTime").asLong();
Long tokenUnixTime = JWTUtil.getClaim(token, "createTime").asLong();
// token > redisToken 則覆蓋
if (tokenUnixTime.compareTo(redisTokenUnixTime) > 0) {
bucket.set(token);
} else {
// 注銷當(dāng)前token
userService.logout(token);
sendJsonResponse(response, 4001, "您的賬號已在其他設(shè)備登錄");
return false;
}
}
return true;
}
}
隊(duì)列踢出
public class QueueKickOutFilter extends KickOutFilter {
/**
* 踢出之前登錄的/之后登錄的用戶 默認(rèn)踢出之前登錄的用戶
*/
private boolean kickoutAfter = false;
/**
* 同一個(gè)帳號最大會話數(shù) 默認(rèn)1
*/
private int maxSession = 1;
public void setKickoutAfter(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}
public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}
@Override
public boolean isAccessAllowed(HttpServletRequest request, HttpServletResponse response) throws Exception {
String token = request.getHeader("Authorization");
UserBO currentSession = CurrentUser.get();
Assert.notNull(currentSession, "currentSession cannot null");
String username = currentSession.getUsername();
String userKey = PREFIX + "deque_" + username;
String lockKey = PREFIX_LOCK + username;
RLock lock = redissonClient.getLock(lockKey);
lock.lock(2, TimeUnit.SECONDS);
try {
RDeque<String> deque = redissonClient.getDeque(userKey);
// 如果隊(duì)列里沒有此token搏予,且用戶沒有被踢出熊锭;放入隊(duì)列
if (!deque.contains(token) && currentSession.isKickout() == false) {
deque.push(token);
}
// 如果隊(duì)列里的sessionId數(shù)超出最大會話數(shù),開始踢人
while (deque.size() > maxSession) {
String kickoutSessionId;
if (kickoutAfter) { // 如果踢出后者
kickoutSessionId = deque.removeFirst();
} else { // 否則踢出前者
kickoutSessionId = deque.removeLast();
}
try {
RBucket<UserBO> bucket = redissonClient.getBucket(kickoutSessionId);
UserBO kickoutSession = bucket.get();
if (kickoutSession != null) {
// 設(shè)置會話的kickout屬性表示踢出了
kickoutSession.setKickout(true);
bucket.set(kickoutSession);
}
} catch (Exception e) {
}
}
// 如果被踢出了雪侥,直接退出碗殷,重定向到踢出后的地址
if (currentSession.isKickout()) {
// 會話被踢出了
try {
// 注銷
userService.logout(token);
sendJsonResponse(response, 4001, "您的賬號已在其他設(shè)備登錄");
} catch (Exception e) {
}
return false;
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
LOGGER.info(Thread.currentThread().getName() + " unlock");
} else {
LOGGER.info(Thread.currentThread().getName() + " already automatically release lock");
}
}
return true;
}
}
比較兩種方法
第一種方法邏輯簡單粗暴, 只維護(hù)一個(gè)key-value 不需要使用鎖,非要說缺點(diǎn)的話沒有第二種方法靈活速缨。
第二種方法我很喜歡锌妻,代碼很優(yōu)雅靈活,但是邏輯相對麻煩一些旬牲,而且為了保證線程安全地操作隊(duì)列仿粹,要使用分布式鎖。目前我們項(xiàng)目中使用的是第一種方法
演示
下載地址: https://gitee.com/yintianwen7/taven-springboot-learning/tree/master/login-control
運(yùn)行項(xiàng)目原茅,訪問localhost:8887 demo中沒有存儲用戶信息吭历,隨意輸入用戶名密碼,用戶名相同則被踢出
訪問 localhost:8887/index.html 彈出用戶信息, 代表當(dāng)前用戶有效
另一個(gè)瀏覽器登錄相同用戶名擂橘,回到第一個(gè)瀏覽器刷新頁面晌区,提示被踢出
application.properties中選擇開啟哪種過濾器模式,默認(rèn)是比較時(shí)間戳踢出贝室,開啟隊(duì)列踢出
queue-filter.enabled=true