会话(Session)

Http的无状态的特性是无法对对用户的访问信息进行记录,为了解决这个问题提出了Session。服务器通过与用户约定每个请求都携带一个id,从而让不同的请求之间就有了联系,id也可以绑定具体的用户。一般生成的SessionId就是存储在Cookie当中的,在用户的会话期每个请求都携带Cookie,系统就可以识别出是哪个用户的请求。

当然也会存在用户禁用Cookie的情况,这样基于Cookie的SessionId就无法使用了,有些服务还支持URL重写的方式来实现

http://域名;jsessionid=xxx

这种方式存在会话固定攻击的风险,黑客访问一次系统并记录下sessionId,将其拼接到URL后,让其他用户进行访问,只要用户在session有效期内通过此URL进行登录,sessionId就会绑定到用户的身份,这样黑客就可以不用用户名和密码享受同样的会话状态,只要每次登录都生成新的session就可以解决这个问题

SessionManagement的配置中可以配置防御会话固定攻击的4种策略

策略

效果

none

不做任何变动,登录后仍沿用旧的session

newSession

登录后创建一个新的session

migrateSession

登录后创建一个新的Session,把旧的Session中的数据复制过来

changeSessionId

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

在SpringSecurity中默认已经启动了migrateSession的策略,可以根据需求进行修改

@Override
    protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        http.authorizeRequests()
                .antMatchers("/css/**", "/img/**", "/js/**", "/bootstrap/**", "/captcha.jpg").permitAll()
                .antMatchers("/app/api/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/myLogin.html")
                .loginProcessingUrl("/login")
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                .authenticationDetailsSource(myWebAuthenticationDetailsSource)
                .permitAll()
                .and()
                .sessionManagement()
                // 防御会话固定攻击策略,默认为migrateSession,即创建新session并复制旧session的值
                .sessionFixation().migrateSession()
                .and()
                .csrf().disable();
    }

上面重写URL的方式其实会被SpringSecurity的拦截器拦截,也不用担心固定会话攻击

会话过期

可以在SpringSecurity中配置会话过期的重定向地址,处理逻辑等

@Override
    protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        http.authorizeRequests()
                .antMatchers("/css/**", "/img/**", "/js/**", "/bootstrap/**", "/captcha.jpg").permitAll()
                .antMatchers("/app/api/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/myLogin.html")
                .loginProcessingUrl("/login")
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                .authenticationDetailsSource(myWebAuthenticationDetailsSource)
                .permitAll()
                .and()
                .sessionManagement()
                // 防御会话固定共计策略,默认为migrateSession,即创建新session并复制旧session的值
                .sessionFixation().migrateSession()
                // session过期后跳转地址
                .invalidSessionUrl("/session/invalid")
                // 自定义session失效策略
                .invalidSessionStrategy(new MyInvalidSessionStrategy())
                // 使登录页不受限
                .and()
                .csrf().disable();
    }

自定义Session失效策略需要实现InvalidSessionStrategy接口,可以在session过期的时候处理自定义逻辑

public class MyInvalidSessionStrategy implements InvalidSessionStrategy {
    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        response.setContentType("/application/json;charset=utf-8");
        response.getWriter().write("session失效");
    }
}

Session的过期时间默认为30分钟,即用户无操作30分钟后Session就会过期,在application.yml中可以配置,Session最少的过期时间为1分钟,配置低于1分钟也会默认按照1分钟计算

server:
  port: 8090
  servlet:
    session:
      timeout: 600

会话并发控制

.sessionManagement()
                // 防御会话固定共计策略,默认为migrateSession,即创建新session并复制旧session的值
                .sessionFixation().migrateSession()
                // session并发数,默认到达设定值会踢掉之前的session
                .maximumSessions(1)

将Session并发数设置为1,即一个用户只能有一个Session登录,当有新的登录的时候会将之前的Session踢掉

如果想达到限制新的登录的效果,可以添加如下配置

.sessionManagement()
                // 防御会话固定共计策略,默认为migrateSession,即创建新session并复制旧session的值
                .sessionFixation().migrateSession()
                // session并发数,默认到达设定值会踢掉之前的session
                .maximumSessions(1)
                // 阻止新会话登录,默认为false
                .maxSessionsPreventsLogin(true)

还需要将注入Spring容器

@Bean
    // 注入监听器监听session注销时间,保证注销后能够更新在线session数量
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

