SpringBoot 并發(fā)登錄人數(shù)控制

通常系統(tǒng)都會限制同一個賬號的登錄人數(shù),多人登錄要么限制后者登錄,要么踢出前者,Spring Security 提供了這樣的功能,本文講解一下在沒有使用Security的時候如何手動實現(xiàn)這個功能

本文借鑒了 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的話,也完全可以借鑒本文的思路,只是代碼上需要加些改動

兩種實現(xiàn)思路

比較時間戳

維護(hù)一個 username: jwtToken 這樣的一個 key-value 在Reids中, Filter邏輯如下

image
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;

    }
}
隊列踢出
image
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);

            // 如果隊列里沒有此token,且用戶沒有被踢出;放入隊列
            if (!deque.contains(token) && currentSession.isKickout() == false) {
                deque.push(token);
            }

            // 如果隊列里的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;
    }

}

比較兩種方法

  1. 第一種方法邏輯簡單粗暴, 只維護(hù)一個key-value 不需要使用鎖,非要說缺點的話沒有第二種方法靈活。

  2. 第二種方法我很喜歡,代碼很優(yōu)雅靈活,但是邏輯相對麻煩一些,而且為了保證線程安全地操作隊列,要使用分布式鎖。目前我們項目中使用的是第一種方法

演示

下載地址: https://gitee.com/yintianwen7/taven-springboot-learning/tree/master/login-control

  1. 運行項目,訪問localhost:8887 demo中沒有存儲用戶信息,隨意輸入用戶名密碼,用戶名相同則被踢出

  2. 訪問 localhost:8887/index.html 彈出用戶信息, 代表當(dāng)前用戶有效

  3. 另一個瀏覽器登錄相同用戶名,回到第一個瀏覽器刷新頁面,提示被踢出

  4. application.properties中選擇開啟哪種過濾器模式,默認(rèn)是比較時間戳踢出,開啟隊列踢出 queue-filter.enabled=true

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容