会话并发管理

会话并发管理就是指在当前系统中,同一个用户可以同时创建多少个会话,如果一台设备对应一个会话,那么也可以简单理解为同一个用户可以同时在多少台设备进行登录。默人情况下,同一用户在多少台设备上登录并没有限制,不过开发者可以在 Spring Security 中对此进行配置。

实战

首先创建一个 Spring Boot 项目,引入 Spring Security 依赖 spring-boot-starter-security。添加配置类,内容如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("javaboy")
                .password("{noop}123")
                .roles("admin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .maximumSessions(1);
    }

    @Bean
    HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

和前面章节的配置类相比,这里主要有两点变化:

  1. 在 configure(HtpSecurity) 方法中通过 sessionManagement() 方法开启会话配置,并设置会话并发数为 1。

  2. 提供一个 httpSessionEventPublisher 实例。Spring Security 中通过一个 Map 集合来维护当前的 HtpSession 记录,进而实现会话的并发管理。当用户登录成功时,就向集合中添加一条 HttpSession 记录;当会话销毁时,就从集合中移除一条 HttpSession 记录。HttpSessionEventPublisher 实现了 HttpSessionListener 接口,可以监听到 HttpSession 的创建和销毁事件,并将 HttpSession 的创建/销毁事件发布出去,这样,当有 HttpSession 销毁时,Spring Security 就可以感知到该事件了。

接下来再提供一个测试 Controller:

@RestController
public class HelloController {
    @GetMapping("/")
    public String hello() {
        return "hello";
    }
}

配置完成后,启动项目。这次测试我们需要两个浏览器,如果读者使用了 Chrome 浏览器,那么也可以使用 Chrome 浏览器中的多用户方式(相当于两个浏览器)。

先在第一个浏览器中输入 http://localhost:8080 ,此时会自动跳转到登录页面,完成登录操作,就可以访问到数据了;接下来在第二个浏览器中也输入 http://localhost:8080 ,也需要登录,完成登录操作:当第二个浏览器登录成功后,再回到第一个浏览器,刷新页面,结果如图 7-1 所示。

image 2024 04 13 14 28 23 241
Figure 1. 图7-1被“挤下线”之后的提示

从提示信息中可以看到,由于使用同一用户身份进行并发登录,所以当前会话已经失效。

如果有需要,开发者也可以自定义会话销毁后的行为,代码如下:

http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin()
        .and()
        .csrf()
        .disable()
        .sessionManagement()
        .maximumSessions(1)
        .expiredUrl("/login");

最后的 expiredUrl 方法配置了当会话失效后(即被人 “挤下线” 后),自动重定向到 /login 页面。如果是前后端分离的项目,就不需要页面跳转了,直接返回一段 JSON 提示即可,配置如下:

http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin()
        .and()
        .csrf()
        .disable()
        .sessionManagement()
        .sessionFixation()
        .none()
        .maximumSessions(1)
        .expiredSessionStrategy(event -> {
            HttpServletResponse response = event.getResponse();
            response.setContentType("application/json;charset=utf-8");
            Map<String, Object> result = new HashMap<>();
            result.put("status", 500);
            result.put("msg", "当前会话已经失效,请重新登录");
            String s = new ObjectMapper().writeValueAsString(result);
            response.getWriter().print(s);
            response.flushBuffer();
        });

此时,当被人挤下线之后,服务端就会返回一段 JSON 响应。

这是一种被 “挤下线” 的效果,后面登录的用户会把前面登录的用户 “挤下线”。还有一种是禁止后来者登录,即一日当前用户登录成功,后来者无法再次使用相同的用户登录,直到当前用户主动注销登录,配置如下:

@Configuration
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)
                .maxSessionPreventsLogin(true);
    }

    @Bean
    HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

这里主要是调用 maxSessionsPreventsLogin() 方法,通过设置参数为 true,来禁止后来者登录。

配置完成后,重启项目。首先在第一个浏览器上进行登录,登录成功后再去第二个浏览器进行登录,此时就会登录失败,结果如图7-2所示。当第一浏览器主动执行注销登录后,第二人浏览器就可以登录了。

image 2024 04 13 14 35 03 280
Figure 2. 图7-2 超过了当前用户的最天会话数,登录失败

