Spring Security中的会话【Session】管理与防御以及会话的并发控制

       众所周知,HTTP本身是没有任何关于当前用户状态的内容,也就是两个HTTP请求之间是没有任何的关联可言,用户在和服务器交互的过程中,站点无法确定是哪个用户访问,也因此不能对其提供相应的个性化服务。Session的诞生就是为了解决这一个难题,提供了无状态的HTTP请求实现用户状态维持的方案——服务器和用户之间约定每个HTTP请求携带一个ID信息【代表当前用户信息】,从而实现不同的请求之间就存在着关联。当用户首次访问时,会自动为该用户生成一个sessionId,然后用Cookie作为载体进行记录,用户在会话周期中间访问都会带上Cookie中的内容,因此系统就可以识别出是哪一个用户。

一、会话固定攻击

        尽管Cookie是非常有用的,但是有些用户出于安全或者保护个人隐私的目的,会关闭Cookie,如上图Google Chrome中的设置。在这种情况下,就不能使用基于Cookie实现Session,这样就使得用户体验不太好。所以针对于这一点,有些网站提供URL重写来实现类似的体验。但是这种情况下会存在问题:URL重写会直接将SessionId拼接在URL上,即例如下面所示。

http://www.test.com/test;jsessionid=ByOK3vjFD75aPnrF7C2HmdnV6QZcEbzWoWiBYEnLerjQ99zWpBng!-145788764

        这种方式很容易被黑客进行利用,黑客只需要访问一次站点,将系统生成的SessionId粘贴到这个URL上面,然后将该URL放给用户,只要用户在session有效期内通过此URL进行登录,该sessionId就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。

        会话固定攻击的防御方式其实是很简单,即用户登录后就刷新SessionId,这样原先的SessionId就失去作用。在SpringSecurity中,默认帮我们开启了这种方式,因此并不需要我们特别配置。但是我们也可以手动配置,在Spring Security中的防御会话固定攻击的策略有四种:

newSession: 登录之后创建一个新的session

migrateSession: 登录之后创建一个新的session,并将旧的session中的数据复制过来。

changeSessionId:不创建新的会话,而是使用由Servlet容器提供的会话固定保护。

none: 不做任何变动,登录之后沿用旧的session

        其实这四种策略对应以下四个方法的的三个对象:SessionFixationProtectionStrategy、ChangeSessionIdAuthenticationStrategy、NullAuthenticatedSessionStrategy。

         配置的方式也是很简单,在我下载的Spring Security 5.2.8版本中默认指定的是changeSessionId。

         除了这种改变SessionId的值,也可以通过设置会话过期策略的方式来防御。默认的情况下,会话过期时间是30分钟。也可以指定失效的策略。

二、会话的并发控制

        对于会话并发控制这一概念,在我们经常使用的视频软件中有所体现,例如腾讯视频和爱奇艺,若我的账号购买了会员,那我可以将这个账号分享给我的朋友、家人。当然这种肯定不能无限制的登录,就像对于腾讯视频和爱奇艺,如果不限制登录账号的设备数量,那肯定就亏惨了,而且也不利于自身信息的安全。所以会限制同时登录的设备数量,一旦超过这个限制,前面的账户就会被踢下来,这就是所谓的并发控制。

