springboot2+shiro+redis limits the number of simultaneous online users of the same account

springboot2 + shiro + redis limit the number of simultaneous online users of the same account

When we write the system, we need to pay attention to account security issues. The best way to deal with it is that the same account can only log in in one place.

Principle

The general principle is to store the logged-in sessionId in the cache every time you log in, and then read whether the logged-in sessionId is still stored in the cache before logging in or before doing any operations. If there is no sessionId, it means that you have been kicked offline , jump directly to the login page. (Probably this logic, I didn’t delve into it)

Code

Not much to say, the code:

Interceptor

There are many versions on the Internet, I found a random version and changed it myself.

import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
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 com.alibaba.fastjson.JSON;
import com.qk.common.util.BaseResultUtils;
import com.qk.platform.database.model.HtUser;
/**
 * @Time: July 17, 2023 10:31:51 AM
 * @Author: Qin Ershao
 * @Description: Blocker to limit the number of simultaneous online users of the same account
 */
public class KickoutSessionControlFilter extends AccessControlFilter {<!-- -->
    private String kickoutUrl; //Address after kickout
    private boolean kickoutAfter = false; //Kick out the user who logged in before/after. By default, the user who logged in before is kicked out
    private int maxSession = 1; //The maximum number of sessions of the same account defaults to 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;
    }
    //Set the prefix of the key of the Cache
    public void setCacheManager(CacheManager cacheManager) {<!-- -->
        this.cache = cacheManager.getCache("shiro_redis_cache");
    }

    @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() & amp; & amp; !subject.isRemembered()) {<!-- -->
            //If not logged in, go directly to the subsequent process
            return true;
        }
        Session session = subject. getSession();
        HtUser user = (HtUser) subject. getPrincipal();
        String username = user. getUserName();
        Serializable sessionId = session. getId();
        //Read the cache, store it if there is no
        Deque<Serializable> deque = cache. get(username);
        //If this user does not have a session queue, that is, has not logged in, there is no
        // Just new an empty queue, otherwise the deque object is empty, and a null pointer will be reported
        if(deque==null){<!-- -->
            deque = new LinkedList<Serializable>();
        }
        //If there is no such sessionId in the queue, and the user has not been kicked out; put it into the queue
        if(!deque.contains(sessionId) & amp; & amp; session.getAttribute("kickout") == null) {<!-- -->
            //Store the sessionId into the queue
            deque.push(sessionId);
            //Cache the user's sessionId queue
            cache. put(username, deque);
        }
        //If the number of sessionIds in the queue exceeds the maximum number of sessions, kick people
        while(deque. size() > maxSession) {<!-- -->
            Serializable kickoutSessionId = null;
            if(kickoutAfter) {<!-- --> //If the latter is kicked out
                kickoutSessionId = deque. removeFirst();
                //Update the cache queue after kicking out
                cache. put(username, deque);
            } else {<!-- --> //Otherwise kick out the former
                kickoutSessionId = deque. removeLast();
                //Update the cache queue after kicking out
                cache. put(username, deque);
            }
            try {<!-- -->
                //Get the session object of the kicked out sessionId
                Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
                //Here, send a prompt message to the front end that logged in to this account
                WebSocketServer.sendMessage(BaseResultUtils.sendMsg("loginOut", "Your account is logged in elsewhere, you have been pushed offline! If you are not logging in, please change your password immediately!"),user.getId( ) + "");
                if(kickoutSession != null) {<!-- -->
                    //Set the kickout attribute of the session to indicate kicked out
                    kickoutSession.setAttribute("kickout", true);
                }
            } catch (Exception e) {<!-- -->//ignore exception
            }
        }
        //If you are kicked out, exit directly and redirect to the kicked out address
        if (session.getAttribute("kickout") != null) {<!-- -->
            //session kicked out
            try {<!-- -->
                //sign out
                subject. logout();
            } catch (Exception e) {<!-- --> //ignore
            }
            saveRequest(request);
            Map<String, String> resultMap = new HashMap<String, String>();
            //Judge if it is an Ajax request
            if ("XMLHttpRequest".equalsIgnoreCase(((HttpServletRequest) request).getHeader("X-Requested-With"))) {<!-- -->
                resultMap.put("user_status", "300");
                resultMap.put("message", "You have logged in elsewhere, please log in again!");
                //Output json string
                out(response, resultMap);
            }else{<!-- -->
                //redirect
                WebUtils. issueRedirect(request, response, kickoutUrl);
            }
            return false;
        }
        return true;
    }
    private void out(ServletResponse hresponse, Map<String, String> resultMap)
            throws IOException {<!-- -->
        try {<!-- -->
            hresponse.setCharacterEncoding("UTF-8");
            PrintWriter out = hresponse. getWriter();
            out.println(JSON.toJSONString(resultMap));
            out. flush();
            out. close();
        } catch (Exception e) {<!-- -->
            System.err.println("KickoutSessionFilter.class outputs JSON exception, which can be ignored.");
        }
    }
}

