spring boot 前后端分离整合shiro(五)整合redis并实现并发登录控制

pom文件添加依赖:

        <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--shiro-redis-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>2.4.2.1-RELEASE</version>
        </dependency>

yml添加redis的配置:

#redis
spring:
  redis:
    database: 0 # Redis数据库索引(默认为0)
    host: 127.0.0.1
#    host: 192.168.65.130
    port: 6379
#    password: 111111
    # 连接超时时间(毫秒)
    timeout: 1000
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 200
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 10
        # 连接池中的最小空闲连接
        min-idle: 0

redis的配置类:

package com.example.shiroStudy.config.redis;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author 黄豪琦
 * 日期:2019-07-05 13:27
 * 说明:
 */
@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings("all")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }

}

一个简单的redis增删改查工具类:

package com.example.shiroStudy.config.redis;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.CollectionUtils;

import java.util.List;

/**
 * @author 黄豪琦
 * 日期:2019-07-05 13:28
 * 说明:
 */
public class RedisUtils {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    //添加 获取

    /**
     * 获取普通缓存
     * @param key
     * @return
     */
    public Object get(String key){
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }

    public boolean set(String key, Object obj){
        try {
            redisTemplate.opsForValue().set(key, obj);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 添加普通缓存
     * @param key
     * @param value
     * @param time
     * @return
     */
    public boolean set(String key, Object value, long time){
        try {
            if(time > 0){
                redisTemplate.opsForValue().set(key, value, time);
                return true;
            } else {
                redisTemplate.opsForValue().set(key, value);
                return true;
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 获取某个list中所有的值
     * @param key
     * @return
     */
    public List<Object> getList(String key){
        try {
            return redisTemplate.opsForList().range(key, 0, -1);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 存一个list
     * @param key 键
     * @param value 值
     * @return false失败
     */
    public boolean setList(String key, Object value){
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 判断key是否存在
     * @param key 键
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除缓存
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }

}

在shiroconfig中添加bean:

	/**
     * redisManager
     */
    @Bean
    public RedisManager redisManager(){
        RedisManager redisManager = new RedisManager();
        //设置过期时间
        redisManager.setExpire(2000);
        return redisManager;
    }

	/**
     * 配置具体catch实现类
     * @return
     * @return
     */
    @Bean
    public RedisCacheManager redisCacheManager(){
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());

        return redisCacheManager;
    }
    
	/**
     * redis操作
     * @return
     */
    @Bean
    public RedisUtils redisUtils(){
        RedisUtils redisUtils = new RedisUtils();
        return redisUtils;
    }
session持久化
package com.example.shiroStudy.config.shiro;

import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;

/**
 * @author 黄豪琦
 * 日期:2019-07-03 11:16
 * 说明: 自定义session管理
 */
public class CustomSessionManager extends DefaultWebSessionManager {

    private static final String TOKEN = "token";

    public CustomSessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {

        String sessionId = WebUtils.toHttp(request).getHeader(TOKEN);
        if(null != sessionId){
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "cookie");
            //让shiro去判断sessionId是否过期 是否存在
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
            //标记sessionId是有效的,如果是false则会抛异常
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);

            return sessionId;
        } else {
            return super.getSessionId(request, response);
        }

    }
}

说明

RedisManager 和 RedisCacheManager 都是crazycake包下面的,这个是大犇写的工具,会用就行。RedisManager可以使用redisManager.setHost(String host);redisManager.setPort(String port);来设置ip地址和端口号,如果不设置的话默认就是本地的6379端口:
在这里插入图片描述

测试

启动项目,登录一下
在这里插入图片描述
使用rdm工具查看缓存中的内容:
在这里插入图片描述
可以看到shiro已经帮我们把用户信息存到redis中了。
编写并发登录控制类:
KicoutSessionControlFilter


import com.alibaba.fastjson.JSONObject;
import com.example.shiroStudy.config.redis.RedisUtils;
import com.example.shiroStudy.entity.JsonData;
import com.example.shiroStudy.entity.Users;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.SessionException;
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 org.crazycake.shiro.RedisManager;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Deque;
import java.util.LinkedList;

/**
 * @author 黄豪琦
 * 日期:2019-07-04 15:34
 * 说明:自定义并发登录拦截器
 */
public class KicoutSessionControlFilter extends AccessControlFilter {


    /**
     * 被踢出后重定向的地址  后台接口路径
     */
    private String kicOutUrl;

    /**
     * 最大登录人数 默认为1
     */
    private int maxNum = 1;

    /**
     * 踢出前者还是后者 为true踢出后者 默认踢出前者
     */
    private boolean kicoutAfter = false;

    private SessionManager sessionManager;

    private RedisUtils redisUtils;

    private static final String DEFAULT_KICKOUT_CACHE_KEY_PREFIX = "shiro:cache:kickout:";

    private String keyPrefix = DEFAULT_KICKOUT_CACHE_KEY_PREFIX;

    private String getRedisKickoutKey(String username) {
        return this.keyPrefix + username;
    }

    private static final String KICOUT_PROPERTY_NAME = "kicout";

    /**
     * 是否允许访问,true表示允许
     * @param servletRequest
     * @param servletResponse
     * @param o
     * @return
     * @throws Exception
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
        return false;
    }

    /**
     * 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了(比如重定向到另一个页面)。
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        Subject subject = SecurityUtils.getSubject();
        //如果用户没有登录且没有配置『记住我』,则跳过
        if(!subject.isAuthenticated() && !subject.isRemembered()){
            return true;
        }

        Users user = (Users)subject.getPrincipal();
        Session session = subject.getSession();

        Serializable sessionId = session.getId();

        //初始化用户队列 放进缓存
        Deque<Serializable> deque = (Deque<Serializable>)redisUtils.get(getRedisKickoutKey(user.getLoginName()));
        if(deque == null || deque.size() == 0){
            deque = new LinkedList<>();
        }

        //如果用户队列里没有此sessionId,且没有被踢出 则放入队列 并放入缓存
        if(!deque.contains(sessionId) && session.getAttribute(KICOUT_PROPERTY_NAME) == null){
            deque.push(sessionId);
            redisUtils.set(getRedisKickoutKey(user.getLoginName()), deque, -1);
        }

        //如果队列里的用户数量超过最大值 开始踢人
        while (deque.size() > maxNum){
            Serializable kicoutSessionId;
            //为true踢出前者  kicoutAfter默认是false
            if(!kicoutAfter){
                kicoutSessionId = deque.removeLast();
            } else {
                //否则踢出后者
                kicoutSessionId = deque.removeFirst();
            }
            try {
                Session kicoutSession = sessionManager.getSession(new DefaultSessionKey(kicoutSessionId));
                if(kicoutSession != null){
                    //设置此属性为true表示这个会话被踢出了
                    kicoutSession.setAttribute(KICOUT_PROPERTY_NAME, true);
                    //更新redis
                    redisUtils.set(getRedisKickoutKey(user.getLoginName()), deque, -1);
                }

            } catch (SessionException e) {
                e.printStackTrace();
            }

        }

        if(session.getAttribute(KICOUT_PROPERTY_NAME) != null){
            //会话被踢出了
            try {
                //从shiro退出登录
                subject.logout();
                //给前端返回信息
                HttpServletResponse response = WebUtils.toHttp(servletResponse);
                response.setCharacterEncoding("utf-8");
                PrintWriter writer = response.getWriter();
                writer.write(JSONObject.toJSON(new JsonData(-3,
                        "您的账号在另一台设备登录。如果不是本人操作,请及时修改密码")).toString());
                writer.close();
                return false;
            } catch (Exception e) {
                e.printStackTrace();
            }

        }
        return true;
    }

    public String getKicOutUrl() {
        return kicOutUrl;
    }

    public void setKicOutUrl(String kicOutUrl) {
        this.kicOutUrl = kicOutUrl;
    }

    public int getMaxNum() {
        return maxNum;
    }

    public void setMaxNum(int maxNum) {
        this.maxNum = maxNum;
    }

    public boolean isKicoutAfter() {
        return kicoutAfter;
    }

    public void setKicoutAfter(boolean kicoutAfter) {
        this.kicoutAfter = kicoutAfter;
    }

    public SessionManager getSessionManager() {
        return sessionManager;
    }

    public void setSessionManager(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    public void setRedisUtils(RedisUtils redisUtils){
        this.redisUtils = redisUtils;
    }

    public static String getKicoutPrefix(){return DEFAULT_KICKOUT_CACHE_KEY_PREFIX;}
}

完整的shiroconfig:

shiroconfig

import com.example.shiroStudy.config.redis.RedisUtils;
import com.example.shiroStudy.config.shiro.filter.CustomRolesAuthorizationFilter;
import com.example.shiroStudy.config.shiro.filter.KicoutSessionControlFilter;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
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.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @author 黄豪琦
 * 日期:2019-07-02 15:04
 * 说明:
 */
@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();

        //SecurityManager 安全管理器
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        //登录,为后台接口名,非前台页面名, 未登录跳转
        shiroFilterFactoryBean.setLoginUrl("/login");
        //登录成功后跳转的地址,为后台接口名,非前台页面名
        shiroFilterFactoryBean.setSuccessUrl("/pub/index");
        //无权限跳转
        shiroFilterFactoryBean.setUnauthorizedUrl("/error/unauthorized");

        //设置自定义filter
        Map<String, Filter> cusFilterMap = new LinkedHashMap<>();
        cusFilterMap.put("roleOn", new CustomRolesAuthorizationFilter());
        cusFilterMap.put("kicout", kicoutSessionControlFilter());
        shiroFilterFactoryBean.setFilters(cusFilterMap);


        // 配置访问权限 必须是LinkedHashMap,因为它必须保证有序
        // 过滤链定义,从上向下顺序执行
        LinkedHashMap<String, String> filterMap = new LinkedHashMap<String, String>();

        filterMap.put("/dev/**", "anon");
        filterMap.put("/wechart/**", "anon");
        filterMap.put("/js/**", "anon");

        //公开的请求,都可以访问
        filterMap.put("/pub/**", "anon");
        //需要登录
        filterMap.put("/auth/**", "authc");
        //需要root权限
        filterMap.put("/root/**", "roles[root]");

        filterMap.put("/manage/**", "roleOn[root, admin]");


        //漏掉的都需要认证
        filterMap.put("/**", "kicout,authc");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
        return shiroFilterFactoryBean;
    }


    /**
     * 配置核心安全管理器
     * @return
     */
    @Bean(name = "securityManager")
    public SecurityManager securityManager(){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(cusRealm());

        securityManager.setCacheManager(redisCacheManager());

        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }

    @Bean
    public CusRealm cusRealm(){
        CusRealm realm = new CusRealm();
        //配置密码比较器
        realm.setCredentialsMatcher(hashedCredentialsMatcher());

        return realm;
    }

    /**
     * 加密器
     * @return
     */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher(){
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();

        //设置散列算法
        hashedCredentialsMatcher.setHashAlgorithmName("md5");

        //散列次数 表示加密几次 此处为加密两次
        hashedCredentialsMatcher.setHashIterations(2);


        return hashedCredentialsMatcher;
    }

    /**
     * 自定义session管理
     * @return
     */
    @Bean
    public SessionManager sessionManager(){
        CustomSessionManager sessionManager = new CustomSessionManager();
        //session过期时间 1800000为半小时
        sessionManager.setGlobalSessionTimeout(1800000);

        //配置session持久化
        sessionManager.setSessionDAO(redisSessionDAO());
        return sessionManager;
    }

    /**
     * 开启 shiro 注解
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    @Value("${redis.host}")
    private String redisHost;
    @Value("${redis.port}")
    private int redisPort;

    /**
     * redisManager
     */
    @Bean
    public RedisManager redisManager(){
        RedisManager redisManager = new RedisManager();

//        redisManager.setHost(redisHost);
//        redisManager.setPort(redisPort);
        //设置过期时间
        redisManager.setExpire(2000);
        return redisManager;
    }

    /**
     * 配置具体catch实现类
     * @return
     * @return
     */
    @Bean
    public RedisCacheManager redisCacheManager(){
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());

        return redisCacheManager;
    }

    /**
     * session持久化
     * @return
     */
    @Bean
    public RedisSessionDAO redisSessionDAO(){
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager());
        return redisSessionDAO;
    }

    /**
     * 管理shiro一些bean的生命周期 初始化和销毁
     * @return
     */
    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
        return new LifecycleBeanPostProcessor();
    }

