Spring Security里Session的探讨与延伸

Spring Security里Session的探讨与延伸

✍️问题的引入

在上文中,我们简单分析了一个基于RBAC模型的Spring Security的设计思路,通过配置,我们的确登陆成功了,但是有没有想过一个问题,你在服务端的安全管理使用了 Spring Security,用户登录成功之后,Spring Security 帮你把用户信息保存在 Session 里,但是具体保存在哪里,要是不深究你可能就不知道, 这带来了一个问题,如果用户在前端操作修改了当前用户信息,在不重新登录的情况下,如何获取到最新的用户信息?

这也是一直困扰我的问题,我是通过阅读源码发现了关键的一步,并利用这个Key去实现了额外的功能,这个我们到时候再分析。

🔫又是Authentication

在 Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息,Authentication 本身是一个接口,它有很多实现类:

image-20210809222725161

在这众多的实现类中,我们最常用的就是UsernamePasswordAuthenticationToken了,但是当我们打开这个类的源码后,却发现这个类平平无奇,他只有两个属性、两个构造方法以及若干个 get/set 方法;当然,他还有更多属性在它的父类上。

但是从它仅有的这两个属性中,我们也能大致看出,这个类就保存了我们登录用户的基本信息。

image-20210809222813938

那么我们的登录信息是如何存到这两个对象中的?这就要来梳理一下登录流程了。

⏳登录流程

在 Spring Security 中,认证与授权的相关校验都是在一系列的过滤器链中完成的,在这一系列的过滤器链中,和认证相关的过滤器就是 UsernamePasswordAuthenticationFilter,篇幅问题,我这里列出来该类中几个重要方法:

public class UsernamePasswordAuthenticationFilter extends
		AbstractAuthenticationProcessingFilter {
	public UsernamePasswordAuthenticationFilter() {
		super(new AntPathRequestMatcher("/login", "POST"));
	}
	public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException {
		String username = obtainUsername(request);
		String password = obtainPassword(request);
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
				username, password);
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(passwordParameter);
	}
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(usernameParameter);
	}
	protected void setDetails(HttpServletRequest request,
			UsernamePasswordAuthenticationToken authRequest) {
		authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
	}
}

根据这段源码我们可以看出:

  1. 首先通过 obtainUsernameobtainPassword 方法提取出请求里边的用户名/密码出来,提取方式就是 request.getParameter ,这也是为什么 Spring Security 中默认的表单登录要通过 key/value 的形式传递参数,而不能传递 JSON 参数,如果像传递 JSON 参数,修改这里的逻辑即可(参考RBAC的实现)。
  2. 获取到请求里传递来的用户名/密码之后,接下来就构造一个 UsernamePasswordAuthenticationToken 对象,传入 username 和 password,username 对应了 UsernamePasswordAuthenticationToken 中的 principal 属性,而 password 则对应了它的 credentials 属性。
  3. 接下来 setDetails 方法给 details 属性赋值,UsernamePasswordAuthenticationToken 本身是没有 details 属性的,这个属性在它的父类 AbstractAuthenticationToken 中。details 是一个对象,这个对象里边放的是 WebAuthenticationDetails 实例,该实例主要描述了两个信息,请求的 remoteAddress 以及请求的 sessionId
  4. 最后一步,就是调用 authenticate 方法去做校验了。

从这段源码中,大家可以看出来请求的各种信息基本上都找到了自己的位置,找到了位置,这就方便我们未来去获取了。

接下来我们再来看请求的具体校验操作。

在前面的attemptAuthentication方法中,该方法的最后一步开始做校验,校验操作首先要获取到一个 AuthenticationManager,这里拿到的是 ProviderManager ,所以接下来我们就进入到ProviderManagerauthenticate方法中,当然这个方法也比较长,我这里仅仅摘列出来几个重要的地方:

public Authentication authenticate(Authentication authentication)
		throws AuthenticationException {
	Class<? extends Authentication> toTest = authentication.getClass();
	for (AuthenticationProvider provider : getProviders()) {
		if (!provider.supports(toTest)) {
			continue;
		}
		result = provider.authenticate(authentication);
		if (result != null) {
			copyDetails(authentication, result);
			break;
		}
	}
	if (result == null && parent != null) {
		result = parentResult = parent.authenticate(authentication);
	}
	if (result != null) {
		if (eraseCredentialsAfterAuthentication
				&& (result instanceof CredentialsContainer)) {
			((CredentialsContainer) result).eraseCredentials();
		}
		if (parentResult == null) {
			eventPublisher.publishAuthenticationSuccess(result);
		}
		return result;
	}
	throw lastException;
}

