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!