fix(security): 文件内联预览默认需登录,公开目录改为显式白名单

- file.public-preview 默认值由 true 改为 false(application.yml 与 @Value 同步),
  上传一律落在 /yyyyMMdd/ 日期目录,默认公开等于把所有上传文件(含盖章证明材料)
  变成匿名可读;公开页面引用的文件必须把所在目录列进 file.public-dirs
- 注释写清「公开页面引用的文件必须列进白名单,否则匿名访问会被拒」,同步更新
  FileAccessRules 类注释
- 删掉只被测试调用的 FileAccessRules.isPublicFileRequest(测试钉住的是没上线的
  平行判定);测试改为断言 downloadPatterns / permitAllPatterns /
  authenticatedPatterns 的内容与顺序
- 顺带把 gxmu.module-registry.strict 改成 ${GXMU_MODULE_REGISTRY_STRICT:true},
  与相邻配置一致

refs #58
This commit is contained in:
2026-11-03 23:14:13 +08:00
parent 43f0119b25
commit 6bfdae5787
3 changed files with 53 additions and 101 deletions
@@ -3,7 +3,6 @@ package com.gxwebsoft.common.core.security;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@@ -17,12 +16,15 @@ import java.util.List;
* <ul> * <ul>
* <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li> * <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li>
* <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}: * <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
* 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;</li> * <b>默认也需要登录</b>({@code file.public-preview} 默认 false)。上传一律落在
* <li>其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。</li> * {@code /yyyyMMdd/} 日期目录,日期目录正好是两段路径,所以默认公开等于把所有上传文件公开;</li>
* <li>公开页面引用的文件必须把所在目录列进 {@code file.public-dirs} 白名单,
* 否则匿名访问会被拒(表现为 401/403 的空图)。白名单是显式声明,默认空。</li>
* </ul> * </ul>
* *
* <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, * <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
* 因此可以在单元测试里直接验证。 * 因此可以在单元测试里直接验证模式的<b>内容与顺序</b>——顺序恰恰是这条改造的关键:
* 下载必须在公开模式之前注册,公开模式必须在 {@code GET /**} 兜底之前注册。
* *
* @author Codex * @author Codex
* @since 2026-11-03 * @since 2026-11-03
@@ -50,20 +52,18 @@ public class FileAccessRules {
*/ */
private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**"; private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**";
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/** /**
* 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠 * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
*/ */
private final List<String> publicDirs; private final List<String> publicDirs;
/** /**
* 内联资源(预览 + 缩略图)是否默认公开 * 内联资源(预览 + 缩略图)是否默认公开;默认 false,只有显式打开才整体放行
*/ */
private final boolean publicPreview; private final boolean publicPreview;
public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs, 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.publicDirs = normalize(publicDirs);
this.publicPreview = publicPreview; this.publicPreview = publicPreview;
} }
@@ -71,7 +71,8 @@ public class FileAccessRules {
/** /**
* 匿名放行的文件路径模式。 * 匿名放行的文件路径模式。
* *
* <p>内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。 * <p>默认({@code public-preview=false})只有 {@code file.public-dirs} 白名单里的目录;
* 显式打开内联公开时才额外放行预览与缩略图,且排在白名单之前。
* *
* @return antMatchers 模式,没有任何公开项时返回空列表 * @return antMatchers 模式,没有任何公开项时返回空列表
*/ */
@@ -104,31 +105,6 @@ public class FileAccessRules {
return Collections.singletonList(DOWNLOAD_PATTERN); 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<String> publicDirPatterns() { private List<String> publicDirPatterns() {
if (publicDirs.isEmpty()) { if (publicDirs.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
+11 -7
View File
@@ -92,12 +92,16 @@ config:
# 文件访问规则(spec #58) # 文件访问规则(spec #58)
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。 # 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
# #
# 确需公开的内联资源(预览 /api/file/<dir>/<name> 与缩略图 /api/file/thumbnail/**): # 内联预览(/api/file/<dir>/<name>)与缩略图(/api/file/thumbnail/**)默认也需要登录:
# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。 # 上传一律落在 /yyyyMMdd/ 日期目录,日期目录正好是两段路径,默认公开等于把所有上传文件
# (含盖章证明材料)变成匿名可读。因此默认值从 true 改成 false。
#
# 公开页面引用的文件必须把所在目录列进下面的 public-dirs 白名单,
# 否则匿名访问会被拒(401/403,表现为公开页面的空图)。
file: file:
public-preview: ${FILE_PUBLIC_PREVIEW:true} public-preview: ${FILE_PUBLIC_PREVIEW:false}
# 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使 # 公开目录白名单(显式声明,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图
# public-preview=false 也允许匿名读取。留空表示没有额外目录。 # 允许匿名读取。留空表示没有任何公开目录——这是安全默认。
# #
# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
# 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。 # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。
@@ -221,9 +225,9 @@ gxmu:
allow-unknown-user: false allow-unknown-user: false
# 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、 # 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、
# 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。 # 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。
# strict 默认 true;本地无库启动可临时设为 false 跳过校验。 # strict 默认 true;本地无库启动可临时设 GXMU_MODULE_REGISTRY_STRICT=false 跳过校验。
module-registry: module-registry:
strict: true strict: ${GXMU_MODULE_REGISTRY_STRICT:true}
openplat-sync: openplat-sync:
enabled: true enabled: true
# 每天 03:30 # 每天 03:30
@@ -2,99 +2,71 @@ package com.gxwebsoft.common.core.security;
import org.junit.jupiter.api.Test; 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; import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* 文件访问规则(spec #58 API 契约、用户故事 14)。 * 文件访问规则(spec #58 API 契约、用户故事 14)。
* *
* <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 * <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL
* 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录; * 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。
* 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。 *
* 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。 * <p>测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的<b>模式内容与顺序</b>——
* 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套
* 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。
* *
* @author Codex * @author Codex
* @since 2026-11-03 * @since 2026-11-03
*/ */
class FileAccessRulesTest { class FileAccessRulesTest {
/** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */ /** 下载接口单独一条模式,且只放这一个:它是"取走原件"的动作,永远要登录。 */
@Test @Test
void downloadAlwaysRequiresLogin() { void downloadPatternIsRegisteredAlone() {
FileAccessRules rules = new FileAccessRules("", true); FileAccessRules rules = new FileAccessRules("", true);
assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); assertEquals(Collections.singletonList("/api/file/download/**"), rules.downloadPatterns());
assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf"));
} }
/** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */ /** 安全默认:不配白名单、不打开内联公开时,没有任何匿名可读的文件路径。 */
@Test @Test
void previewAndThumbnailArePublicByDefault() { void defaultIsClosedSoNoFilePatternIsAnonymous() {
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() {
assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty());
assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty());
assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg"));
} }
/** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ /** 白名单目录产出「原图 → 缩略图」两条模式,按声明顺序展开。 */
@Test @Test
void patternsAreNormalized() { void whitelistedDirsProduceOriginThenThumbnailPatternsInOrder() {
FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false); FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false);
List<String> patterns = rules.permitAllPatterns(); assertEquals(Arrays.asList(
assertTrue(patterns.contains("/api/file/cms-article/**")); "/api/file/cms-article/**",
assertTrue(patterns.contains("/api/file/public/**")); "/api/file/thumbnail/cms-article/**",
assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**")); "/api/file/public/**",
assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file"); "/api/file/thumbnail/public/**"), rules.permitAllPatterns());
} }
/** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */ /** 显式打开内联公开:预览与缩略图排在最前,白名单目录随后。 */
@Test @Test
void nonFileRequestsAreNotTreatedAsFiles() { void publicPreviewAddsPreviewAndThumbnailBeforeWhitelistedDirs() {
FileAccessRules rules = new FileAccessRules("cms-article", true); FileAccessRules rules = new FileAccessRules("cms-article", true);
assertFalse(rules.isPublicFileRequest("/api/cms/article")); assertEquals(Arrays.asList(
assertFalse(rules.isPublicFileRequest("/api/file/page")); "/api/file/*/*",
assertFalse(rules.isPublicFileRequest("/api/file/12")); "/api/file/thumbnail/**",
assertFalse(rules.isPublicFileRequest(null)); "/api/file/cms-article/**",
"/api/file/thumbnail/cms-article/**"), rules.permitAllPatterns());
} }
/** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */ /** 兜底:白名单之外的所有文件接口都要登录,且必须是整段 /api/file/**(不能只覆盖子路径)。 */
@Test @Test
void authenticatedPatternsCoverAllFiles() { void authenticatedFallbackCoversAllFiles() {
FileAccessRules rules = new FileAccessRules("cms-article", true); FileAccessRules rules = new FileAccessRules("cms-article", false);
assertTrue(rules.authenticatedPatterns().contains("/api/file/**")); assertEquals(Collections.singletonList("/api/file/**"), rules.authenticatedPatterns());
assertTrue(rules.downloadPatterns().contains("/api/file/download/**"));
} }
} }