springboot+shiro+redis实现同一个账户同一时只能一处在线

2023年 9月 22日 78.3k 0

背景

二开项目,登录/权限管理用的是shiro,SessionDAO底层依赖的是redis,历史遗留问题是没有做同一个用户登录限制,也就是同一个账号可以同时在N个地方进行登录,留下了很大的安全隐患。

image.png

现在的需求就是要实现同一个账户同一时只能一处在线,暂定的方案是后登录的可以强制先登录的下线。

思路

在登录认证,即调用doGetAuthenticationInfo方法的时候,从sessionDAO中获取全部的session,判断一下与当前用户是否为同一账号,如果是,将历史session清除,只保留当前session

实现

ShiroConfig

package com.abc.sv.conf;

import com.abc.sv.shrio.CustomRealm;
import com.abc.sv.shrio.CustomSessionManager;
import com.abc.sv.shrio.CustomWebSecurityManager;
import com.abc.sv.shrio.RedisSessionDao;
import org.apache.shiro.cache.MemoryConstrainedCacheManager;
import org.apache.shiro.session.mgt.SessionManager;
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.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Value;

@Configuration
public class ShiroConfig {

    @Value("${session.redis.expireTime}")
    private long expireTime;

    @Bean(name = "customRealm")
    public CustomRealm customRealm() {
        return new CustomRealm();
    }

    @Bean(name = "redisSessionDao")
    public RedisSessionDao redisSessionDao() {
        return new RedisSessionDao();
    }

    @Bean(name = "sessionManager")
    public SessionManager sessionManager() {
        CustomSessionManager customSessionManager = new CustomSessionManager();
        customSessionManager.setSessionIdCookieEnabled(false);
        customSessionManager.setSessionDAO(redisSessionDao());
        customSessionManager.setGlobalSessionTimeout(expireTime * 1000);
        customSessionManager.setSessionValidationSchedulerEnabled(true);
        customSessionManager.setDeleteInvalidSessions(true);
        return customSessionManager;
    }

    @Bean(name = "securityManager")
    public CustomWebSecurityManager customWebSecurityManager() {
        CustomWebSecurityManager securityManager = new CustomWebSecurityManager();
        securityManager.setRememberMeManager(null);
        securityManager.setRealm(customRealm());
        securityManager.setSessionManager(sessionManager());
        //暂不开启缓存
        //securityManager.setCacheManager(new MemoryConstrainedCacheManager());
        return securityManager;
    }

    @Bean(name = "lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean(name = "shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        return shiroFilterFactoryBean;
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        advisorAutoProxyCreator.setProxyTargetClass(true);
        return advisorAutoProxyCreator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

}

CustomRealm

package com.abc.sv.shrio;

import cn.hutool.core.collection.CollUtil;
import com.abc.framework.utils.Md5Util;
import com.abc.framework.utils.redis.RedisKeyEnum;
import com.abc.framework.utils.redis.RedisUtils;
import com.abc.sv.constant.UserTypeEnum;
import com.abc.sv.entity.User;
import com.abc.sv.service.management.UserService;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;

import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;

public class CustomRealm extends AuthorizingRealm {
    public static final Integer FAILED_LOGIN_TIME = 5;
    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private UserService userService;

    @Autowired
    private RoleService roleService;


  
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        Long userId = (Long) super.getAvailablePrincipal(principalCollection);
        User user = userService.selectByPrimaryKey(userId);
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        // 鉴权逻辑
        authorizationInfo.addStringPermissions(permissions);
        return authorizationInfo;
    }

