You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Spring Cloud Gateway中获取所有会话并监听其生命周期?

在Spring Cloud Gateway(WebFlux)中实现Back-Channel Logout的会话管理方案

由于Spring WebFlux没有提供Servlet环境中HttpSessionListener这类会话监听器,我们需要通过主动维护会话索引的方式实现无上下文的会话查询与操作,具体方案如下:

核心思路

构建一个以issuer + subject为键、会话ID集合为值的索引,在会话创建/更新/删除、授权客户端增删时同步维护该索引。处理Back-Channel Logout请求时,通过索引快速定位目标会话,再执行客户端移除与会话失效逻辑。

具体实现

1. 扩展ReactiveSessionRepository维护索引

以Redis存储为例,包装默认的RedisReactiveSessionRepository,在会话生命周期各阶段同步更新索引:

@Component
public class IndexedRedisSessionRepo implements ReactiveSessionRepository<WebSession> {

    private final RedisReactiveSessionRepository delegate;
    private final ReactiveRedisOperations<String, String> redisOps;

    public IndexedRedisSessionRepo(RedisReactiveSessionRepository delegate, ReactiveRedisOperations<String, String> redisOps) {
        this.delegate = delegate;
        this.redisOps = redisOps;
    }

    @Override
    public Mono<WebSession> createSession() {
        return delegate.createSession();
    }

    @Override
    public Mono<Void> save(WebSession session) {
        return extractIssuerSubject(session)
                .flatMap(pair -> {
                    String indexKey = String.format("session-index:%s:%s", pair.getIssuer(), pair.getSubject());
                    return redisOps.opsForSet().add(indexKey, session.getId())
                            .then(delegate.save(session));
                })
                .switchIfEmpty(delegate.save(session));
    }

    @Override
    public Mono<WebSession> findById(String id) {
        return delegate.findById(id);
    }

    @Override
    public Mono<Void> deleteById(String id) {
        return delegate.findById(id)
                .flatMap(session -> extractIssuerSubject(session)
                        .flatMap(pair -> {
                            String indexKey = String.format("session-index:%s:%s", pair.getIssuer(), pair.getSubject());
                            return redisOps.opsForSet().remove(indexKey, session.getId());
                        })
                        .then(delegate.deleteById(id)))
                .switchIfEmpty(delegate.deleteById(id));
    }

    // 从会话中提取授权客户端的issuer和subject(需匹配你的存储逻辑)
    private Mono<IssuerSubjectPair> extractIssuerSubject(WebSession session) {
        return Mono.justOrEmpty(session.getAttribute("authorizedClients"))
                .cast(List.class)
                .flatMapMany(Flux::fromIterable)
                .map(client -> {
                    OAuth2AuthorizedClient authClient = (OAuth2AuthorizedClient) client;
                    return new IssuerSubjectPair(
                            authClient.getClientRegistration().getIssuerUri(),
                            authClient.getPrincipalName()
                    );
                })
                .next();
    }

    private record IssuerSubjectPair(String issuer, String subject) {}
}

2. 监听Spring Security事件同步索引

通过响应式认证事件,在授权客户端增删时实时更新索引,避免会话存储层面的遗漏:

@Component
public class AuthClientEventListener {

    private final ReactiveRedisOperations<String, String> redisOps;
    private final ReactiveSessionRepository<WebSession> sessionRepo;

    public AuthClientEventListener(ReactiveRedisOperations<String, String> redisOps, ReactiveSessionRepository<WebSession> sessionRepo) {
        this.redisOps = redisOps;
        this.sessionRepo = sessionRepo;
    }

    @EventListener
    public Mono<Void> handleClientAdded(OAuth2AuthorizedClientReactiveOAuth2AuthorizedClientEvent event) {
        OAuth2AuthorizedClient client = event.getAuthorizedClient();
        String indexKey = String.format("session-index:%s:%s",
                client.getClientRegistration().getIssuerUri(),
                client.getPrincipalName());
        return redisOps.opsForSet().add(indexKey, event.getSessionId()).then();
    }

