文章配套代码:https://gitee.com/lookoutthebush/spring-security-demo
再2个浏览器中用同一个账号登录就会发现,到现在为止,系统还没有任何会话并发限制。一个账号能多处同时登录不是一个好的策略。
一、理解会话
会话(session)就是无状态的 HTTP 实现用户状态可维持的一种解决方案。HTTP 本身的无状态 使得用户在与服务器的交互过程中,每个请求之间都没有关联性。这意味着用户的访问没有身份记 录,站点也无法为用户提供个性化的服务。session的诞生解决了这个难题,服务器通过与用户约定每 个请求都携带一个id类的信息,从而让不同请求之间有了关联,而id又可以很方便地绑定具体用户,所 以我们可以把不同请求归类到同一用户。基于这个方案,为了让用户每个请求都携带同一个id,在不 妨碍体验的情况下,cookie是很好的载体。当用户首次访问系统时,系统会为该用户生成一个 sessionId,并添加到cookie中。在该用户的会话期内,每个请求都自动携带该cookie,因此系统可以很 轻易地识别出这是来自哪个用户的请求。
尽管cookie非常有用,但有时用户会在浏览器中禁用它,可能是出于安全考虑,也可能是为了保 护个人隐私。在这种情况下,基于cookie实现的sessionId自然就无法正常使用了。因此,有些服务还支 持用URL重写的方式来实现类似的体验,例如:
http://xxx.com;jssessionid=xxx
URL重写原本是为了兼容禁用cookie的浏览器而设计的,但也容易被黑客利用。黑客只需访问一 次系统,将系统生成的sessionId提取并拼凑在URL上,然后将该URL发给一些取得信任的用户。只要用户在session有效期内通过此URL进行登录,该sessionId就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。
二、防御会话固定攻击
防御会话固定攻击的方法非常简单,只需在用户登录之后重新生成新的session即可。在继承 WebSecurityConfigurerAdapter时,Spring Security已经启用了该配置。
protected final HttpSecurity getHttp() throws Exception {
if (this.http != null) {
return this.http;
}
AuthenticationEventPublisher eventPublisher = getAuthenticationEventPublisher();
this.localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher);
AuthenticationManager authenticationManager = authenticationManager();
this.authenticationBuilder.parentAuthenticationManager(authenticationManager);
Map<Class<?>, Object> sharedObjects = createSharedObjects();
this.http = new HttpSecurity(this.objectPostProcessor, this.authenticationBuilder, sharedObjects);
if (!this.disableDefaults) {
applyDefaultConfiguration(this.http);
ClassLoader classLoader = this.context.getClassLoader();
List<AbstractHttpConfigurer> defaultHttpConfigurers = SpringFactoriesLoader
.loadFactories(AbstractHttpConfigurer.class, classLoader);
for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
this.http.apply(configurer);
}
}
configure(this.http);
return this.http;
}
private void applyDefaultConfiguration(HttpSecurity http) throws Exception {
http.csrf();
http.addFilter(new WebAsyncManagerIntegrationFilter());
http.exceptionHandling();
http.headers();
//嗲用sessionManagement
http.sessionManagement();
http.securityContext();
http.requestCache();
http.anonymous();
http.servletApi();
http.apply(new DefaultLoginPageConfigurer<>());
http.logout();
}
sessionManagement是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种:
- none:不做任何变动,登录之后沿用旧的session。
- newSession:登录之后创建一个新的session。
- migrateSession:登录之后创建一个新的session,并将旧的session中的数据复制过来。
- changeSessionId:不创建新的会话,而是使用由Servlet容器提供的会话固定保护。
在 Spring Security 中,即便没有配置,也大可不必担心会话固定攻击。这是因为Spring Security的 HTTP防火墙会帮助我们拦截不合法的URL,当我们试图访问带session的URL时会被重定向到错误页。
具体细节可以翻看Spring Security源码,该部分内容在StrictHttpFirewall类中实现。
三、会话过期
除防御会话固定攻击外,还可以通过Spring Security配置一些会话过期策略。例如,会话过期时跳 转到某个URL。
或者完全自定义过期策略。通过添加invalidSessionStrategy()
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(JSON.toJSONString(ResultVO.success("session timeout")));
}
}
invalidSessionStrategy与invalidSessionUrl配置其中一个就可以。
四、会话并发控制
1.控制最大会话数
maximumSessions会限制同时在线的会话数,如果没有额外的配置,重新登录的会话会踢掉旧的会话。
具体的实现细节在ConcurrentSessionControlAuthenticationStrategy类中可以看到。
2.重写hashCode和equals方法
SpringSecurity为了实现会话并发控制,采用会话信息表来管理用户的会话状态,具体实现见 SessionRegistryImpl类。
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
// <principal:Object,SessionIdSet>
private final ConcurrentMap<Object, Set<String>> principals;
// <sessionId:Object,SessionInformation>
private final Map<String, SessionInformation> sessionIds;
public SessionRegistryImpl() {
this.principals = new ConcurrentHashMap<>();
this.sessionIds = new ConcurrentHashMap<>();
}
该类中处理了移除旧会话的操作,是通过对象来对比的,所以需要在自定义用户类中重写hashCode和equals方法
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UsersDO usersDO = (UsersDO) o;
return Objects.equals(username, usersDO.username);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), username);
}
五、集群会话的缺陷
session(会话)保存在服务器上,单个服务的时候没问题。但是通常会有多个服务器来一起工作。通过一用户的登录请求可能在服务器A上,那么session也会被保存到服务器A上,但是后续请求可能被分配到其他服务器上,这个时候就会有问题了,会让用户重新登录。
六、解决方案
通常由三种方案:
- session保持
- session复制
- session共享
session保持也叫粘滞会话(Sticky Sessions),通常采用IP哈希负载策略将来自相同客户端的请求 转发至相同的服务器上进行处理。session保持虽然避开了集群会话,但也存在一些缺陷。例如,某个 营业部的网络使用同个IP出口,那么使用该营业部网络的所有员工实际的源IP其实是同一个,在IP哈 希负载策略下,这些员工的请求都将被转发到相同的服务器上,存在一定程度的负载失衡。
session复制是指在集群服务器之间同步session数据,以达到各个实例之间会话状态一致的做法。 但毫无疑问,在集群服务器之间进行数据同步的做法非常不可取,尤其是在服务器实例很多的情况 下,任何变动都需要其他所有实例同步,不仅消耗数据带宽,还会占用大量的资源。
相较于前两种方案,session 共享则要实用得多。session 共享是指将 session 从服务器内存抽离出 来,集中存储到独立的数据容器,并由各个服务器共享。
由于所有的服务器实例单点存取session,所以集群不同步的问题自然也就不存在了,而且独立的 数据容器容量相较于服务器内存要大得多。另外,与服务本身分离、可持久化等特性使得会话状态不会因为服务停止而丢失。当然,session共享并非没有缺点,独立的数据容器增加了网络交互,数据容 器的读/写性能、稳定性以及网络I/O速度都成为性能的瓶颈。基于这些问题,尽管在理论上使用任何 存储介质都可以实现session共享,但在内网环境下,高可用部署的Redis服务器无疑为最优选择。Redis 基于内存的特性让它拥有极高的读/写性能,高可用部署不仅降低了网络I/O损耗,还提高了稳定性。
七、整合SpringSession解决集群会话问题
SpringSession就是专门用来解决集群会话问题的,它不仅为集群 会话提供了非常完善的支持,与Spring Security的整合也有专门的实现。
SpringSession支持多种类型的存储容器,包括Redis、MongoDB等。由于接下来的整合都是基于 Redis的。
1.准备redis
我们这里使用windows版的redis。https://download.csdn.net/download/LookOutThe/80098914
解压就能使用
2.在pom.xml中引入三个依赖。
<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>
3.修改security的配置:
4. 启动项目,登录:
另一个浏览器登录后,当前浏览器刷新会提示session过期。