Spring Security实现会话管理

1,463 阅读13分钟

前言

要学的还有很多, 不过前期比较重点的知识已经写完了, 接下来是一两章比较无聊的章节, 不过这种无聊的章节却能够体现Spring Security的优势, 当然本章的内容还是比较重要的

本章内容

  1. 会话并发管理
  2. 固定会话攻击和防御
  3. session共享

会话并发管理

什么是会话?

当用户通过浏览器登录成功后, 用户和系统之间便会保持一个会话(用户端会存储一个叫sessionId的属性), 通过这个会话, 系统可以确定出访问用户的身份.

用户端借助sessionId去访问服务端, 根据服务端去查找相应的session会话相关信息, 以找到相应的用户身份

SpringSecuity和会话相关的功能由SessionManagementFilterSessionAuthenticationStrategy接口的组合来处理, 过滤器委托该策略接口对会话进行处理, 比较典型的用法有防止会话固定攻击, 配置会话并发数等

会话并发管理

一个用户持有多个会话, 如果一台设备有一个会话的话, 那么一个用户就有在多台设备上登录

quick start

挤人下线方案

@Configuration
public class SecurityConfig {
	
	@Bean
	public ObjectMapper objectMapper() {
	    return new ObjectMapper();
	}
	
	@Bean
	public UserDetailsService inMemoryUserDetailsManager() {
		InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
		userDetailsManager.createUser(User.withUsername("zhazha")
				.password("{noop}123456")
				.roles("admin")
				.build());
		userDetailsManager.createUser(User.withUsername("user")
				.password("{noop}123456")
				.roles("user")
				.build());
		return userDetailsManager;
	}
	
	/**
	 * session的创建和session的销毁都会被 HttpSessionEventPublisher 监控,
	 * 这种方式可以及时清理session的记录,以确保最新的session状态可以被及时感知到。
	 *
	 * @return
	 */
	@Bean
	public HttpSessionEventPublisher httpSessionEventPublisher() {
		return new HttpSessionEventPublisher();
	}
	
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
		return httpSecurity
				.csrf().disable()
				.sessionManagement() // 设置 session 配置管理
				.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 设置session创建策略
				.maximumSessions(1) // 设置最大会话数
//				.expiredUrl("/login") // session 被挤下线之后, 跳转的位置
				.expiredSessionStrategy(event -> { // 在前后端分离的情况下, 则不能使用上面的 expiredUrl, 但可以使用 expiredSessionStrategy
					HttpServletResponse response = event.getResponse();
					response.setContentType(MediaType.APPLICATION_JSON_VALUE);
					HashMap<String, Object> result = new HashMap<>();
					result.put("status", 500);
					result.put("msg", "当前会话失效, 请重新登录");
					String s = objectMapper().writeValueAsString(result);
					response.getWriter().write(s); // 写入响应缓存
					response.flushBuffer(); // 刷新缓存
				})
				.and().and()
				.authorizeRequests()
				.anyRequest()
				.authenticated()
				.and()
				.formLogin()
				.and().build();
	}
	
}

关键代码:

image-20221127131833403

我们之所以要**创建HttpSessionEventPublisher这个Bean,是因为在 Spring Security 中,它可以通过监听session的创建及销毁事件,来及时的清理session记录。**用户从不同的浏览器登录,都会有对应的session,当用户注销登录之后,session就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的。这一失效事件无法被 Spring 容器感知到,进而导致当前用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来。

注意如果引入了 spring-session 之后, 就不需要配置HttpSessionEventPublisher了, 引入spring-session通过SessionRepositoryFilter将请求对象重新封装为SessionRepositoryRequestWrapper,并重写了getSession方法,在重写的getSession方法中,最终返回的是HttpSessionWrapper实例,而在HttpSessionWrapper定义时,就重写了invalidate方法,当调用会话的invalidate方法去销毁会话时,就会调用RedisIndexedSessionRepository中的方法,从Redis中移除相应的会话信息,所以不再需要HttpSessionEventPublisher实例。

我们直接看源码就能看得出来了

public class HttpSessionEventPublisher implements HttpSessionListener, HttpSessionIdListener {

   private static final String LOGGER_NAME = HttpSessionEventPublisher.class.getName();

   ApplicationContext getContext(ServletContext servletContext) {
      return SecurityWebApplicationContextUtils.findRequiredWebApplicationContext(servletContext);
   }

   /**
    * Handles the HttpSessionEvent by publishing a {@link HttpSessionCreatedEvent} to the
    * application appContext.
    * @param event HttpSessionEvent passed in by the container
    */
   @Override
   public void sessionCreated(HttpSessionEvent event) {
      extracted(event.getSession(), new HttpSessionCreatedEvent(event.getSession()));
   }

