feat(tenant): 实现 ProjectSecurityWebFilter 项目权限集合校验
新增 ProjectSecurityWebFilter: - 集合校验: user.authorizedProjectIds.contains(header.projectId) - 默认项目选择: DEFAULT编码 → 最小ID → 单项目自动选中 → 无授权403 - @ProjectIgnore URL 自动跳过 - 注册在 WebFilterOrderEnum.PROJECT_SECURITY_FILTER (-98) 框架层: - ProjectCommonApi: 新增 getAuthorizedProjectIds, getDefaultProjectId - ProjectFrameworkService: 新增授权查询 + Caffeine 缓存(60s/1000条) - ViewshTenantAutoConfiguration: 注册 Filter + 扫描 @ProjectIgnore 业务层: - ProjectService: 新增 getAuthorizedProjectIds, getDefaultProjectId - ProjectServiceImpl: 默认项目3级回退逻辑 - ProjectApiImpl: 实现 Feign 端点 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,4 +26,13 @@ public interface ProjectCommonApi {
|
||||
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")
|
||||
CommonResult<Boolean> validProject(@RequestParam("id") Long id);
|
||||
|
||||
@GetMapping(PREFIX + "/authorized-ids")
|
||||
@Operation(summary = "获得用户授权的项目编号列表")
|
||||
CommonResult<List<Long>> getAuthorizedProjectIds(@RequestParam("userId") Long userId);
|
||||
|
||||
@GetMapping(PREFIX + "/default-id")
|
||||
@Operation(summary = "获得用户的默认项目编号")
|
||||
@Parameter(name = "userId", description = "用户编号", required = true)
|
||||
CommonResult<Long> getDefaultProjectId(@RequestParam("userId") Long userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.viewsh.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor;
|
||||
import com.viewsh.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer;
|
||||
import com.viewsh.framework.tenant.core.redis.ProjectRedisCacheManager;
|
||||
import com.viewsh.framework.tenant.core.redis.TenantRedisCacheManager;
|
||||
import com.viewsh.framework.tenant.core.aop.ProjectIgnore;
|
||||
import com.viewsh.framework.tenant.core.security.ProjectSecurityWebFilter;
|
||||
import com.viewsh.framework.tenant.core.security.TenantSecurityWebFilter;
|
||||
import com.viewsh.framework.tenant.core.service.ProjectFrameworkService;
|
||||
import com.viewsh.framework.tenant.core.service.ProjectFrameworkServiceImpl;
|
||||
@@ -181,6 +183,46 @@ public class ViewshTenantAutoConfiguration {
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<ProjectSecurityWebFilter> projectSecurityWebFilter(
|
||||
TenantProperties tenantProperties,
|
||||
WebProperties webProperties,
|
||||
GlobalExceptionHandler globalExceptionHandler,
|
||||
ProjectFrameworkService projectFrameworkService) {
|
||||
FilterRegistrationBean<ProjectSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
registrationBean.setFilter(new ProjectSecurityWebFilter(webProperties, tenantProperties,
|
||||
getProjectIgnoreUrls(), globalExceptionHandler, projectFrameworkService));
|
||||
registrationBean.setOrder(WebFilterOrderEnum.PROJECT_SECURITY_FILTER);
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果 Controller 接口上,有 {@link ProjectIgnore} 注解,则添加到忽略项目的 URL 集合中
|
||||
*
|
||||
* @return 忽略项目的 URL 集合
|
||||
*/
|
||||
private Set<String> getProjectIgnoreUrls() {
|
||||
Set<String> ignoreUrls = new HashSet<>();
|
||||
RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping)
|
||||
applicationContext.getBean("requestMappingHandlerMapping");
|
||||
Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods();
|
||||
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethodMap.entrySet()) {
|
||||
HandlerMethod handlerMethod = entry.getValue();
|
||||
if (!handlerMethod.hasMethodAnnotation(ProjectIgnore.class)
|
||||
&& !handlerMethod.getBeanType().isAnnotationPresent(ProjectIgnore.class)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.getKey().getPatternsCondition() != null) {
|
||||
ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns());
|
||||
}
|
||||
if (entry.getKey().getPathPatternsCondition() != null) {
|
||||
ignoreUrls.addAll(
|
||||
convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
|
||||
}
|
||||
}
|
||||
return ignoreUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中
|
||||
*
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.viewsh.framework.tenant.core.security;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.viewsh.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.framework.common.util.servlet.ServletUtils;
|
||||
import com.viewsh.framework.security.core.LoginUser;
|
||||
import com.viewsh.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.viewsh.framework.tenant.config.TenantProperties;
|
||||
import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
|
||||
import com.viewsh.framework.tenant.core.service.ProjectFrameworkService;
|
||||
import com.viewsh.framework.web.config.WebProperties;
|
||||
import com.viewsh.framework.web.core.filter.ApiRequestFilter;
|
||||
import com.viewsh.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 项目 Security Web 过滤器
|
||||
*
|
||||
* 与 TenantSecurityWebFilter 的「相等校验」不同,本过滤器执行「集合包含校验」:
|
||||
* 用户授权的项目 ID 列表必须包含请求携带的项目 ID。
|
||||
*
|
||||
* 主要职责:
|
||||
* 1. 已登录用户未携带 project-id 时,按默认项目选择逻辑自动填充上下文。
|
||||
* 2. 已登录用户携带 project-id 时,校验用户是否拥有该项目的访问权限。
|
||||
* 3. 校验项目合法性(启用状态、是否过期等)。
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Slf4j
|
||||
public class ProjectSecurityWebFilter extends ApiRequestFilter {
|
||||
|
||||
private final TenantProperties tenantProperties;
|
||||
|
||||
/**
|
||||
* 允许忽略项目校验的 URL 列表(来自 @ProjectIgnore 注解扫描)
|
||||
*/
|
||||
private final Set<String> ignoreUrls;
|
||||
|
||||
private final AntPathMatcher pathMatcher;
|
||||
|
||||
private final GlobalExceptionHandler globalExceptionHandler;
|
||||
private final ProjectFrameworkService projectFrameworkService;
|
||||
|
||||
public ProjectSecurityWebFilter(WebProperties webProperties,
|
||||
TenantProperties tenantProperties,
|
||||
Set<String> ignoreUrls,
|
||||
GlobalExceptionHandler globalExceptionHandler,
|
||||
ProjectFrameworkService projectFrameworkService) {
|
||||
super(webProperties);
|
||||
this.tenantProperties = tenantProperties;
|
||||
this.ignoreUrls = ignoreUrls;
|
||||
this.pathMatcher = new AntPathMatcher();
|
||||
this.globalExceptionHandler = globalExceptionHandler;
|
||||
this.projectFrameworkService = projectFrameworkService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
Long projectId = ProjectContextHolder.getProjectId();
|
||||
// 1. 未登录用户,跳过项目校验(租户校验由 TenantSecurityWebFilter 已处理)
|
||||
LoginUser user = SecurityFrameworkUtils.getLoginUser();
|
||||
if (user == null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 忽略项目校验的 URL,直接放行;若未携带 project-id 则标记忽略,避免下游报错
|
||||
if (isIgnoreUrl(request)) {
|
||||
if (projectId == null) {
|
||||
ProjectContextHolder.setIgnore(true);
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 请求未携带 project-id,执行默认项目选择逻辑
|
||||
if (projectId == null) {
|
||||
try {
|
||||
projectId = selectDefaultProject(user.getId());
|
||||
if (projectId == null) {
|
||||
// 用户无任何授权项目,拒绝访问
|
||||
log.error("[doFilterInternal][用户({}) 无任何授权项目,URL({}/{})]",
|
||||
user.getId(), request.getRequestURI(), request.getMethod());
|
||||
ServletUtils.writeJSON(response, CommonResult.error(
|
||||
GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您未被授权访问任何项目"));
|
||||
return;
|
||||
}
|
||||
ProjectContextHolder.setProjectId(projectId);
|
||||
} catch (Throwable ex) {
|
||||
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
|
||||
ServletUtils.writeJSON(response, result);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 4. 请求已携带 project-id,校验用户是否有权限访问该项目
|
||||
List<Long> authorizedProjectIds = projectFrameworkService.getAuthorizedProjectIds(user.getId());
|
||||
if (!authorizedProjectIds.contains(projectId)) {
|
||||
log.error("[doFilterInternal][用户({}) 无权访问项目({}),URL({}/{})]",
|
||||
user.getId(), projectId, request.getRequestURI(), request.getMethod());
|
||||
ServletUtils.writeJSON(response, CommonResult.error(
|
||||
GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您未被授权访问该项目"));
|
||||
return;
|
||||
}
|
||||
// 5. 校验项目是否合法(启用、未过期等)
|
||||
try {
|
||||
projectFrameworkService.validProject(projectId);
|
||||
} catch (Throwable ex) {
|
||||
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
|
||||
ServletUtils.writeJSON(response, result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认项目选择逻辑,委托给系统模块实现:
|
||||
* 1. 取用户授权项目中标记为 DEFAULT 的项目
|
||||
* 2. 若无 DEFAULT,取最小 ID 的项目
|
||||
* 3. 若无任何授权项目,返回 null
|
||||
*/
|
||||
private Long selectDefaultProject(Long userId) {
|
||||
return projectFrameworkService.getDefaultProjectId(userId);
|
||||
}
|
||||
|
||||
private boolean isIgnoreUrl(HttpServletRequest request) {
|
||||
String apiUri = request.getRequestURI().substring(request.getContextPath().length());
|
||||
// 快速精确匹配,保证性能
|
||||
if (CollUtil.contains(tenantProperties.getIgnoreProjectUrls(), apiUri)
|
||||
|| CollUtil.contains(ignoreUrls, apiUri)) {
|
||||
return true;
|
||||
}
|
||||
// 逐个 Ant 路径匹配
|
||||
for (String url : tenantProperties.getIgnoreProjectUrls()) {
|
||||
if (pathMatcher.match(url, apiUri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String url : ignoreUrls) {
|
||||
if (pathMatcher.match(url, apiUri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,4 +23,27 @@ public interface ProjectFrameworkService {
|
||||
*/
|
||||
void validProject(Long id);
|
||||
|
||||
/**
|
||||
* 获得用户授权的项目编号列表(带缓存)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 项目编号列表
|
||||
*/
|
||||
List<Long> getAuthorizedProjectIds(Long userId);
|
||||
|
||||
/**
|
||||
* 获得用户的默认项目编号
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 默认项目编号,若无授权项目则返回 null
|
||||
*/
|
||||
Long getDefaultProjectId(Long userId);
|
||||
|
||||
/**
|
||||
* 清除用户的授权项目缓存(授权变更时调用)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
*/
|
||||
void invalidateAuthorizedProjectCache(Long userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -50,6 +50,22 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService {
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 针对 {@link #getAuthorizedProjectIds(Long)} 的缓存
|
||||
* Key: userId, Value: List<Long> projectIds
|
||||
* TTL: 60秒
|
||||
*/
|
||||
private final LoadingCache<Long, List<Long>> authorizedProjectCache = buildAsyncReloadingCache(
|
||||
Duration.ofSeconds(60L), // 过期时间 60 秒
|
||||
new CacheLoader<Long, List<Long>>() {
|
||||
|
||||
@Override
|
||||
public List<Long> load(Long userId) {
|
||||
return projectApi.getAuthorizedProjectIds(userId).getCheckedData();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public List<Long> getProjectIds() {
|
||||
@@ -62,4 +78,20 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService {
|
||||
validProjectCache.get(id).checkError();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public List<Long> getAuthorizedProjectIds(Long userId) {
|
||||
return authorizedProjectCache.get(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getDefaultProjectId(Long userId) {
|
||||
return projectApi.getDefaultProjectId(userId).getCheckedData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateAuthorizedProjectCache(Long userId) {
|
||||
authorizedProjectCache.invalidate(userId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,4 +31,14 @@ public class ProjectApiImpl implements ProjectCommonApi {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<List<Long>> getAuthorizedProjectIds(Long userId) {
|
||||
return success(projectService.getAuthorizedProjectIds(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Long> getDefaultProjectId(Long userId) {
|
||||
return success(projectService.getDefaultProjectId(userId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -93,4 +93,21 @@ public interface ProjectService {
|
||||
*/
|
||||
Long createDefaultProject(Long tenantId, String tenantName);
|
||||
|
||||
/**
|
||||
* 获得用户授权的项目编号列表
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 项目编号列表
|
||||
*/
|
||||
List<Long> getAuthorizedProjectIds(Long userId);
|
||||
|
||||
/**
|
||||
* 获得用户的默认项目编号
|
||||
* 逻辑:1.找 DEFAULT 编码 → 2.取最小 ID → 3.无授权返回 null
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 默认项目编号,无授权返回 null
|
||||
*/
|
||||
Long getDefaultProjectId(Long userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.viewsh.module.system.service.project;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.viewsh.framework.common.enums.CommonStatusEnum;
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.common.util.collection.CollectionUtils;
|
||||
@@ -138,6 +139,33 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return project.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getAuthorizedProjectIds(Long userId) {
|
||||
List<UserProjectDO> userProjects = userProjectMapper.selectListByUserId(userId);
|
||||
return CollectionUtils.convertList(userProjects, UserProjectDO::getProjectId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getDefaultProjectId(Long userId) {
|
||||
List<Long> authorizedProjectIds = getAuthorizedProjectIds(userId);
|
||||
if (CollUtil.isEmpty(authorizedProjectIds)) {
|
||||
return null;
|
||||
}
|
||||
// 1. 只有一个项目,直接返回
|
||||
if (authorizedProjectIds.size() == 1) {
|
||||
return authorizedProjectIds.get(0);
|
||||
}
|
||||
// 2. 查找 DEFAULT 项目
|
||||
for (Long projectId : authorizedProjectIds) {
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
if (project != null && ProjectDO.CODE_DEFAULT.equals(project.getCode())) {
|
||||
return project.getId();
|
||||
}
|
||||
}
|
||||
// 3. 取最小 ID
|
||||
return authorizedProjectIds.stream().min(Long::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
private void validateProjectExists(Long id) {
|
||||
if (projectMapper.selectById(id) == null) {
|
||||
throw exception(PROJECT_NOT_EXISTS);
|
||||
|
||||
Reference in New Issue
Block a user