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

Spring Boot多租户API网关:多会话处理问题求助

多租户API网关登录会话共享问题

问题描述

  • 调用/tenant/tenant-1-id/test-endpoint时,正常触发Keycloak(兼容任意OpenID服务)的登录流程,输入用户名密码后可正常获取响应
  • 调用/tenant/tenant-2-id/test-endpoint时:
    • 预期:触发对应租户的登录流程,完成后可同时无重复登录访问两个租户的端点
    • 实际:未触发登录,直接返回响应
  • 已知根因:两次调用共用同一个Cookie/会话,Spring Security无法区分租户身份,直接复用了之前的会话认证信息

现有代码实现

TenantContext.java

@Slf4j
public final class TenantContext {

    private TenantContext() {}

    private static InheritableThreadLocal<TenantInfo> currentTenant = new InheritableThreadLocal<>();

    public static void setTenantInfo(TenantInfo tenantId) {
        log.info("Setting tenantId to " + tenantId);
        currentTenant.set(tenantId);
    }

    public static TenantInfo getTenantInfo() {
        return currentTenant.get();
    }

    public static void clear(){
        currentTenant.remove();
    }
}

TenantInfo.java

@ToString
@Getter
@Builder
class TenantInfo {
    private String url;
    private int port;
    private String realm;
    private String clientId; // Should be the name of the service, the same for all realms
    private String secret;
}

WebConfiguration.java

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    private final TenantInterceptor tenantInterceptor;

    @Autowired
    public WebConfiguration(TenantInterceptor tenantInterceptor) {
        this.tenantInterceptor = tenantInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addWebRequestInterceptor(tenantInterceptor);
    }

}

TenantInterceptor.java

@Slf4j
@Component
public class TenantInterceptor implements WebRequestInterceptor {

    private final static Map<String, TenantInfo> idToIss = Map.of(
        "bank1-id",
        TenantInfo.builder()
            .url("...")
            .port(...)
            .realm("...")
            .clientId("...")
            .secret("...")
            .build(),
        "bank2-id",
        TenantInfo.builder()
            .url("...")
            .port(...)
            .realm("...")
            .clientId("...")
            .secret("...")
            .build()
    );

    @Override
    public void preHandle(WebRequest request) throws Exception {

        final String path = ((DispatcherServletWebRequest) request).getRequest().getServletPath(); // E.G. "/tenant/bank2-id/test"

        if (path.equals("/error")) {
            log.warn("Error was detected. Bypassing TenantInterceptor."); // TODO: Implement proper handling for errors
            return;
        }

        final String[] pieces = path.split("/");
        if (pieces.length < 4 || !pieces[1].equals("tenant")) {
            throw new IllegalArgumentException("Invalid path. It should be '/tenant/{id}/*' but was '" + path + "'");
        }
        final String tenantId = pieces[2];
        final TenantInfo tenantInfo = idToIss.get(tenantId);
        if (tenantInfo == null) {
            throw new IllegalStateException("Tenant " + tenantId + " does not exist");
        }

        TenantContext.setTenantInfo(tenantInfo);
    }


    @Override
    public void postHandle(WebRequest request, ModelMap model) throws Exception {
        TenantContext.clear();
    }

    @Override
    public void afterCompletion(WebRequest request, Exception ex) throws Exception {
    }

}

Maven依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

解决方案

要实现租户级别的会话隔离与独立认证,需从会话区分、动态OAuth2客户端配置、拦截器顺序调整三个核心方向着手:

1. 按租户隔离会话

自定义HttpSessionIdResolver,为不同租户生成带租户标识的会话ID,确保Cookie会话按租户区分:

public class TenantHttpSessionIdResolver implements HttpSessionIdResolver {
    private final CookieHttpSessionIdResolver delegate = new CookieHttpSessionIdResolver();

    @Override
    public List<String> resolveSessionIds(HttpServletRequest request) {
        String tenantRealm = TenantContext.getTenantInfo() != null ? TenantContext.getTenantInfo().getRealm() : "";
        List<String> sessionIds = delegate.resolveSessionIds(request);
        return sessionIds.stream().map(id -> tenantRealm + ":" + id).collect(Collectors.toList());
    }

    @Override
    public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
        String tenantRealm = TenantContext.getTenantInfo() != null ? TenantContext.getTenantInfo().getRealm() : "";
        delegate.setSessionId(request, response, tenantRealm + ":" + sessionId);
    }

    @Override
    public void expireSession(HttpServletRequest request, HttpServletResponse response) {
        delegate.expireSession(request, response);
    }
}