   /**
    * Handles the HttpSessionEvent by publishing a {@link HttpSessionDestroyedEvent} to
    * the application appContext.
    * @param event The HttpSessionEvent pass in by the container
    */
   @Override
   public void sessionDestroyed(HttpSessionEvent event) {
      extracted(event.getSession(), new HttpSessionDestroyedEvent(event.getSession()));
   }

   @Override
   public void sessionIdChanged(HttpSessionEvent event, String oldSessionId) {
      extracted(event.getSession(), new HttpSessionIdChangedEvent(event.getSession(), oldSessionId));
   }

   private void extracted(HttpSession session, ApplicationEvent e) {
      Log log = LogFactory.getLog(LOGGER_NAME);
      log.debug(LogMessage.format("Publishing event: %s", e));
      getContext(session.getServletContext()).publishEvent(e);
   }

}

不让挤下线方案

关键代码.maxSessionsPreventsLogin(true)

如果设置为 true, 则在达到最大的session数量时, 阻止用户认证. 否则(设置为 false, 默认情况), 访问时允许重新认证用户并且前一次认证的session被置为过期状态. 前一个认证成功的用户在再次访问时由于session过期将被重定向到expiredUrl(String)配置的地址. 这种情况的好处在于管理员不再需要手动干预或者等待他们的session过期.

image-20221128073424996

存在的问题

从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给session的空间不多,当访问量增大时,内存就会捉襟见肘。

从稳定性上来说,Session依赖于内存,而内存并非持久性存储容器,就算服务器本身是可靠的,但当部署在上面的服务停止或重启时,也会导致所有会话状态丢失。

后面我们会使用 spring session 来解决此问题, 当然最好的方式还是使用无状态JWT

怎么做到的?

根据这句话分析源码:

`SpringSecuity`和会话相关的功能由`SessionManagementFilter`和`SessionAuthenticationStrategy`接口的组合来处理, 过滤器委托该策略接口对会话进行处理, 比较典型的用法有防止会话固定攻击, 配置会话并发数等

流程

在Spring Security中,对会话的并发控制也有特定的执行控制流程,该流程是在如下类中被触发执行的:

UsernamePasswordAuthenticationFilter-->AbstractAuthenticationProcessingFilter-->AbstractAuthenticationProcessingFilter#doFilter()

AbstractAuthenticationProcessingFilter#doFilter()源码

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   if (!requiresAuthentication(request, response)) {
      chain.doFilter(request, response);
      return;
   }
   try {
      Authentication authenticationResult = attemptAuthentication(request, response);
      if (authenticationResult == null) {
         // return immediately as subclass has indicated that it hasn't completed
         return;
      }
      // 核心代码在这里 这个方法就是用来处理 session 的并发问题的
      this.sessionStrategy.onAuthentication(authenticationResult, request, response);
      // Authentication success
      if (this.continueChainBeforeSuccessfulAuthentication) {
         chain.doFilter(request, response);
      }
      successfulAuthentication(request, response, chain, authenticationResult);
   }
   catch (InternalAuthenticationServiceException failed) {
      this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
      unsuccessfulAuthentication(request, response, failed);
   }
   catch (AuthenticationException ex) {
      // Authentication failed
      unsuccessfulAuthentication(request, response, ex);
   }
}

SessionAuthenticationStrategy接口

public interface SessionAuthenticationStrategy {

	/**
	 * 当一个新的认证发生时,执行与session相关的功能。
	 */
	void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response)
			throws SessionAuthenticationException;

}

image-20221128074736587

默认为

image-20221128075001986

ConcurrentSessionControlAuthenticationStrategy

当涉及到session的并发处理时,会由ConcurrentSessionControlAuthenticationStrategy这个子类来实现会话并发处理,核心方法如下:

@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
      HttpServletResponse response) {
   int allowedSessions = getMaximumSessionsForThisUser(authentication);
   if (allowedSessions == -1) {
      // We permit unlimited logins
      return;
   }
   // 获取当前用户的所有 session信息,该方法在调用时会传递两个参数:一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主题对应的 sessionid 组成的一个集合)。接下来计算当前用户已经有几个有效的 session,同时获取允许的 session 并发数。
   List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
   int sessionCount = sessions.size();
   if (sessionCount < allowedSessions) {
      // They haven't got too many login sessions running at present
      return;
   }
   // 如果当前 session 数(sessionCount)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,表示对 session 数量不做任何限制。
   // 如果当前的 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先看看当前 session对象是否不为null,并且是否已经存在于 sessions 中了。如果已经存在了,则不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
   if (sessionCount == allowedSessions) {
      HttpSession session = request.getSession(false);
      if (session != null) {
         // Only permit it though if this request is associated with one of the
         // already registered sessions
         for (SessionInformation si : sessions) {
            if (si.getSessionId().equals(session.getId())) {
               return;
            }
         }
      }
      // If the session is null, a new one will be created by the parent class,
      // exceeding the allowed number
   }
   // 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
   allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}