    /**
     * redis操作
     * @return
     */
    @Bean
    public RedisUtils redisUtils(){
        RedisUtils redisUtils = new RedisUtils();
        return redisUtils;
    }

    @Bean
    public KicoutSessionControlFilter kicoutSessionControlFilter(){
        KicoutSessionControlFilter kicoutSessionControlFilter = new KicoutSessionControlFilter();

        kicoutSessionControlFilter.setMaxNum(1);
        kicoutSessionControlFilter.setSessionManager(sessionManager());
        kicoutSessionControlFilter.setRedisUtils(redisUtils());

        return kicoutSessionControlFilter;
    }
}

思路:
主要是利用缓存来实现并发登录。当用户登录时,以用户名为键,以一个deque(集合的一种)为值存入缓存中。当同一个用户在另一个地方登录时,那么deque中就会有两个sessionId。当deque的大小,超过了设定值的时候就开始踢人。给被踢的session添加一个kicout属性。然后判断当前subject的session,如果有kicout这个属性,说明是被踢下线了,这时就返回给前端信息,并返回false给shiro表示我自定义的拦截器已经处理过了,剩下的拦截器不用执行了。
前端返回有两个情况,一个是前后端分离一个是不分离。分离的情况下用response获取流然后输出就可以了;不分离的情况下可以使用shiro提供的WebUtils进行重定向:

WebUtils.issueRedirect(request, response, url);

参数url就是要重定向的地址,可以在这个地址上添加一个标志,比如

/login?kickout=1

然后在前台js里获取浏览器地址 截取url中kicout的位置,如果有,说明是被踢出的,就可以提示消息。

//被踢出时的提示消息
function kickout(){
    var href=location.href;
    if(href.indexOf("kickout")>0){
        alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!");
    }
}

测试:
第一个用户登录:
在这里插入图片描述
随便访问一个接口:
在这里插入图片描述
第二个用户登录:
在这里插入图片描述
(之所以用这样的方式登录是因为potman有cookie,不能模拟)
第二个用户登录成功:
在这里插入图片描述
第一个用户再次请求时已被踢出
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值