在Security配置中注册:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public HttpSessionIdResolver httpSessionIdResolver() {
        return new TenantHttpSessionIdResolver();
    }
}

2. 动态加载租户OAuth2客户端配置

实现ClientRegistrationRepository,根据当前租户上下文动态返回对应的OpenID客户端配置:

@Component
public class TenantClientRegistrationRepository implements ClientRegistrationRepository {
    private final Map<String, TenantInfo> tenantMap;

    public TenantClientRegistrationRepository(Map<String, TenantInfo> tenantMap) {
        this.tenantMap = tenantMap;
    }

    @Override
    public ClientRegistration findByRegistrationId(String registrationId) {
        TenantInfo tenantInfo = TenantContext.getTenantInfo();
        if (tenantInfo == null) {
            throw new IllegalStateException("No tenant context found");
        }
        // 根据租户信息构建OAuth2客户端配置
        String baseAuthUrl = String.format("%s:%d/realms/%s/protocol/openid-connect",
                tenantInfo.getUrl(), tenantInfo.getPort(), tenantInfo.getRealm());
        return ClientRegistration.withRegistrationId(tenantInfo.getRealm())
                .clientId(tenantInfo.getClientId())
                .clientSecret(tenantInfo.getSecret())
                .authorizationUri(baseAuthUrl + "/auth")
                .tokenUri(baseAuthUrl + "/token")
                .userInfoUri(baseAuthUrl + "/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
                .scope("openid", "profile", "email")
                .build();
    }
}

3. 调整租户识别逻辑到Security过滤器之前

原TenantInterceptor是WebMvc拦截器,执行顺序晚于Security过滤器链,导致认证时无法获取租户上下文。需改为Filter,确保在认证前设置租户信息:

@Component
public class TenantFilter extends OncePerRequestFilter {
    private final Map<String, TenantInfo> idToIss;

    public TenantFilter(Map<String, TenantInfo> idToIss) {
        this.idToIss = idToIss;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String path = request.getServletPath();
        if (path.equals("/error")) {
            filterChain.doFilter(request, response);
            return;
        }
        String[] pieces = path.split("/");
        if (pieces.length >=4 && pieces[1].equals("tenant")) {
            String tenantId = pieces[2];
            TenantInfo tenantInfo = idToIss.get(tenantId);
            if (tenantInfo != null) {
                TenantContext.setTenantInfo(tenantInfo);
            } else {
                response.sendError(HttpServletResponse.SC_NOT_FOUND, "Tenant not found");
                return;
            }
        }
        try {
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

在Security配置中注册该Filter,放在Security过滤器之前:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final TenantFilter tenantFilter;
    private final TenantClientRegistrationRepository clientRegistrationRepository;
    private final TenantAuthenticationSuccessHandler successHandler;

    public SecurityConfig(TenantFilter tenantFilter,
                          TenantClientRegistrationRepository clientRegistrationRepository,
                          TenantAuthenticationSuccessHandler successHandler) {
        this.tenantFilter = tenantFilter;
        this.clientRegistrationRepository = clientRegistrationRepository;
        this.successHandler = successHandler;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.addFilterBefore(tenantFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2Login(oauth2 -> oauth2
                .clientRegistrationRepository(clientRegistrationRepository)
                .successHandler(successHandler)
            );
        return http.build();
    }

    // 注册HttpSessionIdResolver...
}

4. 绑定租户与认证信息(可选)

自定义AuthenticationSuccessHandler,将租户信息与用户认证绑定,后续请求可校验租户是否匹配:

@Component
public class TenantAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        TenantInfo tenantInfo = TenantContext.getTenantInfo();
        if (tenantInfo != null) {
            authentication.setDetails(new TenantAuthenticationDetails(tenantInfo.getRealm()));
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }

    // 自定义认证详情类
    public static class TenantAuthenticationDetails implements AuthenticationDetailsSource<HttpServletRequest, TenantAuthenticationDetails> {
        private final String tenantRealm;

        public TenantAuthenticationDetails(String tenantRealm) {
            this.tenantRealm = tenantRealm;
        }

        @Override
        public TenantAuthenticationDetails buildDetails(HttpServletRequest context) {
            return this;
        }

        public String getTenantRealm() {
            return tenantRealm;
        }
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 10:27:50