// 首先会有 exceptionIfMaximumExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false。如果为 true,就直接抛出异常,那么这次登录就失败了;如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可。
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
                                         SessionRegistry registry) throws SessionAuthenticationException {
    if (this.exceptionIfMaximumExceeded || (sessions == null)) {
        throw new SessionAuthenticationException(
            this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
                                     new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
    }
    // Determine least recently used sessions, and mark them for invalidation
    sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
    int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
    List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
    for (SessionInformation session : sessionsToBeExpired) {
        session.expireNow();
    }
}

会话固定攻击和防御

是什么?

img

每个用户都有属于自己的 sessionId

会话ID(SID):由服务器产生并返回给浏览器的请求,并且在浏览器中存储(通常来说是Cookie),它用来标识浏览器与服务器会话的唯一值。

  • 攻击者将自己的 sessionId 伪造成一个 url 地址, 发送给攻击目标

  • 攻击目标的 sessionId 被替换为攻击者的 sessionId, 然后攻击目标使用自己的 usernamepassword 认证服务器

  • sessionId 就变成标记该用户的地址(换句话说攻击者的 sessionId 转正了)

  • 攻击者 可以拿着 sessionId 获取攻击目标的信息

这就是固定会话攻击

如果你的网站支持 URL 重写方式也就是https://www.xx.com;jsessionid=xxxx这种形式, 那么将更容易

攻击的整个过程,会话ID是没变过的,所以导致此漏洞。

防御方法

1、登录重建会话

每次登录后都重置会话ID,并生成一个新的会话ID,这样攻击者就无法用自己的会话ID来劫持会话,核心代码如下。

2、禁用客户端访问Cookie

此方法也避免了配合XSS攻击来获取Cookie中的会话信息以达成会话固定攻击。在Http响应头中启用HttpOnly属性,或者在tomcat容器中配置。

Spring Security提供哪些落地的防御方案?

  1. Spring Security自带Http防火墙,如果sessionid放在地址栏中,这个请求就会直接被拦截下来
  2. Http响应的Set-Cookie字段中有httpOnly属性,这样避免了通过XSS攻击来获取Cookie中的会话信息,进而达成会话固定攻击
  3. 既然会话固定攻击是由于sessionid不变导致的,那么其中一个解决方案就是在用户登录成功后,改变sessionid,Spring Security中默认实现了这种方案,实现类就是ChangeSessionIdAuthenticationStrategy

前两种都是默认行为,一般来说不需要更改。第三种方案在Spring Security中有几种不同的配置策略,我们先来看以下配置方式:

http.sessionManagement().sessionFixation().changeSessionId();

通过sessionFixation()方法开启会话固定攻击防御的配置,一共有四种不同的策略,不同策略对应了不同的SessionAuthenticationStrategy:

  • changeSessionId():用户登录成功后,直接修改HttpSessionSessionId即可,对应的处理类是ChangeSessionIdAuthenticationStrategy
  • none():用户登录成功后,HttpSession不做任何变化,对应的处理类是NullAuthenticatedSessionStrategy
  • migrateSession():用户登录成功后,创建一个新的HttpSession对象,并将旧的HttpSession中的数据拷贝到新的HttpSession中,对应的处理类是SessionFixationProtectionStrategy
  • newSession():用户登录成功后,创建一个新的HttpSession对象,对应的处理类也是SessionFixationProtectionStrategy,只不过将其里边的migrateSessionAttributes属性设置为false。需要注意的是,该方法并非所有的属性都不可拷贝,一些Spring Security使用的属性,如请求缓存,还是会从旧的HttpSession上复制到新的HttpSession

image-20221128090109619

默认是changeSessionId方案:

image-20221128090443909

其他防御方案

登录前的匿名会话强制失效
session id与浏览器绑定:session id与所访问浏览器有变化,立即重置
session id与所访问的IP绑定:session id与所访问IP有变化,立即重置

Session共享

前面我们留下了两个问题

  • 从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给session的空间不多,当访问量增大时,内存就会捉襟见肘。

  • 从稳定性上来说,Session依赖于内存,而内存并非持久性存储容器,就算服务器本身是可靠的,但当部署在上面的服务停止或重启时,也会导致所有会话状态丢失。

Spring Security提供了三种针对集群session的方案:

  1. Session复制:多个服务之间互相复制Session信息,这样每个服务中都包含有所有的Session信息了,Tomcat通过IP组播对这种方案提供支持。但是这种方案占用带宽,有时延,服务数量越多效率越低,所以使用较少
  2. Session粘滞:也叫会话保持,就是在Nginx上通过一致性Hash,将Hash结果相同的请求总是分发到一个服务上去,这种方案可以解决一部分集群会话带来的问题,但是无法解决集群会话并发管理问题
  3. Session共享:Session共享就是将不同服务的会话统一放在一个地方,所有的服务共享一个会话,一般使用一些Key-Value数据库来存储Session,例如在redis中,比较常见的方案是使用redis存储,session共享方案由于其简便性和稳定性,是目前使用较多的方案

我们会发现最后一种方案能够解决我们提出的两个问题, 而且刚好这套方案也是用的最多以解决集群session的方案

为什么集群session也有问题?

在单机方案中, 每一个tomcat 都有属于自己的一个sessionsession保存在内存中

在集群方案下, 我们使用 nginx 进行反向代理, 此时一个用户的信息只能保存在一个tomcat中, 此时因为是集群关系, 在另一个tomcat中根本就不存在 sessionId对应的信息

为了解决这些问题, 在集群中专门提供一个session管理工具是有必要的

解决方案

1.引入redis,spring security,spring session的依赖 2.配置redis连接信息 3.修改配置类

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>2.7.0</version>
    </dependency>
</dependencies>
server:
  port: 8080
spring:
  application:
    name: spring-session-application
  redis:
    host: 192.168.0.155
    port: 6379
  security:
    user:
      name: zhazha
      password: {noop}123456
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import javax.annotation.Resource;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Resource
	private FindByIndexNameSessionRepository sessionRepository;
	
	@Bean
	SpringSessionBackedSessionRegistry sessionRegistry() {
		return new SpringSessionBackedSessionRegistry(sessionRepository);
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.authorizeRequests()
				.anyRequest()
				.authenticated()
				.and()
				.formLogin()
				.and()
				.csrf()
				.disable()
				.sessionManagement()
				.maximumSessions(1)
				.sessionRegistry(sessionRegistry());
	}
}
  • 首先注入一个FindByIndexNameSessionRepository对象,这是一个会话的存储和加载工具, 会话信息是保存在内存中,现在的会话信息保存在redis中,具体的保存和加载工程则由FindByIndexNameSessionRepository接口的实现类来完成,默认是RedisIndexedSessionRepository即我们一开始注入的实际是一个RedisIndexedSessionRepository类型的对象
  • 接下来我们还配置了一个SpringSessionBackedSessionRegistry实例,构建时传入sessionRepositorySpringSessionBackedSessionRegistry继承自SessionRegistry,用来维护会话信息注册表
  • 最后在HttpSecurity中配置sessionRegistry即可,相当于spring-session提供的SpringSessionBackedSessionRegistry接管了会话信息注册表的维护工作。