    @EventListener
    public Mono<Void> handleClientRemoved(OAuth2AuthorizedClientRemovedReactiveOAuth2AuthorizedClientEvent event) {
        OAuth2AuthorizedClient client = event.getAuthorizedClient();
        String indexKey = String.format("session-index:%s:%s",
                client.getClientRegistration().getIssuerUri(),
                client.getPrincipalName());
        return redisOps.opsForSet().remove(indexKey, event.getSessionId())
                .then(sessionRepo.findById(event.getSessionId()))
                .flatMap(session -> {
                    List<OAuth2AuthorizedClient> remaining = (List<OAuth2AuthorizedClient>) session.getAttribute("authorizedClients");
                    if (remaining == null || remaining.isEmpty()) {
                        return sessionRepo.deleteById(event.getSessionId());
                    }
                    return Mono.empty();
                });
    }
}

3. 实现Back-Channel Logout端点

解析请求体中的Logout JWT,通过索引定位会话并执行移除/失效逻辑:

@RestController
@RequestMapping("/backchannel/logout")
public class BackChannelLogoutEndpoint {

    private final ReactiveRedisOperations<String, String> redisOps;
    private final ReactiveSessionRepository<WebSession> sessionRepo;
    private final JwtDecoder jwtDecoder;

    public BackChannelLogoutEndpoint(ReactiveRedisOperations<String, String> redisOps,
                                     ReactiveSessionRepository<WebSession> sessionRepo,
                                     JwtDecoder jwtDecoder) {
        this.redisOps = redisOps;
        this.sessionRepo = sessionRepo;
        this.jwtDecoder = jwtDecoder;
    }

    @PostMapping
    public Mono<ResponseEntity<Void>> handleLogout(@RequestBody String logoutJwt) {
        return jwtDecoder.decode(logoutJwt)
                .map(jwt -> new IssuerSubjectPair(jwt.getIssuer(), jwt.getSubject()))
                .flatMap(pair -> {
                    String indexKey = String.format("session-index:%s:%s", pair.issuer(), pair.subject());
                    return redisOps.opsForSet().members(indexKey)
                            .flatMap(sessionId -> processTargetSession(sessionId, pair));
                })
                .then(Mono.just(ResponseEntity.noContent().build()));
    }

    private Mono<Void> processTargetSession(String sessionId, IssuerSubjectPair target) {
        return sessionRepo.findById(sessionId)
                .flatMap(session -> {
                    List<OAuth2AuthorizedClient> clients = (List<OAuth2AuthorizedClient>) session.getAttribute("authorizedClients");
                    if (clients == null) {
                        return sessionRepo.deleteById(sessionId);
                    }
                    // 移除匹配的授权客户端
                    List<OAuth2AuthorizedClient> remaining = clients.stream()
                            .filter(c -> !c.getClientRegistration().getIssuerUri().equals(target.issuer())
                                    || !c.getPrincipalName().equals(target.subject()))
                            .collect(Collectors.toList());
                    if (remaining.isEmpty()) {
                        // 无剩余客户端,失效会话
                        return sessionRepo.deleteById(sessionId);
                    } else {
                        // 更新会话中的客户端列表
                        session.getAttributes().put("authorizedClients", remaining);
                        return sessionRepo.save(session);
                    }
                });
    }

    private record IssuerSubjectPair(String issuer, String subject) {}
}

关键注意事项

  • 会话中授权客户端的存储方式需与Spring Security的ServerOAuth2AuthorizedClientRepository配置匹配;
  • 分布式部署场景下必须使用Redis等分布式存储,确保索引和会话数据全局共享;
  • Logout JWT需严格验证签名、issuer、过期时间等字段,防止非法请求。

内容的提问来源于stack exchange,提问作者ch4mp

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.28 00:47:39