这个方法就比较魔幻了,因为几乎关于认证的重要逻辑都将在这里完成:

  1. 首先获取 authentication 的 Class,判断当前 provider 是否支持该 authentication
  2. 如果支持,则调用 providerauthenticate 方法开始做校验,校验完成后,会返回一个新的 Authentication。一会来和大家捋这个方法的具体逻辑。
  3. 这里的 provider 可能有多个,如果 provider authenticate 方法没能正常返回一个 Authentication,则调用 providerparentauthenticate 方法继续校验。
  4. copyDetails 方法则用来把旧的 Token 的 details 属性拷贝到新的 Token 中来。
  5. 接下来会调用 eraseCredentials 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
  6. 最后通过 publishAuthenticationSuccess 方法将登录成功的事件广播出去。

大致的流程,就是上面这样,在 for 循环中,第一次拿到的 provider 是一个 AnonymousAuthenticationProvider,这个 provider 压根就不支持 UsernamePasswordAuthenticationToken,也就是会直接在 provider.supports 方法中返回 false,结束 for 循环,然后会进入到下一个 if 中,直接调用 parent 的 authenticate 方法进行校验。

而 parent 就是 ProviderManager,所以会再次回到这个 authenticate 方法中。再次回到 authenticate 方法中,provider 也变成了 DaoAuthenticationProvider,这个 provider 是支持 UsernamePasswordAuthenticationToken 的,所以会顺利进入到该类的 authenticate 方法去执行,而 DaoAuthenticationProvider 继承自 AbstractUserDetailsAuthenticationProvider 并且没有重写 authenticate 方法,所以 我们最终来到 AbstractUserDetailsAuthenticationProvider#authenticate 方法中:

public Authentication authenticate(Authentication authentication)
		throws AuthenticationException {
	String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
			: authentication.getName();
	user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
	preAuthenticationChecks.check(user);
	additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
	postAuthenticationChecks.check(user);
	Object principalToReturn = user;
	if (forcePrincipalAsString) {
		principalToReturn = user.getUsername();
	}
	return createSuccessAuthentication(principalToReturn, authentication, user);
}

这里的逻辑就比较简单了:

  1. 首先从 Authentication 提取出登录用户名。
  2. 然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象,这一步会调用我们自己在登录时候的写的loadUserByUsername方法,所以这里返回的 user 其实就是你的登录对象,可以参考我github中的29行
  3. 接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。
  4. additionalAuthenticationChecks 方法则是做密码比对的,好多人好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了,因为比较的逻辑很简单,我这里就不贴代码出来了。
  5. 最后在 postAuthenticationChecks.check 方法中检查密码是否过期。
  6. 接下来有一个 forcePrincipalAsString 属性,这个是是否强制将 Authentication 中的 principal 属性设置为字符串,这个属性我们一开始在UsernamePasswordAuthenticationFilter类中其实就是设置为字符串的(即 username),但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。之所以会这样,就是因为 forcePrincipalAsString 默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。
  7. 最后,通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken

好了,那么登录的校验流程现在就基本和大家捋了一遍了。那么接下来还有一个问题,登录的用户信息我们去哪里查找?

⚙️用户信息保存

要去找登录的用户信息,我们得先来解决一个问题,就是上面我们说了这么多,这一切是从哪里开始被触发的?

我们来到 UsernamePasswordAuthenticationFilter 的父类 AbstractAuthenticationProcessingFilter 中,这个类我们经常会见到,因为很多时候当我们想要在 Spring Security 自定义一个登录验证码或者将登录参数改为 JSON 的时候,我们都需自定义过滤器继承自 AbstractAuthenticationProcessingFilter ,毫无疑问,UsernamePasswordAuthenticationFilter#attemptAuthentication 方法就是在 AbstractAuthenticationProcessingFilter 类的 doFilter 方法中被触发的:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
		throws IOException, ServletException {
	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;
	Authentication authResult;
	try {
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			return;
		}
		sessionStrategy.onAuthentication(authResult, request, response);
	}
	catch (InternalAuthenticationServiceException failed) {
		unsuccessfulAuthentication(request, response, failed);
		return;
	}
	catch (AuthenticationException failed) {
		unsuccessfulAuthentication(request, response, failed);
		return;
	}
	if (continueChainBeforeSuccessfulAuthentication) {
		chain.doFilter(request, response);
	}
	successfulAuthentication(request, response, chain, authResult);
}