//session相关的控制
.sessionManagement()
  //指定最大的session并发数量 
  .maximumSessions(1) 

        会话的并发数量设置很简单,使用maximumSessions即可。如果没有额外的配置,重新登录的会话会踢掉旧的会话。在介绍会话并发之前需要理解一个叫做SessionRegistry的对象——管理用户的会话状态,也可以称作为用户会话信息表。之所以可以称作为用户会话信息表,是因为其中维护着两个ConcurrentHashMap对象principals和sessionIds,分别存储着主体Principal和会话信息SessionInformation。Principal在之前的文章中说过是包含主体的信息,SessionInformation其实就是包括了主体信息、sessionId、是否过期以及上次请求时间。

       SessionInformation的主要作用是在Spring Security中并发控制中记录Session的信息。Session的在Security中有三个状态:Active(活跃)、Expired(过期)以及Destroyed(无效)。让一个Session无效可以通过Session本身的invalidate方法使其失效,也可以通过Servlet容器管理进行销毁。Session过期很大程度上是由于用户的最大会话数已经达到限制,此时就必须使会话过期,过期的会话会通过过滤器很快的就被删除。

        有了SessionInformation的理解后,我们再来看SessionRegistryImpl这个实现SessionRegistry的类,对会话信息表的具体操作也是在这个实现类中。

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
    protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
	// 用户及其对应Session,一个用户可能有多个session
    private final ConcurrentMap<Object, Set<String>> principals;
	// SessionId及其对应的SessionInformation
    private final Map<String, SessionInformation> sessionIds;
    public SessionRegistryImpl() {
        this.principals = new ConcurrentHashMap();
        this.sessionIds = new ConcurrentHashMap();
    }
    public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) {
        this.principals = principals;
        this.sessionIds = sessionIds;
    }
	// 获取当前的所有主体信息
    public List<Object> getAllPrincipals() {
        return new ArrayList(this.principals.keySet());
    }
	// 获取主体对应的会话信息,可包含过期或者不过期的SessionInformation
    public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
        // 获取当前主体的所有会话ID
        Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
        if (sessionsUsedByPrincipal == null) {
            return Collections.emptyList();
        } else {
            List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
            Iterator var5 = sessionsUsedByPrincipal.iterator();
            while(true) {
                SessionInformation sessionInformation;
                
                do {
                 // 获取对应ID的SessionInformation,若没有则继续获取,循环结束则直接返回List
                    do {
                        if (!var5.hasNext()) {
                            return list;
                        }
                        String sessionId = (String)var5.next();
                        sessionInformation = this.getSessionInformation(sessionId);
                    } while(sessionInformation == null);
                // 查询是否过期的且当前SessionInformation是否过期
                } while(!includeExpiredSessions && sessionInformation.isExpired());
                // 满足条件则添加至List
                list.add(sessionInformation);
            }
        }
    }
	// 根据ID获取对应的SessionInformation
    public SessionInformation getSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        return (SessionInformation)this.sessionIds.get(sessionId);
    }
	// 实现onApplicationEvent接口,表明处理SessionDestoryedEvent事件
    public void onApplicationEvent(SessionDestroyedEvent event) {
        String sessionId = event.getId();
		// 移除对应sessionId的相关数据
        this.removeSessionInformation(sessionId);
    }
	// 刷新最近操作日期
    public void refreshLastRequest(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            info.refreshLastRequest();
        }
    }
	// 新增会话信息
	// SessionManagementConfigure默认会将RegisterSessionAuthenticationStrategy添加
	// 到一个组合式的SessionAuthenticationStrategy中,
	// 并由AbstractAuthenticationProcessingFilter在成功调用时,触发该动作。
    public void registerNewSession(String sessionId, Object principal) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        Assert.notNull(principal, "Principal required as per interface contract");
		// 若存在,则先删除会话信息
        if (this.getSessionInformation(sessionId) != null) {
            this.removeSessionInformation(sessionId);
        }
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Registering session " + sessionId + ", for principal " + principal);
        }
		// 会话信息
        this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
		// 判断用户是否存在
        this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
            if (sessionsUsedByPrincipal == null) {
                sessionsUsedByPrincipal = new CopyOnWriteArraySet();
            }
			// 若用户存在,将当前sessionId添加到对应的集合中。
			// 用Set即可实现去重
            ((Set)sessionsUsedByPrincipal).add(sessionId);
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
            }
            return (Set)sessionsUsedByPrincipal;
        });
    }
	// 删除会话信息
    public void removeSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            if (this.logger.isTraceEnabled()) {
                this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
            }
			// 以String类型的Key删除对应的sessionId及其Information
            this.sessionIds.remove(sessionId);
            this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
                }
				// 将用户会话记录中的,对应用户的对应session删除
                sessionsUsedByPrincipal.remove(sessionId);
				// 如果获取成功,则清理对应的内容
                if (sessionsUsedByPrincipal.isEmpty()) {
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
                    }
                    sessionsUsedByPrincipal = null;
                }
                if (this.logger.isTraceEnabled()) {
                    this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
                }
                return sessionsUsedByPrincipal;
            });
        }
    }
}

        对于SessionRegistryImpl我们还有额外注意的几点,若是稍不注意很容易在后续的使用过程中碰壁。

        第一点,对象中的Principals采用以用户信息为Key。而在HashMap中,以对象为Key必须覆写hashCode和equals两个方法,因此在自己实现UserDetails时必须重写这两个方法,若没有重写会导致同一个用户每次登录注销时计算得到的Key都不相同,所以每次登录都会向Principals中添加一个用户,而注销时却从来不能有效移除。这种情况下,不仅达不到会话并发控制的效果,还会引起内存泄露。

        第二点,我们注意到SessionRegistryImpl其实是实现了接口ApplicationListener的。在Servlet中监听Session相关事件的方法是实现HttpSessionListener接口,并在系统中注册该监听器。而SpringSecurity中在HttpSessionEventPublisher类中实现HttpSessionEventPublisher接口,并转换成Spring的事件机制,从而也就有了SessionDestroyedEvent这个事件,即会话的销毁事件。所以为了要实现事件的监听,就必须将HttpSessionEventPublisher注册到IOC容器中,这样才能将Java时间转换为Spring事件【只要使用会话管理功能,就应该配置HttpSessionEventPublisher】。

        有了上面的理解,此时我们再来分析并发控制的策略。看完下面的并发控制策略后,其实会发现这里面只有控制当超过会话数量时,使会话的状态过期的操作。当时并没有进行会话的注册和删除。这也就是上面第二点所说,Security中会话创建和删除事件都是通过Spring的事件机制实现的,我们在SessionRegistryImpl同一个包中也可以看到Creation和Destoryed分别都有对应的事件,通过这两个事件才实现注册、销毁。