原理分析

接下来我们来分析上面的效果是怎么实现的。这里涉及了比较多的类,我们逐个来看。

SessionInformation

SessionInformation 主要用作 Spring Security 框架内的会话记录,代码如下:

public class SessionInformation implements Serializable {

	private Date lastRequest;
	private final Object principal;
	private final String sessionId;
	private boolean expired = false;

    public void refreshLastRequest() {
		this.lastRequest = new Date();
	}

    // 省略 getter/setter
}

这里定义了四个属性:

  1. lastRequest:最近一次请求的时间。

  2. principal:会话对应的主体(用户)。

  3. sessionId:会话 Id。

  4. expired:会话是否过期。

  5. refreshLastRequest():该方法用来更新最近一次请求的时间。

SessionRegistry

SessionRegistry 是一个接口,主要用来维护 SessionInformation 实例,该接口只有一个实现类 SessionRegistryImpl,所以这里我们就不看接口了,直接来看实现类 SessionRegistryImpl,SessionRegistryImpl 类的定义比较长,我们拆开来看,先来看属性的定义:

public class SessionRegistryImpl implements SessionRegistry,
		ApplicationListener<SessionDestroyedEvent> {

	private final ConcurrentMap<Object, Set<String>> principals;

	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 void onApplicationEvent(SessionDestroyedEvent event) {
		String sessionId = event.getId();
		removeSessionInformation(sessionId);
	}
}

SessionRegistryImpl 实现了 SessionRegistry 和 ApplicationListener 两个接口,实现了 ApplicationListener 接口,并通过重写其 onApplicationEvent 方法,就可以接收到 HtpSession 的销毁事件,进而移除掉 HttpSession 的记录。

SessionRegistryImpl 中一共定义了两个属性:

  1. principals:该变量用来保存当前登录主体(用户)和 SessionId 之间的关系,key 就是当前登录主体(即当前登录用户对象),value 则是当前登录主体所对应的会话 Id 的集合。

  2. sessionIds:该变量用来保存 sessionId 和 SessionInformation 之间的映射关系,key 是 sessionId,value 则是 SessionInformation。

由于 principals 集合中采用当前登录用户对象做 key,将对象作为集合中的 key,需要重写其 equals 方法和 hashCode 方法。在前面的案例中,由于我们使用了系统默认定义的 User 类,该类已经重写了 equals 方法和 hashCode 方法。如果开发者自定义用户类,记得重写其 equals 方法和 hashCode 方法,否则会话并发管理会失效。

继续来看 SessionRegistryImpl 中的其他方法:

public List<Object> getAllPrincipals() {
    return new ArrayList<>(principals.keySet());
}

public List<SessionInformation> getAllSessions(Object principal,
        boolean includeExpiredSessions) {
    final Set<String> sessionsUsedByPrincipal = principals.get(principal);

    if (sessionsUsedByPrincipal == null) {
        return Collections.emptyList();
    }

    List<SessionInformation> list = new ArrayList<>(
            sessionsUsedByPrincipal.size());

    for (String sessionId : sessionsUsedByPrincipal) {
        SessionInformation sessionInformation = getSessionInformation(sessionId);

        if (sessionInformation == null) {
            continue;
        }

        if (includeExpiredSessions || !sessionInformation.isExpired()) {
            list.add(sessionInformation);
        }
    }

    return list;
}

public SessionInformation getSessionInformation(String sessionId) {
    Assert.hasText(sessionId, "SessionId required as per interface contract");

    return sessionIds.get(sessionId);
}

public void refreshLastRequest(String sessionId) {
    SessionInformation info = getSessionInformation(sessionId);

    if (info != null) {
        info.refreshLastRequest();
    }
}
  1. getAllPrincipals:该方法返回所有的登录用户对象。

  2. getAllSessions:该方法返回某一个用户所对应的所有 SessionInformation。方法第一个参数就是用户对象,第二个参数表示是否包含已经过期的 Session。具体操作就是从 principals 变量中获取该用户对应的所有 sessionId,然后调用 getSessionInformation 方法从 sessionIds 变量中获取每一个 sessionId 所对应的 SessionInformation,最终将获取到的 SessionInformation 存入集合中返回。

  3. getSessionInformation:该方法主要是根据 sessionId 从 sessionIds 集合中获取对应的 SessionInformation。

  4. refreshLastRequest:根据传入的 sessionId 找到对应的 SessionInformation,并调用其 refreshLastRequest 方法刷新最后一次请求的时间。