从上面的代码中,我们可以看到,当 attemptAuthentication 方法被调用时,实际上就是触发了 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法,当登录抛出异常的时候,unsuccessfulAuthentication 方法会被调用,而当登录成功的时候,successfulAuthentication 方法则会被调用,那我们就来看一看 successfulAuthentication 方法:

protected void successfulAuthentication(HttpServletRequest request,
		HttpServletResponse response, FilterChain chain, Authentication authResult)
		throws IOException, ServletException {
  //本文问题的key
	SecurityContextHolder.getContext().setAuthentication(authResult);
	rememberMeServices.loginSuccess(request, response, authResult);
	// Fire event
	if (this.eventPublisher != null) {
		eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
				authResult, this.getClass()));
	}
	successHandler.onAuthenticationSuccess(request, response, authResult);
}

在这里有一段很重要的代码,就是 SecurityContextHolder.getContext().setAuthentication(authResult); ,登录成功的用户信息被保存在这里,也就是说,在任何地方,如果我们想获取用户登录信息,都可以从 SecurityContextHolder.getContext() 中获取到,想修改,也可以在这里修改。

注意,这里的authResult存的是一个ThreadLocal的变量

最后大家还看到有一个 successHandler.onAuthenticationSuccess,这就是我们在 SecurityConfig 中配置登录成功回调方法,就是在这里被触发的,这块大家也可以参考我github里边的配置

到此为止,我们Session的信息也能确认也保存到这里了,拿到这个信息,我们就可以按着这里思路去我github上的后端管理项目中设计新的内容,比如我在个人中心中,想要在不重新登录的情况下,获取到最新的用户信息,下面我将针对个人中心的设计思路进行阐述

💣如果登陆失败呢?

🔑引入

正确情况下,当我们登录成功后,可以通过如下方式获取到当前登录用户信息:

  1. SecurityContextHolder.getContext().getAuthentication()
  2. 在 Controller 的方法中,加入 Authentication 参数

正常情况下,我们通过如上两种方式的任意一种就可以获取到已经登录的用户信息。

但是我们需要思考,异常情况,就是这两种方式中的任意一种,都返回 null。

都返回 null,意味着系统收到当前请求时并不知道你已经登录了(因为你没有在系统中留下任何有效信息),这会带来两个问题:

  1. 无法获取到当前登录用户信息。
  2. 当你发送任何请求,系统都会给你返回 401。

🔒开始剖析

要弄明白这个问题,我们就得明白 Spring Security 中的用户信息到底是在哪里存的?

前面说了两种数据获取方式,但是这两种数据获取方式,获取到的数据又是从哪里来的?

SecurityContextHolder 中的数据,本质上是保存在 ThreadLocal 中,ThreadLocal 的特点是存在它里边的数据,哪个线程存的,哪个线程才能访问到。

这样就带来一个问题,当不同的请求进入到服务端之后,由不同的 thread 去处理,按理说后面的请求就可能无法获取到登录请求的线程存入的数据,例如登录请求在线程 A 中将登录用户信息存入 ThreadLocal,后面的请求来了,在线程 B 中处理,那此时就无法获取到用户的登录信息。

但实际上,正常情况下,我们每次都能够获取到登录用户信息,这又是怎么回事呢?

这我们就要引入 Spring Security 中的 SecurityContextPersistenceFilter了。

小伙伴们都知道,无论是 Spring Security 还是 Shiro,它的一系列功能其实都是由过滤器来完成的,在 Spring Security 中,我们熟悉的是UsernamePasswordAuthenticationFilter 过滤器,在这个过滤器之前,还有一个过滤器就是 SecurityContextPersistenceFilter,请求在到达 UsernamePasswordAuthenticationFilter 之前都会先经过 SecurityContextPersistenceFilter

