前后端分离场景下实现 Spring Security 的会话并发管理
背景
Spring Security 可以通过控制 Session 并发数量来控制同一用户在同一时刻多端登录的个数限制。
在传统的 web 开发实现时,通过开启如下的配置即可实现目标:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests().anyRequest().authenticated()
.and().formLogin()
// .and().sessionManagement().maximumSessions(1)
// .and()
.and().csrf().disable()
// 开启 session 管理
.sessionManagement()
// 设置同一用户在同一时刻允许的 session 最大并发数
.maximumSessions(1)
// 过期会话【即被踢出的旧会话】的跳转路径
.expiredUrl("/login")
;
}
}
通过上述的配置【sessionManagement()
之后的配置】即可开启并发会话数量管理。
然而,在前后端分离开发中,只是简单的开启这个配置,是无法实现 Session 的并发会话管理的。这是我们本次要讨论并解决的问题。
分析
传统 web 开发中的 sessionManagement 入口
由于前后端交互我们通过是采用了 application/json
的数据格式做交互,因此,前后端分离开发中,我们通常会自定义认证过滤器,即 UsernamePasswordAuthenticationFilter
的平替。这个自定义的认证过滤器,就成为了实现会话并发管理的最大阻碍。因此,我们有必要先参考下传统的 web 开发模式中,并发会话管理的业务逻辑处理。
我们先按照传统 web 开发模式,走一下源码,看看 sessionManagement 是在哪里发挥了作用:
在 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)
方法中,有 sessionManagement 的入口:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
// 调用具体的子类实现的 attemptAuthentication 方法,尝试进行认证
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
//【👉🏻】根据配置的 session 管理策略,对 session 进行管理
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
// 认证成功后的处理,主要是包含两方面:
// 1、rememberMe 功能的业务逻辑
// 2、登录成功的 handler 回调处理
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
}
在认证过滤器的公共抽象父类的 doFilter()
中即有对 session 的管理业务逻辑入口。
在传统的 web 开发模式中, this.sessionStrategy
会赋予的对象类型是CompositeSessionAuthenticationStrategy
。
CompositeSessionAuthenticationStrategy
翻译过来即为 “联合认证会话策略”,是一个套壳类,相当于一个容器,里面封装了多个真正执行业务逻辑的 SessionAuthenticationStrategy
。
从 debug 截图中可以看出,他里面有三个真正执行业务逻辑的 Strategy,分别是:
ConcurrentSessionControlAuthenticationStrategy
:并发会话控制策略ChangeSessionIdAuthenticationStrategy
:修改会话的 sessionid 策略【此次不会派上用场,可以忽略】RegisterSessionAuthenticationStrategy
:新会话注册策略
这三个真正执行业务逻辑处理的会话管理策略中,对于控制并发会话管理来限制同一用户多端登录的数量这一功能实现的,只需要第一个和第三个策略联合使用,即可完成该功能实现。
其中,第一个策略,负责对同一用户已有的会话进行管理,并在新会话创建【即该用户通过新的客户端登录进系统】时,负责计算需要将多少旧的会话进行过期标识,并进行标识处理。
第三个策略,负责将本次创建的用户新会话,给注册进 sessionRegistry
对象中进行管理。
贴上 CompositeSessionAuthenticationStrategy.onAuthentication()
的源码:
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException {
int currentPosition = 0;
int size = this.delegateStrategies.size();
for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Preparing session with %s (%d/%d)",
delegate.getClass().getSimpleName(), ++currentPosition, size));
}
// 依次调用内部的每一个 Strategy 的 onAuthentication()
delegate.onAuthentication(authentication, request, response);
}
}
接着,我们就得开始看里面的每一个具体的 Strategy 的作用了。
ConcurrentSessionControlAuthenticationStrategy 核心业务逻辑处理
先贴源码:
public class ConcurrentSessionControlAuthenticationStrategy
implements MessageSourceAware, SessionAuthenticationStrategy {
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private final SessionRegistry sessionRegistry;
private boolean exceptionIfMaximumExceeded = false;
private int maximumSessions = 1;
// 从构造器可以看出,该策略的实例【强依赖】于 SessionRegistry,因此,如果我们自己创建该策略实例,就得先拥有 SessionRegistry 实例
public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
// 获取配置的每一个用户同一时刻允许最多登录的端数,即并发会话的个数,也就是我们在配置类中配置的 maximumSessions()
int allowedSessions = getMaximumSessionsForThisUser(authentication);
if (allowedSessions == -1) {
// We permit unlimited logins
return;
}
// 从 sessionRegistry 中根据本次认证的用户信息,来获取它关联的所有 sessionid 集合
// ⚠️ 注意:由于比较的时候是去调用 key 对象的 hashcode(),因此如果是自己实现了 UserDetails 实例用于封装用户信息,必须要重写 hashcode()
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int sessionCount = sessions.size();
// 还没有达到并发会话的最大限制数,就直接 return 了
if (sessionCount < allowedSessions) {
// They haven't got too many login sessions running at present
return;
}
// 如果当前已有的最大会话数已经达到了限制的并发会话数,就判断本次请求的会话的id,是否已经囊括在已有的会话id集合中了,如果包含在其中,也不进行后续的业务处理了,直接 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, this.sessionRegistry);
}
protected int getMaximumSessionsForThisUser(Authentication authentication) {
return this.maximumSessions;
}
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));
// 当前已有的会话数 + 1【本次新创建的会话】- 限制的最大会话并发数 = 需要踢出的旧会话个数
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
for (SessionInformation session : sessionsToBeExpired) {
// 将 会话 的过期标识设置为 true
session.expireNow();
}
}
}
主要的核心业务,就是判断本次用户的会话是否是新创建的会话,并且是否超出了并发会话个数限制。如果两个条件都满足,那么就针对一定数量的旧会话进行标识,将它们标识为过期会话。
过期的用户会话并不在 ConcurrentSessionControlAuthenticationStrategy 中进行更多的处理。而是当这些旧会话在后续再次进行资源请求时,会被 Spring Security 中的一个特定的 Filter 进行移除处理。具体是什么 Filter,我们后面会提到。
RegisterSessionAuthenticationStrategy 核心业务逻辑处理
照样贴源码:
public class RegisterSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
private final SessionRegistry sessionRegistry;
// 从构造器可以看出,该策略的实例【强依赖】于 SessionRegistry,因此,如果我们自己创建该策略实例,就得先拥有 SessionRegistry 实例
public RegisterSessionAuthenticationStrategy(SessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
//负责将本次创建的新会话注册进 sessionRegistry 中
this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
}
前面我们提到说,第一个策略在进行旧会话标识过期状态时,会有一个计算公式计算需要标识多少个旧会话,其中的 +1 就是因为本次的新会话还没被加入到 sessionRegistry 中,而新会话就是在这第三个策略执行时,才会加入其中的。
所以,这个策略的核心功能就是为了将新会话注册进 sessionRegistry 中。
SessionRegistry
前面的两个策略,我们从构造器中都可以看出,这俩策略都是强依赖于 SessionRegistry 实例。那么这个 SessionRegistry 是干嘛的呢?
照样贴源码如下:
public interface SessionRegistry {
List<Object> getAllPrincipals();
List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions);
SessionInformation getSessionInformation(String sessionId);
void refreshLastRequest(String sessionId);
void registerNewSession(String sessionId, Object principal);
void removeSessionInformation(String sessionId);
}
它在 Spring Security 中只有一个唯一的实现:
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
// <principal:Object,SessionIdSet>
// 存储了同一个用户所关联的所有 session 的 id 集合,以用户实例对象的 hashcode() 返回值为 key
private final ConcurrentMap<Object, Set<String>> principals;
// <sessionId:Object,SessionInformation>
// 存储了每一个用户会话的详细信息,以 sessionId 为 key
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;
}
@Override
public List<Object> getAllPrincipals() {
return new ArrayList<>(this.principals.keySet());
}
// 根本 UserDetails 实例对象的 hashcode(),获取到该用户关联到的所有的 Session
@Override
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
Set<String> sessionsUsedByPrincipal = this.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;
}
// 根据 sessionId 获取到具体的 Session 信息(其中包含了过期标识位)
@Override
public SessionInformation getSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
return this.sessionIds.get(sessionId);
}
@Override
public void onApplicationEvent(AbstractSessionEvent event) {
if (event instanceof SessionDestroyedEvent) {
SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent) event;
String sessionId = sessionDestroyedEvent.getId();
removeSessionInformation(sessionId);
}
else if (event instanceof SessionIdChangedEvent) {
SessionIdChangedEvent sessionIdChangedEvent = (SessionIdChangedEvent) event;
String oldSessionId = sessionIdChangedEvent.getOldSessionId();
if (this.sessionIds.containsKey(oldSessionId)) {
Object principal = this.sessionIds.get(oldSessionId).getPrincipal();
removeSessionInformation(oldSessionId);
registerNewSession(sessionIdChangedEvent.getNewSessionId(), principal);
}
}
}
// 刷新会话的最新活跃时间(用于标识过期逻辑中的排序)
@Override
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = getSessionInformation(sessionId);
if (info != null) {
info.refreshLastRequest();
}
}
// 注册新的用户会话信息
@Override
public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Registering session %s, for principal %s", sessionId, principal));
}
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;
});
}
// 移除过期的用户会话信息
@Override
public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = getSessionInformation(sessionId);
if (info == null) {
return;
}
if (this.logger.isTraceEnabled()) {
this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
}
this.sessionIds.remove(sessionId);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
this.logger.debug(
LogMessage.format("Removing session %s from principal's set of registered sessions", sessionId));
sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
// No need to keep object in principals Map anymore
this.logger.debug(LogMessage.format("Removing principal %s from registry", info.getPrincipal()));
sessionsUsedByPrincipal = null;
}
this.logger.trace(
LogMessage.format("Sessions used by '%s' : %s", info.getPrincipal(), sessionsUsedByPrincipal));
return sessionsUsedByPrincipal;
});
}
}
从源码中可以看出,SessionRegistryImpl
主要是用来存储用户关联的所有会话信息的统计容器,以及每一个会话的详细信息也会存入其中,供各个策略进行会话数据的查询和调用。即SessionRegistryImpl
是一个 session 的“数据中心”。
处理过期会话的过滤器
前面我们提到说,ConcurrentSessionControlAuthenticationStrategy
会对会话的并发数进行统计,并在必要时对一定数量的旧会话进行过期标识。
但它只做标识,不做具体的业务处理。
那么真正对过期会话进行处理的是什么呢?
锵锵锵锵锵锵锵!答案揭晓: ConcurrentSessionFilter
public class ConcurrentSessionFilter extends GenericFilterBean {
private final SessionRegistry sessionRegistry;
private String expiredUrl;
private RedirectStrategy redirectStrategy;
private LogoutHandler handlers = new CompositeLogoutHandler(new SecurityContextLogoutHandler());
private SessionInformationExpiredStrategy sessionInformationExpiredStrategy;
@Override
public void afterPropertiesSet() {
Assert.notNull(this.sessionRegistry, "SessionRegistry required");
}
// 过滤器嘛,最重要的就是这个方法啦
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 获取本次请求的 session
HttpSession session = request.getSession(false);
if (session != null) {
// 判断当前会话是否已经注册进了 sessionRegistry 数据中心
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
if (info != null) {
// 如果已在 sessionRegistry,那么判断下该 session 是否已经过期了【通过前面提到的过期标识位来判断】
if (info.isExpired()) {
// Expired - abort processing
this.logger.debug(LogMessage
.of(() -> "Requested session ID " + request.getRequestedSessionId() + " has expired."));
// 对于已经过期的会话,就不放行本次请求了,而是对本次会话进行 logout 处理,即注销登录处理【具体实现看下一个方法】
doLogout(request, response);
// 调用会话过期处理策略进行过期业务处理
// 如果在自定义的配置类中有显式声明了SessionManagementConfigurer.expiredSessionStrategy() 配置,那么此处就会去回调我们声明的策略实现
this.sessionInformationExpiredStrategy
.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}
// Non-expired - update last request date/time
// 如果会话没有过期,就刷新该会话的最新活跃时间【用于淘汰过期会话时排序使用】
this.sessionRegistry.refreshLastRequest(info.getSessionId());
}
}
chain.doFilter(request, response);
}
private void doLogout(HttpServletRequest request, HttpServletResponse response) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 执行 logout 操作,包括移除会话、重定向到登录请求、返回注销成功的 json 数据等
this.handlers.logout(request, response, auth);
}
// SessionInformationExpiredStrategy 的私有默认实现
// 如果我们在自定义配置类中没有指定 expiredSessionStrategy() 的具体配置,那么就会使用这个实现,这个实现不做任何业务逻辑处理,只负责打印响应日志
private static final class ResponseBodySessionInformationExpiredStrategy
implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
HttpServletResponse response = event.getResponse();
response.getWriter().print("This session has been expired (possibly due to multiple concurrent "
+ "logins being attempted as the same user).");
response.flushBuffer();
}
}
}
通过上面的 doFilter() 解读,可以看出它主要是对每次请求所绑定的会话进行过期判断,并针对过期会话进行特定处理。
落地实现
好了,现在传统 web 开发的会话并发管理源码已经解读完毕了。下一步,我们该来实现前后端分离中的会话并发管理功能了。
从上述的分析中我们可知,在认证过滤器【AbstractAuthenticationProcessingFilter
】中,当认证通过后,就会针对 Session 会话进行逻辑处理。
而在 UsernamePasswordAuthenticationFilter
中,使用的是联合会话处理策略,其中有两个会话处理策略是我们必须要有的。因此,在我们前后端分离开发时,由于我们自定义了认证过滤器用来取代UsernamePasswordAuthenticationFilter
,因此,我们需要给我们自定义的认证过滤器封装好对应的SessionAuthenticationStrategy
。
前后端分离开发的实现步骤:
- 平平无奇的自定义认证过滤器
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Autowired
private 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());
}
if (MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType()) ||
MediaType.APPLICATION_JSON_UTF8_VALUE.equals(request.getContentType())) {
try {
Map<String, String> map = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = map.get(getUsernameParameter());
String password = map.get(getPasswordParameter());
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return super.attemptAuthentication(request, response);
}
}
- 在配置类中声明自定义的认证过滤器实例(代码与第3步合在了一起)
- 为认证过滤器封装
SessionAuthenticationStrategy
,由于SessionAuthenticationStrategy
是实例化需要依赖SessionRegistry
,因此也需要声明该 Bean 实例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
userDetailsManager.createUser(User.builder().username("root").password("{noop}123").authorities("admin").build());
return userDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
// 使用默认的 SessionRegistryImpl 实现类作为 Bean 类型即可
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setFilterProcessesUrl("/login");
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println("登录成功!");
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setStatus(500);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println("登录失败!");
response.getWriter().println(exception.getMessage());
response.getWriter().flush();
});
// ❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕❗️❕
// 为自定义的认证过滤器封装 SessionAuthenticationStrategy。需要两个 Strategy 组合使用才能发挥作用
// ConcurrentSessionControlAuthenticationStrategy -》 控制并发数,让超出的并发会话过期【ConcurrentSessionFilter 会在过期会话再次请求资源时,将过期会话进行 logout 操作并重定向到登录页面】
// RegisterSessionAuthenticationStrategy -》注册新会话进 SessionRegistry 实例中
ConcurrentSessionControlAuthenticationStrategy strategy1 = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
RegisterSessionAuthenticationStrategy strategy2 = new RegisterSessionAuthenticationStrategy(sessionRegistry());
CompositeSessionAuthenticationStrategy compositeStrategy = new CompositeSessionAuthenticationStrategy(Arrays.asList(strategy1, strategy2));
loginFilter.setSessionAuthenticationStrategy(compositeStrategy);
return loginFilter;
}
}
- 重写配置类的
configure(HttpSecurity http)
方法,在其中添加上会话并发管理的相关配置,并将自定义的认证过滤器用于替换UsernamePasswordAuthenticationFilter
位置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// .......
// 省略第3步中已经贴出来的配置代码
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests().anyRequest().authenticated()
.and().csrf().disable()
.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println("未认证,请登录!");
response.getWriter().flush();
})
// 开启会话管理,设置会话最大并发数为 1
.and().sessionManagement().maximumSessions(1)
// 控制的是 ConcurrentSessionFilter 的 this.sessionInformationExpiredStrategy 属性的实例化赋值对象
.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, String> result = new HashMap<>();
result.put("msg", "当前用户已在其他设备登录,请重新登录!");
response.getWriter().println(new ObjectMapper().writeValueAsString(result));
})
;
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
- 启动测试
通过 apifox 的桌面版和网页版,来模拟两个客户端去请求我们的系统:- 首先,在系统中设置了受保护资源
/hello
,并进行访问,结果返回如下:
网页版也会返回相同内容。 - 接着,在桌面版先进行用户信息登录,结果如下:
- 再访问受保护资源,结果如下:
- 网页端作为另一个客户端,也用同一用户进行系统登录
- 网页端访问受保护资源
- 桌面版再次访问受保护资源
从结果截图中可以看出,由于我们设置了会话最大并发数为1,当网页端利用同一用户进行登录时,原本已经登录了的桌面版apifox客户端就会被挤兑下线,无法访问受保护资源。
响应的内容来源于我们在配置类中配置的expiredSessionStrategy()
处理策略。
以上就是整个前后端分离场景下的会话并发管理的落地实现。
- 首先,在系统中设置了受保护资源
好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
如果这篇文章对你有帮助的话,不妨点个关注吧~
期待下次我们共同讨论,一起进步~