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

