前言
上回我们聊了Spring Security之登录跳转,今天我们聊另外一个与功能相关的功能:多点登录控制。
多点登录控制
搞Web应用的同学都知道每个登录者的信息都是保存在Session之中,每个请求都会给带来一个唯一的sessionId。那么,这便是我们控制多点登录的切入口,或者说,基本实现思路。
假设A在C1电脑上登录,然后又在另外一台电脑C登录,那么在后台就会产生2个Session,如果我们把每个账号登录的Session记录下来,那不就能知道这个账号在几处登录了。那么,我们想要禁止账号多点登录——只能在一个地方登录——就可以限制住了。
SpringSecurity的实现
在Spring Security之Session管理中,我们在介绍的同时也聊到这个多点登录控制这个功能点,但并没有完全给大家串起来,因为那里的重点不是这个。今天咱就当复习,也来串一下流程。
多点登录控制流程分析
假设我们不允许多点登录,也就是只能在一个地方登录。
- 在电脑C1上登录成功,我们记录下登录成功的账号和sessionId。
- 再到电脑C2上登录,认证成功。但我们在校验当前登录的账号的session时,发现已经存在一个登录状态的session,且与当前session不一样。于是我们就抛出异常。(当然,我们也可以选择把第一个session设置为失效,即强制退出)
- 回到C1登出。将对应账号的session移除。
- 再到C2登录,校验通过,允许登录。
如果做的完善一些的话,这个功能还应该加上:
每次请求都检查session是否失效,然后移除账号session记录。
多点登录方案分析
基于上述流程分析结果,我们就能得到如下结论
- 需要一个管理账号session信息的组件
- 需要一个新的过滤器来检查每个请求的session是否有效。
- 登录成功时,校验账号的session个数。校验通过,就登记,否则就拒绝。
- 登出成功时,移除账号的session记录。
从上面的流程,我们可以看到,这个功能涉及到登录和登出。所以我们最好把这个功能抽离出来,而不是都放到登录里面。但很明显我们必须要在登录跟登出使用到同一个组件,不然这session记录就容易出问题。
Spring的实现
-
SessionRegistry
负责维护账号的session信息。当前只有一个实现:SessionRegistryImpl
。/** * 值得注意的是,他监听了AbstractSessionEvent, * 可以通过session事件来管理session信息。 * 而这些事件,则是SpringBoot内嵌的Tomcat发布的。 * @see HttpSessionListener * @see HttpSessionEventPublisher */ public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> { // <principal:Object,SessionIdSet> // 映射:登录的用户信息,sessionId集合 private final ConcurrentMap<Object, Set<String>> principals; // <sessionId:Object,SessionInformation> // 映射:sessionId,session信息 private final Map<String, SessionInformation> sessionIds; @Override public void onApplicationEvent(AbstractSessionEvent event) { if (event instanceof SessionDestroyedEvent) { // 销毁session -- 通常是session超时 SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent) event; String sessionId = sessionDestroyedEvent.getId(); // 移除session信息 removeSessionInformation(sessionId); } else if (event instanceof SessionIdChangedEvent) { // session变更 -- 登录成功后,sessionId会发生变化 SessionIdChangedEvent sessionIdChangedEvent = (SessionIdChangedEvent) event; String oldSessionId = sessionIdChangedEvent.getOldSessionId(); if (this.sessionIds.containsKey(oldSessionId)) { Object principal = this.sessionIds.get(oldSessionId).getPrincipal(); // 移除老session removeSessionInformation(oldSessionId); // 注册新session registerNewSession(sessionIdChangedEvent.getNewSessionId(), principal); } } } }
其最主要的接口行为:
public interface SessionRegistry { // 获取某个账号/用户当前的所有session List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions); // 根据sessionId获取session信息 SessionInformation getSessionInformation(String sessionId); // 注册新的session void registerNewSession(String sessionId, Object principal); // 刷新session时间 void refreshLastRequest(String sessionId); // 移除session void removeSessionInformation(String sessionId); }
-
ConcurrentSessionFilter
负责检查每个请求的session是否过期,如果过期,则从session注册器中移除。若没过期,就刷新时间。这样看的话,这个命名似乎有那么一点问题,因为他关注的是session是否过期。public class ConcurrentSessionFilter extends GenericFilterBean { private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { HttpSession session = request.getSession(false); if (session != null) { // 通过session注册器获取session信息 SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId()); if (info != null) { if (info.isExpired()) { // session已失效/过期,调用登出:就是要清理session信息 doLogout(request, response); // 通过SessionInformationExpiredStrategy处理超时 this.sessionInformationExpiredStrategy .onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response)); return; } // 没失效就刷新时间 this.sessionRegistry.refreshLastRequest(info.getSessionId()); } } chain.doFilter(request, response); } private void doLogout(HttpServletRequest request, HttpServletResponse response) { // 获取认证信息 Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication(); // 调用登出处理器 this.handlers.logout(request, response, auth); } }
-
SessionAuthenticationStrategy
在聊Session管理的时候对这个组件详细了解过。既然如此就跟大家一起复习一下。
实际上,SpringSecurity构建了两个不同的组件来协助我们完成这一session并发控制功能。详见我们的Session管理SessionAuthenticationStrategy这一小节。ConcurrentSessionControlAuthenticationStrategy
负责校验并发数量。RegisterSessionAuthenticationStrategy
负责注册新的session。
不过,这区分之后,两个session策略的执行顺序就必须是先校验再注册。实际上,SpringSecurity也是如此配置的。感兴趣的不妨再回头看下Session管理的《SessionManagementConfigurer》这一小结。
小结
眼尖的同学可能发现了,既然SessionRegistryImpl都监听了session事件,那为啥还要ConcurrentSessionFilter干啥?SessionRegitryImpl通过事件不就能实现刷新session记录时间和移除过期session了。
这个问题,是因为默认情况下,spring mvc的HttpSessionEventPublisher组件并不会声明,因此没有作用,自然不会发布相关事件。
要生效也简单,只需要声明一下就行。
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
完整的流程
假设我们配置最大的session个数为1,也就是不允许多点登录。
- 用户在C1登录
- 进入认证过滤器
UsernamePasswordAuthenticationFilter
,登录成功,使用SessionAuthenticationStrategy
处理session。于是,经过ConcurrentSessionControlAuthenticationStrategy
校验并发数量通过。再经过RegisterSessionAuthenticationStrategy
将session登记到SessionRegistry
。 - 用户再到C2登录
- 重复步骤2,但
ConcurrentSessionControlAuthenticationStrategy
检查发现超出数量限制。于是当前登录被拒绝,并抛出SessionAuthenticationException
。 ExceptionTranslationFilter
检测到认证异常,重置用户登录态。包括SecurityContext,并跳转到登录页面。
后记
给大家一个任务,如果让你不使用框架,自己写过滤器实现单点登录功能,会怎么做呢?不妨参考上面的流程试试看?
下次,我准备跟大家聊聊RememberMe功能。