Spring Security 多租户认证实战——SaaS 场景下的用户隔离方案
Spring Security 多租户认证实战——SaaS 场景下的用户隔离方案
适读人群:正在开发或维护SaaS系统的Java后端工程师 | 阅读时长:约18分钟 | 核心价值:掌握SaaS多租户场景下认证与权限隔离的完整实现方案
从一次严重的数据泄漏事故说起
2022年底,我们把一个单租户系统改造成SaaS平台,计划对外运营。改造完的第一天内测,一个测试用户报告:他能在某个接口里看到其他公司的数据。
我当场出了一身冷汗。
排查发现:认证是对的,但数据查询时没有带租户过滤条件。用户登录后能正确被识别身份,但业务查询语句里没有tenant_id = ?的过滤,A公司的用户查询就能拿到所有公司的数据。
那次之后,我把多租户隔离从"业务功能"升级成了"安全要求",在架构层面做了彻底的隔离设计。今天把完整方案分享出来。
多租户认证的核心挑战
单租户认证:认证 → 确认是谁 → 返回Token
多租户认证:认证 → 确认是谁 → 确认属于哪个租户 → 返回Token(携带租户信息)
多出来的这一步,带来了一系列问题:
- 如何识别当前请求属于哪个租户?
- 同一用户名在不同租户下是否允许?(通常是允许的)
- 如何在整个请求链路中传递租户上下文?
- 数据层如何做到租户隔离?
租户识别策略
常见的租户识别方式:
| 方式 | 示例 | 适用场景 |
|---|---|---|
| 子域名 | tenant-a.myapp.com | B2B SaaS,域名隔离 |
| URL路径前缀 | /api/tenant-a/users | 简单场景 |
| 请求Header | X-Tenant-Id: tenant-a | 前后端分离API |
| Token中的Claim | JWT的tid字段 | 无状态认证 |
| 登录时指定 | 登录表单有"公司编码"字段 | 明确的多租户界面 |
实际项目中我用了子域名 + JWT Claim的组合:
- 用户通过
tenant-a.myapp.com访问,服务端从域名解析租户 - 登录成功后,JWT中携带
tenant_id - 后续请求通过JWT中的
tenant_id识别租户,不再依赖域名
完整实现代码
租户上下文(核心组件)
/**
* 租户上下文,基于ThreadLocal存储当前请求的租户信息
* 在整个请求处理链路中传递租户ID
*/
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setCurrentTenant(String tenantId) {
if (tenantId == null || tenantId.isBlank()) {
throw new IllegalArgumentException("租户ID不能为空");
}
CURRENT_TENANT.set(tenantId);
}
public static String getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static String requireCurrentTenant() {
String tenantId = CURRENT_TENANT.get();
if (tenantId == null) {
throw new TenantNotFoundException("无法获取租户上下文,请确保请求携带了有效的租户信息");
}
return tenantId;
}
public static void clear() {
CURRENT_TENANT.remove();
}
public static boolean hasTenant() {
return CURRENT_TENANT.get() != null;
}
}多租户 UserDetailsService
/**
* 多租户UserDetailsService:同一用户名在不同租户下是独立的账号
* 用户名 + 租户ID 共同唯一标识一个用户
*/
@Service
@RequiredArgsConstructor
public class MultiTenantUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从TenantContext获取当前租户(在Filter中已设置)
String tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
throw new UsernameNotFoundException("无法确定租户信息");
}
// 用 username + tenantId 查询用户(跨租户用户名可以重复)
User user = userRepository.findByUsernameAndTenantId(username, tenantId)
.orElseThrow(() -> new UsernameNotFoundException(
"用户 [" + username + "] 在租户 [" + tenantId + "] 中不存在"
));
if (!user.isEnabled()) {
throw new DisabledException("账号已被禁用");
}
// 加载该用户在该租户下的角色和权限
List<String> roles = roleRepository.findRoleCodesByUserIdAndTenantId(user.getId(), tenantId);
Set<String> permissions = new HashSet<>(
roleRepository.findPermCodesByUserIdAndTenantId(user.getId(), tenantId)
);
return new TenantUserDetails(
user.getId(), user.getUsername(), user.getPassword(),
tenantId, roles, permissions, user.isEnabled()
);
}
}
/**
* 携带租户信息的UserDetails
*/
public class TenantUserDetails implements UserDetails {
private final Long userId;
private final String username;
private final String password;
private final String tenantId; // 关键:携带租户ID
private final List<String> roles;
private final Set<String> permissions;
private final boolean enabled;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
permissions.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));
return authorities;
}
@Override public String getPassword() { return password; }
@Override public String getUsername() { return username; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return enabled; }
public Long getUserId() { return userId; }
public String getTenantId() { return tenantId; }
public Set<String> getPermissions() { return permissions; }
}租户解析过滤器
/**
* 租户解析过滤器(优先级最高,在认证过滤器之前执行)
* 从请求中解析租户ID,设置到TenantContext
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantResolutionFilter extends OncePerRequestFilter {
private final TenantRepository tenantRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
try {
String tenantId = resolveTenantId(request);
if (tenantId != null) {
// 验证租户存在且有效
if (!tenantRepository.isActiveTenant(tenantId)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"error\":\"租户不存在或已停用\"}");
return;
}
TenantContext.setCurrentTenant(tenantId);
}
filterChain.doFilter(request, response);
} finally {
TenantContext.clear(); // 请求结束后必须清理,防止线程池数据污染
}
}
private String resolveTenantId(HttpServletRequest request) {
// 策略1:从子域名解析 tenant-a.myapp.com → tenant-a
String serverName = request.getServerName();
if (serverName.contains(".myapp.com")) {
String subdomain = serverName.replace(".myapp.com", "");
if (!subdomain.isEmpty() && !subdomain.equals("www") && !subdomain.equals("api")) {
return subdomain;
}
}
// 策略2:从Header解析
String tenantHeader = request.getHeader("X-Tenant-Id");
if (tenantHeader != null && !tenantHeader.isBlank()) {
return tenantHeader;
}
// 策略3:从URL路径解析 /api/tenant-a/...
String path = request.getServletPath();
if (path.startsWith("/api/")) {
String[] parts = path.split("/");
if (parts.length >= 3) {
return parts[2]; // /api/{tenantId}/...
}
}
return null;
}
}MyBatis 数据隔离拦截器
/**
* MyBatis拦截器:自动在所有查询中添加 tenant_id 过滤条件
* 防止跨租户数据泄漏
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
@Component
public class TenantDataIsolationInterceptor implements Interceptor {
// 不需要租户隔离的表(全局表,如系统配置、权限定义等)
private static final Set<String> GLOBAL_TABLES = Set.of(
"t_permission", "t_system_config", "t_tenant"
);
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
String tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
// 没有租户上下文(如定时任务),不做拦截
return invocation.proceed();
}
// 获取原始SQL
BoundSql boundSql = ms.getSqlSource().getBoundSql(args[1]);
String originalSql = boundSql.getSql();
// 检查是否涉及全局表,全局表不做隔离
if (isGlobalTable(originalSql)) {
return invocation.proceed();
}
// 添加租户过滤条件
String tenantFilteredSql = addTenantFilter(originalSql, tenantId);
// 用过滤后的SQL替换原始SQL(反射)
// ... 实际项目中使用 MyBatis-Plus 的 TenantLineInnerInterceptor 更便捷
return invocation.proceed();
}
private boolean isGlobalTable(String sql) {
return GLOBAL_TABLES.stream()
.anyMatch(table -> sql.toLowerCase().contains(table));
}
private String addTenantFilter(String sql, String tenantId) {
// 简化实现:在WHERE后加条件
// 实际项目建议用 JSQLParser 解析SQL再修改,更安全
if (sql.toUpperCase().contains("WHERE")) {
return sql + " AND tenant_id = '" + tenantId + "'";
} else {
return sql + " WHERE tenant_id = '" + tenantId + "'";
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
}三个踩坑实录
坑一:TenantContext 在异步线程中丢失
现象: 主请求中设置了TenantContext,在@Async方法中读取到null,导致跨租户数据查询或直接报错。
原因: ThreadLocal不跨线程,@Async用的是另一个线程,该线程的ThreadLocal是空的。
// 解法:使用TtlTaskDecorator(TransmittableThreadLocal)
// pom.xml 添加:transmittable-thread-local
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
// 关键:用TtlRunnable包装,自动传递ThreadLocal
executor.setTaskDecorator(TtlRunnable::get);
executor.initialize();
return executor;
}
}坑二:超级管理员跨租户操作时 TenantContext 限制了他的权限
现象: 运营后台的超级管理员需要查询所有租户的数据做统计,但TenantContext过滤把他的查询也限制在了某个租户内。
解法: 在数据隔离拦截器中,识别超级管理员请求,跳过租户过滤。同时,超级管理员的操作日志需要记录他"以哪个租户的管理员身份"进行了操作。
// 拦截器中增加超级管理员判断
String tenantId = TenantContext.getCurrentTenant();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isSuperAdmin = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_SUPER_ADMIN"));
if (isSuperAdmin || tenantId == null) {
return invocation.proceed(); // 超级管理员不做租户过滤
}坑三:JWT中没有租户信息,每次请求都要查数据库
现象: 集成了JWT认证后,每次请求都要查数据库验证租户有效性,单机QPS 2000时数据库CPU飙到90%。
原因: 每次从JWT中拿到userId后,都要去t_user表查tenant_id,再去t_tenant表验证租户有效性,每次请求多2次DB查询。
解法: 登录时把tenant_id直接写入JWT的Claims,后续请求从Token中直接读取,不需要查DB:
// 生成Token时写入租户信息
String token = Jwts.builder()
.subject(userId)
.claim("tid", tenantId) // 租户ID
.claim("tname", tenantName) // 可选:租户名
.signWith(secretKey)
.compact();
// 过滤器中从Token解析租户信息
Claims claims = tokenProvider.parseToken(token);
String tenantId = claims.get("tid", String.class);
TenantContext.setCurrentTenant(tenantId);多租户架构的数据隔离等级
| 隔离级别 | 实现方式 | 隔离强度 | 成本 |
|---|---|---|---|
| 行级隔离 | 每张表加tenant_id列 | 中(SQL过滤) | 低 |
| Schema级隔离 | 每租户一个Schema | 高(DB层隔离) | 中 |
| 数据库级隔离 | 每租户一个数据库 | 极高(完全隔离) | 高 |
大多数SaaS场景用行级隔离就够了,配合上方的MyBatis拦截器,对代码侵入性最小。对安全要求极高的行业(金融、医疗),考虑Schema级或数据库级隔离。
小结
SaaS多租户认证的核心是:
- 请求入口识别租户:从域名/Header/URL解析,尽早设置到TenantContext
- 认证时绑定租户:UserDetailsService根据username+tenantId查用户
- Token中携带租户:避免每次都查DB验证租户信息
- 数据层强制隔离:拦截器自动加tenant_id过滤,防止漏写
最重要的一条:租户隔离是安全要求,不是功能需求,必须在架构层面做,不能依赖开发人员每次都记得写过滤条件。