    /**
     * 认证逻辑
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
            throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
        String username = token.getUsername();
        User user = userService.selectByUserName(username);
        if (user == null) {
            throw new AccountException("账号或密码有误");
        }
        if (user.getStatus() != 1) {
            throw new AccountException("您的账户已被停用,请向系统管理员咨询");
        }
        String passwordInToken = new String(token.getPassword());
        String passwordInDb = user.getPassword();
        String encryptedPwd = Md5Util.encryptPassword(passwordInToken);
        String lockTimeValue = redisUtils.get(RedisKeyEnum.USER_LOGIN_FAILED_LOCK.getFormatKey(username));
        if (!passwordInDb.equals(encryptedPwd)) {
            int lockTime = 1;
            if (StringUtils.isNotEmpty(lockTimeValue)) {
                lockTime = Integer.parseInt(lockTimeValue) + 1;
            }
            redisUtils.set(RedisKeyEnum.USER_LOGIN_FAILED_LOCK, lockTime, username);
            throw new AccountException("账号或密码有误");
        } else {
            if (StringUtils.isEmpty(lockTimeValue)) {
                redisUtils.delete(RedisKeyEnum.USER_LOGIN_FAILED_LOCK.getFormatKey(username));
            }
        }
        //处理多处登录问题
        DefaultSecurityManager securityManager = (DefaultSecurityManager) SecurityUtils.getSecurityManager();
        DefaultSessionManager sessionManager = (DefaultSessionManager)securityManager.getSessionManager();
        SessionDAO sessionDAO = sessionManager.getSessionDAO();
        //获取当前已登录的用户session列表
        Collection sessions = sessionDAO.getActiveSessions();
        if (CollUtil.isNotEmpty(sessions)) {
                for (Session session : sessions) {
                    //清除该用户以前登录时保存的session
                    Object obj = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
                    SimplePrincipalCollection coll = (SimplePrincipalCollection) obj;
                    if (coll != null) {
                        Long historyUserId = (Long) coll.getPrimaryPrincipal();
                        if (user.getId().equals(historyUserId)) {
                            // 强制删除
                            sessionDAO.delete(session);
                        }
                    }

                }
        }
        return new SimpleAuthenticationInfo(user.getId(), passwordInToken, ByteSource.Util.bytes(salt), getName());
    }
}

RedisSessionDao

package com.abc.sv.shrio;

import cn.hutool.core.util.ObjectUtil;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;

import java.io.Serializable;
import java.util.Collection;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

public class RedisSessionDao extends AbstractSessionDAO {

    @Value("${session.redis.expireTime}")
    private long expireTime;

    private final String SESSION_PREFIX = "session:";

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        String key = SESSION_PREFIX + session.getId();
        redisTemplate.opsForValue().set(key, session, expireTime, TimeUnit.SECONDS);
        return sessionId;
    }

    @Override
    protected Session doReadSession(Serializable sessionId) {
        Session session = null;
        if (ObjectUtil.isNotNull(sessionId)){
            String key = SESSION_PREFIX + sessionId;
            Object o = redisTemplate.opsForValue().get(key);
            if (ObjectUtil.isNotNull(o)){
                session = (Session)o;
            }
        }
        return session;
    }

    @Override
    public void update(Session session) throws UnknownSessionException {
        if (session != null && session.getId() != null) {
            session.setTimeout(expireTime * 1000);
            String key = SESSION_PREFIX + session.getId();
            redisTemplate.opsForValue().set(key, session, expireTime, TimeUnit.SECONDS);
        }
    }

    @Override
    public void delete(Session session) {
        if (session != null && session.getId() != null) {
            String key = SESSION_PREFIX + session.getId();
            redisTemplate.opsForValue().getOperations().delete(key);
        }
    }

    @Override
    public Collection getActiveSessions() {
        Set keys = redisTemplate.keys(SESSION_PREFIX + "*");
        if (keys != null) {
            return keys.stream().map(o -> (Session) redisTemplate.opsForValue().get(o)).collect(Collectors.toSet());
        } else {
            return null;
        }
    }

}

RedisConfig

package com.abc.sv.service.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.yhdja.framework.utils.redis.RedisUtils;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    @Primary
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        //!!!默认JdkSerializationRedisSerializer ,increment操作不会进行序列化,get会出错!!!需要设置反序列出来的类型
        redisTemplate.setValueSerializer(new CollectionSerializer());
        redisTemplate.setHashValueSerializer(new JdkSerializationRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);


        return redisTemplate;
    }

    @Bean
    public RedisUtils redisUtils(StringRedisTemplate stringRedisTemplate) {
        return new RedisUtils(stringRedisTemplate);
    }

}

CollectionSerializer

package com.abc.sv.service.config;



import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.SerializationUtils;
import java.io.Serializable;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

public class CollectionSerializer implements RedisSerializer{
    CollectionSerializer(){}
    public static volatile CollectionSerializer collectionSerializer=null;
    public static CollectionSerializer getInstance(){
        if(collectionSerializer==null){
            synchronized (CollectionSerializer.class) {
                if(collectionSerializer==null){
                    collectionSerializer=new CollectionSerializer();
                }
            }
        }
        return collectionSerializer;
    }
    @Override
    public byte[] serialize(T t) throws SerializationException {
        return SerializationUtils.serialize(t);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (ArrayUtils.isNotEmpty(bytes)){
            return SerializationUtils.deserialize(bytes);
        }
        return null;
    }


}

遇到的坑

redis序列化方式没设置好,导致sessionDAO.getActiveSessions()取不到数据

测试

正常登录

image.png

换个浏览器,再次登录

image.png

回到首次登录的页面,刷新

image.png

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论