public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private final SessionRegistry sessionRegistry;
	// 如果超出最大会话数是否阻止新会话的建立
    private boolean exceptionIfMaximumExceeded = false;
	// 最大的会话数
    private int maximumSessions = 1;
    public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) {
        Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
        this.sessionRegistry = sessionRegistry;
    }
    public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
		// 获取当前用户的所有有效的会话信息
        List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
		
        int sessionCount = sessions.size();
        int allowedSessions = this.getMaximumSessionsForThisUser(authentication);
		// 判断当前用户的会话数量是否超过最大值
        if (sessionCount >= allowedSessions) {
			// 如果最大会话数量为-1,则默认不限制会话数量
            if (allowedSessions != -1) {
				// 当已存在的会话数量等于最大会话数时
                if (sessionCount == allowedSessions) {
					// 判断当前会话是否已经在用户对应的会话列表中
                    HttpSession session = request.getSession(false);
                    if (session != null) {
                        Iterator var8 = sessions.iterator();
                        while(var8.hasNext()) {
                            SessionInformation si = (SessionInformation)var8.next();
							// 当前验证的会话并不是新的会话,则不做任何的处理
                            if (si.getSessionId().equals(session.getId())) {
                                return;
                            }
                        }
                    }
                }
				// 进行策略判断
                this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
            }
        }
    }
    ......
    protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException {
        // 当用户达到最大会话数时,是否阻止新会话的建立
		if (!this.exceptionIfMaximumExceeded && sessions != null) {
			// 按照建立会话时间先后升序排序,
            sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
			// 取待过期的会话 
            int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
            List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
            Iterator var6 = sessionsToBeExpired.iterator();
            while(var6.hasNext()) {
				// 新会话建立,使最早的会话过期
                SessionInformation session = (SessionInformation)var6.next();
                session.expireNow();
            }

        } else {
			// 提示会话已超过数量
            throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded"));
        }
    }
    ......
}

        通过上面的分析,也可以让我们理解为什么官网对于SessionInformation的解释中一开始就提出了Session的三个状态:Active、Expired、Destoryed。因为在会话并发控制,三个状态都是通过三个模块进行控制。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

黑白键的约定

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值