Spring Security 6.x 一文讲透Session认证管理机制

2024-06-27 22:37:58 浏览数 (3)

之前几篇文章,主要围绕着身份认证的相关内容,今天主要讨论一下认证状态的保持,由于HTTP协议是无状态的,因此在认证成功之后,为了让后续的请求可以继续保持住这个认证状态,避免每次请求都要重新发起认证过程,就需要对认证结果进行持久化,然后在新的请求到达时查询并还原回来对应的认证状态,通常有两种实现方案,一种是经典的cookie-session方案,即在服务端的session属性中存取认证信息,优点是实现方法比较简单,另一种是token令牌方案,利用一些算法对认证信息进行编码和解码,优点是无需落地,有效地减轻服务端存储的压力,本文主要介绍Spring Security框架中基于session的认证及常用的管理机制。

一、Tomcat中Session的底层实现

为了更好地理解session的工作方式,有必要先回顾一下session的一些背景知识,下面以Tomcat为例,大致介绍一下Session是如何在服务端维护的。

说明:下面出现的Session是Tomcat内定义的一个接口,而我们通常所说的Session,是jakarta.servlet.http(或java.servlet.http)中定义的HttpSession接口,在Tomcat中它们有一个共同的实现类为StandardSession,通过门面模式,最终实际操作的都是StandardSession对象。

在Tomcat中,主要由ManagerBase负责维护Session对象,源码如下,可以看到,session对象其实在保存在一个ConcurrentHashMap中,其Key为sessionId,根据请求携带的Cookie中JSESSIONID的值,就可以通过findSession方法查询到对应的session对象。

代码语言:java复制
public abstract class ManagerBase extends LifecycleMBeanBase implements Manager {
    ...
    protected Map<String,Session> sessions = new ConcurrentHashMap<>();
    ...
    
    @Override
    public Session findSession(String id) throws IOException {
        if (id == null) {
            return null;
        }
        return sessions.get(id);
    }
    
        @Override
    public void add(Session session) {
        sessions.put(session.getIdInternal(), session);
        int size = getActiveSessions();
        if (size > maxActive) {
            synchronized (maxActiveUpdateLock) {
                if (size > maxActive) {
                    maxActive = size;
                }
            }
        }
    }
    
    @Override
    public Session createSession(String sessionId) {
    
        if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) {
            rejectedSessions  ;
            throw new TooManyActiveSessionsException(sm.getString("managerBase.createSession.ise"), maxActiveSessions);
        }
    
        // Recycle or create a Session instance
        Session session = createEmptySession();
    
        // Initialize the properties of the new session and return it
        session.setNew(true);
        session.setValid(true);
        session.setCreationTime(System.currentTimeMillis());
        session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
        String id = sessionId;
        if (id == null) {
            id = generateSessionId();
        }
        session.setId(id); // 该方法内,会调用上面的add方法,将该session保存到ConcurrentHashMap中
    
        SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
        synchronized (sessionCreationTiming) {
            sessionCreationTiming.add(timing);
            sessionCreationTiming.poll();
        }
        return session;
    }
}

另外,createSession方法可以生成一个新的session对象,其中会调用session#setId方法,此时会将该session保存到ConcurrentHashMap中,该方法主要用于HttpServleRequest获取当前session的场景,可以再看一下HttpServleRequest在Tomcat中的实现类Request,当调用Request#doGetSession方法时,若当前未能查询到session对象,就会调用createSession方法,创建出一个新的session,然后再创建出一个对应的Cookie,其默认名称为“JSESSIONID”,并添加到Response中,最后返回session对象。

代码语言:java复制
@Override
public HttpSession getSession(boolean create) {
    Session session = doGetSession(create);
    if (session == null) {
        return null;
    }

    return session.getSession(); // 门面模式,把session自身封装到StandardSessionFacade对象中返回,StandardSessionFacade是HttpSession接口在Tomcat中的实现类
}

protected Session doGetSession(boolean create) {
    Context context = getContext();
    ...
    Manager manager = context.getManager();
    ...
    if (requestedSessionId != null) { // 从Cookie JSESSIONID中解析出来的sessionId,也有可能为null
        try {
            session = manager.findSession(requestedSessionId); // 在manager中的ConcurrentHashMap查找
        } catch (IOException e) {
           ...
        }
        ...
        if (session != null) {
            session.access(); // 记录该session的访问次数
            return session;
        }
    }

    // Create a new session if requested and the response is not committed
    if (!create) {
        return null;
    }
    ...
    String sessionId = getRequestedSessionId(); 
    ...
    session = manager.createSession(sessionId); // 创建新的Session
    // Creating a new session cookie based on that session
    if (session != null && trackModesIncludesCookie) {
        Cookie cookie =
                ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure()); // 这里会创建一个名为"JSESSIONID"的Cookie
        response.addSessionCookieInternal(cookie);
    }

    if (session == null) {
        return null;
    }

    session.access();
    return session;
}

