springboot + shiro 尝试登录次数限制与并发登录人数控制

源码项目地址

尝试登录次数控制实现

实现原理

Realm在验证用户身份的时候,要进行密码匹配。最简单的情况就是明文直接匹配,然后就是加密匹配,这里的匹配工作则就是交给CredentialsMatcher来完成的。我们在这里继承这个接口,自定义一个密码匹配器,缓存入键值对用户名以及匹配次数,若通过密码匹配,则删除该键值对,若不匹配则匹配次数自增。超过给定的次数限制则抛出错误。这里缓存用的是ehcache。

shiro-ehcache配置

maven依赖

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.3.2</version>
</dependency>

ehcache配置

<?xml version="1.0" encoding="UTF-8"?>
<ehcache name="es">

    <diskStore path="java.io.tmpdir"/>

    <!--
       name:缓存名称。
       maxElementsInMemory:缓存最大数目
       maxElementsOnDisk:硬盘最大缓存个数。
       eternal:对象是否永久有效,一但设置了,timeout将不起作用。
       overflowToDisk:是否保存到磁盘,当系统当机时
       timeToIdleSeconds:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
       timeToLiveSeconds:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0.,也就是对象存活时间无穷大。
       diskPersistent:是否缓存虚拟机重启期数据 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
       diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
       diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
       memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
        clearOnFlush:内存数量最大时是否清除。
         memoryStoreEvictionPolicy:
            Ehcache的三种清空策略;
            FIFO,first in first out,这个是大家最熟的,先进先出。
            LFU, Less Frequently Used,就是上面例子中使用的策略,直白一点就是讲一直以来最少被使用的。如上面所讲,缓存的元素有一个hit属性,hit值最小的将会被清出缓存。
            LRU,Least Recently Used,最近最少使用的,缓存的元素有一个时间戳,当缓存容量满了,而又需要腾出地方来缓存新的元素的时候,那么现有缓存元素中时间戳离当前时间最远的元素将被清出缓存。
    -->
    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
    />

    <!-- 登录记录缓存锁定10分钟 -->
    <cache name="passwordRetryCache"
           maxEntriesLocalHeap="2000"
           eternal="false"
           timeToIdleSeconds="3600"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true">
    </cache>

</ehcache>

#RetryLimitCredentialsMatcher

/** 
 * 验证器,增加了登录次数校验功能 
 * 此类不对密码加密
 * @author wgc
 */
@Component
public class RetryLimitCredentialsMatcher extends SimpleCredentialsMatcher {  
    private static final Logger log = LoggerFactory.getLogger(RetryLimitCredentialsMatcher.class);

    private int maxRetryNum = 5;
    private EhCacheManager shiroEhcacheManager;

    public void setMaxRetryNum(int maxRetryNum) {
        this.maxRetryNum = maxRetryNum;
    }

    public RetryLimitCredentialsMatcher(EhCacheManager shiroEhcacheManager) {
        this.shiroEhcacheManager = shiroEhcacheManager; 
    }
    
  
    @Override  
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {  
        Cache<String, AtomicInteger> passwordRetryCache = shiroEhcacheManager.getCache("passwordRetryCache");
        String username = (String) token.getPrincipal();  
        //retry count + 1  
        AtomicInteger retryCount = passwordRetryCache.get(username);  
        if (null == retryCount) {  
            retryCount = new AtomicInteger(0);
            passwordRetryCache.put(username, retryCount);  
        }
        if (retryCount.incrementAndGet() > maxRetryNum) {
            log.warn("用户[{}]进行登录验证..失败验证超过{}次", username, maxRetryNum);
            throw new ExcessiveAttemptsException("username: " + username + " tried to login more than 5 times in period");  
        }  
        boolean matches = super.doCredentialsMatch(token, info);  
        if (matches) {  
            //clear retry data  
            passwordRetryCache.remove(username);  
        }  
        return matches;  
    }  
} 

Shiro配置修改

注入CredentialsMatcher

     /**
     * 缓存管理器
     * @return cacheManager
     */
    @Bean
    public EhCacheManager ehCacheManager(){
        EhCacheManager cacheManager = new EhCacheManager();
        cacheManager.setCacheManagerConfigFile("classpath:config/ehcache-shiro.xml");
        return cacheManager;
    }
     /**
     * 限制登录次数
     * @return 匹配器
     */
    @Bean
    public CredentialsMatcher retryLimitCredentialsMatcher() {
        RetryLimitCredentialsMatcher retryLimitCredentialsMatcher = new RetryLimitCredentialsMatcher(ehCacheManager());
        retryLimitCredentialsMatcher.setMaxRetryNum(5);
        return retryLimitCredentialsMatcher;

    }

realm添加认证器

myShiroRealm.setCredentialsMatcher(retryLimitCredentialsMatcher());

并发在线人数控制实现

KickoutSessionControlFilter


