diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java new file mode 100644 index 0000000..008079b --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java @@ -0,0 +1,106 @@ +package com.gxwebsoft.common.core.security; + +import cn.hutool.core.util.StrUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 文件访问规则:{@code /api/file/**} 下哪些路径可以匿名读取。 + * + *

改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料 + * 这类私人文件可以被未登录者按 URL 直接取走。现在默认全部需要登录,只有 + * {@code file.public-dirs} 里显式登记的目录(原图与缩略图)才放行;下载接口 + * {@code /api/file/download/**} 不在放行之列——"取走原件"一律要登录。 + * + *

规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, + * 因此可以在单元测试里直接验证。 + * + * @author Codex + * @since 2026-11-03 + */ +@Component +public class FileAccessRules { + + /** + * 文件接口统一前缀 + */ + public static final String FILE_PATH_PREFIX = "/api/file"; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + /** + * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠 + */ + private final List publicDirs; + + public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs) { + this.publicDirs = normalize(publicDirs); + } + + /** + * 匿名放行的路径模式:白名单目录下的原图与缩略图。 + * + * @return antMatchers 模式,白名单为空时返回空列表 + */ + public List permitAllPatterns() { + if (publicDirs.isEmpty()) { + return Collections.emptyList(); + } + List patterns = new ArrayList<>(publicDirs.size() * 2); + for (String dir : publicDirs) { + patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**"); + patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**"); + } + return patterns; + } + + /** + * 需要登录才能访问的文件路径模式(兜底规则,注册时必须排在 {@code GET /**} 之前)。 + * + * @return antMatchers 模式 + */ + public List authenticatedPatterns() { + return Collections.singletonList(FILE_PATH_PREFIX + "/**"); + } + + /** + * 这条文件请求是否按公开资源处理(匿名可读)。 + * + * @param path 请求路径,可带查询串 + * @return true 表示命中白名单;下载路径、未命中目录、非文件路径一律 false + */ + public boolean isPublicFileRequest(String path) { + if (StrUtil.isBlank(path)) { + return false; + } + String clean = StrUtil.subBefore(path, "?", false); + if (!clean.startsWith(FILE_PATH_PREFIX + "/")) { + return false; + } + for (String pattern : permitAllPatterns()) { + if (pathMatcher.match(pattern, clean)) { + return true; + } + } + return false; + } + + private static List normalize(String raw) { + if (StrUtil.isBlank(raw)) { + return Collections.emptyList(); + } + List dirs = new ArrayList<>(); + for (String item : StrUtil.split(raw, ',')) { + String dir = StrUtil.strip(StrUtil.trim(item), "/"); + if (StrUtil.isNotBlank(dir) && !dirs.contains(dir)) { + dirs.add(dir); + } + } + return dirs; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index aca9b21..61985a6 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -7,11 +7,13 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import javax.annotation.Resource; +import java.util.List; /** * Spring Security配置 @@ -29,13 +31,25 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; + @Resource + private FileAccessRules fileAccessRules; @Override protected void configure(HttpSecurity http) throws Exception { - http.authorizeRequests() - .antMatchers(HttpMethod.OPTIONS, "/**") - .permitAll() - .antMatchers(HttpMethod.GET, "/api/file/**","/**", "/api/captcha", "/") + ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry urlRegistry = + http.authorizeRequests() + .antMatchers(HttpMethod.OPTIONS, "/**") + .permitAll(); + // 文件默认需登录:先放行确需公开的资源目录(file.public-dirs), + // 再兜住其余 /api/file/**。这两条必须排在 GET /** 之前,否则等于没配。 + List publicFilePatterns = fileAccessRules.permitAllPatterns(); + if (!publicFilePatterns.isEmpty()) { + urlRegistry.antMatchers(HttpMethod.GET, publicFilePatterns.toArray(new String[0])) + .permitAll(); + } + urlRegistry.antMatchers(HttpMethod.GET, fileAccessRules.authenticatedPatterns().toArray(new String[0])) + .authenticated() + .antMatchers(HttpMethod.GET, "/**", "/api/captcha", "/") .permitAll() .antMatchers( "/api/login", @@ -84,9 +98,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { ) .permitAll() .anyRequest() - .authenticated() - .and() - .sessionManagement() + .authenticated(); + http.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .csrf() diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java index 4d12d7f..de68ad3 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java @@ -1,5 +1,7 @@ package com.gxwebsoft.gxmu.controller; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; @@ -98,9 +100,18 @@ public class ReviewListController extends BaseController { @ApiOperation("修改审核列表") @PutMapping() public ApiResult update(@RequestBody ReviewList reviewList) { + // 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行 + ApiResult denied = denyIfNotAssignee(reviewList.getId()); + if (denied != null) { + return denied; + } + denied = denyIfRejectWithoutContent(reviewList); + if (denied != null) { + return denied; + } if (reviewListService.updateById(reviewList)) { // 通过进入下一个 - if (reviewList.getStatus().equals(1)) { + if (Integer.valueOf(1).equals(reviewList.getStatus())) { ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule()); if (reviewFlowConfig != null) { ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); @@ -155,6 +166,15 @@ public class ReviewListController extends BaseController { @ApiOperation("批量修改审核列表") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + // 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库 + ApiResult denied = denyIfBatchNotAssignee(batchParam); + if (denied != null) { + return denied; + } + denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData()); + if (denied != null) { + return denied; + } if (batchParam.update(reviewListService, "id")) { syncBatchReviewStatus(batchParam); return success("修改成功"); @@ -162,6 +182,55 @@ public class ReviewListController extends BaseController { return fail("修改失败"); } + /** + * 审批动作的归属校验:操作者必须是该待办行的 user_id(当前一级的审批人)。 + * + *

待办不存在时也按无权限处理,不区分"不存在"与"不是你的"。 + * + * @param id 待办行 id + * @return null 表示校验通过,否则返回 403 结果 + */ + private ApiResult denyIfNotAssignee(Integer id) { + ReviewList existing = id == null ? null : reviewListService.getById(id); + Integer loginUserId = getLoginUserId(); + if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) { + return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG); + } + return null; + } + + /** + * 批量审批的归属校验:逐行确认都属于当前用户。 + * + * @param batchParam 批量参数 + * @return null 表示全部通过,否则返回 403 结果 + */ + private ApiResult denyIfBatchNotAssignee(BatchParam batchParam) { + if (batchParam == null || batchParam.getIds() == null) { + return null; + } + for (java.io.Serializable id : batchParam.getIds()) { + ApiResult denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null); + if (denied != null) { + return denied; + } + } + return null; + } + + /** + * 驳回必须携带意见,否则申报人不知道要改什么。 + * + * @param data 提交的审批数据 + * @return null 表示校验通过,否则返回可读的失败结果 + */ + private ApiResult denyIfRejectWithoutContent(ReviewList data) { + if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) { + return fail("驳回必须填写审核意见"); + } + return null; + } + /** 批量审核后同样要回写申报记录状态,否则重复申报限制会读到旧状态 */ private void syncBatchReviewStatus(BatchParam batchParam) { if (batchParam == null || batchParam.getIds() == null) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0687ff6..0812eb1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -89,6 +89,17 @@ config: upload-path: /Users/liangxin/Project/JAVA/tower-java/ local-upload-path: /Users/gxwebsoft/Documents/uploads/ +# 文件访问白名单(spec #58):确需公开的资源目录,逗号分隔,相对 /api/file。 +# file.public-dirs: cms-article,public +# 列进这里的目录,其原图 /api/file/

/** 与缩略图 /api/file/thumbnail//** 允许匿名读取; +# 下载接口 /api/file/download/** 永远需要登录。留空 = 所有文件都需要登录。 +# +# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), +# 目录本身不区分公开/私有,所以生产要放开公开站点图片,只能按日期目录逐条登记, +# 或后续给上传链路加语义前缀。不要图省事写成 /api/file/**。 +file: + public-dirs: ${FILE_PUBLIC_DIRS:} + wx: miniapp: configs: diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java new file mode 100644 index 0000000..ce5597a --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java @@ -0,0 +1,84 @@ +package com.gxwebsoft.common.core.security; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 文件访问规则(spec #58 API 契约、用户故事 14)。 + * + *

改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 + * 未登录者直接按 URL 取走。规则收敛成"默认需登录 + 配置白名单",白名单由 + * {@code file.public-dirs} 显式声明。测试只钉外部可观察的三件事: + * 下载要登录、白名单目录匿名可读、其余目录仍要登录。 + * + * @author Codex + * @since 2026-11-03 + */ +class FileAccessRulesTest { + + /** 下载接口永远要登录,公开目录也一样——它是"取走原件"的动作。 */ + @Test + void downloadAlwaysRequiresLogin() { + FileAccessRules rules = new FileAccessRules("cms-article"); + + assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); + assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf")); + } + + /** 白名单目录:原图与缩略图都匿名可读,公开页面才能正常渲染。 */ + @Test + void whitelistedDirectoryIsPublic() { + FileAccessRules rules = new FileAccessRules("cms-article"); + + assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png")); + assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png")); + } + + /** 没进白名单的目录(当前所有上传都落在日期目录)默认要登录。 */ + @Test + void otherDirectoriesStillRequireLogin() { + FileAccessRules rules = new FileAccessRules("cms-article"); + + assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg")); + assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/盖章证明.jpg")); + } + + /** 白名单为空时不产生任何放行模式,安全默认 = 全部需登录。 */ + @Test + void blankConfigurationYieldsNoPublicPatterns() { + assertTrue(new FileAccessRules("").permitAllPatterns().isEmpty()); + assertTrue(new FileAccessRules(null).permitAllPatterns().isEmpty()); + assertFalse(new FileAccessRules("").isPublicFileRequest("/api/file/20260708/a.jpg")); + } + + /** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ + @Test + void patternsAreNormalized() { + FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ "); + + List patterns = rules.permitAllPatterns(); + assertTrue(patterns.contains("/api/file/cms-article/**")); + assertTrue(patterns.contains("/api/file/public/**")); + assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**")); + assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file"); + } + + /** 登录态之外的接口(CMS 文章 JSON)不受文件规则影响。 */ + @Test + void nonFileRequestsAreNotTreatedAsFiles() { + FileAccessRules rules = new FileAccessRules("cms-article"); + + assertFalse(rules.isPublicFileRequest("/api/cms/article")); + assertFalse(rules.isPublicFileRequest(null)); + } + + /** 授权链最后一条:除白名单外的所有文件请求都要登录。 */ + @Test + void authenticatedPatternsCoverAllFiles() { + assertTrue(new FileAccessRules("cms-article").authenticatedPatterns().contains("/api/file/**")); + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java new file mode 100644 index 0000000..7cdd178 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java @@ -0,0 +1,216 @@ +package com.gxwebsoft.gxmu.controller; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.gxmu.entity.ReviewList; +import com.gxwebsoft.gxmu.service.DeclareGuardService; +import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; +import com.gxwebsoft.gxmu.service.ReviewFlowService; +import com.gxwebsoft.gxmu.service.ReviewListService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.io.Serializable; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 审批动作的归属校验(spec #58 用户故事 10、API 契约)。 + * + *

待办行的 {@code user_id} 就是这一级审批人。控制器必须先确认"操作者 = + * 该待办的责任人",再落库——此前任何人都能替别人通过/驳回,前端把按钮藏起来 + * 只是假象。这里钉住拒绝路径:越权、匿名、驳回不写意见都不能落库。 + * + * @author Codex + * @since 2026-11-03 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReviewListControllerAuthTest { + + private static final Integer OWNER_ID = 9; + private static final Integer OTHER_ID = 7; + private static final Integer ROW_ID = 11; + private static final Integer PK = 100; + + @Mock + private ReviewListService reviewListService; + + @Mock + private ReviewFlowConfigService reviewFlowConfigService; + + @Mock + private ReviewFlowService reviewFlowService; + + @Mock + private DeclareGuardService declareGuardService; + + @InjectMocks + private ReviewListController controller; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** 越权审批:待办属于别人,即使带了合法 id 也不能落库。 */ + @Test + void approveIsDeniedWhenOperatorIsNotTheAssignee() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OTHER_ID)); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode()); + verify(reviewListService, never()).updateById(any()); + } + + /** 未登录(无 SecurityContext)一律拒绝,不能靠"没人登"这条路径改状态。 */ + @Test + void approveIsDeniedWhenAnonymous() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OTHER_ID)); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode()); + verify(reviewListService, never()).updateById(any()); + } + + /** 待办不存在时同样按无权限处理,不泄露"这条记录存在但归别人"。 */ + @Test + void approveIsDeniedWhenRowIsMissing() { + when(reviewListService.getById(ROW_ID)).thenReturn(null); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode()); + verify(reviewListService, never()).updateById(any()); + } + + /** 本人审批:这是正常路径,必须落库并回写申报状态。 */ + @Test + void approveIsAllowedForTheAssignee() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID)); + when(reviewListService.updateById(any())).thenReturn(true); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertEquals(Constants.RESULT_OK_CODE, result.getCode()); + verify(reviewListService).updateById(any()); + verify(declareGuardService).syncReviewStatus("jhqn", PK); + } + + /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */ + @Test + void rejectIsDeniedWithoutContent() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID)); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 2, " ")); + + assertNotEquals(Constants.RESULT_OK_CODE, result.getCode()); + assertTrue(result.getMessage() != null && result.getMessage().contains("意见"), + "驳回没写意见要给出可读提示,实际:" + result.getMessage()); + verify(reviewListService, never()).updateById(any()); + } + + /** 驳回写了意见则正常落库。 */ + @Test + void rejectIsAllowedWithContent() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID)); + when(reviewListService.updateById(any())).thenReturn(true); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 2, "材料不全,退回补充")); + + assertEquals(Constants.RESULT_OK_CODE, result.getCode()); + verify(reviewListService).updateById(any()); + } + + /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */ + @Test + void batchIsDeniedWhenAnyRowIsNotOwned() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID)); + when(reviewListService.getById(ROW_ID + 1)).thenReturn(row(ROW_ID + 1, OTHER_ID)); + loginAs(OWNER_ID); + + ApiResult result = controller.updateBatch(batch(1, ROW_ID, ROW_ID + 1)); + + assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode()); + verify(reviewListService, never()).update(any(), any()); + } + + /** 批量驳回同样要校验意见,否则等于绕过单条驳回的限制。 */ + @Test + void batchRejectIsDeniedWithoutContent() { + when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID)); + loginAs(OWNER_ID); + + ApiResult result = controller.updateBatch(batch(2, ROW_ID)); + + assertNotEquals(Constants.RESULT_OK_CODE, result.getCode()); + assertTrue(result.getMessage() != null && result.getMessage().contains("意见"), + "批量驳回落库前也要拦,实际:" + result.getMessage()); + verify(reviewListService, never()).update(any(), any()); + } + + private static ReviewList row(Integer id, Integer userId) { + ReviewList row = new ReviewList(); + row.setId(id); + row.setModule("jhqn"); + row.setPk(PK); + row.setUserId(userId); + row.setStatus(0); + row.setSortNumber(1); + return row; + } + + private static ReviewList request(Integer id, Integer status, String content) { + ReviewList request = new ReviewList(); + request.setId(id); + request.setStatus(status); + request.setContent(content); + request.setModule("jhqn"); + request.setPk(PK); + request.setSortNumber(1); + return request; + } + + private static BatchParam batch(Integer status, Integer... ids) { + BatchParam batch = new BatchParam<>(); + Serializable[] serializableIds = new Serializable[ids.length]; + System.arraycopy(ids, 0, serializableIds, 0, ids.length); + batch.setIds(Arrays.asList(serializableIds)); + ReviewList data = new ReviewList(); + data.setStatus(status); + data.setContent(null); + batch.setData(data); + return batch; + } + + private static void loginAs(Integer userId) { + User user = new User(); + user.setUserId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } +}