当浏览器登录后,服务器和浏览器之间会建立一个会话(Session), 浏览器在每次发送请求时都会携带一个SessionId,服务端根据这个SessionId判断用户身份。浏览器关闭后Session不会自动销毁。需要开发者手动在服务端调用Session销毁方法,或者等待Session过期自动销毁。
在Spring Security中与HttpSession有关的功能由SessionManagementFilter和SessionAuthenticationStrategy接口处理,SessionManagementFilter将Session相关操作委托给SessionAuthenticationStrategy接口去完成。
会话并发管理
会话并发管理是指在当前系统中,同一个用户可以同时创建多少个会话,如果一台设备对应一个会话,也可以理解为同一个用户可以同时在多少个设备上进行登录。
在Spring Security中默认情况下,同一个用户在多少个设备上登录并没有限制,但是我们可以自己设置。
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1);
}
}
(1)在configure(HttpSecurity)方法中通过sessionManagerment()方法开启会话配置,并设置并发数为1
(2)提供一个httpSessionEventPublisher实例。Spring Security中通过一个Map集合来维护当前HttpSession记录,进而实现会话的并发管理,当用户登录成功后,就向集合添加一条HttpSession记录,会话销毁后,就从集合移除一条HttpSession记录。HttpSessionEventPublisher实现了HttpSessionListener接口,可以监听到HttpSession的创建和销毁事件,并将HttpSession的创建和销毁事件发布出去,这样当有HttpSession销毁时,Spring Security就可以感知到该事件了
我们也可以自定义销毁后的行为
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.expiredUrl("/expireSession");
}
}
表明被挤下线后访问/expireSession这个请求,我们可以在controller中为这个请求返回页面或json数据
当然这一种“挤下线”的行为,我们也可以定义后者无法登陆
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.expiredUrl("/expireSession");
}
}
会话固定攻击与防御
会话固定攻击是一种潜在的风险,恶意攻击者可能通过访问当前应用程序来创建会话,然后诱导用户以相同的会话id登陆(通常是将会话ID作为请求参数放在请求连接中然后诱导用户去点击) 进而获取用户的登录身份
举一个简单的例子:
(1)攻击者自己可以正常访问javaboy网站,在访问的过程中,网站给攻击者分配了一个sessionId
(2)攻击者利用自己的SessionId构造了一个javaboy的链接,并把链接发给受害者
(3)受害者访问该链接,登录网站,一个合法的会话就建立了
(4)攻击者利用手里的sessionId冒充受害者
如果网站支持URL重写就更容易了!(支持把sessionId放在请求地址中)
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:
(1)changeSessionId():用户登录成功后,直接修改HttpSession的SessionId即可,默认方案即此,对应的处理类是ChangeSessionIdAuthenticationStrategy
(2)none():用户登录成功后,HttpSession不做任何变化,对应的处理类是NullAuthenticatedSessionStrategy
(3)migrateSession():用户登录成功后,创建一个新的HttpSession对象,并将旧的HttpSession中的数据拷贝到新的HttpSession中,对应的处理类是SessionFixationProtectionStrategy
(4)newSession():用户登录成功后,穿件一个新的HttpSession对象,对应的处理类也是SessionFixationProtectionStrategy,只不过将其里边的migrateSessionAttributes属性设置为false。需要注意的是,该方法并非所有的属性都不可拷贝,一些Spring Security使用的属性,如请求缓存,还是会从旧的HttpSession上复制到新的HttpSession。
Session共享
需要注意的是,我们这里讨论的范畴是有状态登录,如果用户采用无状态的认证方式,那么就不涉及会话。也不存在我们接下来要讨论的问题。
为了解决集群环境下的会话问题,我们有三种方案:
(1)Session复制:多个服务之间互相复制Session信息,这样每个服务中都包含有所有的Session信息了,Tomcat通过IP组播对这种方案提供支持。但是这种方案占用带宽,有时延,服务数量越多效率越低,所以使用较少
(2)Session粘滞:也叫会话保持,就是在Nginx上通过一致性Hash,将Hash结果相同的请求总是分发到一个服务上去,这种方案可以解决一部分集群会话带来的问题,但是无法解决集群会话并发管理问题
(3)Session共享:Session共享就是将不同服务的会话统一放在一个地方,所有的服务共享一个会话,一般使用一些Key-Value数据库来存储Session,例如在redis中,比较常见的方案是使用redis存储,session共享方案由于其简便性和稳定性,是目前使用较多的方案。
Session共享目前使用较多的是spring-session,利用spring-session可以方便的实现session的管理
1.引入redis,spring security,spring session的依赖
2.配置redis连接信息
3.修改配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
FindByIndexNameSessionRepository sessionRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.sessionRegistry(sessionRegistry());
}
@Bean
SpringSessionBackedSessionRegistry sessionRegistry(){
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
}
在这段配置中,我们首先注入一个FindByIndexNameSessionRepository对象,这是一个会话的存储和加载工具。在前面的案例中,会话信息是保存在内存中,现在的会话信息保存在redis中,具体的保存和加载工程则由FindByIndexNameSessionRepository接口的实现类来完成,默认是RedisIndexedSessionRepository即我们一开始注入的实际是一个RedisIndexedSessionRepository类型的对象
接下来我们还配置了一个SpringSessionBackedSessionRegistry实例,构建时传入sessionRepository,SpringSessionBackedSessionRegistry继承自SessionRegistry,用来维护会话信息注册表
最后在HttpSecurity中配置sessionRegistry即可,相当于spring-session提供的SpringSessionBackedSessionRegistry接管了会话信息注册表的维护工作。
需要注意的是引入spring-session后不需要在配置HttpSessionEventPublicsher实例,引入spring-session通过SessionRepositoryFilter将请求对象重新封装为SessionRepositoryRequestWrapper,并重写了getSession方法,在重写的getSession方法中,最终返回的是HttpSessionWrapper实例,而在HttpSessionWrapper定义时,就重写了invalidate方法,当调用会话的invalidate方法去销毁会话时,就会调用RedisIndexedSessionRepository中的方法,从Redis中移除相应的会话信息,所以不再需要HttpSessionEventPublisher实例。