Spring Security 前后端分離場(chǎng)景下的會(huì)話并發(fā)管理
背景
Spring Security 可以通過控制 Session 并發(fā)數(shù)量來控制同一用戶在同一時(shí)刻多端登錄的個(gè)數(shù)限制。
在傳統(tǒng)的 web 開發(fā)實(shí)現(xiàn)時(shí),通過開啟如下的配置即可實(shí)現(xiàn)目標(biāo):
@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()
// 設(shè)置同一用戶在同一時(shí)刻允許的 session 最大并發(fā)數(shù)
.maximumSessions(1)
// 過期會(huì)話【即被踢出的舊會(huì)話】的跳轉(zhuǎn)路徑
.expiredUrl("/login")
;
}
}
通過上述的配置【sessionManagement() 之后的配置】即可開啟并發(fā)會(huì)話數(shù)量管理。
然而,在前后端分離開發(fā)中,只是簡(jiǎn)單的開啟這個(gè)配置,是無法實(shí)現(xiàn) Session 的并發(fā)會(huì)話管理的。這是我們本次要討論并解決的問題。
分析
傳統(tǒng) web 開發(fā)中的 sessionManagement 入口
由于前后端交互我們通過是采用了 application/json 的數(shù)據(jù)格式做交互,因此,前后端分離開發(fā)中,我們通常會(huì)自定義認(rèn)證過濾器,即 UsernamePasswordAuthenticationFilter 的平替。這個(gè)自定義的認(rèn)證過濾器,就成為了實(shí)現(xiàn)會(huì)話并發(fā)管理的最大阻礙。因此,我們有必要先參考下傳統(tǒng)的 web 開發(fā)模式中,并發(fā)會(huì)話管理的業(yè)務(wù)邏輯處理。
我們先按照傳統(tǒng) web 開發(fā)模式,走一下源碼,看看 sessionManagement 是在哪里發(fā)揮了作用:
在 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 {
// 調(diào)用具體的子類實(shí)現(xiàn)的 attemptAuthentication 方法,嘗試進(jìn)行認(rèn)證
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
//【????】根據(jù)配置的 session 管理策略,對(duì) session 進(jìn)行管理
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
// 認(rèn)證成功后的處理,主要是包含兩方面:
// 1、rememberMe 功能的業(yè)務(wù)邏輯
// 2、登錄成功的 handler 回調(diào)處理
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);
}
}
}
在認(rèn)證過濾器的公共抽象父類的 doFilter() 中即有對(duì) session 的管理業(yè)務(wù)邏輯入口。
在傳統(tǒng)的 web 開發(fā)模式中, this.sessionStrategy 會(huì)賦予的對(duì)象類型是CompositeSessionAuthenticationStrategy。

