1. 背景
不管出於安全的原因,或者三級等保要求,我們在實現站點的登錄時,都少不了會面臨需要限制同一賬號(及同一應用)同時上線數的限制。
擠人下線從邏輯上來說,並不是一個非常困難的邏輯,但是面臨以下挑戰:
- 賬號在線計數需要緩存session與用戶(+應用)的關係及登錄時間順序,並綁定session的生命週期,否則容易誤擠或者內存泄漏;
- 擠下線邏輯需要操作當前session外的其它session;
以上兩點很容易造成擠下線邏輯與session強耦合,且代碼分散在登錄、session的實現中,複雜且難以維護。
如果我們使用Spring Session Redis,那事情就變得簡單了,雖然沒有實現在線擠人邏輯,但是Spring Session框架爲我們提供瞭解決以上兩個問題所需的擴展能力。
圖1 效果圖
2. 技術準備
由於Spring Session良好的封裝性,其本身是通過裝飾者模式,對Servlet的Session進行了透明封裝,使得業務代碼對用沒用到Spring Session完全無感,其思想非常值得我們研究學習。
Spring Session是通過SessionRepository來提供對Session管理,關鍵是它有個子接口定義了一個很重要的方法:
/**
* Extends a basic {@link SessionRepository} to allow finding sessions by the specified
* index name and index value.
*
* @param <S> the type of Session being managed by this
* {@link FindByIndexNameSessionRepository}
* @author Rob Winch
* @author Vedran Pavic
*/
public interface FindByIndexNameSessionRepository<S extends Session> extends SessionRepository<S> {
/**
* A session index that contains the current principal name (i.e. username).
* <p>
* It is the responsibility of the developer to ensure the index is populated since
* Spring Session is not aware of the authentication mechanism being used.
*
* @since 1.1
*/
String PRINCIPAL_NAME_INDEX_NAME = FindByIndexNameSessionRepository.class.getName()
.concat(".PRINCIPAL_NAME_INDEX_NAME");
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the specified index name index value.
* @param indexName the name of the index (i.e.
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME})
* @param indexValue the value of the index to search for.
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified index name and index value. If no
* results are found, an empty {@code Map} is returned.
*/
Map<String, S> findByIndexNameAndIndexValue(String indexName, String indexValue);
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the index with the name
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME} and the
* specified principal name.
* @param principalName the principal name
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified principal name. If no results are found,
* an empty {@code Map} is returned.
* @since 2.1.0
*/
default Map<String, S> findByPrincipalName(String principalName) {
return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
}
}
findByIndexNameAndIndexValue()方法定義瞭如何通過索引來查詢所有匹配的Session(確切地說是sessionId->Session的Map映射),而Spring Session Redis實現了這個接口。因此只要使用到了Spring Session Redis,我們就得到了解決背景中兩個問題的手段:
- 通過FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()找出某賬號的所有Session;
- 通過SessionRepository.deleteById()來使需要被擠掉的Session失效;
3. 方案設計
通過對Spring Session文檔和源代碼的研讀,一個簡單清晰的“賬號被另一處登錄擠下線”方案呼之欲出:
- 登錄成功後,創建Session的索引,可以使用[用戶名+應用ID]作爲索引鍵,這樣可以實現限制同一賬號在不同應用(如移動端、PC端)中分別只能登錄一次;
- 創建Session索引後,檢查當前用戶的[用戶名+應用ID]去檢查登錄Session數有無超過上限,超過則將最早的Session失效,並記錄失效原因;
- 對所有需要登錄訪問的URL,使用攔截器檢查Session是否失效,如已失效返回失效原因;
以上邏輯是不是特別簡單?看到這裏,建議你可以自己開始擼代碼了。
當然實際上Spring Session Redis的實現並非那麼完善,裏面有些小問題,下面就讓我們邊看代碼邊聽我娓娓道來
4. 代碼實現
4.1 工程依賴
項目開始前,確保有以下依賴,以maven爲例
<!-- Spring Session Redis的依賴 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
4.2 編寫主要邏輯
4.2.1 創建MaxOnlineService類,主要邏輯都將在此實現
@Service
public class MaxOnlineService {
// 允許的單賬號最大在線數,小於等於0時,表示不限制
private final int maxOnlinePerUser;
// Spring Session中可索引session的SessionRepository實例bean
private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;
// Spring的響應式Redis客戶端,也可以用StringRedisTemplate代替
private final StringRedisTemplate redisTemplate;
// Session超時時間,重用於設置被擠下線狀態的存儲有效期
private final long sessionTimeout;
// 依賴注入,自動引入Spring properties和Bean
public MaxOnlineService(@Value("${sso.maxOnlinePerUser}") int maxOnlinePerUser,
FindByIndexNameSessionRepository<? extends Session> sessionRepository,
StringRedisTemplate redisTemplate,
@Value("${spring.session.timeout}") long sessionTimeout) {
this.maxOnlinePerUser = maxOnlinePerUser;
this.sessionRepository = sessionRepository;
this.redisTemplate = redisTemplate;
this.sessionTimeout = sessionTimeout;
}
/**
* 當登錄成功後邏輯,創建session索引,並檢查、擠掉多餘的session
*
* @param session 當前用戶的HttpSession
* @param sessionIndexKey session索引鍵名
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {}
/**
* 判斷當前用戶是否已經被擠下線,供攔截器使用
*
* @param request 請求對象,用於獲取sessionId
* @return 是否被擠下線
*/
public boolean hasBeenKickoff(HttpServletRequest request) {}
}
4.2.2 實現創建Session索引邏輯
public class MaxOnlineService {
//...
/**
* 創建session索引
* @param session Servlet的HttpSession對象
* @param sessionIndexKey 賬號唯一標識,據此限制在線數量,建議[用戶名:應用標識]
*/
private void createSessionIndex(HttpSession session, String sessionIndexKey){
// 將索引與session關聯,以便spring-session可按用戶查詢全部session
session.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, sessionIndexKey);
}
/**
* 當登錄成功後,檢查session數量是否已達上限
*
* @param session 當前用戶的HttpSession
* @param sessionIndexKey 賬號唯一標識,據此限制在線數量,建議[用戶名:應用標識]
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {
// 創建session索引
createSessionIndex(session, sessionIndexKey);
// TODO: 檢查並踢除同一indexKey超過數量上限的session
}
//...
}
按照FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()的設計,indexName本該支持任意自定義的參數,但是查看Spring Session Redis的代碼,實現得並非那麼完善:
public class RedisIndexedSessionRepository implements FindByIndexNameSessionRepository<RedisIndexedSessionRepository.RedisSession>, MessageListener {
//...
@Override
public Map<String, RedisSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) {
return Collections.emptyMap();
}
String principalKey = getPrincipalKey(indexValue);
Set<Object> sessionIds = this.sessionRedisOperations.boundSetOps(principalKey).members();
Map<String, RedisSession> sessions = new HashMap<>(sessionIds.size());
for (Object id : sessionIds) {
RedisSession session = findById((String) id);
if (session != null) {
sessions.put(session.getId(), session);
}
}
return sessions;
}
//...
}
RedisIndexedSessionRepository代碼寫死了只能使用FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,因此session.setAttribute的第一個參數只能是FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME
4.2.3 擠掉多餘Session邏輯
public class MaxOnlineService {
// ...
/**
* 踢除session
*
* @param session spring的session對象
*/
private void kickoffSession(Session session) {
// 將被踢session記錄到Redis,以備提示
redisTemplate.opsForValue().set(toKickoffSessionKey(session.getId()), "1", Duration.ofSeconds(sessionTimeout));
// 將session從session倉庫中移除
sessionRepository.deleteById(session.getId());
log.info("Session:{}已被踢下線!", session.getId());
}
/**
* 確保只有指定數量的session在線
*
* @param session 當前用戶的Servlet HttpSession
* @param sessionIndexKey 賬號唯一標識,據此限制在線數量,建議[用戶名:應用標識]
*/
private void ensureOnlineCount(final HttpSession session, final String sessionIndexKey) {
if (maxOnlinePerUser <= 0) {
return;
}
int allowedSessionCount = session.isNew() ? (maxOnlinePerUser - 1) : maxOnlinePerUser;
Map<String, ? extends Session> sessionMap = sessionRepository.findByPrincipalName(sessionIndexKey);
if (allowedSessionCount < sessionMap.size()) {
//踢除已達在線上限的session:按創建時間排序>取最早的多餘條目>確保沒有當前用戶session>記錄被踢狀態>session過期
sessionMap.values().stream()
.sorted(Comparator.comparing(Session::getCreationTime))
.limit(sessionMap.size() - allowedSessionCount)
.filter(s -> !s.getId().equals(session.getId()))
.forEach(this::kickoffSession);
}
}
/**
* 當登錄成功後,檢查session數量是否已達上限
*
* @param session 當前用戶的Servlet HttpSession
* @param sessionIndexKey 賬號唯一標識,據此限制在線數量,建議[用戶名:應用標識]
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {
// 創建session索引
createSessionIndex(session, sessionIndexKey);
// 檢查同一系統-用戶id的session數量是否已達上限
ensureOnlineCount(session, sessionIndexKey);
}
// ...
}
此處代碼邏輯仍然比較簡單,只不過使用到了java8的stream來地完成此事
4.2.4 編寫檢查是否在線的邏輯
public class MaxOnlineService {
//...
/**
* 判斷當前用戶是否已經被擠下線
*
* @param request 請求對象,用於獲取sessionId
* @return 是否被擠下線
*/
public boolean hasBeenKickoff(HttpServletRequest request) {
String sessionId = request.getRequestedSessionId();
// 跳過無sessionId的情況,通常是未登錄,使用其它的邏輯處理
if (sessionId == null) {
return false;
}
String v = redisTemplate.opsForValue().get(toKickoffSessionKey(sessionId));
return v != null && !v.isEmpty();
}
}
MaxOnlineService的全部邏輯完成,接下來完成登錄成功後的調用。
4.3 登錄成功後調用擠下線邏輯
@RestController
public class LoginController {
//...
//賬號最大在線服務持有屬性
private final MaxOnlineService maxOnlineService;
//spring構造函數注入
public LoginController(/* 其它流入代碼 */MaxOnlineService maxOnlineService) {
//...
this.maxOnlineService = maxOnlineService;
}
@PostMapping(value = "/anonymous/login")
public Result<Map<String, Object>> loginByUserName(@RequestBody LoginBean loginBean, HttpSession session) {
// 登錄邏輯
//...
final String userName = ...
final String appId = ...
maxOnlineService.onLoginSucceed(session, userName + ":" + appId);
// 返回登錄成功響應
return Result.success(map);
}
4.4 攔截器實現被擠下線的提示
4.4.1 攔截器實現
@Service //將攔截器註冊爲Spring Bean
@Order(9) //通過Order可以標識攔截器的優先級
PathMapping(includes = "/**", excludes = {"/error", "/anonymous/**"}) //定義攔截器的攔截路徑、忽略路徑
public class MaxOnlineInterceptor implements AutoConfigInterceptor {
private final MaxOnlineService maxOnlineService;
// 依賴注入
public MaxOnlineInterceptor(MaxOnlineService maxOnlineService) {
this.maxOnlineService = maxOnlineService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 跳過Http協議的OPTIONS方法,此方法通常用於瀏覽器跨域檢查,如果返回false瀏覽器提示的是跨域錯誤,並非本意
if (RequestMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) {
return true;
}
// 檢查是否已被擠下線
if (maxOnlineService.hasBeenKickoff(request)) {
// 已被擠下線時發送被擠下線響應
return sendJsonError(ErrorCode.System.userLoginForcedOffline, "您的帳號在另一地點登錄,您被迫下線。如果不是您本人操作,建議您修改密碼。");
} else {
return true;
}
}
// 可重用的Json視圖轉換對象
private final View jsonView = new MappingJackson2JsonView();
/**
* 發送json錯誤消息作爲響應
* @param errorCode 錯誤碼
* @param errorMsg 錯誤消息
* @return 無返回值
* @throws ModelAndViewDefiningException
*/
private boolean sendJsonError(int errorCode, String errorMsg) throws ModelAndViewDefiningException {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setView(jsonView);
modelAndView.addObject("code", errorCode);
modelAndView.addObject("msg", errorMsg);
throw new ModelAndViewDefiningException(modelAndView);
}
}
此處使用了一些技巧:
- 將攔截器也作爲Spring Bean拉起,從而攔截器也可以使用到Spring Bean;
- 通過註解聲明攔截器的攔截路徑,從而使裝配代碼能用化;
- 定義自定義的攔截器接口,以聲明需要自動裝配的攔截器
// 攔截路徑定義註解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PathMapping {
@AliasFor("includes")
String[] value() default {};
@AliasFor("value")
String[] includes() default {};
String[] excludes() default {};
}
// 自動裝配攔截器接口
public interface AutoConfigInterceptor extends HandlerInterceptor{}
4.4.2 裝配攔截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final List<AutoConfigInterceptor> interceptors;
public WebConfig(List<AutoConfigInterceptor> interceptors) {
this.interceptors = interceptors;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
interceptors.forEach(interceptor -> {
final PathMapping mapping = interceptor.getClass().getAnnotation(PathMapping.class);
if (mapping != null) {
registry.addInterceptor(interceptor)
.addPathPatterns(mapping.includes()).excludePathPatterns(mapping.excludes());
}
});
}
}
4.5 定義Spring屬性
# application.properties
# [可選,默認30分鐘]spring session超時時間
spring.session.timeout=1200
# 允許的同一賬號最大在線數,數值爲幾則允許幾次登錄同時在線,大於零時生效
sso.maxOnlinePerUser=1
5. 結束語
以上就是全部代碼和注意事項,編寫完成後,你可以打開多個瀏覽器+無痕瀏覽器測試,還可以個性sso.maxOnlinePerUser的值來允許同時多個在線。
通過對spring session redis的深入使用,是不是發現代碼邏輯變得很簡單清晰?