shiro(8)-SessionManager(单处登录)

shiro安全控制目录

Http协议是无状态的,即一次用户请求时,浏览器/服务器无法知道之前这个用户做过什么,每一次请求均是新的请求。故我们使用Cookie—Session技术来交互存储状态。

1. cookie和session的区别

1.1 cookie简介

1. 什么叫做cookie
Cookie是客户端技术,程序把每个用户的数据以cookie的形式写给用户各自的浏览器。当用户使用浏览器再去访问服务器中的web资源时,就会带着各自的数据去。这样,web资源处理的就是用户各自的数据了。
2. cookie的生命周期
那么cookie的有效期只在一次会话过程中有效,用户开一个浏览器,点击多个超链接,访问服务器多个web资源,然后关闭浏览器,整个过程称之为一次会话,当用户关闭浏览器,会话就结束了,此时cookie就会失效,

1.2 session简介

1. 什么叫做session
  Session是服务器端技术,利用这个技术,服务器在运行时可以为每一个用户的浏览器创建一个其独享的session对象,由于session为用户浏览器独享,所以用户在访问服务器的web资源时,可以把各自的数据放在各自的session中,当用户再去访问服务器中的其它web资源时,其它web资源再从用户各自的session中取出数据为用户服务。

1.3 sessionId和cookie的关系

客户端用cookie保存了sessionID,我们上面知道cookie默认的生命周期是一次会话,注意此时session在浏览器可能没有超时,cookie被销毁后,我们无法再找到sessionId,下次在请求服务器的时候,服务器会生成一个新的sessionId,保存在cookie中,返回客户端。

2. Shiro如何处理session

  1. 用户在登录成功之后,shiro会将用户的一些信息存储到sessionDAO。保存形式为sessionID:session

  2. 用户在不同设备上登录成功之后(sessionId不同,但是session对象里面的"主键"相同),此时证明用户已经在其他设备上已登录。我们就可以处理我们的业务逻辑。

  3. 用户每次请求都会携带SESSIONID,服务器拦截到请求,判断是否要进行鉴权,如果进行鉴权的话,判断SESSION是否存在。

  4. 服务器将(旧的)session删除后,当用户再请求服务器时,服务端会抛出there is no session的异常,然后从新为请求建立一个新的session【返回到重新登录页面】,就像用户很长时间没有点击浏览器,shiro的定时器定时将失效的session清除的时候也抛出这个异常一样。不过这个对用户是透明的,对用户的体验没有影响。

3. 实现单处登录

3.1 XML配置

这里实现一个简易的xml配置。

本质上是通过session来完成用户认证,那么我们可以通过配置SessionManager来操纵session。

1. 加载sessionDao

<bean id="sessionDao" 
 class="org.apache.shiro.session.mgt.eis.MemorySessionDAO"/>
MemorySessionDAO实现的方法

2. 加载我们的sessionManage,它需要依赖我们的sessionDao

    <bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
        <property name="sessionDAO" ref="sessionDao"/>
    </bean>

3. 加载securityManage

    <bean id="securityManager"
          class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="customRealm"></property>
        <!-- 注入缓存管理器 -->
        <property name="cacheManager" ref="cacheManager"></property>
        <property name="sessionManager" ref="sessionManager"></property>
    </bean>

3.2 代码配置

这里需要重写认证代码

  1. sessionDAO.getActiveSessions()顾名思义获取到所有存活的session对象。
  2. session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY)可以获取simpleAuthenticationInfo的第一个参数的值,其实就是principal对象,我们自定义的参数对象(在Realm认证的时候创建)
  3. 根据session创建subject对象。
  4. 获取subject的principal对象(此处是自定义对象)。
  5. 判断principal对象里面的参数是否相同。
  6. 相同的话,可以删除session。两种方式:
    • session.setTimeout(0);设置session的失效时间;
    • sessionDAO.delete(session);直接删除session;
 @Autowired
    private SessionDAO sessionDAO;
 
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken aToken) throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken)aToken;
        String username = token.getUsername();
        //判断用户名是否存在
        User user = userService.selectUser(username);
        //声明一个user用来获取sessionDao中的user
        User userSession = null;
 
        if(user != null){
            //获取在线的session
            Collection<Session> sessionCollection = sessionDAO.getActiveSessions();
            for (Session session : sessionCollection){
                if(session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) != null) {
                    //根据session build出一个subject
                    Subject subject = new Subject.Builder().session(session).buildSubject();
                    //拿到这个登陆的对象
                    userSession = (User) subject.getPrincipal();
                    //判断她的code和我现在登陆的code是否一致  (code是我在数据库里面设置一个标识码  采用uuid  保证唯一性 这里你可以id)
                    if (user.getCode().equals(userSession.getCode())) {
                        //两者一致的时候,设置这个session的失效时间 (0:立刻)
                        session.setTimeout(0);
                        break;
                    }
                }
            }
            AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user,user.getPassword(),this.getName());
            return authenticationInfo;
        } else{
            throw new AuthenticationException("用户名错误!");
        }
    }