我们来看下它的源码(部分):

public class SecurityContextPersistenceFilter extends GenericFilterBean {
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
		HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
				response);
		SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
		try {
			SecurityContextHolder.setContext(contextBeforeChainExecution);
			chain.doFilter(holder.getRequest(), holder.getResponse());
		}
		finally {
			SecurityContext contextAfterChainExecution = SecurityContextHolder
					.getContext();
			SecurityContextHolder.clearContext();
			repo.saveContext(contextAfterChainExecution, holder.getRequest(),
					holder.getResponse());
		}
	}
}

原本的方法很长,我这里列出来了比较关键的几个部分:

  1. SecurityContextPersistenceFilter 继承自 GenericFilterBean,而 GenericFilterBean 则是 Filter 的实现,所以 SecurityContextPersistenceFilter 作为一个过滤器,它里边最重要的方法就是 doFilter 了。
  2. 在 doFilter 方法中,它首先会从 repo 中读取一个 SecurityContext 出来,这里的 repo 实际上就是 HttpSessionSecurityContextRepository,读取 SecurityContext 的操作会进入到 readSecurityContextFromSession 方法中,在这里我们看到了读取的核心方法 Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);,这里的 springSecurityContextKey 对象的值就是 SPRING_SECURITY_CONTEXT,读取出来的对象最终会被转为一个 SecurityContext 对象。
  3. SecurityContext 是一个接口,它有一个唯一的实现类 SecurityContextImpl,这个实现类其实就是用户信息在 session 中保存的 value。
  4. 在拿到 SecurityContext 之后,通过 SecurityContextHolder.setContext 方法将这个 SecurityContext 设置到 ThreadLocal 中去,这样,在当前请求中,Spring Security 的后续操作,我们都可以直接从SecurityContextHolder中获取到用户信息了。
  5. 接下来,通过 chain.doFilter 让请求继续向下走(这个时候就会进入到 UsernamePasswordAuthenticationFilter 过滤器中了)。
  6. 在过滤器链走完之后,数据响应给前端之后,finally 中还有一步收尾操作,这一步很关键。这里从 SecurityContextHolder 中获取到 SecurityContext,获取到之后,会把 SecurityContextHolder 清空,然后调用 repo.saveContext 方法将获取到的SecurityContext存入 session 中。

至此,整个流程就很明了了。

每一个请求到达服务端的时候,首先从 session 中找出来 SecurityContext ,然后设置到 SecurityContextHolder 中去,方便后续使用,当这个请求离开的时候,SecurityContextHolder 会被清空,SecurityContext 会被放回 session 中,方便下一个请求来的时候获取。

搞明白这一点之后,再去解决 Spring Security 登录后无法获取到当前登录用户这个问题,就非常 easy 了。

😄问题解决

经过上面的分析之后,我们再来回顾一下为什么会发生登录之后无法获取到当前用户信息这样的事情?

最简单情况的就是你在一个新的线程中去执行 SecurityContextHolder.getContext().getAuthentication(),这肯定获取不到用户信息,无需多说。例如下面这样:

@GetMapping("/menu")
public List<Menu> getMenusByHrId() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            System.out.println(authentication);
        }
    }).start();
    return menuService.getMenusByHrId();
}

这种简单的问题相信大家都能够很容易排查到。

还有一种隐藏比较深的就是在 SecurityContextPersistenceFilter 的 doFilter 方法中没能从 session 中加载到用户信息,进而导致 SecurityContextHolder 里边空空如也。

SecurityContextPersistenceFilter 中没能加载到用户信息,原因可能就比较多了,例如:

  • 「上一个请求临走的时候,没有将数据存储到 session 中去。」
  • 「当前请求自己没走过滤器链。」

什么时候会发生这个问题呢?有的小伙伴可能在配置 SecurityConfig#configure(WebSecurity) 方法时,会忽略掉一个重要的点。

当我们想让 Spring Security 中的资源可以匿名访问时,我们有两种办法:

  1. 不走 Spring Security 过滤器链。
  2. 继续走 Spring Security 过滤器链,但是可以匿名访问。

这两种办法对应了两种不同的配置方式。其中第一种配置可能会影响到我们获取登录用户信息,第二种则不影响,所以这里我们来重点看看第一种。