再次强调:

需要注意的是引入spring-session后不需要在配置HttpSessionEventPublicsher实例,引入spring-session通过SessionRepositoryFilter将请求对象重新封装为SessionRepositoryRequestWrapper,并重写了getSession方法,在重写的getSession方法中,最终返回的是HttpSessionWrapper实例,而在HttpSessionWrapper定义时,就重写了invalidate方法,当调用会话的invalidate方法去销毁会话时,就会调用RedisIndexedSessionRepository中的方法,从Redis中移除相应的会话信息,所以不再需要HttpSessionEventPublisher实例。

image-20221128153621520

key:

spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:zhazha

value:

\xac\xed\x00\x05t\x00$e354335b-48a3-4e51-91d7-d814ac996913

其他解决方案

使用Token代替Session

Token是指访问资源的令牌凭据,用于检验请求的合法性,适用于前后端分离的项目。常用的TokenJSON Web Token(JWT)认证授权机制,这是目前最流行的跨域认证解决方案。

img

该方案的优点如下:

  • 可以用数据库存储token,也可以选择放在内存当中,比如 redis 很适合对 token 查询的需求。
  • token 可以避免 CSRF 攻击(因为不需要 cookie 了)。
  • 完美契合移动端的需求。

Session持久化到数据库

该方案就是需要设计一个数据库表,专门用来存储Session信息,保证Session的持久化。

优点: 服务器出现问题时,Session不会丢失。

缺点: 如果网站的访问量很大,把所有的Session存储到数据库中,会对数据库造成很大压力,还需要增加额外的开销维护数据库。

Terracotta实现Session复制

Terracotta的基本原理是,对于集群间共享的数据,当在一个节点发生变化的时候,Terracotta只把变化的部分发送给Terracotta服务器,然后由服务器把它转发给真正需要这个数据的节点,这可以看成是对第二种方案的优化。

img

优点: 对网络的压力就非常小,各个节点也不必浪费CPU时间和内存进行大量的序列化操作。把这种集群间数据共享的机制应用在Session同步上,既避免了对数据库的依赖,又能达到负载均衡和灾难恢复的效果。