CompositeSessionAuthenticationStrategy 翻譯過來即為 “聯(lián)合認(rèn)證會(huì)話策略”,是一個(gè)套殼類,相當(dāng)于一個(gè)容器,里面封裝了多個(gè)真正執(zhí)行業(yè)務(wù)邏輯的 SessionAuthenticationStrategy。
從 debug 截圖中可以看出,他里面有三個(gè)真正執(zhí)行業(yè)務(wù)邏輯的 Strategy,分別是:
ConcurrentSessionControlAuthenticationStrategy:并發(fā)會(huì)話控制策略ChangeSessionIdAuthenticationStrategy:修改會(huì)話的 sessionid 策略【此次不會(huì)派上用場(chǎng),可以忽略】RegisterSessionAuthenticationStrategy:新會(huì)話注冊(cè)策略
這三個(gè)真正執(zhí)行業(yè)務(wù)邏輯處理的會(huì)話管理策略中,對(duì)于控制并發(fā)會(huì)話管理來限制同一用戶多端登錄的數(shù)量這一功能實(shí)現(xiàn)的,只需要第一個(gè)和第三個(gè)策略聯(lián)合使用,即可完成該功能實(shí)現(xiàn)。
其中,第一個(gè)策略,負(fù)責(zé)對(duì)同一用戶已有的會(huì)話進(jìn)行管理,并在新會(huì)話創(chuàng)建【即該用戶通過新的客戶端登錄進(jìn)系統(tǒng)】時(shí),負(fù)責(zé)計(jì)算需要將多少舊的會(huì)話進(jìn)行過期標(biāo)識(shí),并進(jìn)行標(biāo)識(shí)處理。
第三個(gè)策略,負(fù)責(zé)將本次創(chuàng)建的用戶新會(huì)話,給注冊(cè)進(jìn) sessionRegistry 對(duì)象中進(jìn)行管理。
貼上 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));
}
// 依次調(diào)用內(nèi)部的每一個(gè) Strategy 的 onAuthentication()
delegate.onAuthentication(authentication, request, response);
}
}
接著,我們就得開始看里面的每一個(gè)具體的 Strategy 的作用了。
ConcurrentSessionControlAuthenticationStrategy 核心業(yè)務(wù)邏輯處理
先貼源碼:
public class ConcurrentSessionControlAuthenticationStrategy
implements MessageSourceAware, SessionAuthenticationStrategy {
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private final SessionRegistry sessionRegistry;
private boolean exceptionIfMaximumExceeded = false;
private int maximumSessions = 1;
// 從構(gòu)造器可以看出,該策略的實(shí)例【強(qiáng)依賴】于 SessionRegistry,因此,如果我們自己創(chuàng)建該策略實(shí)例,就得先擁有 SessionRegistry 實(shí)例
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) {
// 獲取配置的每一個(gè)用戶同一時(shí)刻允許最多登錄的端數(shù),即并發(fā)會(huì)話的個(gè)數(shù),也就是我們?cè)谂渲妙愔信渲玫?maximumSessions()
int allowedSessions = getMaximumSessionsForThisUser(authentication);
if (allowedSessions == -1) {
// We permit unlimited logins
return;
}
// 從 sessionRegistry 中根據(jù)本次認(rèn)證的用戶信息,來獲取它關(guān)聯(lián)的所有 sessionid 集合
// ?? 注意:由于比較的時(shí)候是去調(diào)用 key 對(duì)象的 hashcode(),因此如果是自己實(shí)現(xiàn)了 UserDetails 實(shí)例用于封裝用戶信息,必須要重寫 hashcode()
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int sessionCount = sessions.size();
// 還沒有達(dá)到并發(fā)會(huì)話的最大限制數(shù),就直接 return 了
if (sessionCount < allowedSessions) {
// They haven't got too many login sessions running at present
return;
}
// 如果當(dāng)前已有的最大會(huì)話數(shù)已經(jīng)達(dá)到了限制的并發(fā)會(huì)話數(shù),就判斷本次請(qǐng)求的會(huì)話的id,是否已經(jīng)囊括在已有的會(huì)話id集合中了,如果包含在其中,也不進(jìn)行后續(xù)的業(yè)務(wù)處理了,直接 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
}
//【??】:如果當(dāng)前用戶所關(guān)聯(lián)的會(huì)話已經(jīng)達(dá)到了并發(fā)會(huì)話管理的限制個(gè)數(shù),并且本次的會(huì)話不再已有的會(huì)話集合中,即本次的會(huì)話是一個(gè)新創(chuàng)建的會(huì)話,那么就會(huì)走下面的方法
// 該方法主要是計(jì)算需要踢出多少個(gè)該用戶的舊會(huì)話來為新會(huì)話騰出空間,所謂的踢出,只是將一定數(shù)量的舊會(huì)話進(jìn)行標(biāo)識(shí)為“已過期”,真正進(jìn)行踢出動(dòng)作的不是策略本身
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
// 根據(jù)會(huì)話的最新一次活動(dòng)時(shí)間來排序
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
// 當(dāng)前已有的會(huì)話數(shù) + 1【本次新創(chuàng)建的會(huì)話】- 限制的最大會(huì)話并發(fā)數(shù) = 需要踢出的舊會(huì)話個(gè)數(shù)
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
for (SessionInformation session : sessionsToBeExpired) {
// 將 會(huì)話 的過期標(biāo)識(shí)設(shè)置為 true
session.expireNow();
}
}
}
主要的核心業(yè)務(wù),就是判斷本次用戶的會(huì)話是否是新創(chuàng)建的會(huì)話,并且是否超出了并發(fā)會(huì)話個(gè)數(shù)限制。如果兩個(gè)條件都滿足,那么就針對(duì)一定數(shù)量的舊會(huì)話進(jìn)行標(biāo)識(shí),將它們標(biāo)識(shí)為過期會(huì)話。
過期的用戶會(huì)話并不在 ConcurrentSessionControlAuthenticationStrategy 中進(jìn)行更多的處理。而是當(dāng)這些舊會(huì)話在后續(xù)再次進(jìn)行資源請(qǐng)求時(shí),會(huì)被 Spring Security 中的一個(gè)特定的 Filter 進(jìn)行移除處理。具體是什么 Filter,我們后面會(huì)提到。
RegisterSessionAuthenticationStrategy 核心業(yè)務(wù)邏輯處理
照樣貼源碼:
public class RegisterSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
private final SessionRegistry sessionRegistry;
// 從構(gòu)造器可以看出,該策略的實(shí)例【強(qiáng)依賴】于 SessionRegistry,因此,如果我們自己創(chuàng)建該策略實(shí)例,就得先擁有 SessionRegistry 實(shí)例
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) {
//負(fù)責(zé)將本次創(chuàng)建的新會(huì)話注冊(cè)進(jìn) sessionRegistry 中
this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
}
前面我們提到說,第一個(gè)策略在進(jìn)行舊會(huì)話標(biāo)識(shí)過期狀態(tài)時(shí),會(huì)有一個(gè)計(jì)算公式計(jì)算需要標(biāo)識(shí)多少個(gè)舊會(huì)話,其中的 +1 就是因?yàn)楸敬蔚男聲?huì)話還沒被加入到 sessionRegistry 中,而新會(huì)話就是在這第三個(gè)策略執(zhí)行時(shí),才會(huì)加入其中的。
所以,這個(gè)策略的核心功能就是為了將新會(huì)話注冊(cè)進(jìn) sessionRegistry 中。
SessionRegistry
前面的兩個(gè)策略,我們從構(gòu)造器中都可以看出,這倆策略都是強(qiáng)依賴于 SessionRegistry 實(shí)例。那么這個(gè) 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 中只有一個(gè)唯一的實(shí)現(xiàn):
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
// <principal:Object,SessionIdSet>
// 存儲(chǔ)了同一個(gè)用戶所關(guān)聯(lián)的所有 session 的 id 集合,以用戶實(shí)例對(duì)象的 hashcode() 返回值為 key
private final ConcurrentMap<Object, Set<String>> principals;
// <sessionId:Object,SessionInformation>
// 存儲(chǔ)了每一個(gè)用戶會(huì)話的詳細(xì)信息,以 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 實(shí)例對(duì)象的 hashcode(),獲取到該用戶關(guān)聯(lián)到的所有的 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;
}
// 根據(jù) sessionId 獲取到具體的 Session 信息(其中包含了過期標(biāo)識(shí)位)
@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);
}
}
}
// 刷新會(huì)話的最新活躍時(shí)間(用于標(biāo)識(shí)過期邏輯中的排序)
@Override
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = getSessionInformation(sessionId);
if (info != null) {
info.refreshLastRequest();
}
}
// 注冊(cè)新的用戶會(huì)話信息
@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;
});
}
// 移除過期的用戶會(huì)話信息
@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 主要是用來存儲(chǔ)用戶關(guān)聯(lián)的所有會(huì)話信息的統(tǒng)計(jì)容器,以及每一個(gè)會(huì)話的詳細(xì)信息也會(huì)存入其中,供各個(gè)策略進(jìn)行會(huì)話數(shù)據(jù)的查詢和調(diào)用。即SessionRegistryImpl 是一個(gè) session 的“數(shù)據(jù)中心”。
處理過期會(huì)話的過濾器
前面我們提到說,ConcurrentSessionControlAuthenticationStrategy 會(huì)對(duì)會(huì)話的并發(fā)數(shù)進(jìn)行統(tǒng)計(jì),并在必要時(shí)對(duì)一定數(shù)量的舊會(huì)話進(jìn)行過期標(biāo)識(shí)。
但它只做標(biāo)識(shí),不做具體的業(yè)務(wù)處理。
那么真正對(duì)過期會(huì)話進(jìn)行處理的是什么呢?
鏘鏘鏘鏘鏘鏘鏘!答案揭曉: 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");
}
// 過濾器嘛,最重要的就是這個(gè)方法啦
@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 {
// 獲取本次請(qǐng)求的 session
HttpSession session = request.getSession(false);
if (session != null) {
// 判斷當(dāng)前會(huì)話是否已經(jīng)注冊(cè)進(jìn)了 sessionRegistry 數(shù)據(jù)中心
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
if (info != null) {
// 如果已在 sessionRegistry,那么判斷下該 session 是否已經(jīng)過期了【通過前面提到的過期標(biāo)識(shí)位來判斷】
if (info.isExpired()) {
// Expired - abort processing
this.logger.debug(LogMessage
.of(() -> "Requested session ID " + request.getRequestedSessionId() + " has expired."));
// 對(duì)于已經(jīng)過期的會(huì)話,就不放行本次請(qǐng)求了,而是對(duì)本次會(huì)話進(jìn)行 logout 處理,即注銷登錄處理【具體實(shí)現(xiàn)看下一個(gè)方法】
doLogout(request, response);
// 調(diào)用會(huì)話過期處理策略進(jìn)行過期業(yè)務(wù)處理
// 如果在自定義的配置類中有顯式聲明了SessionManagementConfigurer.expiredSessionStrategy() 配置,那么此處就會(huì)去回調(diào)我們聲明的策略實(shí)現(xiàn)
this.sessionInformationExpiredStrategy
.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}
// Non-expired - update last request date/time
// 如果會(huì)話沒有過期,就刷新該會(huì)話的最新活躍時(shí)間【用于淘汰過期會(huì)話時(shí)排序使用】
this.sessionRegistry.refreshLastRequest(info.getSessionId());
}
}
chain.doFilter(request, response);
}
private void doLogout(HttpServletRequest request, HttpServletResponse response) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 執(zhí)行 logout 操作,包括移除會(huì)話、重定向到登錄請(qǐng)求、返回注銷成功的 json 數(shù)據(jù)等
this.handlers.logout(request, response, auth);
}
// SessionInformationExpiredStrategy 的私有默認(rèn)實(shí)現(xiàn)
// 如果我們?cè)谧远x配置類中沒有指定 expiredSessionStrategy() 的具體配置,那么就會(huì)使用這個(gè)實(shí)現(xiàn),這個(gè)實(shí)現(xiàn)不做任何業(yè)務(wù)邏輯處理,只負(fù)責(zé)打印響應(yīng)日志
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() 解讀,可以看出它主要是對(duì)每次請(qǐng)求所綁定的會(huì)話進(jìn)行過期判斷,并針對(duì)過期會(huì)話進(jìn)行特定處理。
落地實(shí)現(xiàn)
好了,現(xiàn)在傳統(tǒng) web 開發(fā)的會(huì)話并發(fā)管理源碼已經(jīng)解讀完畢了。下一步,我們?cè)搧韺?shí)現(xiàn)前后端分離中的會(huì)話并發(fā)管理功能了。
從上述的分析中我們可知,在認(rèn)證過濾器【AbstractAuthenticationProcessingFilter】中,當(dāng)認(rèn)證通過后,就會(huì)針對(duì) Session 會(huì)話進(jìn)行邏輯處理。
而在 UsernamePasswordAuthenticationFilter 中,使用的是聯(lián)合會(huì)話處理策略,其中有兩個(gè)會(huì)話處理策略是我們必須要有的。因此,在我們前后端分離開發(fā)時(shí),由于我們自定義了認(rèn)證過濾器用來取代UsernamePasswordAuthenticationFilter,因此,我們需要給我們自定義的認(rèn)證過濾器封裝好對(duì)應(yīng)的SessionAuthenticationStrategy。
前后端分離開發(fā)的實(shí)現(xiàn)步驟:
- 平平無奇的自定義認(rèn)證過濾器
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);
}
}
- 在配置類中聲明自定義的認(rèn)證過濾器實(shí)例(代碼與第3步合在了一起)
- 為認(rèn)證過濾器封裝
SessionAuthenticationStrategy,由于SessionAuthenticationStrategy是實(shí)例化需要依賴SessionRegistry,因此也需要聲明該 Bean 實(shí)例
@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();
}
// 使用默認(rèn)的 SessionRegistryImpl 實(shí)現(xiàn)類作為 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();
});
// ???????????????????????????????????????
// 為自定義的認(rèn)證過濾器封裝 SessionAuthenticationStrategy。需要兩個(gè) Strategy 組合使用才能發(fā)揮作用
// ConcurrentSessionControlAuthenticationStrategy -》 控制并發(fā)數(shù),讓超出的并發(fā)會(huì)話過期【ConcurrentSessionFilter 會(huì)在過期會(huì)話再次請(qǐng)求資源時(shí),將過期會(huì)話進(jìn)行 logout 操作并重定向到登錄頁面】
// RegisterSessionAuthenticationStrategy -》注冊(cè)新會(huì)話進(jìn) SessionRegistry 實(shí)例中
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)方法,在其中添加上會(huì)話并發(fā)管理的相關(guān)配置,并將自定義的認(rèn)證過濾器用于替換UsernamePasswordAuthenticationFilter位置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// .......
// 省略第3步中已經(jīng)貼出來的配置代碼
@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("未認(rèn)證,請(qǐng)登錄!");
response.getWriter().flush();
})
// 開啟會(huì)話管理,設(shè)置會(huì)話最大并發(fā)數(shù)為 1
.and().sessionManagement().maximumSessions(1)
// 控制的是 ConcurrentSessionFilter 的 this.sessionInformationExpiredStrategy 屬性的實(shí)例化賦值對(duì)象
.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", "當(dāng)前用戶已在其他設(shè)備登錄,請(qǐng)重新登錄!");
response.getWriter().println(new ObjectMapper().writeValueAsString(result));
})
;
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
啟動(dòng)測(cè)試
通過 apifox 的桌面版和網(wǎng)頁版,來模擬兩個(gè)客戶端去請(qǐng)求我們的系統(tǒng):
首先,在系統(tǒng)中設(shè)置了受保護(hù)資源 /hello,并進(jìn)行訪問,結(jié)果返回如下:

