前言
要学的还有很多, 不过前期比较重点的知识已经写完了, 接下来是一两章比较无聊的章节, 不过这种无聊的章节却能够体现Spring Security的优势, 当然本章的内容还是比较重要的
本章内容
- 会话并发管理
- 固定会话攻击和防御
- session共享
会话并发管理
什么是会话?
当用户通过浏览器登录成功后, 用户和系统之间便会保持一个会话(用户端会存储一个叫sessionId
的属性), 通过这个会话, 系统可以确定出访问用户的身份.
用户端借助
sessionId
去访问服务端, 根据服务端去查找相应的session
会话相关信息, 以找到相应的用户身份
SpringSecuity
和会话相关的功能由SessionManagementFilter
和SessionAuthenticationStrategy
接口的组合来处理, 过滤器委托该策略接口对会话进行处理, 比较典型的用法有防止会话固定攻击, 配置会话并发数等
会话并发管理
一个用户持有多个会话, 如果一台设备有一个会话的话, 那么一个用户就有在多台设备上登录
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();
}
}
关键代码:
我们之所以要**创建
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
过期.
存在的问题
从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给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;
}
默认为
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();
}
}
会话固定攻击和防御
是什么?
每个用户都有属于自己的 sessionId
会话ID(SID):由服务器产生并返回给浏览器的请求,并且在浏览器中存储(通常来说是Cookie),它用来标识浏览器与服务器会话的唯一值。
-
攻击者将自己的
sessionId
伪造成一个url
地址, 发送给攻击目标 -
攻击目标的
sessionId
被替换为攻击者的sessionId
, 然后攻击目标使用自己的username
和password
认证服务器 -
sessionId
就变成标记该用户的地址(换句话说攻击者的sessionId
转正了) -
攻击者 可以拿着
sessionId
获取攻击目标的信息
这就是固定会话攻击
如果你的网站支持 URL 重写方式也就是
https://www.xx.com;jsessionid=xxxx
这种形式, 那么将更容易
攻击的整个过程,会话ID是没变过的,所以导致此漏洞。
防御方法
1、登录重建会话
每次登录后都重置会话ID
,并生成一个新的会话ID
,这样攻击者就无法用自己的会话ID
来劫持会话,核心代码如下。
2、禁用客户端访问Cookie
此方法也避免了配合XSS
攻击来获取Cookie
中的会话信息以达成会话固定攻击。在Http
响应头中启用HttpOnly
属性,或者在tomcat
容器中配置。
Spring Security提供哪些落地的防御方案?
- Spring Security自带
Http
防火墙,如果sessionid
放在地址栏中,这个请求就会直接被拦截下来 - 在
Http
响应的Set-Cookie
字段中有httpOnly
属性,这样避免了通过XSS
攻击来获取Cookie
中的会话信息,进而达成会话固定攻击 - 既然会话固定攻击是由于
sessionid
不变导致的,那么其中一个解决方案就是在用户登录成功后,改变sessionid
,Spring Security中默认实现了这种方案,实现类就是ChangeSessionIdAuthenticationStrategy
前两种都是默认行为,一般来说不需要更改。第三种方案在Spring Security中有几种不同的配置策略,我们先来看以下配置方式:
http.sessionManagement().sessionFixation().changeSessionId();
通过sessionFixation()
方法开启会话固定攻击防御的配置,一共有四种不同的策略,不同策略对应了不同的SessionAuthenticationStrategy
:
changeSessionId()
:用户登录成功后,直接修改HttpSession
的SessionId
即可,对应的处理类是ChangeSessionIdAuthenticationStrategy
none()
:用户登录成功后,HttpSession
不做任何变化,对应的处理类是NullAuthenticatedSessionStrategy
migrateSession()
:用户登录成功后,创建一个新的HttpSession
对象,并将旧的HttpSession
中的数据拷贝到新的HttpSession
中,对应的处理类是SessionFixationProtectionStrategy
newSession()
:用户登录成功后,创建一个新的HttpSession
对象,对应的处理类也是SessionFixationProtectionStrategy
,只不过将其里边的migrateSessionAttributes
属性设置为false
。需要注意的是,该方法并非所有的属性都不可拷贝,一些Spring Security使用的属性,如请求缓存,还是会从旧的HttpSession
上复制到新的HttpSession
。
默认是changeSessionId
方案:
其他防御方案
登录前的匿名会话强制失效
session id与浏览器绑定:session id与所访问浏览器有变化,立即重置
session id与所访问的IP绑定:session id与所访问IP有变化,立即重置
Session共享
前面我们留下了两个问题
-
从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给
session
的空间不多,当访问量增大时,内存就会捉襟见肘。 -
从稳定性上来说,Session依赖于内存,而内存并非持久性存储容器,就算服务器本身是可靠的,但当部署在上面的服务停止或重启时,也会导致所有会话状态丢失。
Spring Security提供了三种针对集群session的方案:
Session
复制:多个服务之间互相复制Session
信息,这样每个服务中都包含有所有的Session
信息了,Tomcat
通过IP
组播对这种方案提供支持。但是这种方案占用带宽,有时延,服务数量越多效率越低,所以使用较少Session
粘滞:也叫会话保持,就是在Nginx
上通过一致性Hash
,将Hash
结果相同的请求总是分发到一个服务上去,这种方案可以解决一部分集群会话带来的问题,但是无法解决集群会话并发管理问题Session
共享:Session
共享就是将不同服务的会话统一放在一个地方,所有的服务共享一个会话,一般使用一些Key-Value
数据库来存储Session
,例如在redis
中,比较常见的方案是使用redis
存储,session
共享方案由于其简便性和稳定性,是目前使用较多的方案。
我们会发现最后一种方案能够解决我们提出的两个问题, 而且刚好这套方案也是用的最多以解决集群session
的方案
为什么集群session也有问题?
在单机方案中, 每一个tomcat
都有属于自己的一个session
且session
保存在内存中
在集群方案下, 我们使用 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
实例,构建时传入sessionRepository
,SpringSessionBackedSessionRegistry
继承自SessionRegistry
,用来维护会话信息注册表 - 最后在
HttpSecurity
中配置sessionRegistry
即可,相当于spring-session
提供的SpringSessionBackedSessionRegistry
接管了会话信息注册表的维护工作。
再次强调:
需要注意的是引入
spring-session
后不需要在配置HttpSessionEventPublicsher
实例,引入spring-session
通过SessionRepositoryFilter
将请求对象重新封装为SessionRepositoryRequestWrapper
,并重写了getSession
方法,在重写的getSession
方法中,最终返回的是HttpSessionWrapper
实例,而在HttpSessionWrapper
定义时,就重写了invalidate
方法,当调用会话的invalidate
方法去销毁会话时,就会调用RedisIndexedSessionRepository
中的方法,从Redis
中移除相应的会话信息,所以不再需要HttpSessionEventPublisher
实例。
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
是指访问资源的令牌凭据,用于检验请求的合法性,适用于前后端分离的项目。常用的Token
是JSON Web Token(JWT)
认证授权机制,这是目前最流行的跨域认证解决方案。
该方案的优点如下:
- 可以用数据库存储
token
,也可以选择放在内存当中,比如redis
很适合对token
查询的需求。token
可以避免CSRF
攻击(因为不需要cookie
了)。- 完美契合移动端的需求。
Session
持久化到数据库
该方案就是需要设计一个数据库表,专门用来存储Session
信息,保证Session
的持久化。
优点: 服务器出现问题时,Session
不会丢失。
缺点: 如果网站的访问量很大,把所有的Session
存储到数据库中,会对数据库造成很大压力,还需要增加额外的开销维护数据库。
Terracotta
实现Session
复制
Terracotta
的基本原理是,对于集群间共享的数据,当在一个节点发生变化的时候,Terracotta
只把变化的部分发送给Terracotta
服务器,然后由服务器把它转发给真正需要这个数据的节点,这可以看成是对第二种方案的优化。
优点: 对网络的压力就非常小,各个节点也不必浪费CPU
时间和内存进行大量的序列化操作。把这种集群间数据共享的机制应用在Session
同步上,既避免了对数据库的依赖,又能达到负载均衡和灾难恢复的效果。