不想走 Spring Security 过滤器链,我们一般可以通过如下方式配置:

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode");
}

正常这样配置是没有问题的。

如果你很不巧,把登录请求地址放进来了,那就 gg 了。虽然登录请求可以被所有人访问,但是不能放在这里(而应该通过允许匿名访问的方式来给请求放行)。「如果放在这里,登录请求将不走 SecurityContextPersistenceFilter 过滤器,也就意味着不会将登录用户信息存入 session,进而导致后续请求无法获取到登录用户信息。」

👤沿着路:个人中心的设计

🔑如何获取用户信息

在Spring Security中提供了Authentication,可以直接在Controller中直接注入就可以使用

@GetMapping("/hr/info")
public Hr getCurrentHr(Authentication authentication) {
    return ((Hr) authentication.getPrincipal());
}

🔑如何修改用户信息

因为Spring Security帮你把Session保存好了,自己没处理,所以只能通过上诉分析的结果来更改,接下来定义接口:

相当于重新构建一个Authentication 实例放到Context中去,又分为:

  • 修改用户的基础信息,不需要重新登录
  • 修改密码后需要重新登录(在前端处理此逻辑)
@PutMapping("/hr/info")
public RespBean updateHr(@RequestBody Hr hr, Authentication authentication) {
    if (hrService.updateHr(hr) == 1) {
      	//利用上面的思路,直接修改
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(hr, authentication.getCredentials(), authentication.getAuthorities()));
        return RespBean.ok("更新成功!");
    }
    return RespBean.error("更新失败!");
}

@PutMapping("/hr/pass")
public RespBean updateHrPasswd(@RequestBody Map<String, Object> info) {
    String oldpass = (String) info.get("oldpass");
    String pass = (String) info.get("pass");
    Integer hrid = (Integer) info.get("hrid");
    if (hrService.updateHrPasswd(oldpass, pass, hrid)) {
        return RespBean.ok("更新成功!");
    }
    return RespBean.error("更新失败!");
}

因为修改密码需要设计到数据库的操作,然后设计Service层

public boolean updateHrPasswd(String oldpass, String pass, Integer hrid) {
    Hr hr = hrMapper.selectByPrimaryKey(hrid);
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    if (encoder.matches(oldpass, hr.getPassword())) {
        String encodePass = encoder.encode(pass);
        Integer result = hrMapper.updatePasswd(hrid, encodePass);
        if (result == 1) {
            return true;
        }
    }
    return false;
}

然后定义Mapper

<update id="updatePasswd">
  update hr set password = #{encodePass} where id=#{hrid};
</update>

这样就完成了个人中心的基础配置,修改头像可以存在云端或者自己本地的服务器上,看自己如何选择,我是用FaseDFS完成了存储的问题,以后有机会另外写一篇文章讨论

这样关于Session的问题,我们还能继续探讨,在现实中我们会有这样的请求,已经登陆了的用户,再另一端登陆的时候,需要把之前登陆的信息抹除并踢掉,防止多端登陆,造成不必要的麻烦

🦶继续走:踢掉已登录用户

🤔分析需求

在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑

要实现一个用户不可以同时在两台设备上登录,我们有两种思路:

  • 后来的登录自动踢掉前面的登录,就像大家在QQ中看到的效果。
  • 如果用户已经登录,则不允许后来者登录。

这种思路都能实现这个功能,具体使用哪一个,还要看我们具体的需求。

🔧基础配置

想要踢掉已登录的用户配置起来比较简单,我们只需要将最大会话数设置为 1 即可,配置如下:

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

setMaximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。

如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .sessionManagement()
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true);
}

添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。

maxSessionsPreventsLogin提供两种session保护策略:

  • true表示已经登录就不予许再次登录,
  • false表示允许再次登录但是之前的登录账户会被踢下线

不过还没完,我们还需要再提供一个 Bean:

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

为什么要加这个 Bean 呢?因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的,这一个失效事件无法被 Spring 容器感知到,进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来(大家可以不添加上面的 Bean,然后让用户注销登录之后再重新登录)。

为了解决这一问题,我们提供一个 HttpSessionEventPublisher ,这个类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到,该类部分源码如下:

public void sessionCreated(HttpSessionEvent event) {
	HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession());
	getContext(event.getSession().getServletContext()).publishEvent(e);
}
public void sessionDestroyed(HttpSessionEvent event) {
	HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
	getContext(event.getSession().getServletContext()).publishEvent(e);
}

OK,虽然多了一个配置,但是依然很简单!

📖实现原理

上面这个功能,在 Spring Security 中是怎么实现的呢?我们来稍微分析一下源码。

首先我们知道,在用户登录的过程中,会经过 UsernamePasswordAuthenticationFilter,而 UsernamePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFilter 中触发的,我们来看下 AbstractAuthenticationProcessingFilter#doFilter 方法的调用:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
		throws IOException, ServletException {
	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;
	if (!requiresAuthentication(request, response)) {
		chain.doFilter(request, response);
		return;
	}
	Authentication authResult;
	try {
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			return;
		}
		sessionStrategy.onAuthentication(authResult, request, response);
	}
	catch (InternalAuthenticationServiceException failed) {
		unsuccessfulAuthentication(request, response, failed);
		return;
	}
	catch (AuthenticationException failed) {
		unsuccessfulAuthentication(request, response, failed);
		return;
	}
	// Authentication success
	if (continueChainBeforeSuccessfulAuthentication) {
		chain.doFilter(request, response);
	}
	successfulAuthentication(request, response, chain, authResult);

在这段代码中,我们可以看到,调用 attemptAuthentication 方法走完认证流程之后,回来之后,接下来就是调用 sessionStrategy.onAuthentication 方法,这个方法就是用来处理 session 的并发问题的。具体在:

public class ConcurrentSessionControlAuthenticationStrategy implements
		MessageSourceAware, SessionAuthenticationStrategy {
	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);
	}
	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();
		}
	}
}

这段核心代码我来给大家稍微解释下:

  1. 首先调用 sessionRegistry.getAllSessions 方法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主题对应的 sessionid 组成的一个集合)。
  2. 接下来计算出当前用户已经有几个有效 session 了,同时获取允许的 session 并发数。
  3. 如果当前 session 数(sessionCount)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,表示对 session 数量不做任何限制。
  4. 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先看看当前 session 是否不为 null,并且已经存在于 sessions 中了,如果已经存在了,那都是自家人,不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
  5. 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
  6. allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximumExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false,如果为 true,就直接抛出异常,那么这次登录就失败了(禁止新的登录),如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可(踢掉已经登录用户)。

🪣用户配置放入库

但是,就是我们的用户是配置在内存中的用户,我们没有将用户放到数据库中去。在做 Spring Security 的 session 并发处理时,直接将内存中的用户切换为数据库中的用户会有问题

我们就要先搞明白 Spring Security 是怎么保存用户对象和 session 的。

Spring Security 中通过 SessionRegistryImpl 类来实现对会话信息的统一管理,我们来看下这个类的源码(部分):

public class SessionRegistryImpl implements SessionRegistry,
		ApplicationListener<SessionDestroyedEvent> {
	/** <principal:Object,SessionIdSet> */
	private final ConcurrentMap<Object, Set<String>> principals;
	/** <sessionId:Object,SessionInformation> */
	private final Map<String, SessionInformation> sessionIds;
	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;
		});
	}
	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;
		});
	}

}

这个类的源码还是比较长,我这里提取出来一些比较关键的部分:

  1. 首先大家看到,一上来声明了一个principals对象,这是一个支持并发访问的 map 集合,集合的 key 就是用户的主体(principal),正常来说,用户的 principal 其实就是用户对象,在之前的文章中也和大家讲过 principal 是怎么样存入到 Authentication 中的,而集合的 value 则是一个 set 集合,这个 set 集合中保存了这个用户对应的 sessionid
  2. 如有新的 session 需要添加,就在 registerNewSession 方法中进行添加,具体是调用 principals.compute 方法进行添加,key 就是 principal
  3. 如果用户注销登录,sessionid 需要移除,相关操作在 removeSessionInformation 方法中完成,具体也是调用 principals.computeIfPresent 方法,这些关于集合的基本操作我就不再赘述了。

看到这里,大家发现一个问题,ConcurrentMap 集合的 key 是 principal 对象,用对象做 key,一定要重写 equals 方法和 hashCode 方法,否则第一次存完数据,下次就找不到了,这是 JavaSE 方面的知识,我就不用多说了。

