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
相关产品推荐
相关产品推荐