以上就是在tomcat中创建和读取Session的底层实现,限于篇幅,其他与Session相关方法,这里就不再展开介绍。

二、SecurityContext存取的基本流程

首先简单介绍一下如何通过Session机制保存和读取SecurityContext对象,实际上整个流程基本由Spring Security框架封装完成,对开发者来说并不需要太多的开发成本。整个流程大致用一个简单的时序图表示一下:

概括地说,主要有以下几步:

  1. 通常在每一种认证机制的具体实现中(图中用AbstractAuthenticationProcessingFilter表示),每当认证成功后,会新建一个SecurityContext对象,并设置已认证的Authentication对象
  2. 通过SecurityContextRepository将SecurityContext保存在session的属性中,securityContextRepository接口有多个实现类,下文作进一步介绍
  3. 将该session的sessionid写入到Cookie中
  4. 后续发起的请求再次发起访问时就会携带这个Cookie,在SecurityContextHolderFilter中,也是利用SecurityContextRepository得到对应session对象,进而从session的属性中取出对应的SecurityContext对象,并设置到SecurityContextHolder的ThreadLocal中,以便下游其他组件获取

下面看一下具体的实现细节。

2.1 保存SecurityContext

上文提到用于保存SecurityContext的接口SecurityContextRepository,它的默认实现类为DelegatingSecurityContextRepository,其save方法在默认配置下委托给了两个对象,即HttpSessionSecurityContextRepository和RequestAttributeSecurityContextRepository,其中RequestAttributeSecurityContextRepository实际上不进行持久化,只是将SecurityContext保存在request的属性中,因此在后续的其他请求中,无法获取到SecurityContext对象,只适用于后端dispatch的场景,而HttpSessionSecurityContextRepository则主要负责使用session实现持久化,过程比较简单:首先由request#getSession方法生成一个session,然后将SecurityContext对象写入session的一个属性(SPRING_SECURITY_CONTEXT),最后会在Response中设置对应的Cookie,写入浏览器客户端。以下是HttpSessionSecurityContextRepository#saveContext方法的源码,在Spring Security的新版本中,一般不太会发生SaveContextOnUpdateOrErrorResponseWrapper对象不为空的场景(2.3小节会解释原因),实际保存SecurityContext对象的方法为setContextInSession。

代码语言:java复制
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
    SaveContextOnUpdateOrErrorResponseWrapper responseWrapper = WebUtils.getNativeResponse(response,
          SaveContextOnUpdateOrErrorResponseWrapper.class);
    if (responseWrapper == null) {
       saveContextInHttpSession(context, request); // 通常执行这个方法
       return;
    }
    responseWrapper.saveContext(context);
}

private void saveContextInHttpSession(SecurityContext context, HttpServletRequest request) {
    if (isTransient(context) || isTransient(context.getAuthentication())) {
       return;
    }
    SecurityContext emptyContext = generateNewContext();
    if (emptyContext.equals(context)) {
       HttpSession session = request.getSession(false);
       removeContextFromSession(context, session);
    }
    else {
       boolean createSession = this.allowSessionCreation;
       HttpSession session = request.getSession(createSession);
       setContextInSession(context, session);
    }
}

private void setContextInSession(SecurityContext context, HttpSession session) {
    if (session != null) {
       session.setAttribute(this.springSecurityContextKey, context); //将SecurityContext对象,保存在session的属性中,其中springSecurityContextKey默认值为"SPRING_SECURITY_CONTEXT_KEY"
       if (this.logger.isDebugEnabled()) {
          this.logger.debug(LogMessage.format("Stored %s to HttpSession [%s]", context, session));
       }
    }
}

2.2 加载SecurityContext

加载SecurityContext的过程主要在SecurityContextHolderFilter过滤器中完成,由于很多其他的Filter在执行业务逻辑时都需要依赖SecurityContext获取认证信息,因此这个Filter在整个SecurityFilterChain的排序优先级比较高,源码如下:

代码语言:java复制
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
       throws ServletException, IOException {
    if (request.getAttribute(FILTER_APPLIED) != null) {
       chain.doFilter(request, response);
       return;
    }
    request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
    Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
    try {
       this.securityContextHolderStrategy.setDeferredContext(deferredContext);
       chain.doFilter(request, response);
    }
    finally {
       this.securityContextHolderStrategy.clearContext();
       request.removeAttribute(FILTER_APPLIED);
    }
}

加载时调用securityContextRepository的loadDeferredContext方法,该方法返回一个Supplier模式的延迟访问对象(简单理解为返回了一个访问SecurityContext对象的入口,它只在需要访问SecurityContext对象时,才执行具体的逻辑,可以提升一定的效率),如果在此之前没有发起过认证流程,这里会创建一个空的SecurityContext,而如果已经认证过,则会从session的属性中获得之前保存好的SecurityContext实例。以下是HttpSessionSecurityContextRepository#loadDeferredContext方法的源码

代码语言:java复制
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
    Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false));
    return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}

private SecurityContext readSecurityContextFromSession(HttpSession httpSession) {
    if (httpSession == null) {
       this.logger.trace("No HttpSession currently exists");
       return null;
    }
    // Session exists, so try to obtain a context from it.
    Object contextFromSession = httpSession.getAttribute(this.springSecurityContextKey); //通过springSecurityContextKey获取session对应的属性值,并通过类型转换得到SecurityContext对象
   ...
    // Everything OK. The only non-null return from this method.
    return (SecurityContext) contextFromSession;
}

2.3 新版本变化

事实上,在Spring Security 5.7版本之前,SecurityContext的加载并不是由SecurityContextHolderFilter负责的,而是SecurityContextPersistenceFilter,他们之间有一个比较大的区别,就是SecurityContextPersistenceFilter还负责自动保存SecurityContext对象,看一下它的doFilter方法中finally代码块,这里会调用一次securityContextRepository的saveContext方法。

代码语言:java复制
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
       throws IOException, ServletException {
    ...
    HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
    SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
    try {
       this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
      ...
       chain.doFilter(holder.getRequest(), holder.getResponse());
    }
    finally {
       SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
       // Crucial removal of SecurityContextHolder contents before anything else.
       this.securityContextHolderStrategy.clearContext();
       this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); // 在SecurityContextHolderFilter中,不再自动保存SecurityContext
       request.removeAttribute(FILTER_APPLIED);
       this.logger.debug("Cleared SecurityContextHolder to complete request");
    }
}

在旧版本中,各认证机制并不会直接操作securityContextRepository保存认证后生成的SecurityContext对象,因此在Reponse提交之前,如果本次请求执行完之后SecurityContext发生了变更,例如新设置了一个已认证的Authentication对象,那么就需要对该SecurityContext进行持久化。

举个例子,RememberMeAuthenticationFilter过滤器,用于实现“记住我”的登录机制,即通过特定cookie标识登录状态,可以在比较长一段时间内避免重新发起认证请求,Spring Security 5.6的源码如下

代码语言:java复制
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
       throws IOException, ServletException {
    ...
    Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
    if (rememberMeAuth != null) {
       // Attempt authentication via AuthenticationManager
       try {
          rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
          // Store to SecurityContextHolder
      SecurityContext context = SecurityContextHolder.createEmptyContext();
          context.setAuthentication(rememberMeAuth);
      SecurityContextHolder.setContext(context);
          onSuccessfulAuthentication(request, response, rememberMeAuth);
          // this.securityContextRepository.saveContext(context, request, response); 在Spring Security 5.7及更高版本,增加了保存SecurityContext的步骤
          ...
       }
       catch (AuthenticationException ex) {
          ...
       }
    }
    chain.doFilter(request, response);
}

可以看到,在RememberMe的AuthenticationProvider认证通过之后,创建了一个新的SecurityContext,设置了Authentication对象,然后由给SecurityContextHolder托管,但其实最后并没有持久化,因此在最终Response提交之前,需要由SecurityContextPersistenceFilter统一完成持久化,这种机制看似比较合理,避免了每一种认证机制都要去操作securityContextRepository,那么新版本为何要舍弃SecurityContextPersistenceFilter这个过滤器?官方文档中,对此做了一定解释:因为对SecurityContext是否有变更,这个追踪过程是相对比较复杂的,可以翻看旧版本的源码,它借助HttpServletResponse的各种包装类,在Repsonse的各种方法内,如重定向sendRedirect,异常sendError等进行了埋点,经过很多条件的判断,才能确定是否应该保存SecurityContext,这样就会触发多次HttpSession的读写操作,但其实大部分读写操作是没有必要的,而每次请求执行完成之后,都要经历这些操作,出于效率和性能的考虑,在新版本中去除了自动保存的逻辑,使得SecurityContext的存取流程更加轻量。

