diff --git a/blade-core-secure/src/main/java/org/springblade/core/secure/utils/SecureUtil.java b/blade-core-secure/src/main/java/org/springblade/core/secure/utils/SecureUtil.java index 5142ac9..2d385c6 100644 --- a/blade-core-secure/src/main/java/org/springblade/core/secure/utils/SecureUtil.java +++ b/blade-core-secure/src/main/java/org/springblade/core/secure/utils/SecureUtil.java @@ -174,6 +174,15 @@ public class SecureUtil { * @return boolean */ public static boolean isAdministrator() { + return StringUtil.containsAny(getUserRole(), RoleConstant.ADMINISTRATOR); + } + + /** + * 是否为管理员 + * + * @return boolean + */ + public static boolean isAdmin() { return StringUtil.containsAny(getUserRole(), RoleConstant.ADMIN); } diff --git a/blade-starter-tenant/src/main/java/org/springblade/core/tenant/TenantGuard.java b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/TenantGuard.java new file mode 100644 index 0000000..7e8b0df --- /dev/null +++ b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/TenantGuard.java @@ -0,0 +1,196 @@ +/** + * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com). + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springblade.core.tenant; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.core.secure.utils.SecureUtil; +import org.springblade.core.tenant.exception.TenantException; +import org.springblade.core.tool.utils.CollectionUtil; +import org.springblade.core.tool.utils.ReflectUtil; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 租户归属守卫 + *

+ * 统一拦截「目标实体是否属于当前会话租户」的越权写操作。平台超管(administrator)默认放行。 + * 通过反射访问实体的 getId / getTenantId / setTenantId 方法,调用方无需传入 lambda。 + * + * @author Chill + */ +public class TenantGuard { + + private static final String METHOD_GET_ID = "getId"; + private static final String METHOD_GET_TENANT_ID = "getTenantId"; + private static final String METHOD_SET_TENANT_ID = "setTenantId"; + + private static final ConcurrentHashMap, Method> ID_GETTER_CACHE = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap, Method> TENANT_GETTER_CACHE = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap, Method> TENANT_SETTER_CACHE = new ConcurrentHashMap<>(); + + private TenantGuard() { + } + + /** + * 受租户隔离保护的实体类型 + *

+ * 集中管理实体中文标签,避免调用处散落硬编码字符串。新增受保护实体时, + * 在此枚举追加常量即可,调用方使用 static import 让代码更紧凑。 + */ + public enum EntityType { + + ROLE("角色"), + USER("用户"), + DEPT("部门"), + DEPT_PARENT("上级部门"), + POST("岗位"), + ; + + private final String label; + + EntityType(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + /** + * 校验单个 id 归属当前会话租户 + *

+ * 适用于 update/detail by id 等场景。校验通过后返回查询出的实体, + * 调用方可基于返回值进行后续业务(如把 existEntity.tenantId 回写到入参以防篡改)。 + * + * @param service MyBatis-Plus IService 实例 + * @param id 目标主键 + * @param entityType 实体类型枚举 + * @param 实体类型 + * @return 查询出的实体(仅当超管查询不存在的 id 时可能为 null) + * @throws TenantException 当非超管且目标实体不存在或归属其他租户时抛出 + */ + public static T verify(IService service, Long id, EntityType entityType) { + T entity = service.getById(id); + if (SecureUtil.isAdministrator()) { + return entity; + } + if (entity == null || !SecureUtil.getTenantId().equals(tenantIdOf(entity))) { + throw new TenantException("无权操作非本租户的" + entityType.label()); + } + return entity; + } + + /** + * 提交时(新增 / 修改)的租户绑定守卫 + *

+ * 新增(id 为空):超管放行,非超管强制写入当前会话 tenantId,避免前端注入。
+ * 修改(id 不空):调用 {@link #verify} 校验归属,并把已存在实体的 tenantId 回写到入参,防止 update 篡改。 + * + * @param service MyBatis-Plus IService 实例 + * @param entity 入参实体 + * @param entityType 实体类型 + * @param 实体类型 + * @throws TenantException 当修改路径下目标实体不存在或归属其他租户时抛出 + */ + public static void bindTenant(IService service, T entity, EntityType entityType) { + Long id = idOf(entity); + if (id == null) { + if (!SecureUtil.isAdministrator()) { + bindTenantId(entity, SecureUtil.getTenantId()); + } + return; + } + T existEntity = verify(service, id, entityType); + if (existEntity == null) { + return; + } + // 非超管路径下 verify 已确保 existEntity.tenantId 等于会话 tenantId,直接复用避免重复反射 + String tenantId = SecureUtil.isAdministrator() ? tenantIdOf(existEntity) : SecureUtil.getTenantId(); + bindTenantId(entity, tenantId); + } + + /** + * 校验批量 ids 全部归属当前会话租户,并返回查询出的实体列表 + *

+ * 适用于 batch remove/grant/reset/unlock 等场景。任一 id 不存在或归属其他租户,整体抛异常。 + * 超管放行。返回值供调用方复用,避免二次查询。 + * + * @param service MyBatis-Plus IService 实例 + * @param ids 目标主键集合 + * @param entityType 实体类型枚举 + * @param 实体类型 + * @return 查询出的实体列表(ids 为空时返回空列表) + * @throws TenantException 当非超管且任一目标实体不存在或归属其他租户时抛出 + */ + public static List verifyBatch(IService service, List ids, EntityType entityType) { + if (CollectionUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + List list = service.listByIds(ids); + if (SecureUtil.isAdministrator()) { + return list; + } + String currentTenantId = SecureUtil.getTenantId(); + if (list.size() != ids.size() || list.stream().anyMatch(entity -> !currentTenantId.equals(tenantIdOf(entity)))) { + throw new TenantException("无权操作非本租户的" + entityType.label()); + } + return list; + } + + /** + * 反射读取实体主键 + */ + private static Long idOf(Object entity) { + Method method = ID_GETTER_CACHE.computeIfAbsent(entity.getClass(), + clazz -> requireMethod(clazz, METHOD_GET_ID)); + Object value = ReflectUtil.invokeMethod(method, entity); + if (value != null && !(value instanceof Long)) { + throw new IllegalArgumentException("实体 " + entity.getClass().getSimpleName() + " 的主键类型必须为 Long"); + } + return (Long) value; + } + + /** + * 反射读取实体 tenantId + */ + private static String tenantIdOf(Object entity) { + Method method = TENANT_GETTER_CACHE.computeIfAbsent(entity.getClass(), + clazz -> requireMethod(clazz, METHOD_GET_TENANT_ID)); + return (String) ReflectUtil.invokeMethod(method, entity); + } + + /** + * 反射写入实体 tenantId + */ + private static void bindTenantId(Object entity, String tenantId) { + Method method = TENANT_SETTER_CACHE.computeIfAbsent(entity.getClass(), + clazz -> requireMethod(clazz, METHOD_SET_TENANT_ID, String.class)); + ReflectUtil.invokeMethod(method, entity, tenantId); + } + + private static Method requireMethod(Class clazz, String name, Class... paramTypes) { + Method method = ReflectUtil.findMethod(clazz, name, paramTypes); + if (method == null) { + throw new IllegalArgumentException("实体 " + clazz.getSimpleName() + " 缺少 " + name + " 方法,不支持租户守卫"); + } + return method; + } + +} diff --git a/blade-starter-tenant/src/main/java/org/springblade/core/tenant/error/BladeTenantExceptionTranslator.java b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/error/BladeTenantExceptionTranslator.java new file mode 100644 index 0000000..e0b5ae9 --- /dev/null +++ b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/error/BladeTenantExceptionTranslator.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com). + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springblade.core.tenant.error; + +import jakarta.servlet.Servlet; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tenant.exception.TenantException; +import org.springblade.core.tool.api.R; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * 租户异常处理器 + *

+ * 仅当 tenant starter 上线时随 {@code TenantConfiguration} 一起激活, + * 把 {@link TenantException} 翻译为 HTTP 403。优先级高于全局兜底翻译器, + * 避免被 {@code Throwable} handler 吞掉。 + * + * @author Chill + */ +@Slf4j +@AutoConfiguration +@ConditionalOnClass({Servlet.class, DispatcherServlet.class}) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class BladeTenantExceptionTranslator { + + @ExceptionHandler(TenantException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public R handleError(TenantException e) { + log.error("租户异常", e); + return R.fail(e.getResultCode(), e.getMessage()); + } + +} diff --git a/blade-starter-tenant/src/main/java/org/springblade/core/tenant/exception/TenantException.java b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/exception/TenantException.java new file mode 100644 index 0000000..1800a55 --- /dev/null +++ b/blade-starter-tenant/src/main/java/org/springblade/core/tenant/exception/TenantException.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com). + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springblade.core.tenant.exception; + +import lombok.Getter; +import org.springblade.core.tool.api.IResultCode; +import org.springblade.core.tool.api.ResultCode; + +/** + * 租户业务异常 + * + * @author Chill + */ +public class TenantException extends RuntimeException { + private static final long serialVersionUID = 1L; + + @Getter + private final IResultCode resultCode; + + public TenantException(String message) { + super(message); + this.resultCode = ResultCode.FAILURE; + } + + public TenantException(IResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + public TenantException(IResultCode resultCode, Throwable cause) { + super(cause); + this.resultCode = resultCode; + } + + /** + * 提高性能 + * + * @return Throwable + */ + @Override + public Throwable fillInStackTrace() { + return this; + } + + public Throwable doFillInStackTrace() { + return super.fillInStackTrace(); + } + +}