再来看会话的保存操作:

public void registerNewSession(String sessionId, Object principal) {

    if (getSessionInformation(sessionId) != null) {
        removeSessionInformation(sessionId);
    }

    sessionIds.put(sessionId,
            new SessionInformation(principal, sessionId, new Date()));

    principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
        if (sessionsUsedByPrincipal == null) {
            sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
        }
        sessionsUsedByPrincipal.add(sessionId);

        return sessionsUsedByPrincipal;
    });
}

当用户登录成功后,会执行会话保存操作,传入当前请求的 sessionId 和当前登录主体 principal 对象。如果 sessionId 已经存在,则先将其移除,然后先往 sessionIds 中保存,key 是 sessionId,value 则是一个新创建的 SessionInformation 对象。

在向 principals 集合中保存时使用了 compute 方法(如果读者对 Java8 中的 compute 方法还不太熟悉,可以自行学习,这里不做过多介绍),第一个参数就是当前登录主体,第二个参数则进行了计算。如果当前登录主体在 principals 中已经有对应的 value,则在 value 的基础上继续添加一个 sessionId。如果当前登录主体在 principals 中没有对应的 value,则新建一个 sessionsUsedByPrincipal 对象,然后再将 sessionId 添加进去。

最后我们再来看会话的移除操作:

public void removeSessionInformation(String sessionId) {

    SessionInformation info = getSessionInformation(sessionId);

    if (info == null) {
        return;
    }

    sessionIds.remove(sessionId);

    principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {

        sessionsUsedByPrincipal.remove(sessionId);

        if (sessionsUsedByPrincipal.isEmpty()) {
            sessionsUsedByPrincipal = null;
        }

        return sessionsUsedByPrincipal;
    });
}

移除也是两方面的工作,一方面就是从 sessionIds 变量中移除,这个直接调用 remove 方法即可;另一方面就是从 principals 变量中移除,principals 中 key 是当前登录的用户对象,value 则是一个集合,里边保存着当前用户对应的所有 sessionId,这里主要是移除 value 中对应的 sessionId。

SessionAuthenticationStrategy

SessionAuthenticationStrategy 是一个接口,主要在用户登录成功后,对 HttpSession 进行处理。它里边只有一个 onAuthentication 方法,用来处理和 HttpSession 相关的事情:

public interface SessionAuthenticationStrategy {

	void onAuthentication(Authentication authentication, HttpServletRequest request,
			HttpServletResponse response) throws SessionAuthenticationException;

}

SessionAuthenticationStrategy 有如下一些实现类:

  • CsrfAuthenticationStrategy:CsrfAuthenticationStrategy 和 CSRF 攻击有关,该类主要负责在身份验证后删除旧的 CsrfToken 并生成一个新的 CsrfToken。

  • ConcurrentSessionControlAuthenticationStrategy:该类主要用来处理 Session 并发问题。前面案例中 Session 并发的控制,实际上就是通过该类来完成的。

  • RegisterSessionAuthenticationStrategy:该类用于在认证成功后将HttpSession 信息记录到 SessionRegistry 中。

  • CompositeSessionAuthenticationStrategy:这是一个复合策略,它里边维护了一个集合,集合中保存了多个不同的 SessionAuthenticationStrategy 对象,相当于该类代理了多个 SessionAuthenticationStrategy 对象,大部分情况下,在 Spring Security 框架中直接使用的也是该类的实例。

  • NullAuthenticatedSessionStrategy:这是一个空的实现,未做任何处理。

  • AbstractSessionFixationProtectionStrategy:处理会话固定攻击的基类。

  • ChangeSessionIdAuthenticationStrategy:通过修改 sessionId 来防止会话固定攻击。

  • SessionFixationProtectionStrategy:通过创建一个新的会话来防止会话固定攻击。

ConcurrentSessionControlAuthenticationStrategy

在前面的案例中,起主要作用的是 ConcurrentSessionControlAuthenticationStrategy,因此这里先对该类进行重点分析,先来看它里边的 onAuthentication 方法:

public void onAuthentication(Authentication authentication,
        HttpServletRequest request, HttpServletResponse response) {

    final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
            authentication.getPrincipal(), false);

    int sessionCount = sessions.size();
    int allowedSessions = getMaximumSessionsForThisUser(authentication);

    if (sessionCount < allowedSessions) {
        // They haven't got too many login sessions running at present
        return;
    }

    if (allowedSessions == -1) {
        // We permit unlimited logins
        return;
    }

    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
    }

    allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
}

在该方法中,首先从 sessionRegistry 中获取当前用户的所有未失效的 SessionInformation 实例,然后获取到当前项目允许的最大 session 数。如果获取到的 SessionInformation 实例数小于当前项目允许的最大 session 数,说明当前登录没问题,直接 return 即可。如果允许的最大 session 数量为 -1,则表示应用并不限制登录并发数,当前登录也没有问题,直接返回即可。如果获取到的 SessionInformation 实例等于当前项目允许的最大 session 数,则去判断当前登录的 sessionId 是否存在于获取到的 SessionInformation 实例中,如果存在,说明登录也没问题,直接返回即可。

如果在前面的判断中没有 return,说明当前用户登录的并发数已经超过允许的并发数了,进入到 allowableSessionsExceeded 方法中进行处理,代码如下:

protected void allowableSessionsExceeded(List<SessionInformation> sessions,
        int allowableSessions, SessionRegistry registry)
        throws SessionAuthenticationException {
    if (exceptionIfMaximumExceeded || (sessions == null)) {
        throw new SessionAuthenticationException(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();
    }
}

如果 exceptionIfMaximumExceeded 属性为 true,则直接抛出异常,该属性的值也就是我们在 SecurityConfig 中通过 maxSessionsPreventsLogin 方法配置的值,即禁止后来者登录,抛出异常后,本次登录失败。否则说明不禁止后来者登录,此时对查询出来的当前用户所有登录会话按照最后一次请求时间进行排序,然后计算出需要过期的 session 数量,从 sessions 集合中取出来进行遍历,依次调用其 expireNow 方法使之过期。

这便是 ConcurrentSessionControlAuthenticationStrategy 类的实现逻辑。

RegisterSessionAuthenticationStrategy

在前面的案例中,默认也用到了 RegisterSessionAuthenticationStrategy,该类的作用主要是向 SessionRegistry 中记录 HttpSession 信息,我们来看一下它的 onAuthentication 方法:

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

可以看到,这里的 onAuthentication 方法非常简单,就是调用 registerNewSession 方法向 sessionRegistry 中添加一条登录会话信息。

CompositeSessionAuthenticationStrategy

CompositeSessionAuthenticationStrategy 类相当于一个代理类,默认使用的其实就是该类的实例,我们来看一下该类的 onAuthentication 方法:

public void onAuthentication(Authentication authentication,
        HttpServletRequest request, HttpServletResponse response)
                throws SessionAuthenticationException {
    for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Delegating to " + delegate);
        }
        delegate.onAuthentication(authentication, request, response);
    }
}

可以看到,这里就是遍历它所维护的 SessionAuthenticationStrategy 集合,然后分别调用其 onAuthentication 方法。

在前面的案例中,主要涉及这些 SessionAuthenticationStrategy 实例,还有其他一些 SessionAuthenticationStrategy 实例,我们将在接下来的小节中详细介绍,这里不再赞述。

SessionManagementFilter

和会话并发管理相关的过滤器主要有两个,先来看第一个 SessionManagementFilter。