三、Session管理机制

3.1 核心组件

接口

  • SessionAuthenticationStrategy:只定义了一个方法,即onAuthentication,即对当前Authentication应用不同的session管理策略,它有几个常见的实现类ConcurrentSessionControlAuthenticationStrategy,ChangeSessionIdAuthenticationStrategy等(下文详述)
  • sessionRegistry:主要定义了读取,新增,删除等维护SessionInfomation的方法,常用于session并发控制场景,默认实现类为SessionRegistryImpl,内部维护了两个Map,即principals和sessionIds,前者维护的是principal和sessionId的对应关系(一对多),后者维护的是sessionId和SessionInformation对象的关系(一对一)

  • SessionInformation:它的作用相当于是Spring Security框架中用来对应HttpSession的一个标记对象,它有以下几个成员变量
代码语言:java复制
public class SessionInformation implements Serializable {
    ...
    private Date lastRequest; // 最近访问时间,每次发起请求时,如果当前Session还未失效,则刷新该时间
    private final Object principal; // 标识当前用户,通常就是用户名
    private final String sessionId; // 对应HttpSession的sessionId
    private boolean expired = false; // 标识当前session是否过期
    ...    
}

与Session管理相关的过滤器

  • SessionManagementFilter:该过滤器是最主要负责Session管理的过滤器,上一节中提到SecurityContextHolderFilter过滤器负责将SecurityContext加载到SecurityContextHolderStrategy中,这里SessionManagementFilter则是用来SecurityContext中的Authentication对象是否已经通过认证,如果已认证,它就会调用SessionAuthenticationStrategy不同实现类对应的策略对当前session进行处理,例如session并发控制,session固定攻击等,不过SessionManagementFilter在新版本中是默认不开启的,官方文档给出的解释是每次请求都要读取session来获取SecurityContext对象,这或多或少会带来一些性能上的损耗,因此现在这项工作由认证机制本身负责完成,也就是说,session管理策略只会应用在Authentication认证通过的时候,并且仅有一次调用,这样就避免了每次请求都要读取session的问题,也属于是Spring Security框架轻量化的改进措施之一
  • ConcurrentSessionFilter:该过滤器主要有两个功能,一是如果当前sessionInformation已过期,则将其清理掉,二是刷新未过期的sessionInformation的refreshLastRequest时间,则执行登出逻辑,它用于session并发控制的场景(下文详述)
  • DisableEncodeUrlFilter:该过滤器是在5.7版本引入,主要用于禁止对URL重新编码,当客户端Cookie被禁用时,默认的响应就会把sessionId拼接到URL中,这样就会暴露session,存在一定的安全隐患,因此该过滤器是默认开启的。
  • ForceEagerSessionCreationFilter:该过滤器是在5.7版本引入的,如果配置sessionCreationPolicy为SessionCreationPolicy.ALWAYS,它就会添加到过滤器链,且就有非常高的优先级,作用就是在请求进入过滤器链最开始的时候,就创建一个session对象,虽然这不是一种比较经济的方式,但是如果要用session来跟踪一些客户端信息时,这样做就非常有必要了

下面介绍几个比较常见的session管理场景

3.2 Session并发控制

Session并发控制,最常用的场景就是限制一个账号无法让多个客户端同时登录,即当第二个客户端发起登录,并认证通过之后,前一次认证的session就会被置为过期,用户也会被强制登出。

该配置也非常简单,即在sessionMangement DSL中的配置项sessionConcurrency配置maxinumSession为“1”。

代码语言:java复制
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
    http.sessionManagement(session -> session.sessionConcurrency(concurrency -> concurrency.maximumSessions(1)));
    ...
    return http.build();
}

具体实现由策略实现类ConcurrentSessionControlAuthenticationStrategy和过滤器ConcurrentSessionFilter配合完成,首先在ConcurrentSessionControlAuthenticationStrategy中,根据规则将某些不符合要求的session置为过期,保留下合法的session,从而在ConcurrentSessionFilter中将其清理,或者刷新refreshLastRequest时间。

