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:
@@ -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();
|
||||||
|
|||||||
@@ -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/**"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user