What should be noted here is that before kicking out the login, I sent a reminder to the last person who logged in:
WebSocketServer.sendMessage(BaseResultUtils.sendMsg(“loginOut”, “Your account is logged in elsewhere, you have been pushed offline! If you are not logging in, please change your password immediately!”), user.getId() + “” );
I am using the websocket method here (see my other article for details), which will realize that once you log in here, a prompt box will pop up immediately on the previous login, prompting that he has been pushed offline, and will Automatically redirect to the login page.

shiro configuration class

This is the key point. There are many versions of this configuration class on the Internet. The following is my own version.

import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;

@Configuration
public class ShiroConfig {<!-- -->
/**
     * Identity authentication realm; (account password verification written by myself; permissions, etc.)
     */
    @Bean
    public MyShiroRealm myShiroRealm() {<!-- -->
        MyShiroRealm myShiroRealm = new MyShiroRealm();
        myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
        return myShiroRealm;
    }
    
    /**
* @class name: ShiroConfig.java
* @Time: December 6, 2021 4:37:42 pm
* @Author: Qin Ershao
* @return
* @Description: Authority management, the configuration is mainly Realm management authentication
*/
    @Bean
    public SecurityManager securityManager() {<!-- -->
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // Set realm.
        securityManager.setRealm(myShiroRealm());
        // Custom cache implementation using redis
        securityManager.setCacheManager(cacheManager());
        // Customize session management using redis
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }
    
    /**
     * @class name: ShiroConfig.java
     * @Time: December 6, 2021 4:38:40 pm
     * @Author: Qin Ershao
     * @return
     * @Description: credential matcher (Password verification is handed over to Shiro's SimpleAuthenticationInfo for processing)
     */
    @Bean
    public HashedCredentialsMatcherhashedCredentialsMatcher(){<!-- -->
        HashedCredentialsMatcherhashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("md5");//Hash algorithm: MD5 algorithm is used here;
        hashedCredentialsMatcher.setHashIterations(1);//The number of hashes, such as hashing twice, is equivalent to md5(md5(""));
        return hashedCredentialsMatcher;
    }
    