先来看一下ConcurrentSessionControlAuthenticationStrategy#onAuthentication方法的源码,其中allowedSessions就是我们在配置中设置的maximumSessions,然后通过sessionRegistry获取当前用户所有的sessionInformation对象,并统计其数量,当超过指定的maximumSessions时,则调用allowableSessionsExceeded方法,它会根据LastRequest的时间进行排序,最终,超过maximumSessions数量部分的,且较早时间的那些session将会被置为过期。

代码语言:java复制
public void onAuthentication(Authentication authentication, HttpServletRequest request,
       HttpServletResponse response) {
    int allowedSessions = getMaximumSessionsForThisUser(authentication);
    if (allowedSessions == -1) { // maximumSessions为-1时,则表示不限制session数量
       // We permit unlimited logins
       return;
    }
    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;
    }
    if (sessionCount == allowedSessions) { // 如果该用户所有的sessionInformation数量恰好等于maximumSessions,则判断当前session的id是否包含在这些sessionInformation,如没有,则表示该session需要被失效
       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
    }
    allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}

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();
    }
}

而在ConcurrentSessionFilter中,会查询出当前sessionId对应的SessionInformation对象,判断是否已标记为过期,若未过期,则调用sessionRegistry#refreshLastRequest刷新时间,若已过期,则调用登出逻辑,包括将HttpSession对象置为失效,清空securityContextRepository中的SecurityContext对象等,因此它在SecurityFilterChain的优先级通常排在SessionManagementFilter(早期版本)以及各种认证机制对应的Filter(如AbstractAuthenticationProcessingFilter的实现类)之后,以下是doFilter方法源码

代码语言:java复制
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
       throws IOException, ServletException {
    HttpSession session = request.getSession(false);
    if (session != null) {
       SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
       if (info != null) {
          if (info.isExpired()) {
             // Expired - abort processing
             this.logger.debug(LogMessage
                .of(() -> "Requested session ID "   request.getRequestedSessionId()   " has expired."));
             doLogout(request, response); // sessionInformation被标记为已过期,执行登出逻辑,以及过期策略
             this.sessionInformationExpiredStrategy
                .onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response)); //默认实现为ResponseBodySessionInformationExpiredStrategy,它向Response写了一段告知用户session已过期的文字
             return;
          }
          // Non-expired - update last request date/time
          this.sessionRegistry.refreshLastRequest(info.getSessionId()); // 对于未过期的session,则更新其lastRequest时间
       }
    }
    chain.doFilter(request, response);
}

3.3 Session注册

为了让ConcurrentSessionControlAuthenticationStrategy在清理多余session时,能够快速获得该用户下所有的session,就需要在每次认证通过后注册新的session信息,因此RegisterSessionAuthenticationStrategy经常与上一节提到的ConcurrentSessionControlAuthenticationStrategy配套使用。

代码语言:java复制
public class RegisterSessionAuthenticationStrategy implements SessionAuthenticationStrategy {

    private final SessionRegistry sessionRegistry;

    ...
    @Override
    public void onAuthentication(Authentication authentication, HttpServletRequest request,
          HttpServletResponse response) {
       this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
    }

}

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
    ...
    private final ConcurrentMap<Object, Set<String>> principals;

    private final Map<String, SessionInformation> sessionIds;
    ...
    @Override
    public void registerNewSession(String sessionId, Object principal) {
       ...
        if (getSessionInformation(sessionId) != null) {
           removeSessionInformation(sessionId);
        }
       ...
        this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
        this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
           if (sessionsUsedByPrincipal == null) {
              sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
           }
           sessionsUsedByPrincipal.add(sessionId);
           this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", principal, sessionsUsedByPrincipal));
           return sessionsUsedByPrincipal;
        });
    }
}

注册时,若原sessionId有对应的SessionInformation对象,先将其清理掉,然后创建一个新的SessionInformation对象,将sessionId和SessionInformation对象的关系写入sessionIds的Map,将principal和sessionId的关系写入principals的Map。

3.4 Session固定攻击保护

所谓Session固定攻击,主要是指用户登录前和登录后所使用的session保持不变,这样攻击者可以事先准备好一个session,然后诱导用户使用该session进行登录,最后攻击者就可以使用这个session成功冒充该用户进入系统。

在Spring Security提供3种可配置的Session固定攻击保护策略,即changeSessionId、newSession和migrateSession(配置如下),其中changeSessionId,对应ChangeSessionIdAuthenticationStrategy实现类,newSession和migrateSession,对应SessionFixationProtectionStrategy实现类,前者不需要创建新的session,后面两个都会创建新的session,区别在于newSession不会保留原session的属性值(仅保留Spring Security自己定义的属性),而migrateSession则会迁移原session属性。

