SpringBoot 并發(fā)登錄人數(shù)控制
轉(zhuǎn)自:簡書,作者:殷天文
www.jianshu.com/p/b6f5ec98d790
通常系統(tǒng)都會限制同一個賬號的登錄人數(shù),多人登錄要么限制后者登錄,要么踢出前者,Spring Security 提供了這樣的功能,本文講解一下在沒有使用Security的時候如何手動實(shí)現(xiàn)這個功能。
demo 技術(shù)選型
SpringBoot
JWT
Filter
Redis + Redisson
JWT(token)存儲在Redis中,類似 JSessionId-Session的關(guān)系,用戶登錄后每次請求在Header中攜帶jwt
如果你是使用session的話,也完全可以借鑒本文的思路,只是代碼上需要加些改動
兩種實(shí)現(xiàn)思路
比較時間戳
維護(hù)一個 username: jwtToken 這樣的一個 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;
/**
* 同一個帳號最大會話數(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ù)一個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)前用戶有效
另一個瀏覽器登錄相同用戶名,回到第一個瀏覽器刷新頁面,提示被踢出
application.properties中選擇開啟哪種過濾器模式,默認(rèn)是比較時間戳踢出,開啟隊(duì)列踢出queue-filter.enabled=true
本文借鑒了https://jinnianshilongnian.iteye.com/blog/2039760, 如果你是使用 Shiro + Session 的模式,推薦閱讀此文
-- end --
喜歡就三連呀
關(guān)注 Stephen,一起學(xué)習(xí),一起成長。