SessionManagementFilter 主要用来处理 RememberMe 登录时的会话管理:即如果用户使用了 RememberMe 的方式进行认证,则认证成功后需要进行会话管理,相关的管理操作通过 SessionManagementFilter 过滤器触发。我们来看一下该过滤器的 doFilter 方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    if (request.getAttribute(FILTER_APPLIED) != null) {
        chain.doFilter(request, response);
        return;
    }

    request.setAttribute(FILTER_APPLIED, Boolean.TRUE);

    if (!securityContextRepository.containsContext(request)) {
        Authentication authentication = SecurityContextHolder.getContext()
                .getAuthentication();

        if (authentication != null && !trustResolver.isAnonymous(authentication)) {
            // The user has been authenticated during the current request, so call the
            // session strategy
            try {
                sessionAuthenticationStrategy.onAuthentication(authentication,
                        request, response);
            }
            catch (SessionAuthenticationException e) {
                // The session strategy can reject the authentication

                SecurityContextHolder.clearContext();
                failureHandler.onAuthenticationFailure(request, response, e);

                return;
            }
            // Eagerly save the security context to make it available for any possible
            // re-entrant
            // requests which may occur before the current request completes.
            // SEC-1396.
            securityContextRepository.saveContext(SecurityContextHolder.getContext(),
                    request, response);
        }
        else {
            // No security context or authentication present. Check for a session
            // timeout
            if (request.getRequestedSessionId() != null
                    && !request.isRequestedSessionIdValid()) {

                if (invalidSessionStrategy != null) {
                    invalidSessionStrategy
                            .onInvalidSessionDetected(request, response);
                    return;
                }
            }
        }
    }

    chain.doFilter(request, response);
}

在该过滤器中,通过 containsContext 方法去判断当前会话中是否存在 SPRING_SECURITY_CONTEXT 变量。如果是正常的认证流程,则 SPRING_SECURITY_CONTEXT 变量是存在于当前会话中的(本书 2.3 节关于 containsContext 方法的详细阐述)。那么什么时候不存在呢?有两种情况:

  1. 用户使用了 RememberMe 方式进行认证。

  2. 用户匿名访问某一个接口。

对于第一种情况,SecurityContextHolder 中获取到的当前用户实例是RememberMeAuthenticationToken;对于第二种情况,SecurityContextHolder 中获取到的当前用户实例是 AnonymousAuthenticationToken。所以,接下来就是对这两种情况进行区分,如果是第一种情况,则调用 SessionAuthenticationStrategy 中的 onAuthentication 方法进行会话管理;如果是第二种情况,则进行会话失效处理。

ConcurrentSessionFilter

ConcurrentSessionFilter 过滤器是一个处理会话并发管理的过滤器,我们来看一下它的 doFilter 方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    HttpSession session = request.getSession(false);

    if (session != null) {
        SessionInformation info = sessionRegistry.getSessionInformation(session
                .getId());

        if (info != null) {
            if (info.isExpired()) {
                // Expired - abort processing
                if (logger.isDebugEnabled()) {
                    logger.debug("Requested session ID "
                            + request.getRequestedSessionId() + " has expired.");
                }
                doLogout(request, response);

                this.sessionInformationExpiredStrategy.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
                return;
            }
            else {
                // Non-expired - update last request date/time
                sessionRegistry.refreshLastRequest(info.getSessionId());
            }
        }
    }

    chain.doFilter(request, response);
}

从 doFilter 方法中可以看到,当请求通过时,首先获取当前会话,如果当前会话不为 null,则进而获取当前会话所对应的 SessionInformation 实例:如果 SessionInformation 实例已经过期,则调用 doLogout 方法执行注销操作,同时调用会话过期的回调:如果 SessionInformation 实例没有过期,则刷新当前会话的最后一次请求时间。

Session创建时机

在讲解配置类之前,需要先了解一下 Spring Security 中 Session 的创建时机问题。在 Spring Security 中,HttpSession 的创建策略一共分为四种:

  • ALWAYS:如果 HttpSession 不存在,就创建。

  • NEVER:从不创建 HttpSession,但是如果 HttpSession 已经存在了,则会使用它。

  • IF_REQUIRED:当有需要时,会创建 HttpSession,默认即此。

  • STATELESS:从不创建 HttpSession,也不使用 HttpSession。

需要注意的是,这四种策略仅仅是指 Spring Security 中 HttpSession 的创建策略,而并非整个应用程序中 HttpSession 的创建策略。前三种策略都好理解,第四种策略完全不使用 HtpSession,有读者可能会有疑惑,完全不使用 HttpSession,那么 Spring Security 还能发挥作用吗?当然是可以的!如果系统使用了无状态认证方式,就可以使用 STATELESS 策略,这就意味着服务端不会创建 HttpSession,客户端的每一个请求都需要携带认证信息,同时,一些,和 HttpSession 相关的过滤器也将失效,如 SessionManagementFilter、ConcurrentSessionFilter 等。