代码语言:java复制
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
    http.sessionManagement(session -> session.sessionFixation(fixation -> fixation.changeSessionId()));
    http.sessionManagement(session -> session.sessionFixation(fixation -> fixation.newSession()));
    http.sessionManagement(session -> session.sessionFixation(fixation -> fixation.migrateSession()));
    ...
    return http.build();
}

下面分别介绍一下两个实现类的具体细节。

ChangeSessionIdAuthenticationStrategy的实现最为简单,源码如下,它利用了HttpServletRequest#changeSessionId方法,给当前Session赋予一个新的sessionId,这时会对Tomcat中ManagerBase维护的ConcurrentHashMap进行更新,删除旧sessionId,添加新sessionId作为key,这样就无法使用旧的sessionId查询到session对象了,这种实现方式比较轻量,不过也依赖底层容器的支持,因此只能在 Servlet 3.1及更新版本的容器中使用,同时也是默认的实现类。

代码语言:java复制
public final class ChangeSessionIdAuthenticationStrategy extends AbstractSessionFixationProtectionStrategy {

    @Override
    HttpSession applySessionFixation(HttpServletRequest request) {
       request.changeSessionId();
       return request.getSession();
    }

}

SessionFixationProtectionStrategy的实现则稍微复杂一点,它相当于给session做了一次迁移工作:首先把原session的属性暂存到一个Map中(上文提到,当选择newSession时,仅保留Spring Security自己定义的属性),然后把旧的session被置为失效,并创建出一个新的Session,最后之前暂存在Map中的属性都迁移进去,这种的实现方式比较重,因此它只是在Servlet3.0及更早版本的容器中作为默认实现。

代码语言:java复制
final HttpSession applySessionFixation(HttpServletRequest request) {
    HttpSession session = request.getSession();
    String originalSessionId = session.getId();
    this.logger.debug(LogMessage.of(() -> "Invalidating session with Id '"   originalSessionId   "' "
            (this.migrateSessionAttributes ? "and" : "without")   " migrating attributes."));
    Map<String, Object> attributesToMigrate = extractAttributes(session);
    int maxInactiveIntervalToMigrate = session.getMaxInactiveInterval();
    session.invalidate();
    session = request.getSession(true); // we now have a new session
    this.logger.debug(LogMessage.format("Started new session: %s", session.getId()));
    transferAttributes(attributesToMigrate, session);
    if (this.migrateSessionAttributes) {
       session.setMaxInactiveInterval(maxInactiveIntervalToMigrate);
    }
    return session;
}

四、总结

本文就session的底层实现,SecurityContext在Session中的存取流程,以及常用的Session管理场景做了相关介绍,最后,再做一个总结:

  • session是存储在服务端的一个对象,在生成session对象时,会添加一个Cookie到Response中,cookie的值即为sessionId
  • 在Tomcat中,由ManagerBase负责维护session对象,内部定义了一个ConcurrentHashMap的变量sessions,其key为sessionId,用以存储和查询session对象
  • 在新版本Spring Security中,出于性能等方面的考虑,SecurityContextPersistenceFilter已经默认不生效,取而代之的是SecurityContextHolderFilter,它主要负责通过securityContextRepository加载已认证的SecurityContext对象到securityContextHolderStrategy中
  • 存储SecurityContext的工作由每个认证机制的实现类负责,具体执行存储逻辑在HttpSessionSecurityContextRepository中,保存SecurityContext对象,即写入session的一个属性值“SPRING_SECURITY_CONTEXT”
  • Spring Security框架提供若干session管理的配置,常见的有session并发控制,session固定攻击
  • session并发控制主要由策略实现类ConcurrentSessionControlAuthenticationStrategy和过滤器ConcurrentSessionFilter配合完成,前者负责将不符合规则的session标记为过期,后者负责清理掉这些过期session
  • session固定攻击保护有三种对应的配置changeSessionId、newSession和migrateSession,其中changeSessionId主要由ChangeSessionIdAuthenticationStrategy实现,后两种由SessionFixationProtectionStrategy实现,ChangeSessionIdAuthenticationStrategy实现比较轻量简单,即生成一个新的sessionId,赋予当前session,SessionFixationProtectionStrategy实现相对复杂,需要生成一个新的session,然后将原session的属性迁移过去。

我正在参与2024腾讯技术创作特训营最新征文,快来和我瓜分大奖!

1 人点赞