    /**
     * @class name: ShiroConfig.java
     * @Time: December 6, 2021 4:39:33 PM
     * @Author: Qin Ershao
     * @param securityManager
     * @return
     * @description: permission management
     */
@Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {<!-- -->
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// Users who are not logged in can only access the login page
shiroFilterFactoryBean.setLoginUrl("/login");
// The link to be redirected after successful login
shiroFilterFactoryBean.setSuccessUrl("/index");
// Unauthorized jump link
shiroFilterFactoryBean.setUnauthorizedUrl("/error/toUnauthorized");
//custom interceptor
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
//Limit the number of simultaneous online accounts of the same account.
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);
// permission control map.
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// <!-- authc: All urls must be authenticated before they can be accessed; anon: All urls can be accessed anonymously -->
//filterChainDefinitionMap.put("/index/**", "authc");//homepage
\t\t
filterChainDefinitionMap.put("/company/register/**", "anon");//Jump to the enterprise registration page
filterChainDefinitionMap.put("/company/addCompany/**", "anon");//Enterprise registration
\t\t
filterChainDefinitionMap.put("/register/**", "anon");//Jump to the new supplier registration page
filterChainDefinitionMap.put("/login/**", "anon");//Jump to the login page
filterChainDefinitionMap.put("/user/LoginValidate/**", "anon");//login verification
filterChainDefinitionMap.put("/drawRandomJPG/**", "anon");//login verification code
filterChainDefinitionMap.put("/getSMSCode/**", "anon");//SMS verification code
filterChainDefinitionMap.put("/error/**", "anon");//error
filterChainDefinitionMap.put("/script/**", "anon");//static file
filterChainDefinitionMap.put("/api/**", "anon");//Interface
//The main line of code must be placed at the end of all permission settings, otherwise all urls will be blocked and the rest need to be authenticated
//filterChainDefinitionMap.put("/**", "authc");
filterChainDefinitionMap.put("/**", "kickout,authc");//It must be written in this way to enter the interceptor that limits the number of simultaneous online accounts of the same account.
        //load permissions
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * cacheManager cache redis implementation
     * Using the shiro-redis open source plug-in
     * @return
     */
    public RedisCacheManager cacheManager() {<!-- -->
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        // To configure the cache, the entity class placed in the session must have an id identifier
        redisCacheManager.setPrincipalIdFieldName("id");
        return redisCacheManager;
    }

    /**
     * Configure shiro redisManager
     * Using the shiro-redis open source plug-in
     * @return
     */
    public RedisManager redisManager() {<!-- -->
        RedisManager redisManager = new RedisManager();
        //In the older version, host and port are written separately, and in the new version, host and port need to be spliced together.
        redisManager.setHost("127.0.0.1");//127.0.0.1
        redisManager.setPort(6379);//6379
        redisManager.setTimeout(3000);//3000
        //redisManager.setExpire(2160000);// Configure cache expiration time
        redisManager.setDatabase(2);//2
        //redisManager.setPassword(password);
        return redisManager;
    }

    /**
     * Session Manager
     * Using the shiro-redis open source plug-in
     */
    @Bean
    public DefaultWebSessionManager sessionManager() {<!-- -->
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO());
        return sessionManager;
    }

    /**
     * Realization of RedisSessionDAO shiro sessionDao layer via redis
     * Using the shiro-redis open source plug-in
     */
    @Bean
    public RedisSessionDAO redisSessionDAO() {<!-- -->
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setExpire(21600000);
        redisSessionDAO.setRedisManager(redisManager());
        return redisSessionDAO;
    }

    /**
     * Limit the number of simultaneous logins with the same account
     * Note: Bean annotations cannot be added here, because the interceptor already has it
     */
    public KickoutSessionControlFilter kickoutSessionControlFilter() {<!-- -->
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        kickoutSessionControlFilter.setCacheManager(cacheManager());
        kickoutSessionControlFilter.setSessionManager(sessionManager());
        kickoutSessionControlFilter.setKickoutAfter(false);
        kickoutSessionControlFilter.setMaxSession(1);
        kickoutSessionControlFilter.setKickoutUrl("/login");
        return kickoutSessionControlFilter;
    }


    /***
     * Configuration used for authorization
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {<!-- -->
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    /**
     * @class name: ShiroConfig.java
     * @Time: December 6, 2021 4:40:03 PM
     * @Author: Qin Ershao
     * @param securityManager
     * @return
     * @Description: Enable shiro aop annotation support.
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){<!-- -->
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    /**
     * Shiro lifecycle handler
     *
     */
    @Bean
    public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {<!-- -->
        return new LifecycleBeanPostProcessor();
    }
    
}

The other places are similar. The key thing to pay attention to is the shiroFilter method.
filterChainDefinitionMap.put(“/**”, “kickout,authc”); It must be added like this, but I added it wrong. This restriction has never taken effect, and it took a long time to find out that it is the problem here.

Others are common configurations, if you don’t understand, you can contact me. Let’s study together!

Done!