代码地址
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方法
在项目有一定规模后,会用集群的方式缓解单台服务器的压力,但是不同用户登录在一台服务器,进行其他操作的时候被负载到另外一个服务器就会出现问题。
解决集群会话一般有如下三种方案
使用Session保持,可以通过比如Nginx配置hash一致性负载(ip_hash),来保证同一个IP会被负载到同一个服务器上,但是访问的并非个体而是一个公司,一个公司的实际IP其实为同一个,这个时候就会出现问题,所有的请求都会被转发到相同的服务器上,会有一定的负载失衡
session复制即集群服务器之间同步session数据,这样会消耗网络带宽和大量的网络资源
Session共享使用比较多,使用独立的数据容器存储Session,集群之间也就不存在同步的问题了
接下来以Redis为例来配置
添加依赖
org.springframework.session
spring-session-core
org.springframework.session
spring-session-data-redis
org.springframework.boot
spring-boot-starter-data-redis
添加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";
}