/**
 * 并发登录人数控制
 * @author wgc
 */
public class KickoutSessionControlFilter extends AccessControlFilter {

    private static final Logger logger = LoggerFactory.getLogger(KickoutSessionControlFilter.class);
    

    /**
     * 踢出后到的地址
     */
    private String kickoutUrl;

    /**
     * 踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
     */
    private boolean kickoutAfter = false;
    /**
     * 同一个帐号最大会话数 默认1
     */
    private int maxSession = 1;

    private String kickoutAttrName = "kickout";
    
    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-kickout-session");
    }
    
    @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();
        UserInfo user = (UserInfo) subject.getPrincipal(); 
        String username = user.getUsername();
        Serializable sessionId = session.getId();
        
        logger.info("进入KickoutControl, sessionId:{}", sessionId);
        //读取缓存 没有就存入 
        Deque<Serializable> deque = cache.get(username); 
         if(deque == null) {
             deque = new LinkedList<Serializable>();  
             cache.put(username, deque);  
         }  
         
        //如果队列里没有此sessionId,且用户没有被踢出;放入队列
        if(!deque.contains(sessionId) && session.getAttribute(kickoutAttrName) == null) {
            //将sessionId存入队列 
            deque.push(sessionId); 
        } 
        logger.info("deque.size:{}",deque.size());
        //如果队列里的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(kickoutAttrName, true);
                }
            } catch (Exception e) {
                logger.error(e.getMessage());
            } 
        } 
        //如果被踢出了,直接退出,重定向到踢出后的地址
        if (session.getAttribute(kickoutAttrName) != null && (Boolean)session.getAttribute(kickoutAttrName) == true) {
            //会话被踢出了 
            try { 
                //退出登录
                subject.logout(); 
            } catch (Exception e) { 
                logger.warn(e.getMessage());
                e.printStackTrace();
            }
            saveRequest(request); 
            //重定向   
            logger.info("用户登录人数超过限制, 重定向到{}", kickoutUrl);
            String reason = URLEncoder.encode("账户已超过登录人数限制", "UTF-8");
            String redirectUrl = kickoutUrl  + (kickoutUrl.contains("?") ? "&" : "?") + "shiroLoginFailure=" + reason;  
            WebUtils.issueRedirect(request, response, redirectUrl); 
            return false;
        } 
        return true; 
    } 
}

ehcache配置

ehcache-shiro.xml加入

    <!-- 用户队列缓存10分钟 -->
    <cache name="shiro-kickout-session"
           maxEntriesLocalHeap="2000"
           eternal="false"
           timeToIdleSeconds="3600"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true">
    </cache>

shiro配置

ShiroConfig.java中注入相关对象

     /**
     * 会话管理器
     * @return sessionManager
     */
    @Bean
    public DefaultWebSessionManager configWebSessionManager(){
        DefaultWebSessionManager manager = new DefaultWebSessionManager();
        // 加入缓存管理器
        manager.setCacheManager(ehCacheManager());
        // 删除过期的session
        manager.setDeleteInvalidSessions(true);

        // 设置全局session超时时间
        manager.setGlobalSessionTimeout(1 * 60 *1000);

        // 是否定时检查session
        manager.setSessionValidationSchedulerEnabled(true);
        manager.setSessionValidationScheduler(configSessionValidationScheduler());
        manager.setSessionIdUrlRewritingEnabled(false);
        manager.setSessionIdCookieEnabled(true);
        return manager;
    }

    /**
     * session会话验证调度器
     * @return session会话验证调度器
     */
    @Bean
    public ExecutorServiceSessionValidationScheduler configSessionValidationScheduler() {
        ExecutorServiceSessionValidationScheduler sessionValidationScheduler = new ExecutorServiceSessionValidationScheduler();
        //设置session的失效扫描间隔,单位为毫秒
        sessionValidationScheduler.setInterval(300*1000);
        return sessionValidationScheduler;
    }

    /**
     * 限制同一账号登录同时登录人数控制
     * @return 过滤器
     */
    @Bean
    public KickoutSessionControlFilter kickoutSessionControlFilter() {
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        //使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
        //这里我们还是用之前shiro使用的redisManager()实现的cacheManager()缓存管理
        //也可以重新另写一个,重新配置缓存时间之类的自定义缓存属性
        kickoutSessionControlFilter.setCacheManager(ehCacheManager());
        //用于根据会话ID,获取会话进行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(configWebSessionManager());
        //是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序。
        kickoutSessionControlFilter.setKickoutAfter(false);
        //同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
        kickoutSessionControlFilter.setMaxSession(1);

        //被踢出后重定向到的地址;
        kickoutSessionControlFilter.setKickoutUrl("/login");
        return kickoutSessionControlFilter;
    }

shiro过滤链中加入并发登录人数过滤器

filterChainDefinitionMap.put("/**", "kickout,user");

访问任意链接均需要认证通过以及限制并发登录次数

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容