使用Spring Session Redis優雅地實現“賬號被另一處登錄擠下線”

1. 背景

    不管出於安全的原因,或者三級等保要求,我們在實現站點的登錄時,都少不了會面臨需要限制同一賬號(及同一應用)同時上線數的限制。

    擠人下線從邏輯上來說,並不是一個非常困難的邏輯,但是面臨以下挑戰:

  1. 賬號在線計數需要緩存session與用戶(+應用)的關係及登錄時間順序,並綁定session的生命週期,否則容易誤擠或者內存泄漏;
  2. 擠下線邏輯需要操作當前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,我們就得到了解決背景中兩個問題的手段:

  1. 通過FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()找出某賬號的所有Session;
  2. 通過SessionRepository.deleteById()來使需要被擠掉的Session失效;

3. 方案設計

    通過對Spring Session文檔和源代碼的研讀,一個簡單清晰的“賬號被另一處登錄擠下線”方案呼之欲出:

  1. 登錄成功後,創建Session的索引,可以使用[用戶名+應用ID]作爲索引鍵,這樣可以實現限制同一賬號在不同應用(如移動端、PC端)中分別只能登錄一次;
  2. 創建Session索引後,檢查當前用戶的[用戶名+應用ID]去檢查登錄Session數有無超過上限,超過則將最早的Session失效,並記錄失效原因;
  3. 對所有需要登錄訪問的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);
    }
}

此處使用了一些技巧:

  1. 將攔截器也作爲Spring Bean拉起,從而攔截器也可以使用到Spring Bean;
  2. 通過註解聲明攔截器的攔截路徑,從而使裝配代碼能用化;
  3. 定義自定義的攔截器接口,以聲明需要自動裝配的攔截器
// 攔截路徑定義註解
@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的深入使用,是不是發現代碼邏輯變得很簡單清晰?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章