在基于内存的用户的情况下以上方式是没有任何问题的,如果使用数据库用户,自定义UserDetails的需要注意保证自定义UserDetails实现重写equals和hashcode方法,否则配置的会话并发是没有任何效果的。

会话并发控制,是在SessionRegistryImpl类中实现的

private final ConcurrentMap<Object, Set<String>> principals;
    private final Map<String, SessionInformation> sessionIds;

在其中principals的key及时UserDetails,使用HashMap的Key如果是实体类,需要重写hashCode和equals方法

集群会话

在项目有一定规模后,会用集群的方式缓解单台服务器的压力,但是不同用户登录在一台服务器,进行其他操作的时候被负载到另外一个服务器就会出现问题。

解决集群会话一般有如下三种方案

  1. session保持
  2. session复制
  3. session共享

使用Session保持,可以通过比如Nginx配置hash一致性负载(ip_hash),来保证同一个IP会被负载到同一个服务器上,但是访问的并非个体而是一个公司,一个公司的实际IP其实为同一个,这个时候就会出现问题,所有的请求都会被转发到相同的服务器上,会有一定的负载失衡

session复制即集群服务器之间同步session数据,这样会消耗网络带宽和大量的网络资源

Session共享使用比较多,使用独立的数据容器存储Session,集群之间也就不存在同步的问题了

接下来以Redis为例来配置

添加依赖

<dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

添加Redis配置

redis:
    host: 192.168.146.10
    port: 6379
    database: 0
    timeout: 1000s  # 数据库连接超时时间,2.0 中该参数的类型为Duration,这里在配置的时候需要指明单位
    # 连接池配置,2.0中直接使用jedis或者lettuce配置连接池
    jedis:
      pool:
        # 最大空闲连接数
        max-idle: 500
        # 最小空闲连接数
        min-idle: 50
        # 等待可用连接的最大时间,负数为不限制
        max-wait: -1
        # 最大活跃连接数,负数为不限制
        max-active: -1

创建Session相关配置

// 开启基于Redis的HttpSession
@EnableRedisHttpSession
public class HttpSessionConfig {

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


    @Bean
    public RedisConnectionFactory connectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setDatabase(0);
        return new JedisConnectionFactory(redisStandaloneConfiguration);
    }
    
    @Bean
    // 注入监听器监听session注销时间,保证注销后能够更新在线session数量
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

修改WebSecurityConfig

@Autowired
 	// 用于查询session
    private FindByIndexNameSessionRepository mySessionRepository;

    // 是session为Spring Security提供的
    // 用于在集群环境下控制会话并发的会话注册表实现
    @Bean
    public SpringSessionBackedSessionRegistry sessionRegistry(){
        return new SpringSessionBackedSessionRegistry(mySessionRepository);
    }

    // 将新的会话注册表提供给Spring Security
    @Autowired
    private SpringSessionBackedSessionRegistry redisSessionRegistry;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        http.authorizeRequests()
                .antMatchers("/css/**", "/img/**", "/js/**", "/bootstrap/**", "/captcha.jpg").permitAll()
                .antMatchers("/app/api/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/myLogin.html")
                .loginProcessingUrl("/login")
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                .authenticationDetailsSource(myWebAuthenticationDetailsSource)
                .permitAll()
                .and()
                .sessionManagement()
                // 防御会话固定共计策略,默认为migrateSession,即创建新session并复制旧session的值
                .sessionFixation().migrateSession()
                // session并发数,默认到达设定值会踢掉之前的session
                .maximumSessions(1)
                // 使用session提供的会话注册表
                .sessionRegistry(redisSessionRegistry)
                // 阻止新会话登录,默认为false
                .maxSessionsPreventsLogin(true)
                .and()
                // session过期后跳转地址
                .invalidSessionUrl("/session/invalid")
                // 自定义session失效策略
                .invalidSessionStrategy(new MyInvalidSessionStrategy())
                // 使登录页不受限
                .and()
                .csrf().disable();
    }

如果需要获取用户信息,可以如下

@GetMapping("/api")
    @PreAuthorize("hasAnyRole('USER')")
    public String api(){
        // 从session中获取用户信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Object user = (authentication !=null) ? authentication.getPrincipal() : null;
        if(user instanceof User){
            User sessionUser = (User) user;
            System.out.println(user);
        }else {
            throw new UsernameNotFoundException("当前用户不存在");
        }
        return "hello user";
    }