引言
开篇时说些题外话,最近刚刚被公司CY,不过很快找到了下家,也同时拿到了三家公司的Offer。一周面试下来,总体感觉面试题少了,不过多了上机程序题。新公司是做外包,不过相比于上一家公司,也算是因祸得福,有新的东西学习,也有更多的工作等待我去完成,比较于之前的闲的蛋疼和打杂的活,对我的个人技术磨炼应该是有百利而无一害。所以,非常庆幸公司把我CY。
刚入职的第三天,开通Git账号的第二天,临时分配的渗透测试漏洞修复,总共有四个漏洞修复,分别是:
1、管理员快速冻结普通用户,session 实时注销 - 《Web应用安全————账号冻结与 Session 实时失效》
2、会话固定漏洞 - 《Web应用安全————Shiro 解决会话固定漏洞》
3、多点登录互斥 - 《Web应用安全————多点登录互斥》
4、暴力破解(开启验证码)
其中,第一条算是比较常见的web安全功能,第二条不太常见,但是非常重要,也是解决起来最困难的一个,我前后花了整整两天的时间,才总算解决这个问题,由于这个问题掺杂了一些Shiro 框架的知识,我会在下一篇文章中仔细阐述一下这个问题的解决思路,和我都遇到了哪些疑惑,以及是如何思考和解决的。
第三条是一个session 互斥操作,其实也是比较简单的,而第四条暴力破解漏洞,通常只要开启一个验证码就可以解决了。
本篇文章讨论第一条,管理员如何在冻结普通用户的同时,使其Session 注销掉。
一、注销用户 session 的解决思路
处理掉他人session 的最核心思路就是:
1、明确当前使用的安全框架(或没有)的 session 管理的 session 失效API 是什么。
2、找到要被注销的 session 的 session id。
3、冻结或删除用户后,通过 session id,找到其对应的session 对象,并立刻调用 session 失效 API 方法。
简单来说,就是 先找到 session id ,然后再找到 session ,最后在冻结用户或者删除用户的时候,立刻调用使 session 失效的方法。
二、确定 Session 失效的方法
我们以往在处理Web应用的Session时,会使用HttpSession对象,它是javax.servlet.api中的底层接口。而我的项目使用的是Shiro 安全管理框架,又有自己的 Session 接口:org.apache.shiro.session.Session。
不过我想说的是,对于处理 Session 失效的问题,其实无关框架,只要是处理会话操作,无非就是几个:获取 session id、存取session 中的属性、设置/获取超时时间、获取最后访问时间、以及和我们本篇相关的: 使Session 失效 等等。
在HttpSession对象中,我们使用 void invalidate(); 方法 来使 Session 失效。那么同样的,在 Shiro 中也有类似的方法,只不过换了个葫芦:void stop();
所以,不论是使用哪种安全框架 ,什么 Shiro、Spring Security,甚至是原始接口,都可以找到对应的 Session 失效的方法。
明确了如何让Session 失效,那么接下来就是如何确定用户的 session id。
三、管理员清理用户 session 的实现
在 Shiro 中,session的创建是通过org.apache.shiro.session.mgt.DefaultSessionManager.doCreateSession(SessionContext),它先是 new SimpleSession(host); 创建了一个Session 对象,然后再通过 this.sessionDAO.create(session) 创建了 session id ,并将 session 一同 放入一个 MapCache<K, V> 中。
MapCache<K, V> 这是一个值是 session 对象,键是 session id 的 Map对象。
3.1 自定义 session id 池
对于指定用户,要想获得他的session id ,恐怕没什么好的途径。那么我们可以利用Map 来自行维护一个 用户与其 Session id 的唯一对应关系,但值得注意的是,必须要考虑多个管理员同时操作同一个用户的情况,因此就必须做线程安全处理,并且要全局唯一。
我们可以创建一个类,来专门维护用户的 Account和 session id的对应关系类:
/*** * session id 管理池,方便超级管理员获取普通用户的session id,并及时注销。 (临时解决方案)* * @author mouhaotian* @date 2019/09/12*/
public class CcShiroSessionIdPoolVo {/** this Object */private volatile static CcShiroSessionIdPoolVo sessionIdPoolVo;private volatile Map<String, ShiroSessionKey> sessionIdPool;private CcShiroSessionIdPoolVo() {this.sessionIdPool = new ConcurrentHashMap<>();}/*** 获取vo对象,双重检查* * @return*/public static CcShiroSessionIdPoolVo getInstance() {if (sessionIdPoolVo == null) {synchronized (CcShiroSessionIdPoolVo.class) {if (sessionIdPoolVo == null) {sessionIdPoolVo = new CcShiroSessionIdPoolVo();}}}return sessionIdPoolVo;}/*** 通过账号信息获取用户的sessionid,并直接从pool中销毁* * @param account* @return*/public synchronized ShiroSessionKey getAndDestroySessionId(String account) {return deleteSessionId(account);}public void putSessionId(String account, ShiroSessionKey sessionId) {sessionIdPool.put(account, sessionId);}private ShiroSessionKey deleteSessionId(String account) {return sessionIdPool.remove(account);}public String getKey(Session session) {String sessionId = session.getId().toString();for (String acc : sessionIdPool.keySet()) {if (sessionId.equals(sessionIdPool.get(acc).toString()))return acc;}return null;}}
CcShiroSessionIdPoolVo 类是一个单例类,这是为了可以通过其内置的ConcurrentHaspMap 管理所有登录用户的 session id。
使用ConcurrentHashMap是为了能够在大量用户登录,并存储 session id 的时候,能够有一个比较不错的并发性。提供的方法并不多,主要就是存入和取出,而取出方法:getAndDestroySessionId() 也只是为了配合冻结账号的功能,在取出的时候直接从map 中移除。
注意,自行维护账号和 session id 的关键是要与 系统内部的 session 管理的生命周期保持一致!换句话说,当我们系统的session 没有创建,就不能在我们自行维护的map 中插入数据,而当系统中用户的 session 过期或者主动注销的时候,就必须要同步将我们map 中对应的session id 也移除,一般情况下,如果是手动注销 session ,那么我们可以控制这个流程,并一同删除 session id,但如果是系统自动过期,如何处理呢?这个时候我们就可以利用 session 发布的事件,用监听的方式,来检测session 的过期事件,并移除对应的 session id。
另外,id 容器的单例实现也是需要考虑的重要课题,必须要考虑性能问题。
3.2 Session id 池处理器
创建好了 session id 的容器,我们就来创建一个对这个池容器的处理器类:
import org.apache.shiro.session.Session;
import org.apache.shiro.session.SessionListenerAdapter;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;@Service
public class ShiroSessionProcessor {/** ShiroConfig 中配置的 session 管理器 */@AutowiredDefaultWebSessionManager sessionManager;/*** Shiro session 监听器*/public static class ExpiredSessionListener extends SessionListenerAdapter {@Overridepublic void onExpiration(Session session) {CcShiroSessionIdPoolVo sessionIdPool = CcShiroSessionIdPoolVo.getInstance();// 当监听到清理过期的Session ,清理掉CcShiroSessionIdPoolVo中对应的Session idsessionIdPool.getAndDestroySessionId(sessionIdPool.getKey(session));}}/*** 将每个用户的shiro session id放入shiro session池中管理* * @author mouhaotian* @date 2019年9月12日 下午5:42:15*/public void shiroSessionIdIntoPool() {String currAccount = ShiroKit.getUser().getAccount();// CcShiroSessionIdPoolVo单例全局唯一,且线程安全CcShiroSessionIdPoolVo sessionIdPool = CcShiroSessionIdPoolVo.getInstance();// 用户浏览器关闭后,或有其他人在异端登录,旧的失效的 session 依然存在,需要手动注销ShiroSessionKey invaliSessionId = sessionIdPool.getAndDestroySessionId(currAccount);if (invaliSessionId != null) {Session invaliSession = sessionManager.getSession(invaliSessionId);if (invaliSession != null) {invaliSession.stop();}}// 刷新当前 shiro sessionShiroKit.getSession().stop();Session newSession = ShiroKit.getSession();// 将新的session id 放入管理池中sessionIdPool.putSessionId(currAccount, new ShiroSessionKey(newSession.getId()));}/*** 用户登出后,需要手动清理自定义维护的Session id* * @author mouhaotian* @date 2019年9月15日 下午8:39:10*/public void clearSessionId(ShiroUser subject) {CcShiroSessionIdPoolVo.getInstance().getAndDestroySessionId(subject.getAccount());}}
这个 处理器类只有两个方法最关键:
1、shiroSessionIdIntoPool()
2、clearSessionId()
一个负责将 session id 和对应的 用户名 一起存入 刚刚定义的 session id 容器中,另一个则是在一些必要的时候,手动清理对应的 session id。
说明:上面的实现稍显冗余,主要是在 sessionid 入池的时候,用到了一个 session 刷新的操作,这是一个解决会话固定的操作,后面会介绍。ExpiredSessionListener 是一个内部类,主要负责监听系统的 session 过期事件,因为如果用户并没有点击“退出”按钮,而是下意识的直接关闭了浏览器,那么一般情况下,应用程序是收不到任何 注销 session 的请求的,这种情况下就会依赖于 session 的超时时间,自动进行注销操作,我们的ExpiredSessionListener 就是负责监听“过期事件” 并即时处理掉 池中的 session id ,与系统的 session 状态保持一致。
3.2 在冻结或删除用户的同时使 session 失效
到了这一步,我们已经完成了 用户登录时,将用户名与 session id 保存起来的操作,有了这个 session id 池,我们就可以在删除用户或者是冻结用户的时候通过 用户名,直接在 池中 查找对应的 session id ,并注销。
@AutowiredDefaultWebSessionManager defaultWebSessionManager; /*** 冻结用户前,第一时间注销该用户的session* * @author mouhaotian* @date 2019/09/12*/@Before("clearSession()")public void freezeUserShiroSession() {Integer userId = Integer.valueOf(getRequest().getParameter("userId"));// 处理掉该用户的 sessionCcShiroSessionIdPoolVo pool = CcShiroSessionIdPoolVo.getInstance();ShiroSessionKey sessionId = pool.getAndDestroySessionId(managerDao.selectUser(userId).getAccount());if (sessionId != null) {// 冻结该用户的sessiondefaultWebSessionManager.getSession(sessionId).stop();}}
这里我使用了 Spring AOP 来切入相应的方法完成注销 session 。或者干脆在删除或冻结的 Service 方法中加入这段逻辑。
简单来说就是先获取 session id 池,然后通过 用户的用户名或者是id (具体根据你实际维护的 对应关系 是 用户名 - session id ,还是 用户ID - session id)来获取池中的 session id。注意,一定要判空,这是因为如果有两个管理员同时操作的话,可能有一个取到的是null 。最后调用 Shiro 的会话管理器的 getSession() 方法,并执行 stop()操作。
至于会话管理器,不需要追究太深,总之任何安全框架肯定都有 getSession(sessionId) 方,Shiro 是通过 SessionManager 的Session getSession(SessionKey key) 方法 来获取对应的 session 。如果在 Shiro 配置的JavaConfig 中你是用DefaultWebSessionManager 来作为shiro 的默认会话管理器,那么只需要通过@Autowired 注解注入到你需要的位置就可以使用 会话管理器对象了。
总结
一般的Web 安全框架都集成了会话管理机制,这是一个最基本的功能需求。
一般的 session 管理,都没有太好的获取非当前用户 Session 的API 接口,因此,我们可以考虑自行定义一个 Map 对象,来维护每个用户 和其 session id 的对应管理。
在用户登录成功后,我们将 用户名(或用户id)与其对应的 session id 存入 map 中,在用户登出或 系统 session 自动超时的时候将其清理出 map。这些自动的操作,一定要与系统的 session 状态保持一致,以免造成不必要的内存消耗。
在管理员进行手动冻结用户或删除用户的时候,可以通过 Spring AOP来统一管理切面,统一执行注销 session 的操作。具体做法就是通过 map 中的用户与 session id 的对应关系,按照 用户名——>session id ——> session ——> 注销 的流程完成指定用户的注销操作。
注意在实现 map 的时候,要考虑单例和 线程安全的问题。在插入、移除操作的时候要考虑性能问题。