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 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;
* <ul>
* <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li>
* <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
* 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;</li>
* <li>其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。</li>
* <b>默认也需要登录</b>({@code file.public-preview} 默认 false)。上传一律落在
* {@code /yyyyMMdd/} 日期目录,日期目录正好是两段路径,所以默认公开等于把所有上传文件公开;</li>
* <li>公开页面引用的文件必须把所在目录列进 {@code file.public-dirs} 白名单,
* 否则匿名访问会被拒(表现为 401/403 的空图)。白名单是显式声明,默认空。</li>
* </ul>
*
* <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
* 因此可以在单元测试里直接验证。
* 因此可以在单元测试里直接验证模式的<b>内容与顺序</b>——顺序恰恰是这条改造的关键:
* 下载必须在公开模式之前注册,公开模式必须在 {@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<String> 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 {
/**
* 匿名放行的文件路径模式。
*
* <p>内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。
* <p>默认({@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<String> publicDirPatterns() {
if (publicDirs.isEmpty()) {
return Collections.emptyList();
+11 -7
View File
@@ -92,12 +92,16 @@ config:
# 文件访问规则(spec #58)
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
#
# 确需公开的内联资源(预览 /api/file/<dir>/<name> 与缩略图 /api/file/thumbnail/**):
# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。
# 内联预览(/api/file/<dir>/<name>)与缩略图(/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
@@ -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)。
*
* <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被
* 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录;
* 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。
* 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。
* <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL
* 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。
*
* <p>测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的<b>模式内容与顺序</b>——
* 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套
* 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。
*
* @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<String> 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());
}
}