众所周知,HTTP本身是没有任何关于当前用户状态的内容,也就是两个HTTP请求之间是没有任何的关联可言,用户在和服务器交互的过程中,站点无法确定是哪个用户访问,也因此不能对其提供相应的个性化服务。Session的诞生就是为了解决这一个难题,提供了无状态的HTTP请求实现用户状态维持的方案——服务器和用户之间约定每个HTTP请求携带一个ID信息【代表当前用户信息】,从而实现不同的请求之间就存在着关联。当用户首次访问时,会自动为该用户生成一个sessionId,然后用Cookie作为载体进行记录,用户在会话周期中间访问都会带上Cookie中的内容,因此系统就可以识别出是哪一个用户。
一、会话固定攻击
尽管Cookie是非常有用的,但是有些用户出于安全或者保护个人隐私的目的,会关闭Cookie,如上图Google Chrome中的设置。在这种情况下,就不能使用基于Cookie实现Session,这样就使得用户体验不太好。所以针对于这一点,有些网站提供URL重写来实现类似的体验。但是这种情况下会存在问题:URL重写会直接将SessionId拼接在URL上,即例如下面所示。
http://www.test.com/test;jsessionid=ByOK3vjFD75aPnrF7C2HmdnV6QZcEbzWoWiBYEnLerjQ99zWpBng!-145788764
这种方式很容易被黑客进行利用,黑客只需要访问一次站点,将系统生成的SessionId粘贴到这个URL上面,然后将该URL放给用户,只要用户在session有效期内通过此URL进行登录,该sessionId就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。
会话固定攻击的防御方式其实是很简单,即用户登录后就刷新SessionId,这样原先的SessionId就失去作用。在SpringSecurity中,默认帮我们开启了这种方式,因此并不需要我们特别配置。但是我们也可以手动配置,在Spring Security中的防御会话固定攻击的策略有四种:
newSession: 登录之后创建一个新的session
migrateSession: 登录之后创建一个新的session,并将旧的session中的数据复制过来。
changeSessionId:不创建新的会话,而是使用由Servlet容器提供的会话固定保护。
none: 不做任何变动,登录之后沿用旧的session
其实这四种策略对应以下四个方法的的三个对象:SessionFixationProtectionStrategy、ChangeSessionIdAuthenticationStrategy、NullAuthenticatedSessionStrategy。
配置的方式也是很简单,在我下载的Spring Security 5.2.8版本中默认指定的是changeSessionId。
除了这种改变SessionId的值,也可以通过设置会话过期策略的方式来防御。默认的情况下,会话过期时间是30分钟。也可以指定失效的策略。
二、会话的并发控制
对于会话并发控制这一概念,在我们经常使用的视频软件中有所体现,例如腾讯视频和爱奇艺,若我的账号购买了会员,那我可以将这个账号分享给我的朋友、家人。当然这种肯定不能无限制的登录,就像对于腾讯视频和爱奇艺,如果不限制登录账号的设备数量,那肯定就亏惨了,而且也不利于自身信息的安全。所以会限制同时登录的设备数量,一旦超过这个限制,前面的账户就会被踢下来,这就是所谓的并发控制。
//session相关的控制
.sessionManagement()
//指定最大的session并发数量
.maximumSessions(1)
会话的并发数量设置很简单,使用maximumSessions即可。如果没有额外的配置,重新登录的会话会踢掉旧的会话。在介绍会话并发之前需要理解一个叫做SessionRegistry的对象——管理用户的会话状态,也可以称作为用户会话信息表。之所以可以称作为用户会话信息表,是因为其中维护着两个ConcurrentHashMap对象principals和sessionIds,分别存储着主体Principal和会话信息SessionInformation。Principal在之前的文章中说过是包含主体的信息,SessionInformation其实就是包括了主体信息、sessionId、是否过期以及上次请求时间。
SessionInformation的主要作用是在Spring Security中并发控制中记录Session的信息。Session的在Security中有三个状态:Active(活跃)、Expired(过期)以及Destroyed(无效)。让一个Session无效可以通过Session本身的invalidate方法使其失效,也可以通过Servlet容器管理进行销毁。Session过期很大程度上是由于用户的最大会话数已经达到限制,此时就必须使会话过期,过期的会话会通过过滤器很快的就被删除。
有了SessionInformation的理解后,我们再来看SessionRegistryImpl这个实现SessionRegistry的类,对会话信息表的具体操作也是在这个实现类中。
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
// 用户及其对应Session,一个用户可能有多个session
private final ConcurrentMap<Object, Set<String>> principals;
// SessionId及其对应的SessionInformation
private final Map<String, SessionInformation> sessionIds;
public SessionRegistryImpl() {
this.principals = new ConcurrentHashMap();
this.sessionIds = new ConcurrentHashMap();
}
public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) {
this.principals = principals;
this.sessionIds = sessionIds;
}
// 获取当前的所有主体信息
public List<Object> getAllPrincipals() {
return new ArrayList(this.principals.keySet());
}
// 获取主体对应的会话信息,可包含过期或者不过期的SessionInformation
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
// 获取当前主体的所有会话ID
Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
if (sessionsUsedByPrincipal == null) {
return Collections.emptyList();
} else {
List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
Iterator var5 = sessionsUsedByPrincipal.iterator();
while(true) {
SessionInformation sessionInformation;
do {
// 获取对应ID的SessionInformation,若没有则继续获取,循环结束则直接返回List
do {
if (!var5.hasNext()) {
return list;
}
String sessionId = (String)var5.next();
sessionInformation = this.getSessionInformation(sessionId);
} while(sessionInformation == null);
// 查询是否过期的且当前SessionInformation是否过期
} while(!includeExpiredSessions && sessionInformation.isExpired());
// 满足条件则添加至List
list.add(sessionInformation);
}
}
}
// 根据ID获取对应的SessionInformation
public SessionInformation getSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
return (SessionInformation)this.sessionIds.get(sessionId);
}
// 实现onApplicationEvent接口,表明处理SessionDestoryedEvent事件
public void onApplicationEvent(SessionDestroyedEvent event) {
String sessionId = event.getId();
// 移除对应sessionId的相关数据
this.removeSessionInformation(sessionId);
}
// 刷新最近操作日期
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = this.getSessionInformation(sessionId);
if (info != null) {
info.refreshLastRequest();
}
}
// 新增会话信息
// SessionManagementConfigure默认会将RegisterSessionAuthenticationStrategy添加
// 到一个组合式的SessionAuthenticationStrategy中,
// 并由AbstractAuthenticationProcessingFilter在成功调用时,触发该动作。
public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
// 若存在,则先删除会话信息
if (this.getSessionInformation(sessionId) != null) {
this.removeSessionInformation(sessionId);
}
if (this.logger.isDebugEnabled()) {
this.logger.debug("Registering session " + sessionId + ", for principal " + principal);
}
// 会话信息
this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
// 判断用户是否存在
this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet();
}
// 若用户存在,将当前sessionId添加到对应的集合中。
// 用Set即可实现去重
((Set)sessionsUsedByPrincipal).add(sessionId);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
}
return (Set)sessionsUsedByPrincipal;
});
}
// 删除会话信息
public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = this.getSessionInformation(sessionId);
if (info != null) {
if (this.logger.isTraceEnabled()) {
this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
}
// 以String类型的Key删除对应的sessionId及其Information
this.sessionIds.remove(sessionId);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
}
// 将用户会话记录中的,对应用户的对应session删除
sessionsUsedByPrincipal.remove(sessionId);
// 如果获取成功,则清理对应的内容
if (sessionsUsedByPrincipal.isEmpty()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
}
sessionsUsedByPrincipal = null;
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
}
return sessionsUsedByPrincipal;
});
}
}
}
对于SessionRegistryImpl我们还有额外注意的几点,若是稍不注意很容易在后续的使用过程中碰壁。
第一点,对象中的Principals采用以用户信息为Key。而在HashMap中,以对象为Key必须覆写hashCode和equals两个方法,因此在自己实现UserDetails时必须重写这两个方法,若没有重写会导致同一个用户每次登录注销时计算得到的Key都不相同,所以每次登录都会向Principals中添加一个用户,而注销时却从来不能有效移除。这种情况下,不仅达不到会话并发控制的效果,还会引起内存泄露。
第二点,我们注意到SessionRegistryImpl其实是实现了接口ApplicationListener的。在Servlet中监听Session相关事件的方法是实现HttpSessionListener接口,并在系统中注册该监听器。而SpringSecurity中在HttpSessionEventPublisher类中实现HttpSessionEventPublisher接口,并转换成Spring的事件机制,从而也就有了SessionDestroyedEvent这个事件,即会话的销毁事件。所以为了要实现事件的监听,就必须将HttpSessionEventPublisher注册到IOC容器中,这样才能将Java时间转换为Spring事件【只要使用会话管理功能,就应该配置HttpSessionEventPublisher】。
有了上面的理解,此时我们再来分析并发控制的策略。看完下面的并发控制策略后,其实会发现这里面只有控制当超过会话数量时,使会话的状态过期的操作。当时并没有进行会话的注册和删除。这也就是上面第二点所说,Security中会话创建和删除事件都是通过Spring的事件机制实现的,我们在SessionRegistryImpl同一个包中也可以看到Creation和Destoryed分别都有对应的事件,通过这两个事件才实现注册、销毁。
public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private final SessionRegistry sessionRegistry;
// 如果超出最大会话数是否阻止新会话的建立
private boolean exceptionIfMaximumExceeded = false;
// 最大的会话数
private int maximumSessions = 1;
public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
// 获取当前用户的所有有效的会话信息
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int sessionCount = sessions.size();
int allowedSessions = this.getMaximumSessionsForThisUser(authentication);
// 判断当前用户的会话数量是否超过最大值
if (sessionCount >= allowedSessions) {
// 如果最大会话数量为-1,则默认不限制会话数量
if (allowedSessions != -1) {
// 当已存在的会话数量等于最大会话数时
if (sessionCount == allowedSessions) {
// 判断当前会话是否已经在用户对应的会话列表中
HttpSession session = request.getSession(false);
if (session != null) {
Iterator var8 = sessions.iterator();
while(var8.hasNext()) {
SessionInformation si = (SessionInformation)var8.next();
// 当前验证的会话并不是新的会话,则不做任何的处理
if (si.getSessionId().equals(session.getId())) {
return;
}
}
}
}
// 进行策略判断
this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}
}
}
......
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException {
// 当用户达到最大会话数时,是否阻止新会话的建立
if (!this.exceptionIfMaximumExceeded && sessions != null) {
// 按照建立会话时间先后升序排序,
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
// 取待过期的会话
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
Iterator var6 = sessionsToBeExpired.iterator();
while(var6.hasNext()) {
// 新会话建立,使最早的会话过期
SessionInformation session = (SessionInformation)var6.next();
session.expireNow();
}
} else {
// 提示会话已超过数量
throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded"));
}
}
......
}
通过上面的分析,也可以让我们理解为什么官网对于SessionInformation的解释中一开始就提出了Session的三个状态:Active、Expired、Destoryed。因为在会话并发控制,三个状态都是通过三个模块进行控制。