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")
|
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")
|
||||||
CommonResult<Boolean> validProject(@RequestParam("id") Long id);
|
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.mq.rocketmq.TenantRocketMQInitializer;
|
||||||
import com.viewsh.framework.tenant.core.redis.ProjectRedisCacheManager;
|
import com.viewsh.framework.tenant.core.redis.ProjectRedisCacheManager;
|
||||||
import com.viewsh.framework.tenant.core.redis.TenantRedisCacheManager;
|
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.security.TenantSecurityWebFilter;
|
||||||
import com.viewsh.framework.tenant.core.service.ProjectFrameworkService;
|
import com.viewsh.framework.tenant.core.service.ProjectFrameworkService;
|
||||||
import com.viewsh.framework.tenant.core.service.ProjectFrameworkServiceImpl;
|
import com.viewsh.framework.tenant.core.service.ProjectFrameworkServiceImpl;
|
||||||
@@ -181,6 +183,46 @@ public class ViewshTenantAutoConfiguration {
|
|||||||
return registrationBean;
|
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 集合中
|
* 如果 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);
|
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
|
@Override
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public List<Long> getProjectIds() {
|
public List<Long> getProjectIds() {
|
||||||
@@ -62,4 +78,20 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService {
|
|||||||
validProjectCache.get(id).checkError();
|
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);
|
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);
|
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;
|
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.enums.CommonStatusEnum;
|
||||||
import com.viewsh.framework.common.pojo.PageResult;
|
import com.viewsh.framework.common.pojo.PageResult;
|
||||||
import com.viewsh.framework.common.util.collection.CollectionUtils;
|
import com.viewsh.framework.common.util.collection.CollectionUtils;
|
||||||
@@ -138,6 +139,33 @@ public class ProjectServiceImpl implements ProjectService {
|
|||||||
return project.getId();
|
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) {
|
private void validateProjectExists(Long id) {
|
||||||
if (projectMapper.selectById(id) == null) {
|
if (projectMapper.selectById(id) == null) {
|
||||||
throw exception(PROJECT_NOT_EXISTS);
|
throw exception(PROJECT_NOT_EXISTS);
|
||||||
|
|||||||
Reference in New Issue
Block a user