首先第一步,我们重写实体类的 equals 和 hashCode 方法,如下:

public class Hr implements UserDetails {
    ...
    ...
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Hr hr = (Hr) o;
        return Objects.equals(username, hr.username);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username);
    }
    ...
    ...
}

💻结合项目

由于目前是采用了 JSON 格式登录,所以如果项目控制 session 并发数,就会有一些额外的问题要处理。

最大的问题在于我们用自定义的过滤器代替了 UsernamePasswordAuthenticationFilter,进而导致前面所讲的关于 session 的配置,统统失效。所有相关的配置我们都要在新的过滤器 LoginFilter 中进行配置 ,包括 SessionAuthenticationStrategy 也需要我们自己手动配置了。

接下来在 SecurityConfig 中进行配置。

这里我们要自己提供 SessionAuthenticationStrategy,而前面处理 session 并发的是 ConcurrentSessionControlAuthenticationStrategy,也就是说,我们需要自己提供一个 ConcurrentSessionControlAuthenticationStrategy 的实例,然后配置给 LoginFilter,但是在创建 ConcurrentSessionControlAuthenticationStrategy 实例的过程中,还需要有一个 SessionRegistryImpl 对象。

前面我们说过,SessionRegistryImpl 对象是用来维护会话信息的,现在这个东西也要我们自己来提供,SessionRegistryImpl 实例很好创建,如下:

@Bean
SessionRegistryImpl sessionRegistry() {
    return new SessionRegistryImpl();
}

然后在 SecurityConfig 中的 LoginFilter 中配置 SessionAuthenticationStrategy,如下:

@Bean
LoginFilter loginFilter() throws Exception {
    LoginFilter loginFilter = new LoginFilter();
		//自己提供   
    ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
    sessionStrategy.setMaximumSessions(1);
    loginFilter.setSessionAuthenticationStrategy(sessionStrategy);
    return loginFilter;
}

我们在这里自己手动构建 ConcurrentSessionControlAuthenticationStrategy 实例,构建时传递 SessionRegistryImpl 参数,然后设置 session 的并发数为 1,最后再将 sessionStrategy 配置给 LoginFilter

这就配置完了吗?没有!session 处理还有一个关键的过滤器叫做 ConcurrentSessionFilter,本来这个过滤器是不需要我们管的,但是这个过滤器中也用到了 SessionRegistryImpl,而 SessionRegistryImpl 现在是由我们自己来定义的,所以,该过滤器我们也要重新配置一下,如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            ...
    http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> {
        HttpServletResponse resp = event.getResponse();
        resp.setContentType("application/json;charset=utf-8");
        resp.setStatus(401);
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另一台设备登录,本次登录已下线!")));
        out.flush();
        out.close();
    }), ConcurrentSessionFilter.class);
}

在这里,我们重新创建一个 ConcurrentSessionFilter 的实例,代替系统默认的即可。在创建新的 ConcurrentSessionFilter 实例时,需要两个参数:

  1. sessionRegistry 就是我们前面提供的 SessionRegistryImpl 实例。
  2. 第二个参数,是一个处理 session 过期后的回调函数,也就是说,当用户被另外一个登录踢下线之后,你要给什么样的下线提示,就在这里来完成。

最后,我们还需要在处理完登录数据之后,手动向 SessionRegistryImpl 中添加一条记录:

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    @Autowired
    SessionRegistry sessionRegistry;
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        //省略
            Hr principal = new Hr();
            principal.setUsername(username);
            sessionRegistry.registerNewSession(request.getSession(true).getId(), principal);
            return this.getAuthenticationManager().authenticate(authRequest);
        } 
        ...
        ...
    }
}

在这里,我们手动调用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一条 session 记录。

