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:
lzh
2026-04-16 23:35:56 +08:00
parent c85f84ea46
commit 423bf3ec3f
8 changed files with 320 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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 集合中
*

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -50,6 +50,22 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService {
});
/**
* 针对 {@link #getAuthorizedProjectIds(Long)} 的缓存
* Key: userId, Value: List&lt;Long&gt; 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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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);