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

基于JWT认证的共享库共享Schema多租户实现求助

共享数据库共享Schema多租户实现方案(基于已完成JWT认证项目)

一、用户-租户关联模型设计

针对「用户可访问多个租户」的需求,新增用户-租户关联表,实现多对多关系,并存储租户维度的权限:

// 用户实体
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password; // 已加密存储
    // 关联用户-租户关系
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<UserTenant> userTenants = new ArrayList<>();
    // getter/setter
}

// 用户-租户关联实体(存储租户维度权限)
@Entity
public class UserTenant {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
    private String subdomain; // 租户ID(子域名)
    private String roles; // 租户内角色,如"ADMIN,USER"
    private String permissions; // 可选:细粒度权限列表
    // getter/setter
}

二、生成包含租户ID的JWT Token

登录接口校验用户-租户合法性后,将subdomain写入JWT Claims:

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestParam String username, 
                                   @RequestParam String password,
                                   @RequestAttribute String subdomain) {
        // 校验用户是否有权访问该租户(已实现的existsByUsernameAndSubdomain)
        if (!userRepository.existsByUsernameAndSubdomain(username, subdomain)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("无该租户访问权限");
        }
        User user = userRepository.findByUsernameAndSubdomain(username, subdomain);
        // 密码校验
        if (!passwordEncoder.matches(password, user.getPassword())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("密码错误");
        }
        // 组装JWT额外Claims,写入租户ID和权限
        Map<String, Object> claims = new HashMap<>();
        claims.put("subdomain", subdomain);
        // 从关联表取当前租户下的用户角色
        String roles = user.getUserTenants().stream()
                .filter(ut -> ut.getSubdomain().equals(subdomain))
                .findFirst()
                .map(UserTenant::getRoles)
                .orElse("USER");
        claims.put("roles", roles);
        // 生成Token
        String token = jwtTokenUtil.generateToken(user.getUsername(), claims);
        return ResponseEntity.ok(new AuthResponse(token));
    }
}

JwtTokenUtil补充实现:

@Component
public class JwtTokenUtil {
    private static final String SECRET_KEY = "your-secret-key";
    private static final long EXPIRATION_TIME = 86400000; // 24小时

    public String generateToken(String username, Map<String, Object> extraClaims) {
        return Jwts.builder()
                .setClaims(extraClaims)
                .setSubject(username)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .compact();
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }

    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }
}

三、全局租户校验拦截器增强

在已实现的TenantInterceptor基础上,增加JWT租户匹配校验、用户权限二次校验,并将租户ID存入ThreadLocal供业务使用:

@Component
public class TenantInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Autowired
    private UserTenantRepository userTenantRepository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取请求子域名(已实现,从RequestAttribute中拿)
        String requestSubdomain = (String) request.getAttribute("subdomain");
        if (requestSubdomain == null) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "缺少租户标识");
            return false;
        }
        // 2. 解析JWT中的租户ID
        String token = request.getHeader("Authorization").replace("Bearer ", "");
        String tokenSubdomain = jwtTokenUtil.getClaimFromToken(token, claims -> claims.get("subdomain", String.class));
        // 3. 校验请求租户与Token租户是否一致
        if (!requestSubdomain.equals(tokenSubdomain)) {
            response.sendError(HttpStatus.FORBIDDEN.value(), "租户不匹配");
            return false;
        }
        // 4. 二次校验用户是否有权访问该租户(防止Token篡改)
        String username = jwtTokenUtil.getUsernameFromToken(token);
        if (!userTenantRepository.existsByUserUsernameAndSubdomain(username, requestSubdomain)) {
            response.sendError(HttpStatus.FORBIDDEN.value(), "无该租户访问权限");
            return false;
        }
        // 5. 将租户ID存入ThreadLocal,供后续业务逻辑使用
        TenantContextHolder.setTenantId(requestSubdomain);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 清理ThreadLocal,避免内存泄漏
        TenantContextHolder.clear();
    }
}

// 租户上下文工具类
public class TenantContextHolder {
    private static final ThreadLocal<String> tenantIdHolder = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        tenantIdHolder.set(tenantId);
    }

    public static String getTenantId() {
        return tenantIdHolder.get();
    }

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

四、业务数据的租户隔离

在所有需要隔离的业务实体中添加subdomain字段,并通过Repository自动过滤租户数据:

// 业务实体示例:订单
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNo;
    private BigDecimal amount;
    private String subdomain; // 租户标识
    // getter/setter
}

// Repository层自动过滤当前租户数据
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
    default List<Order> findAllCurrentTenant() {
        String subdomain = TenantContextHolder.getTenantId();
        return findAll((root, query, cb) -> cb.equal(root.get("subdomain"), subdomain));
    }
}

可选:用Spring Data JPA审计自动填充租户ID

// 审计处理器
@Component
public class TenantAuditorAware implements AuditorAware<String> {
    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.ofNullable(TenantContextHolder.getTenantId());
    }
}

// 实体类修改
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
    // ...其他字段
    @CreatedBy // 自动填充当前租户ID
    private String subdomain;
}

五、租户维度权限控制

结合Spring Security,按用户-租户维度校验权限:

@RestController
@RequestMapping("/orders")
public class OrderController {
    @Autowired
    private OrderRepository orderRepository;

    @GetMapping
    @PreAuthorize("hasRole('USER') and #subdomain == authentication.principal.subdomain")
    public List<Order> getOrders(@RequestAttribute String subdomain) {
        return orderRepository.findAllCurrentTenant();
    }

    @PostMapping
    @PreAuthorize("hasRole('ADMIN') and #subdomain == authentication.principal.subdomain")
    public Order createOrder(@RequestBody Order order, @RequestAttribute String subdomain) {
        order.setSubdomain(subdomain); // 或通过审计自动填充
        return orderRepository.save(order);
    }
}

自定义UserDetailsService加载租户维度权限:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserTenantRepository userTenantRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从ThreadLocal获取当前租户ID
        String subdomain = TenantContextHolder.getTenantId();
        UserTenant userTenant = userTenantRepository.findByUserUsernameAndSubdomain(username, subdomain)
                .orElseThrow(() -> new UsernameNotFoundException("用户无该租户权限"));
        // 组装权限
        List<GrantedAuthority> authorities = Arrays.stream(userTenant.getRoles().split(","))
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
        return new org.springframework.security.core.userdetails.User(
                userTenant.getUser().getUsername(),
                userTenant.getUser().getPassword(),
                authorities
        );
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.01 20:00:16