此刻,我们在基于上篇文章“浅谈Spring Security实现RBAC模型”中的配置类又变得有所丰富起来,这里附上过滤器和配置类的全部代码:

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    @Autowired
    SessionRegistry sessionRegistry;
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        String verify_code = (String) request.getSession().getAttribute("verify_code");
        if (request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().contains(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
            Map<String, String> loginData = new HashMap<>();
            try {
                loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            } catch (IOException e) {
            }finally {
                String code = loginData.get("code");
                checkCode(response, code, verify_code);
            }
            String username = loginData.get(getUsernameParameter());
            String password = loginData.get(getPasswordParameter());
            if (username == null) {
                username = "";
            }
            if (password == null) {
                password = "";
            }
            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    username, password);
            setDetails(request, authRequest);
            Hr principal = new Hr();
            principal.setUsername(username);
            sessionRegistry.registerNewSession(request.getSession(true).getId(), principal);
            return this.getAuthenticationManager().authenticate(authRequest);
        } else {
            checkCode(response, request.getParameter("code"), verify_code);
            return super.attemptAuthentication(request, response);
        }
    }

    public void checkCode(HttpServletResponse resp, String code, String verify_code) {
        if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) {
            //验证码不正确
            throw new AuthenticationServiceException("验证码不正确");
        }
    }
}

配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    HrService hrService;

    @Autowired
    CustomFilterInvocationSecurityMetadataSource customFilterInvocationSecurityMetadataSource;

    @Autowired
    CustomUrlDecisionManager customUrlDecisionManager;

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(hrService);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //防止访问登录页面死循环,不用进入Security拦截
        web.ignoring().antMatchers("/css/**", "/js/**", "/index.html", "/img/**", "/fonts/**", "/favicon.ico", "/verifyCode");
    }

    @Bean
    LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    Hr hr = (Hr) authentication.getPrincipal();
                    hr.setPassword(null);
                    RespBean ok = RespBean.ok("登录成功!", hr);
                    String s = new ObjectMapper().writeValueAsString(ok);
                    out.write(s);
                    out.flush();
                    out.close();
                }
        );
        loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    RespBean respBean = RespBean.error(exception.getMessage());
                    if (exception instanceof LockedException) {
                        respBean.setMsg("账户被锁定,请联系管理员!");
                    } else if (exception instanceof CredentialsExpiredException) {
                        respBean.setMsg("密码过期,请联系管理员!");
                    } else if (exception instanceof AccountExpiredException) {
                        respBean.setMsg("账户过期,请联系管理员!");
                    } else if (exception instanceof DisabledException) {
                        respBean.setMsg("账户被禁用,请联系管理员!");
                    } else if (exception instanceof BadCredentialsException) {
                        respBean.setMsg("用户名或者密码输入错误,请重新输入!");
                    }
                    out.write(new ObjectMapper().writeValueAsString(respBean));
                    out.flush();
                    out.close();
                }
        );
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        loginFilter.setFilterProcessesUrl("/doLogin");
        //手动构建
        ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
        //最多一个用户登录
        sessionStrategy.setMaximumSessions(1);
        loginFilter.setSessionAuthenticationStrategy(sessionStrategy);
        return loginFilter;
    }

    //用来维护会话信息的
    @Bean
    SessionRegistryImpl sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setAccessDecisionManager(customUrlDecisionManager);
                        object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource);
                        return object;
                    }
                })
                .and()
                .logout()
                .logoutSuccessHandler((req, resp, authentication) -> {
                            resp.setContentType("application/json;charset=utf-8");
                            PrintWriter out = resp.getWriter();
                            out.write(new ObjectMapper().writeValueAsString(RespBean.ok("注销成功!")));
                            out.flush();
                            out.close();
                        }
                )
                .permitAll()
                .and()
                .csrf().disable().exceptionHandling()
                //没有认证时,在这里处理结果,不要重定向
                .authenticationEntryPoint((req, resp, authException) -> {
                            resp.setContentType("application/json;charset=utf-8");
                            resp.setStatus(401);
                            PrintWriter out = resp.getWriter();
                            RespBean respBean = RespBean.error("访问失败!");
                            if (authException instanceof InsufficientAuthenticationException) {
                                respBean.setMsg("请求失败,请联系管理员!");
                            }
                            out.write(new ObjectMapper().writeValueAsString(respBean));
                            out.flush();
                            out.close();
                        }
                );
        http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> {
            HttpServletResponse resp = event.getResponse();
            resp.setContentType("application/json;charset=utf-8");
            resp.setStatus(401);
            PrintWriter out = resp.getWriter();
            out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另一台设备登录,本次登录已下线!")));
            out.flush();
            out.close();
        }), ConcurrentSessionFilter.class);
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!