4. 单处登录-改进版

若是1亿用户登录,每次登录的用户都要循环1亿次判断是否异处登录吗?显然性不好,如何改进呢?

shiro整合redis中,将sessionId和session对象保存redis中后,我们将userId(业务字段)和sessionId也存储在redis中。

注意:在登录成功后,也将userId和sessionId保存到Redis中。

这样的话,我们可以快速的获取到sessionId和session对象了。

1. 在doGetAuthenticationInfo认证的时候,调用该方法,判断用户是否已登录。使用业务id去Redis中获取sessionId,若包含,并且本次上送的sessionId不是redis存储的sessionId,则证明该用户在别的机器上登录。那我们便将别得机器上的sessionId保存下来。等到用户再次选择登录的时候,删除该sessionId。

    private boolean isLogin(String userId, Session session) {
        //获取新请求的sessionId值
        Serializable httpSessionId = session.getId();
        //在Redis中获取到旧的sessionId的值
        String sessionId = jedisCluster.get(Constant.isLoginPrefix + userId);
        //如果没有存入到Redis中
        if(StringUtils.isBlank(sessionId)) {//未登录
            return false;
        }
        
        if(httpSessionId != null) {
            //格式化httpSessionId 的值
            httpSessionId = Base64.encode(SerializeUtil.serialize(Constant.REDIS_SHIRO_SESSION + httpSessionId));
            //若sessionId相等则是一个浏览器登录
            if(sessionId.equals(httpSessionId)) {//同一个浏览器二次登陆
                return false;
            }
        }
        //如果sessionId和session对应中,其实session为空
        String sessionObj = jedisCluster.get(sessionId);
        if(StringUtils.isBlank(sessionObj)) {//未登录
        //删除userId和sessionId对应关系
            jedisCluster.del(userId);
            return false;
        }else{//已登录
            //将旧的sessionId保存到新的session对象,后续删除该session对象。
            session.setAttribute("user.old.sessionId", sessionId);
            return true;
        }
    }

保存用户的token信息,然后抛出异常;

session.setAttribute("user.login.token", token);
throw new ConcurrentAccessException("用户已在其他地方登陆,是否继续登录");

2. 返回特殊的错误码
3. 前端收到特殊错误码后,要求用户是否执行再次登录,此时返回客户端一个新的sessionId。
4. 用户点击再次登录,请求携带新的sessionId到达Controller层

    @RequestMapping("/continueLogin")
    @ResponseBody
    public ResponseVo continueLogin() {
        ResponseVo vo = new ResponseVo(ResponseVo.FAIL, "登陆失败");
        //此时拿到的是新的SESSIONID的SESSION对象
        Session session = SecurityUtils.getSubject().getSession();
         //获取登录用户的账号密码对象
        Object tokenObj = session.getAttribute("user.login.token");

        if (tokenObj == null) {
            vo.setMessage("请重新点击登录!");
            return vo;
        }

        UsernamePasswordToken token = (UsernamePasswordTokenEx) tokenObj;
        
        if (StringUtils.isBlank(token.getUsername()) || token.getPassword() == null) {
            vo.setMessage("用户名或密码不能为空");
            return vo;
        }

        try {
            //内部封装了subject.login()方法
            if (loginService.login(token)) {
                //再次验证成功之后,删除已登陆的sessionId
                Object oldSessionIdObj = session.getAttribute("user.old.sessionId");
                if (oldSessionIdObj != null) {
                    String oldSessionId = String.valueOf(oldSessionIdObj);
                    String newSessionId = Base64.encode(SerializeUtil.serialize(Constant.REDIS_SHIRO_SESSION + session.getId()));
                    if (!StringUtils.isBlank(oldSessionId) && !oldSessionId.equals(newSessionId)) {
                         //删除Redis中的sessionId和就是删除Session中的SessionId
                        jedisCluster.del(oldSessionId);
                        logger.info("删除已登陆Session【{}】成功", oldSessionId);
                    }
                }
                vo = new ResponseVo(200, "登陆成功");
                return vo;
            }
      catch (Exception e) {
            vo.setMessage("登录失败!");
            logger.error("登陆失败", e);
        }
        return vo;
    }

需要注意的是,因为redis整合了session,session最终保存在Redis中,而不是服务器内存中。

文章参考:
1. shiro账号安全(每一个账号只能同时存在于一台设备上)
2. cookie和session的区别
3. 用户Cookie和会话Session、SessionId的关系
4. Shiro教程(十一)Shiro 控制并发登录人数限制实现,登录踢出实现

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