網(wǎng)頁版也會(huì)返回相同內(nèi)容。
接著,在桌面版先進(jìn)行用戶信息登錄,結(jié)果如下:

再訪問受保護(hù)資源,結(jié)果如下:

網(wǎng)頁端作為另一個(gè)客戶端,也用同一用戶進(jìn)行系統(tǒng)登錄

網(wǎng)頁端訪問受保護(hù)資源

桌面版再次訪問受保護(hù)資源

從結(jié)果截圖中可以看出,由于我們?cè)O(shè)置了會(huì)話最大并發(fā)數(shù)為1,當(dāng)網(wǎng)頁端利用同一用戶進(jìn)行登錄時(shí),原本已經(jīng)登錄了的桌面版apifox客戶端就會(huì)被擠兌下線,無法訪問受保護(hù)資源。
響應(yīng)的內(nèi)容來源于我們?cè)谂渲妙愔信渲玫?expiredSessionStrategy() 處理策略。
到此這篇關(guān)于Spring Security 前后端分離場(chǎng)景下的會(huì)話并發(fā)管理的文章就介紹到這了,更多相關(guān)Spring Security 前后端分離會(huì)話并發(fā)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Apache?SkyWalking?監(jiān)控?MySQL?Server?實(shí)戰(zhàn)解析
這篇文章主要介紹了Apache?SkyWalking?監(jiān)控?MySQL?Server?實(shí)戰(zhàn)解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
java+testng+selenium的自動(dòng)化測(cè)試實(shí)例
這篇文章主要介紹了java+testng+selenium的自動(dòng)化測(cè)試實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-11-11
SpringBoot集成JWT實(shí)現(xiàn)登陸驗(yàn)證的方法詳解
JSON?Web?Token(JWT)是一個(gè)開放的標(biāo)準(zhǔn)(RFC?7519),它定義了一個(gè)緊湊且自包含的方式,用于在各方之間以JSON對(duì)象安全地傳輸信息。本文將利用SpringBoot集成JWT實(shí)現(xiàn)登陸驗(yàn)證,感興趣的可以了解一下2022-05-05

