diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java index bdb0cfb..b8fc6bf 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java +++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java @@ -3,7 +3,6 @@ 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; @@ -17,12 +16,15 @@ import java.util.List; * * *

规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, - * 因此可以在单元测试里直接验证。 + * 因此可以在单元测试里直接验证模式的内容与顺序——顺序恰恰是这条改造的关键: + * 下载必须在公开模式之前注册,公开模式必须在 {@code GET /**} 兜底之前注册。 * * @author Codex * @since 2026-11-03 @@ -50,20 +52,18 @@ public class FileAccessRules { */ private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**"; - private final AntPathMatcher pathMatcher = new AntPathMatcher(); - /** * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠 */ private final List publicDirs; /** - * 内联资源(预览 + 缩略图)是否默认公开 + * 内联资源(预览 + 缩略图)是否默认公开;默认 false,只有显式打开才整体放行 */ private final boolean publicPreview; public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs, - @Value("${file.public-preview:true}") boolean publicPreview) { + @Value("${file.public-preview:false}") boolean publicPreview) { this.publicDirs = normalize(publicDirs); this.publicPreview = publicPreview; } @@ -71,7 +71,8 @@ public class FileAccessRules { /** * 匿名放行的文件路径模式。 * - *

内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。 + *

默认({@code public-preview=false})只有 {@code file.public-dirs} 白名单里的目录; + * 显式打开内联公开时才额外放行预览与缩略图,且排在白名单之前。 * * @return antMatchers 模式,没有任何公开项时返回空列表 */ @@ -104,31 +105,6 @@ public class FileAccessRules { return Collections.singletonList(DOWNLOAD_PATTERN); } - /** - * 这条文件请求是否按公开资源处理(匿名可读)。 - * - * @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; - } - if (pathMatcher.match(DOWNLOAD_PATTERN, clean)) { - return false; - } - for (String pattern : permitAllPatterns()) { - if (pathMatcher.match(pattern, clean)) { - return true; - } - } - return false; - } - private List publicDirPatterns() { if (publicDirs.isEmpty()) { return Collections.emptyList(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c986a26..28c7b4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -92,12 +92,16 @@ config: # 文件访问规则(spec #58) # 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。 # -# 确需公开的内联资源(预览 /api/file/

/ 与缩略图 /api/file/thumbnail/**): -# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。 +# 内联预览(/api/file//)与缩略图(/api/file/thumbnail/**)默认也需要登录: +# 上传一律落在 /yyyyMMdd/ 日期目录,日期目录正好是两段路径,默认公开等于把所有上传文件 +# (含盖章证明材料)变成匿名可读。因此默认值从 true 改成 false。 +# +# 公开页面引用的文件必须把所在目录列进下面的 public-dirs 白名单, +# 否则匿名访问会被拒(401/403,表现为公开页面的空图)。 file: - public-preview: ${FILE_PUBLIC_PREVIEW:true} - # 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使 - # public-preview=false 也允许匿名读取。留空表示没有额外目录。 + public-preview: ${FILE_PUBLIC_PREVIEW:false} + # 公开目录白名单(显式声明,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图 + # 允许匿名读取。留空表示没有任何公开目录——这是安全默认。 # # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。 @@ -221,9 +225,9 @@ gxmu: allow-unknown-user: false # 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、 # 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。 - # strict 默认 true;本地无库启动可临时设为 false 跳过校验。 + # strict 默认 true;本地无库启动可临时设 GXMU_MODULE_REGISTRY_STRICT=false 跳过校验。 module-registry: - strict: true + strict: ${GXMU_MODULE_REGISTRY_STRICT:true} openplat-sync: enabled: true # 每天 03:30 diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java index 7a091d6..2e04c9b 100644 --- a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java +++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java @@ -2,99 +2,71 @@ package com.gxwebsoft.common.core.security; import org.junit.jupiter.api.Test; -import java.util.List; +import java.util.Arrays; +import java.util.Collections; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 文件访问规则(spec #58 API 契约、用户故事 14)。 * - *

改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 - * 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录; - * 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。 - * 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。 + *

改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL + * 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。 + * + *

测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的模式内容与顺序—— + * 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套 + * 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。 * * @author Codex * @since 2026-11-03 */ class FileAccessRulesTest { - /** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */ + /** 下载接口单独一条模式,且只放这一个:它是"取走原件"的动作,永远要登录。 */ @Test - void downloadAlwaysRequiresLogin() { + void downloadPatternIsRegisteredAlone() { FileAccessRules rules = new FileAccessRules("", true); - assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); - assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf")); + assertEquals(Collections.singletonList("/api/file/download/**"), rules.downloadPatterns()); } - /** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */ + /** 安全默认:不配白名单、不打开内联公开时,没有任何匿名可读的文件路径。 */ @Test - void previewAndThumbnailArePublicByDefault() { - FileAccessRules rules = new FileAccessRules("", true); - - assertTrue(rules.isPublicFileRequest("/api/file/20260708/封面.png")); - assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); - } - - /** 显式关闭内联公开后,预览与缩略图都要登录——白名单开关确实生效。 */ - @Test - void previewRequiresLoginWhenPublicPreviewDisabled() { - FileAccessRules rules = new FileAccessRules("", false); - - assertFalse(rules.isPublicFileRequest("/api/file/20260708/封面.png")); - assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); - assertFalse(rules.permitAllPatterns().contains("/api/file/*/*")); - } - - /** 额外公开目录即使在内联关闭时也生效(原图与缩略图)。 */ - @Test - void whitelistedDirectoryIsPublic() { - FileAccessRules rules = new FileAccessRules("cms-article", false); - - assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png")); - assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png")); - assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg")); - } - - /** 没有公开项时不产生任何放行模式,安全默认 = 全部需登录。 */ - @Test - void noPublicItemYieldsNoPublicPatterns() { + void defaultIsClosedSoNoFilePatternIsAnonymous() { assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty()); - assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg")); } - /** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ + /** 白名单目录产出「原图 → 缩略图」两条模式,按声明顺序展开。 */ @Test - void patternsAreNormalized() { + void whitelistedDirsProduceOriginThenThumbnailPatternsInOrder() { FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false); - 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"); + assertEquals(Arrays.asList( + "/api/file/cms-article/**", + "/api/file/thumbnail/cms-article/**", + "/api/file/public/**", + "/api/file/thumbnail/public/**"), rules.permitAllPatterns()); } - /** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */ + /** 显式打开内联公开:预览与缩略图排在最前,白名单目录随后。 */ @Test - void nonFileRequestsAreNotTreatedAsFiles() { + void publicPreviewAddsPreviewAndThumbnailBeforeWhitelistedDirs() { FileAccessRules rules = new FileAccessRules("cms-article", true); - assertFalse(rules.isPublicFileRequest("/api/cms/article")); - assertFalse(rules.isPublicFileRequest("/api/file/page")); - assertFalse(rules.isPublicFileRequest("/api/file/12")); - assertFalse(rules.isPublicFileRequest(null)); + assertEquals(Arrays.asList( + "/api/file/*/*", + "/api/file/thumbnail/**", + "/api/file/cms-article/**", + "/api/file/thumbnail/cms-article/**"), rules.permitAllPatterns()); } - /** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */ + /** 兜底:白名单之外的所有文件接口都要登录,且必须是整段 /api/file/**(不能只覆盖子路径)。 */ @Test - void authenticatedPatternsCoverAllFiles() { - FileAccessRules rules = new FileAccessRules("cms-article", true); + void authenticatedFallbackCoversAllFiles() { + FileAccessRules rules = new FileAccessRules("cms-article", false); - assertTrue(rules.authenticatedPatterns().contains("/api/file/**")); - assertTrue(rules.downloadPatterns().contains("/api/file/download/**")); + assertEquals(Collections.singletonList("/api/file/**"), rules.authenticatedPatterns()); } }