一般来说,我们使用默认的 IF_REQUIRED 即可,如果读者需要配置,可以通过如下方式进行:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}

SessionManagementConfigurer

最后我们再来看看 SessionManagementConfigurer,正是在该配置类中,完成了上面两个过滤器的配置。

作为一个配置类,我们主要看 SessionManagementConfigurer 的 init 方法和 configure 方法,先来看 init 方法:

@Override
public void init(H http) {
    SecurityContextRepository securityContextRepository = http
            .getSharedObject(SecurityContextRepository.class);
    boolean stateless = isStateless();

    if (securityContextRepository == null) {
        if (stateless) {
            http.setSharedObject(SecurityContextRepository.class,
                    new NullSecurityContextRepository());
        }
        else {
            HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
            httpSecurityRepository
                    .setDisableUrlRewriting(!this.enableSessionUrlRewriting);
            httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
            AuthenticationTrustResolver trustResolver = http
                    .getSharedObject(AuthenticationTrustResolver.class);
            if (trustResolver != null) {
                httpSecurityRepository.setTrustResolver(trustResolver);
            }
            http.setSharedObject(SecurityContextRepository.class,
                    httpSecurityRepository);
        }
    }

    RequestCache requestCache = http.getSharedObject(RequestCache.class);
    if (requestCache == null) {
        if (stateless) {
            http.setSharedObject(RequestCache.class, new NullRequestCache());
        }
    }
    http.setSharedObject(SessionAuthenticationStrategy.class,
            getSessionAuthenticationStrategy(http));
    http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}

在该方法中,首先从 HttpSecurity 中获取 SecurityContextRepository 实例,如果没有获取到,则进行创建。创建的时候分两种情况,如果 Spring Security 中的 HtpSession 创建策略是 STATELESS,则使用 NullSecurityContextRepository 来保存 SecurityContext(相当于不保存,参见本书2.3节);如果 Spring Security 中的 HttpSession 创建策略不是 STATELESS,则构建 HttpSessionSecurityContextRepository 对象,并最终存入 HttpSecurity 的共享对象中以备使用。

如果 HttpSession 创建策略是 STATELESS,还需要将保存在 HttpSecurity 共享对象中的请求缓存对象替换为 NullRequestCache 的实例。

最后则是分别构建 SessionAuthenticationStrategy 实例和 InvalidSessionStrategy 实例存入 HttpSecurity 共享对象中,其中 SessionAuthenticationStrategy 实例是通过 getSessionAuthenticationStrategy 方法来获取的,在该方法中,一共构建了三个 SessionAuthenticationStrategy 实 例,分别是 ConcurrentSessionControlAuthenticationStrategy、ChangeSessionIdAuthenticationStrategy 以及 RegisterSessionAuthenticationStrategy,并将这三个实例由 CompositeSessionAuthenticationStrategy 进行代理,所以 getSessionAuthenticationStrategy 方法最终返回的是 CompositeSessionAuthenticationStrategy 类的实例。

再来看 configure 方法的定义:

@Override
public void configure(H http) {
    SecurityContextRepository securityContextRepository = http
            .getSharedObject(SecurityContextRepository.class);
    SessionManagementFilter sessionManagementFilter = new SessionManagementFilter(
            securityContextRepository, getSessionAuthenticationStrategy(http));
    if (this.sessionAuthenticationErrorUrl != null) {
        sessionManagementFilter.setAuthenticationFailureHandler(
                new SimpleUrlAuthenticationFailureHandler(
                        this.sessionAuthenticationErrorUrl));
    }
    InvalidSessionStrategy strategy = getInvalidSessionStrategy();
    if (strategy != null) {
        sessionManagementFilter.setInvalidSessionStrategy(strategy);
    }
    AuthenticationFailureHandler failureHandler = getSessionAuthenticationFailureHandler();
    if (failureHandler != null) {
        sessionManagementFilter.setAuthenticationFailureHandler(failureHandler);
    }
    AuthenticationTrustResolver trustResolver = http
            .getSharedObject(AuthenticationTrustResolver.class);
    if (trustResolver != null) {
        sessionManagementFilter.setTrustResolver(trustResolver);
    }
    sessionManagementFilter = postProcess(sessionManagementFilter);

    http.addFilter(sessionManagementFilter);
    if (isConcurrentSessionControlEnabled()) {
        ConcurrentSessionFilter concurrentSessionFilter = createConcurrencyFilter(http);

        concurrentSessionFilter = postProcess(concurrentSessionFilter);
        http.addFilter(concurrentSessionFilter);
    }
}

configure 方法中主要是构建了两个过滤器 SessionManagementFilter 和 ConcurrentSessionFilter。SessionManagementFilter 过滤器在创建时,也是通过 getSessionAuthenticationStrategy 方法获取 SessionAuthenticationStrategy 实例并传入 sessionManagementFilter 实例中,然后为其配置各种回调函数,最终将创建好的 SessionManagementFilter 加入 HttpSecurity 过滤器链中。

如果配置了会话并发控制(只要用户调用 .maximumSessions() 方法配置了会话最大并发数,就算开启了会话并发控制),就再创建一个 ConcurrentSessionFilter 过滤器链并加入 HttpSecurity 中。

这就是 SessionManagementConfigurer 的主要功能。

AbstractAuthenticationFilterConfigurer

看完前面的分析,读者可能还是有疑问,登录成功后,Session 并发管理到底是在哪里触发的?虽然经过前面的分析,大家知道有两个过滤器存在:SessionManagementFilter 和 ConcurrentSessionFilter,但是前者在用户使用 RememberMe 认证时,才会触发 Session 并发管理,后者则根本不会触发 Session 并发管理,那么用户登录成功后,到底是在哪里触发 Session 并发管理的呢?

这里我们可以回到登录过滤器 AbstractAuthenticationProcessingFilter 的 doFilter 方法中去看一看了。我们发现,在其 doFilter 方法中有如下一段代码:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {

    // 省略

    try {
        authResult = attemptAuthentication(request, response);
        if (authResult == null) {
            // return immediately as subclass has indicated that it hasn't completed
            // authentication
            return;
        }
        sessionStrategy.onAuthentication(authResult, request, response);
    }

    // 省略
}

可以看到,在调用 attemptAuthentication 方法进行登录认证之后,接下来就调用了 sessionStrategy.onAuthentication 方法触发 Session 并发管理。

这里的 sessionStrategy 对象则是在 AbstractAuthenticationFilterConfigurer 类的 configure 方法中进行配置的,代码如下:

public void configure(B http) throws Exception {

    // 省略

    SessionAuthenticationStrategy sessionAuthenticationStrategy = http
            .getSharedObject(SessionAuthenticationStrategy.class);
    if (sessionAuthenticationStrategy != null) {
        authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
    }

    // 省略
}

可以看到,这里从 HttpSecurity 的共享对象中获取到 SessionAuthenticationStrategy 实例(在 SessionManagementConfigurer#init 方法中存入 HttpSecurity 共享对象),并设置到 authFilter 过滤器中。

我们再来梳理一下:

用户通过用户名/密码发起一个认证请求,当认证成功后,在 AbstractAuthenticationProcessingFilter#doFilter 方法中触发了 Session 并发管理。默认的 sessionStrategy 是 CompositeSessionAuthenticationStrategy,它一共代理了三个 SessionAuthenticationStrategy,分别是 ConcurrentSessionControlAuthenticationStrategy 、ChangeSessionIdAuthenticationStrategy 以及 RegisterSessionAuthenticationStrategy。当前请求在这三个 SessionAuthenticationStrategy 中分别走一圈,第一个用来判断当前登录用户的 Session 数是否已经超出限制,如果超出限制就根据配置好的规则作出处理:第二个用来修改 sessionId(防止会话固定攻击):第三个用来将当前 Session 注册到 SessionRegistry 中。使用用户名/密码的方式完成认证,将不会涉及 ConcurrentSessionFilter 和 SessionManagementFilter 两个过滤器。如果用户使用了 RememberMe 的方式来进行身份认证,则会通过 SessionManagementFilter#doFilter 方法触发 Session 并发管理。当用户认证成功后,以后的每一次请求都会经过 ConcurrentSessionFilter,在该过滤器中,判断当前会话是否已经过期,如果过期就执行注销登录流程;如果没有过期,则更新最近一次请求时间。