阅读完需:约 16 分钟
1.需求分析
在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,松哥之前遇到的需求就是业务原因要求一个用户只能在一个设备上登录。
要实现一个用户不可以同时在两台设备上登录,我们有两种思路:
- 后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的效果。
- 如果用户已经登录,则不允许后来者登录。
这种思路都能实现这个功能,具体使用哪一个,还要看我们具体的需求。
在 Spring Security 中,这两种都很好实现,一个配置就可以搞定。
2.具体实现
2.1 踢掉已经登录用户
想要用新的登录踢掉旧的登录,我们只需要将最大会话数设置为 1 即可,配置如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.permitAll()
.and()
.csrf().disable()
.sessionManagement()
.maximumSessions(1);
}
maximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。这里其他的配置都是我们前面文章讲过的,我就不再重复介绍,文末可以下载案例完整代码。
配置完成后,分别用 Chrome 和 Firefox 两个浏览器进行测试(或者使用 Chrome 中的多用户功能)。
- Chrome 上登录成功后,访问 /hello 接口。
- Firefox 上登录成功后,访问 /hello 接口。
- 在 Chrome 上再次访问 /hello 接口,此时会看到如下提示:
This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).
可以看到,这里说这个 session 已经过期,原因则是由于使用同一个用户进行并发登录。
2.2 禁止新的登录
如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.permitAll()
.and()
.csrf().disable()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。
是不是很简单?
不过还没完,我们还需要再提供一个 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,虽然多了一个配置,但是依然很简单!
3.实现原理
上面这个功能,在 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();
}
}
}
这段核心代码我来给大家稍微解释下:
- 首先调用 sessionRegistry.getAllSessions 方法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主题对应的 sessionid 组成的一个集合)。
- 接下来计算出当前用户已经有几个有效 session 了,同时获取允许的 session 并发数。
- 如果当前 session 数(sessionCount)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,表示对 session 数量不做任何限制。
- 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先看看当前 session 是否不为 null,并且已经存在于 sessions 中了,如果已经存在了,那都是自家人,不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
- 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
- allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximumExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false,如果为 true,就直接抛出异常,那么这次登录就失败了(对应 2.2 小节的效果),如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可(对应 2.1 小节的效果)。
如此,两行简单的配置就实现了 Spring Security 中 session 的并发管理。
Spring Boot + Vue 前后端分离项目,如何踢掉已登录用户
讲了在 Spring Security 中如何踢掉前一个登录用户,或者禁止用户二次登录,通过一个简单的案例,实现了我们想要的效果。
但是有一个不太完美的地方,就是我们的用户是配置在内存中的用户,我们没有将用户放到数据库中去。正常情况下,将数据切换为数据库中的数据即可。
但是,在做 Spring Security 的 session 并发处理时,直接将内存中的用户切换为数据库中的用户会有问题
4.SecurityConfig
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**", "/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
...
.and()
.sessionManagement()
.maximumSessions(1);
}
这里都是常规配置,我就不再多说。注意最后面我们将 session 数量设置为 1。
好了,配置完成后,我们启动项目,并行性多端登录测试。
打开多个浏览器,分别进行多端登录测试,我们惊讶的发现,每个浏览器都能登录成功,每次登录成功也不会踢掉已经登录的用户!
这是怎么回事?
2.问题分析
要搞清楚这个问题,我们就要先搞明白 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;
});
}
}
这个类的源码还是比较长,我这里提取出来一些比较关键的部分:
- 首先大家看到,一上来声明了一个 principals 对象,这是一个支持并发访问的 map 集合,集合的 key 就是用户的主体(principal),正常来说,用户的 principal 其实就是用户对象,和大家讲过 principal 是怎么样存入到 Authentication 中的,而集合的 value 则是一个 set 集合,这个 set 集合中保存了这个用户对应的 sessionid。
- 如有新的 session 需要添加,就在 registerNewSession 方法中进行添加,具体是调用 principals.compute 方法进行添加,key 就是 principal。
- 如果用户注销登录,sessionid 需要移除,相关操作在 removeSessionInformation 方法中完成,具体也是调用 principals.computeIfPresent 方法,这些关于集合的基本操作我就不再赘述了。
看到这里,大家发现一个问题,ConcurrentMap 集合的 key 是 principal 对象,用对象做 key,一定要重写 equals 方法和 hashCode 方法,否则第一次存完数据,下次就找不到了,这是 JavaSE 方面的知识,我就不用多说了。
如果我们使用了基于内存的用户,我们来看下 Spring Security 中的定义:
public class User implements UserDetails, CredentialsContainer {
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
@Override
public boolean equals(Object rhs) {
if (rhs instanceof User) {
return username.equals(((User) rhs).username);
}
return false;
}
@Override
public int hashCode() {
return username.hashCode();
}
}
可以看到,他自己实际上是重写了 equals 和 hashCode 方法了。
所以我们使用基于内存的用户时没有问题,而我们使用自定义的用户就有问题了。
找到了问题所在,那么解决问题就很容易了,重写 User 类的 equals 方法和 hashCode 方法即可:
@Entity(name = "t_user")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialsNonExpired;
private boolean enabled;
@ManyToMany(fetch = FetchType.EAGER,cascade = CascadeType.PERSIST)
private List<Role> roles;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(username, user.username);
}
@Override
public int hashCode() {
return Objects.hash(username);
}
...
...
}
配置完成后,重启项目,再去进行多端登录测试,发现就可以成功踢掉已经登录的用户了。
如果你使用了 MyBatis 而不是 Jpa,也是一样的处理方案,只需要重写登录用户的 equals 方法和 hashCode 方法即可。
注意:
采用了 JSON 格式登录,所以如果项目控制 session 并发数,就会有一些额外的问题要处理。
最大的问题在于我们用自定义的过滤器代替了 UsernamePasswordAuthenticationFilter,进而导致前面所讲的关于 session 的配置,统统失效。所有相关的配置我们都要在新的过滤器 LoginFilter (具体看自己的过滤器)中进行配置 ,包括 SessionAuthenticationStrategy 也需要我们自己手动配置了。
首先第一步,我们重写 Hr 类的 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);
}
...
...
}
接下来在 SecurityConfig 中进行配置。
这里我们要自己提供 SessionAuthenticationStrategy,而前面处理 session 并发的是 ConcurrentSessionControlAuthenticationStrategy,也就是说,我们需要自己提供一个 ConcurrentSessionControlAuthenticationStrategy 的实例,然后配置给 LoginFilter,但是在创建 ConcurrentSessionControlAuthenticationStrategy 实例的过程中,还需要有一个 SessionRegistryImpl 对象。
前面我们说过,SessionRegistryImpl 对象是用来维护会话信息的,现在这个东西也要我们自己来提供,SessionRegistryImpl 实例很好创建,如下:
@Bean
SessionRegistryImpl sessionRegistry() {
return new SessionRegistryImpl();
}
然后在 LoginFilter 中配置 SessionAuthenticationStrategy,如下:
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
//省略
}
);
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
//省略
}
);
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/doLogin");
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);
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
在这里,我们重新创建一个 ConcurrentSessionFilter 的实例,代替系统默认的即可。在创建新的 ConcurrentSessionFilter 实例时,需要两个参数:
- sessionRegistry 就是我们前面提供的 SessionRegistryImpl 实例。
- 第二个参数,是一个处理 session 过期后的回调函数,也就是说,当用户被另外一个登录踢下线之后,你要给什么样的下线提示,就在这里来完成。
最后,我们还需要在处理完登录数据之后,手动向 SessionRegistryImpl 中添加一条记录:
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Autowired
SessionRegistry sessionRegistry;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//省略
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);
}
...
...
}
}
在这里,我们手动调用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一条 session 记录。
OK,如此之后,我们的项目就配置完成了。