diff --git a/server/src/main/config/application.properties b/server/src/main/config/application.properties index 9bc1bd9e7..ecb5495cb 100644 --- a/server/src/main/config/application.properties +++ b/server/src/main/config/application.properties @@ -436,6 +436,78 @@ kk.addTask = true # 启用后需要提供密钥才能调用API kk.key = false +# 签名预览,默认为false(关闭);关闭时预览接口的行为与参数完全不变 +# 开启后 /onlinePreview、/picturesPreview 只接受带有效签名令牌(JWT)的请求: +# 1. 令牌由接入方的业务系统用下面的密钥签发,必须带有效期 exp(秒) +# 2. 文件地址、文件名、水印、是否强制重新转换从令牌里取;请求参数只保留放行名单里的 +# 3. trust.host / not.trust.host、kk.key 照常生效,令牌里的文件地址同样要过信任站点检查 +# 令牌示例(HS256,载荷): +# {"url":"https://files.example.com/demo/test.docx","watermarkTxt":"张三 0012","exp":1767225600} +# 预览地址:/onlinePreview?token=<令牌>;多图预览的令牌用 urls 字段(数组,或用 | 分隔的字符串) +signed.preview.enabled = ${KK_SIGNED_PREVIEW_ENABLED:false} + +# 签名密钥,默认为false(未配置)。开启签名预览或取件签名时必须配置,建议不少于32个字符 +signed.preview.secret = ${KK_SIGNED_PREVIEW_SECRET:false} + +# 签名算法,支持 HS256、HS384、HS512;令牌头里的 alg 必须与这里一致 +signed.preview.algorithm = ${KK_SIGNED_PREVIEW_ALGORITHM:HS256} + +# 令牌放在哪个请求参数里 +signed.preview.token.param = ${KK_SIGNED_PREVIEW_TOKEN_PARAM:token} + +# 令牌里各字段的名字:文件地址、多图地址、文件名、水印文字、是否强制重新转换 +# 其余水印参数(watermarkXSpace、watermarkYSpace、watermarkFont、watermarkFontsize、watermarkColor、 +# watermarkAlpha、watermarkWidth、watermarkHeight、watermarkAngle)在令牌里用与请求参数相同的名字 +signed.preview.claim.url = ${KK_SIGNED_PREVIEW_CLAIM_URL:url} +signed.preview.claim.urls = ${KK_SIGNED_PREVIEW_CLAIM_URLS:urls} +signed.preview.claim.filename = ${KK_SIGNED_PREVIEW_CLAIM_FILENAME:fullfilename} +signed.preview.claim.watermark = ${KK_SIGNED_PREVIEW_CLAIM_WATERMARK:watermarkTxt} +signed.preview.claim.force = ${KK_SIGNED_PREVIEW_CLAIM_FORCE:forceUpdatedCache} + +# 其余水印参数和 currentUrl 在令牌里的字段名映射,格式:请求参数名:令牌字段名,多个用逗号分隔,默认为default(同名) +# 例如 watermarkAlpha:wmAlpha,currentUrl:cur。上面五项单独配置的字段不走这张表,写了也以单独那项为准 +signed.preview.claim.map = ${KK_SIGNED_PREVIEW_CLAIM_MAP:default} + +# 令牌里表示用途的字段名,以及三种令牌各自的用途值:接入方签发的预览令牌、 +# kkFileView 自己签发的压缩包令牌和取件令牌。三个值必须互不相同,否则签名预览拒绝全部请求、也不签发自己的令牌 +signed.preview.claim.use = ${KK_SIGNED_PREVIEW_CLAIM_USE:use} +signed.preview.use.preview = ${KK_SIGNED_PREVIEW_USE_PREVIEW:preview} +signed.preview.use.archive = ${KK_SIGNED_PREVIEW_USE_ARCHIVE:archive} +signed.preview.use.fetch = ${KK_SIGNED_PREVIEW_USE_FETCH:fetch} + +# 预览令牌是否必须写用途,默认为false(不写用途字段的令牌也当作预览令牌) +signed.preview.use.required = ${KK_SIGNED_PREVIEW_USE_REQUIRED:false} + +# 开启签名预览后仍允许浏览器自己带的请求参数,多个用逗号分隔,其余参数一律丢弃 +# 可以限定取值:officePreviewType=image|pdf|html,page 表示 officePreviewType 只放过这三个值(区分大小写, +# 值不在其中或同一参数给了多个值时丢弃这个参数,不拒绝整个请求),page 任意值 +# 由令牌决定的参数(文件地址、水印、强制重新转换等)写进这里也不会生效 +signed.preview.allow.params = ${KK_SIGNED_PREVIEW_ALLOW_PARAMS:officePreviewType,tifPreviewType,page,highlightall,filePassword,key} + +# kkFileView 自己签发的令牌(压缩包内文件、取原文件)的有效期,单位秒 +signed.preview.ttl = ${KK_SIGNED_PREVIEW_TTL:600} + +# 压缩包令牌、取件令牌各自的有效期,单位秒,默认为default(使用上面的 signed.preview.ttl) +signed.preview.archive.ttl = ${KK_SIGNED_PREVIEW_ARCHIVE_TTL:default} +signed.fetch.ttl = ${KK_SIGNED_FETCH_TTL:default} + +# 校验有效期时允许的时钟误差,单位秒 +signed.preview.leeway = ${KK_SIGNED_PREVIEW_LEEWAY:30} + +# 取原文件时是否附带 kkFileView 自己签发的令牌,默认为false(关闭),与签名预览各自独立开关 +# 开启后用 http/https 下载原文件时在下面的请求头里带一枚令牌,密钥与算法同上, +# 载荷为 {"use":"fetch","url":"<本次请求的地址>","iat":...,"exp":...},文件来源一方可据此确认请求来自本服务 +signed.fetch.enabled = ${KK_SIGNED_FETCH_ENABLED:false} +signed.fetch.header = ${KK_SIGNED_FETCH_HEADER:kk-fetch-token} + +# 请求头的值在令牌前面加的前缀,默认为default(不加)。不为空时与令牌之间用一个空格隔开, +# 例如请求头配成 Authorization、前缀配成 Bearer,发出的就是 Authorization: Bearer <令牌> +signed.fetch.header.prefix = ${KK_SIGNED_FETCH_HEADER_PREFIX:default} + +# 取件令牌里「本次请求的地址」放在载荷的哪个字段,支持用点分隔的嵌套对象, +# 例如 payload.url 签成 {"payload":{"url":"<地址>"}} +signed.fetch.claim.url = ${KK_SIGNED_FETCH_CLAIM_URL:url} + # AES加密密钥,必须为16位字符 # 启用AES加密时,接入方需使用相同的密钥 # 用于敏感数据传输加密 diff --git a/server/src/main/config/test.properties b/server/src/main/config/test.properties index 45bf7bdf2..a421c8fa1 100644 --- a/server/src/main/config/test.properties +++ b/server/src/main/config/test.properties @@ -432,6 +432,78 @@ kk.addTask = true # 启用后需要提供密钥才能调用API kk.key = false +# 签名预览,默认为false(关闭);关闭时预览接口的行为与参数完全不变 +# 开启后 /onlinePreview、/picturesPreview 只接受带有效签名令牌(JWT)的请求: +# 1. 令牌由接入方的业务系统用下面的密钥签发,必须带有效期 exp(秒) +# 2. 文件地址、文件名、水印、是否强制重新转换从令牌里取;请求参数只保留放行名单里的 +# 3. trust.host / not.trust.host、kk.key 照常生效,令牌里的文件地址同样要过信任站点检查 +# 令牌示例(HS256,载荷): +# {"url":"https://files.example.com/demo/test.docx","watermarkTxt":"张三 0012","exp":1767225600} +# 预览地址:/onlinePreview?token=<令牌>;多图预览的令牌用 urls 字段(数组,或用 | 分隔的字符串) +signed.preview.enabled = ${KK_SIGNED_PREVIEW_ENABLED:false} + +# 签名密钥,默认为false(未配置)。开启签名预览或取件签名时必须配置,建议不少于32个字符 +signed.preview.secret = ${KK_SIGNED_PREVIEW_SECRET:false} + +# 签名算法,支持 HS256、HS384、HS512;令牌头里的 alg 必须与这里一致 +signed.preview.algorithm = ${KK_SIGNED_PREVIEW_ALGORITHM:HS256} + +# 令牌放在哪个请求参数里 +signed.preview.token.param = ${KK_SIGNED_PREVIEW_TOKEN_PARAM:token} + +# 令牌里各字段的名字:文件地址、多图地址、文件名、水印文字、是否强制重新转换 +# 其余水印参数(watermarkXSpace、watermarkYSpace、watermarkFont、watermarkFontsize、watermarkColor、 +# watermarkAlpha、watermarkWidth、watermarkHeight、watermarkAngle)在令牌里用与请求参数相同的名字 +signed.preview.claim.url = ${KK_SIGNED_PREVIEW_CLAIM_URL:url} +signed.preview.claim.urls = ${KK_SIGNED_PREVIEW_CLAIM_URLS:urls} +signed.preview.claim.filename = ${KK_SIGNED_PREVIEW_CLAIM_FILENAME:fullfilename} +signed.preview.claim.watermark = ${KK_SIGNED_PREVIEW_CLAIM_WATERMARK:watermarkTxt} +signed.preview.claim.force = ${KK_SIGNED_PREVIEW_CLAIM_FORCE:forceUpdatedCache} + +# 其余水印参数和 currentUrl 在令牌里的字段名映射,格式:请求参数名:令牌字段名,多个用逗号分隔,默认为default(同名) +# 例如 watermarkAlpha:wmAlpha,currentUrl:cur。上面五项单独配置的字段不走这张表,写了也以单独那项为准 +signed.preview.claim.map = ${KK_SIGNED_PREVIEW_CLAIM_MAP:default} + +# 令牌里表示用途的字段名,以及三种令牌各自的用途值:接入方签发的预览令牌、 +# kkFileView 自己签发的压缩包令牌和取件令牌。三个值必须互不相同,否则签名预览拒绝全部请求、也不签发自己的令牌 +signed.preview.claim.use = ${KK_SIGNED_PREVIEW_CLAIM_USE:use} +signed.preview.use.preview = ${KK_SIGNED_PREVIEW_USE_PREVIEW:preview} +signed.preview.use.archive = ${KK_SIGNED_PREVIEW_USE_ARCHIVE:archive} +signed.preview.use.fetch = ${KK_SIGNED_PREVIEW_USE_FETCH:fetch} + +# 预览令牌是否必须写用途,默认为false(不写用途字段的令牌也当作预览令牌) +signed.preview.use.required = ${KK_SIGNED_PREVIEW_USE_REQUIRED:false} + +# 开启签名预览后仍允许浏览器自己带的请求参数,多个用逗号分隔,其余参数一律丢弃 +# 可以限定取值:officePreviewType=image|pdf|html,page 表示 officePreviewType 只放过这三个值(区分大小写, +# 值不在其中或同一参数给了多个值时丢弃这个参数,不拒绝整个请求),page 任意值 +# 由令牌决定的参数(文件地址、水印、强制重新转换等)写进这里也不会生效 +signed.preview.allow.params = ${KK_SIGNED_PREVIEW_ALLOW_PARAMS:officePreviewType,tifPreviewType,page,highlightall,filePassword,key} + +# kkFileView 自己签发的令牌(压缩包内文件、取原文件)的有效期,单位秒 +signed.preview.ttl = ${KK_SIGNED_PREVIEW_TTL:600} + +# 压缩包令牌、取件令牌各自的有效期,单位秒,默认为default(使用上面的 signed.preview.ttl) +signed.preview.archive.ttl = ${KK_SIGNED_PREVIEW_ARCHIVE_TTL:default} +signed.fetch.ttl = ${KK_SIGNED_FETCH_TTL:default} + +# 校验有效期时允许的时钟误差,单位秒 +signed.preview.leeway = ${KK_SIGNED_PREVIEW_LEEWAY:30} + +# 取原文件时是否附带 kkFileView 自己签发的令牌,默认为false(关闭),与签名预览各自独立开关 +# 开启后用 http/https 下载原文件时在下面的请求头里带一枚令牌,密钥与算法同上, +# 载荷为 {"use":"fetch","url":"<本次请求的地址>","iat":...,"exp":...},文件来源一方可据此确认请求来自本服务 +signed.fetch.enabled = ${KK_SIGNED_FETCH_ENABLED:false} +signed.fetch.header = ${KK_SIGNED_FETCH_HEADER:kk-fetch-token} + +# 请求头的值在令牌前面加的前缀,默认为default(不加)。不为空时与令牌之间用一个空格隔开, +# 例如请求头配成 Authorization、前缀配成 Bearer,发出的就是 Authorization: Bearer <令牌> +signed.fetch.header.prefix = ${KK_SIGNED_FETCH_HEADER_PREFIX:default} + +# 取件令牌里「本次请求的地址」放在载荷的哪个字段,支持用点分隔的嵌套对象, +# 例如 payload.url 签成 {"payload":{"url":"<地址>"}} +signed.fetch.claim.url = ${KK_SIGNED_FETCH_CLAIM_URL:url} + # AES加密密钥,必须为16位字符 # 启用AES加密时,接入方需使用相同的密钥 # 用于敏感数据传输加密 diff --git a/server/src/main/java/cn/keking/config/ConfigConstants.java b/server/src/main/java/cn/keking/config/ConfigConstants.java index 686a4cd5e..4bbe55419 100644 --- a/server/src/main/java/cn/keking/config/ConfigConstants.java +++ b/server/src/main/java/cn/keking/config/ConfigConstants.java @@ -6,6 +6,9 @@ import java.io.File; import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -135,6 +138,34 @@ public class ConfigConstants { public static final String DEFAULT_IGNORE_SSL = "true"; public static final String DEFAULT_ENABLE_REDIRECT = "true"; + // ================================================== + // 签名预览配置常量 + // ================================================== + public static final String DEFAULT_SIGNED_PREVIEW_ENABLED = "false"; + public static final String DEFAULT_SIGNED_PREVIEW_SECRET = "false"; + public static final String DEFAULT_SIGNED_PREVIEW_ALGORITHM = "HS256"; + public static final String DEFAULT_SIGNED_PREVIEW_TOKEN_PARAM = "token"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_URL = "url"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_URLS = "urls"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_FILENAME = "fullfilename"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_WATERMARK = "watermarkTxt"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_FORCE = "forceUpdatedCache"; + public static final String DEFAULT_SIGNED_PREVIEW_ALLOW_PARAMS = "officePreviewType,tifPreviewType,page,highlightall,filePassword,key"; + public static final String DEFAULT_SIGNED_PREVIEW_TTL = "600"; + public static final String DEFAULT_SIGNED_PREVIEW_LEEWAY = "30"; + public static final String DEFAULT_SIGNED_FETCH_ENABLED = "false"; + public static final String DEFAULT_SIGNED_FETCH_HEADER = "kk-fetch-token"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_USE = "use"; + public static final String DEFAULT_SIGNED_PREVIEW_USE_PREVIEW = "preview"; + public static final String DEFAULT_SIGNED_PREVIEW_USE_ARCHIVE = "archive"; + public static final String DEFAULT_SIGNED_PREVIEW_USE_FETCH = "fetch"; + public static final String DEFAULT_SIGNED_PREVIEW_USE_REQUIRED = "false"; + public static final String DEFAULT_SIGNED_PREVIEW_CLAIM_MAP = "default"; + public static final String DEFAULT_SIGNED_PREVIEW_ARCHIVE_TTL = "default"; + public static final String DEFAULT_SIGNED_FETCH_TTL = "default"; + public static final String DEFAULT_SIGNED_FETCH_CLAIM_URL = "url"; + public static final String DEFAULT_SIGNED_FETCH_HEADER_PREFIX = "default"; + // ================================================== // 十一、媒体文件处理配置常量 // ================================================== @@ -253,6 +284,34 @@ public class ConfigConstants { private static String userAgent; private static String basicName; + // ================================================== + // 签名预览配置变量 + // ================================================== + private static boolean signedPreviewEnabled; + private static String signedPreviewSecret = DEFAULT_SIGNED_PREVIEW_SECRET; + private static String signedPreviewAlgorithm = DEFAULT_SIGNED_PREVIEW_ALGORITHM; + private static String signedPreviewTokenParam = DEFAULT_SIGNED_PREVIEW_TOKEN_PARAM; + private static String signedPreviewClaimUrl = DEFAULT_SIGNED_PREVIEW_CLAIM_URL; + private static String signedPreviewClaimUrls = DEFAULT_SIGNED_PREVIEW_CLAIM_URLS; + private static String signedPreviewClaimFilename = DEFAULT_SIGNED_PREVIEW_CLAIM_FILENAME; + private static String signedPreviewClaimWatermark = DEFAULT_SIGNED_PREVIEW_CLAIM_WATERMARK; + private static String signedPreviewClaimForce = DEFAULT_SIGNED_PREVIEW_CLAIM_FORCE; + private static Map> signedPreviewAllowParams = parseSignedPreviewAllowParams(DEFAULT_SIGNED_PREVIEW_ALLOW_PARAMS); + private static long signedPreviewTtl = Long.parseLong(DEFAULT_SIGNED_PREVIEW_TTL); + private static long signedPreviewLeeway = Long.parseLong(DEFAULT_SIGNED_PREVIEW_LEEWAY); + private static boolean signedFetchEnabled; + private static String signedFetchHeader = DEFAULT_SIGNED_FETCH_HEADER; + private static String signedPreviewClaimUse = DEFAULT_SIGNED_PREVIEW_CLAIM_USE; + private static String signedPreviewUsePreview = DEFAULT_SIGNED_PREVIEW_USE_PREVIEW; + private static String signedPreviewUseArchive = DEFAULT_SIGNED_PREVIEW_USE_ARCHIVE; + private static String signedPreviewUseFetch = DEFAULT_SIGNED_PREVIEW_USE_FETCH; + private static boolean signedPreviewUseRequired; + private static Map signedPreviewClaimMap = Collections.emptyMap(); + private static Long signedPreviewArchiveTtl; + private static Long signedFetchTtl; + private static String signedFetchClaimUrl = DEFAULT_SIGNED_FETCH_CLAIM_URL; + private static String signedFetchHeaderPrefix = ""; + // ================================================== // 10. 媒体文件配置变量 // ================================================== @@ -408,6 +467,34 @@ public static int getOptimizedDpi(int pageCount) { public static String getUserAgent() { return userAgent; } public static String getBasicName() { return basicName; } + // ================================================== + // 签名预览配置获取方法 + // ================================================== + public static boolean isSignedPreviewEnabled() { return signedPreviewEnabled; } + public static String getSignedPreviewSecret() { return signedPreviewSecret; } + public static String getSignedPreviewAlgorithm() { return signedPreviewAlgorithm; } + public static String getSignedPreviewTokenParam() { return signedPreviewTokenParam; } + public static String getSignedPreviewClaimUrl() { return signedPreviewClaimUrl; } + public static String getSignedPreviewClaimUrls() { return signedPreviewClaimUrls; } + public static String getSignedPreviewClaimFilename() { return signedPreviewClaimFilename; } + public static String getSignedPreviewClaimWatermark() { return signedPreviewClaimWatermark; } + public static String getSignedPreviewClaimForce() { return signedPreviewClaimForce; } + public static Map> getSignedPreviewAllowParams() { return signedPreviewAllowParams; } + public static long getSignedPreviewTtl() { return signedPreviewTtl; } + public static long getSignedPreviewLeeway() { return signedPreviewLeeway; } + public static boolean isSignedFetchEnabled() { return signedFetchEnabled; } + public static String getSignedFetchHeader() { return signedFetchHeader; } + public static String getSignedPreviewClaimUse() { return signedPreviewClaimUse; } + public static String getSignedPreviewUsePreview() { return signedPreviewUsePreview; } + public static String getSignedPreviewUseArchive() { return signedPreviewUseArchive; } + public static String getSignedPreviewUseFetch() { return signedPreviewUseFetch; } + public static boolean isSignedPreviewUseRequired() { return signedPreviewUseRequired; } + public static Map getSignedPreviewClaimMap() { return signedPreviewClaimMap; } + public static long getSignedPreviewArchiveTtl() { return signedPreviewArchiveTtl == null ? signedPreviewTtl : signedPreviewArchiveTtl; } + public static long getSignedFetchTtl() { return signedFetchTtl == null ? signedPreviewTtl : signedFetchTtl; } + public static String getSignedFetchClaimUrl() { return signedFetchClaimUrl; } + public static String getSignedFetchHeaderPrefix() { return signedFetchHeaderPrefix; } + // ================================================== // 10. 媒体文件配置获取方法 // ================================================== @@ -738,6 +825,137 @@ public static int getOptimizedDpi(int pageCount) { public void setEnableRedirect(String enableRedirect) { setEnableRedirectValue(Boolean.parseBoolean(enableRedirect)); } public static void setEnableRedirectValue(Boolean enableRedirect) { ConfigConstants.enableRedirect = enableRedirect; } + // ================================================== + // 签名预览配置Setter方法 + // ================================================== + @Value("${signed.preview.enabled:false}") + public void setSignedPreviewEnabled(String signedPreviewEnabled) { setSignedPreviewEnabledValue(Boolean.parseBoolean(signedPreviewEnabled)); } + public static void setSignedPreviewEnabledValue(boolean signedPreviewEnabled) { ConfigConstants.signedPreviewEnabled = signedPreviewEnabled; } + + @Value("${signed.preview.secret:false}") + public void setSignedPreviewSecret(String signedPreviewSecret) { setSignedPreviewSecretValue(signedPreviewSecret); } + public static void setSignedPreviewSecretValue(String signedPreviewSecret) { ConfigConstants.signedPreviewSecret = signedPreviewSecret; } + + @Value("${signed.preview.algorithm:HS256}") + public void setSignedPreviewAlgorithm(String signedPreviewAlgorithm) { setSignedPreviewAlgorithmValue(signedPreviewAlgorithm); } + public static void setSignedPreviewAlgorithmValue(String signedPreviewAlgorithm) { ConfigConstants.signedPreviewAlgorithm = signedPreviewAlgorithm; } + + @Value("${signed.preview.token.param:token}") + public void setSignedPreviewTokenParam(String signedPreviewTokenParam) { setSignedPreviewTokenParamValue(signedPreviewTokenParam); } + public static void setSignedPreviewTokenParamValue(String signedPreviewTokenParam) { ConfigConstants.signedPreviewTokenParam = signedPreviewTokenParam; } + + @Value("${signed.preview.claim.url:url}") + public void setSignedPreviewClaimUrl(String signedPreviewClaimUrl) { setSignedPreviewClaimUrlValue(signedPreviewClaimUrl); } + public static void setSignedPreviewClaimUrlValue(String signedPreviewClaimUrl) { ConfigConstants.signedPreviewClaimUrl = signedPreviewClaimUrl; } + + @Value("${signed.preview.claim.urls:urls}") + public void setSignedPreviewClaimUrls(String signedPreviewClaimUrls) { setSignedPreviewClaimUrlsValue(signedPreviewClaimUrls); } + public static void setSignedPreviewClaimUrlsValue(String signedPreviewClaimUrls) { ConfigConstants.signedPreviewClaimUrls = signedPreviewClaimUrls; } + + @Value("${signed.preview.claim.filename:fullfilename}") + public void setSignedPreviewClaimFilename(String signedPreviewClaimFilename) { setSignedPreviewClaimFilenameValue(signedPreviewClaimFilename); } + public static void setSignedPreviewClaimFilenameValue(String signedPreviewClaimFilename) { ConfigConstants.signedPreviewClaimFilename = signedPreviewClaimFilename; } + + @Value("${signed.preview.claim.watermark:watermarkTxt}") + public void setSignedPreviewClaimWatermark(String signedPreviewClaimWatermark) { setSignedPreviewClaimWatermarkValue(signedPreviewClaimWatermark); } + public static void setSignedPreviewClaimWatermarkValue(String signedPreviewClaimWatermark) { ConfigConstants.signedPreviewClaimWatermark = signedPreviewClaimWatermark; } + + @Value("${signed.preview.claim.force:forceUpdatedCache}") + public void setSignedPreviewClaimForce(String signedPreviewClaimForce) { setSignedPreviewClaimForceValue(signedPreviewClaimForce); } + public static void setSignedPreviewClaimForceValue(String signedPreviewClaimForce) { ConfigConstants.signedPreviewClaimForce = signedPreviewClaimForce; } + + @Value("${signed.preview.allow.params:officePreviewType,tifPreviewType,page,highlightall,filePassword,key}") + public void setSignedPreviewAllowParams(String signedPreviewAllowParams) { setSignedPreviewAllowParamsValue(signedPreviewAllowParams); } + public static void setSignedPreviewAllowParamsValue(String signedPreviewAllowParams) { ConfigConstants.signedPreviewAllowParams = parseSignedPreviewAllowParams(signedPreviewAllowParams); } + + /** + * 放行名单:参数名,或 参数名=值1|值2(只放过列出的值)。返回 参数名 -> 允许的值,值集合为空表示任意值 + */ + private static Map> parseSignedPreviewAllowParams(String allowParams) { + Map> result = new LinkedHashMap<>(); + for (String item : allowParams.replaceAll("\\s+", "").split(",")) { + int index = item.indexOf('='); + String name = index < 0 ? item : item.substring(0, index); + if (!name.isEmpty()) { + result.put(name, index < 0 ? Set.of() : new CopyOnWriteArraySet<>(Arrays.asList(item.substring(index + 1).split("\\|")))); + } + } + return Collections.unmodifiableMap(result); + } + + @Value("${signed.preview.ttl:600}") + public void setSignedPreviewTtl(String signedPreviewTtl) { setSignedPreviewTtlValue(Long.parseLong(signedPreviewTtl)); } + public static void setSignedPreviewTtlValue(long signedPreviewTtl) { ConfigConstants.signedPreviewTtl = signedPreviewTtl; } + + @Value("${signed.preview.leeway:30}") + public void setSignedPreviewLeeway(String signedPreviewLeeway) { setSignedPreviewLeewayValue(Long.parseLong(signedPreviewLeeway)); } + public static void setSignedPreviewLeewayValue(long signedPreviewLeeway) { ConfigConstants.signedPreviewLeeway = signedPreviewLeeway; } + + @Value("${signed.fetch.enabled:false}") + public void setSignedFetchEnabled(String signedFetchEnabled) { setSignedFetchEnabledValue(Boolean.parseBoolean(signedFetchEnabled)); } + public static void setSignedFetchEnabledValue(boolean signedFetchEnabled) { ConfigConstants.signedFetchEnabled = signedFetchEnabled; } + + @Value("${signed.fetch.header:kk-fetch-token}") + public void setSignedFetchHeader(String signedFetchHeader) { setSignedFetchHeaderValue(signedFetchHeader); } + public static void setSignedFetchHeaderValue(String signedFetchHeader) { ConfigConstants.signedFetchHeader = signedFetchHeader; } + + @Value("${signed.preview.claim.use:use}") + public void setSignedPreviewClaimUse(String signedPreviewClaimUse) { setSignedPreviewClaimUseValue(signedPreviewClaimUse); } + public static void setSignedPreviewClaimUseValue(String signedPreviewClaimUse) { ConfigConstants.signedPreviewClaimUse = signedPreviewClaimUse; } + + @Value("${signed.preview.use.preview:preview}") + public void setSignedPreviewUsePreview(String signedPreviewUsePreview) { setSignedPreviewUsePreviewValue(signedPreviewUsePreview); } + public static void setSignedPreviewUsePreviewValue(String signedPreviewUsePreview) { ConfigConstants.signedPreviewUsePreview = signedPreviewUsePreview; } + + @Value("${signed.preview.use.archive:archive}") + public void setSignedPreviewUseArchive(String signedPreviewUseArchive) { setSignedPreviewUseArchiveValue(signedPreviewUseArchive); } + public static void setSignedPreviewUseArchiveValue(String signedPreviewUseArchive) { ConfigConstants.signedPreviewUseArchive = signedPreviewUseArchive; } + + @Value("${signed.preview.use.fetch:fetch}") + public void setSignedPreviewUseFetch(String signedPreviewUseFetch) { setSignedPreviewUseFetchValue(signedPreviewUseFetch); } + public static void setSignedPreviewUseFetchValue(String signedPreviewUseFetch) { ConfigConstants.signedPreviewUseFetch = signedPreviewUseFetch; } + + @Value("${signed.preview.use.required:false}") + public void setSignedPreviewUseRequired(String signedPreviewUseRequired) { setSignedPreviewUseRequiredValue(Boolean.parseBoolean(signedPreviewUseRequired)); } + public static void setSignedPreviewUseRequiredValue(boolean signedPreviewUseRequired) { ConfigConstants.signedPreviewUseRequired = signedPreviewUseRequired; } + + @Value("${signed.preview.claim.map:default}") + public void setSignedPreviewClaimMap(String signedPreviewClaimMap) { setSignedPreviewClaimMapValue(signedPreviewClaimMap); } + public static void setSignedPreviewClaimMapValue(String signedPreviewClaimMap) { + Map result = new LinkedHashMap<>(); + if (!DEFAULT_VALUE.equalsIgnoreCase(signedPreviewClaimMap.trim())) { + for (String item : signedPreviewClaimMap.replaceAll("\\s+", "").split(",")) { + int index = item.indexOf(':'); + if (index > 0 && index < item.length() - 1) { + result.put(item.substring(0, index), item.substring(index + 1)); + } + } + } + ConfigConstants.signedPreviewClaimMap = Collections.unmodifiableMap(result); + } + + @Value("${signed.preview.archive.ttl:default}") + public void setSignedPreviewArchiveTtl(String signedPreviewArchiveTtl) { setSignedPreviewArchiveTtlValue(signedPreviewArchiveTtl); } + public static void setSignedPreviewArchiveTtlValue(String signedPreviewArchiveTtl) { + ConfigConstants.signedPreviewArchiveTtl = DEFAULT_VALUE.equalsIgnoreCase(signedPreviewArchiveTtl.trim()) ? null : Long.valueOf(signedPreviewArchiveTtl.trim()); + } + + @Value("${signed.fetch.ttl:default}") + public void setSignedFetchTtl(String signedFetchTtl) { setSignedFetchTtlValue(signedFetchTtl); } + public static void setSignedFetchTtlValue(String signedFetchTtl) { + ConfigConstants.signedFetchTtl = DEFAULT_VALUE.equalsIgnoreCase(signedFetchTtl.trim()) ? null : Long.valueOf(signedFetchTtl.trim()); + } + + @Value("${signed.fetch.claim.url:url}") + public void setSignedFetchClaimUrl(String signedFetchClaimUrl) { setSignedFetchClaimUrlValue(signedFetchClaimUrl); } + public static void setSignedFetchClaimUrlValue(String signedFetchClaimUrl) { ConfigConstants.signedFetchClaimUrl = signedFetchClaimUrl; } + + @Value("${signed.fetch.header.prefix:default}") + public void setSignedFetchHeaderPrefix(String signedFetchHeaderPrefix) { setSignedFetchHeaderPrefixValue(signedFetchHeaderPrefix); } + public static void setSignedFetchHeaderPrefixValue(String signedFetchHeaderPrefix) { + ConfigConstants.signedFetchHeaderPrefix = DEFAULT_VALUE.equalsIgnoreCase(signedFetchHeaderPrefix.trim()) ? "" : signedFetchHeaderPrefix.trim(); + } + // ================================================== // 10. 媒体文件配置Setter方法 // ================================================== diff --git a/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java b/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java index e15b313e9..af1e60024 100644 --- a/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java +++ b/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java @@ -221,6 +221,32 @@ private void updateConfigConstants(Properties properties) { // 14. Basic认证配置 ConfigConstants.setBasicNameValue(getProperty(properties, "basic.name", ConfigConstants.DEFAULT_BASIC_NAME)); + // 签名预览配置 + ConfigConstants.setSignedPreviewEnabledValue(Boolean.parseBoolean(getProperty(properties, "signed.preview.enabled", ConfigConstants.DEFAULT_SIGNED_PREVIEW_ENABLED))); + ConfigConstants.setSignedPreviewSecretValue(getProperty(properties, "signed.preview.secret", ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET)); + ConfigConstants.setSignedPreviewAlgorithmValue(getProperty(properties, "signed.preview.algorithm", ConfigConstants.DEFAULT_SIGNED_PREVIEW_ALGORITHM)); + ConfigConstants.setSignedPreviewTokenParamValue(getProperty(properties, "signed.preview.token.param", ConfigConstants.DEFAULT_SIGNED_PREVIEW_TOKEN_PARAM)); + ConfigConstants.setSignedPreviewClaimUrlValue(getProperty(properties, "signed.preview.claim.url", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_URL)); + ConfigConstants.setSignedPreviewClaimUrlsValue(getProperty(properties, "signed.preview.claim.urls", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_URLS)); + ConfigConstants.setSignedPreviewClaimFilenameValue(getProperty(properties, "signed.preview.claim.filename", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_FILENAME)); + ConfigConstants.setSignedPreviewClaimWatermarkValue(getProperty(properties, "signed.preview.claim.watermark", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_WATERMARK)); + ConfigConstants.setSignedPreviewClaimForceValue(getProperty(properties, "signed.preview.claim.force", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_FORCE)); + ConfigConstants.setSignedPreviewAllowParamsValue(getProperty(properties, "signed.preview.allow.params", ConfigConstants.DEFAULT_SIGNED_PREVIEW_ALLOW_PARAMS)); + ConfigConstants.setSignedPreviewTtlValue(Long.parseLong(getProperty(properties, "signed.preview.ttl", ConfigConstants.DEFAULT_SIGNED_PREVIEW_TTL))); + ConfigConstants.setSignedPreviewLeewayValue(Long.parseLong(getProperty(properties, "signed.preview.leeway", ConfigConstants.DEFAULT_SIGNED_PREVIEW_LEEWAY))); + ConfigConstants.setSignedFetchEnabledValue(Boolean.parseBoolean(getProperty(properties, "signed.fetch.enabled", ConfigConstants.DEFAULT_SIGNED_FETCH_ENABLED))); + ConfigConstants.setSignedFetchHeaderValue(getProperty(properties, "signed.fetch.header", ConfigConstants.DEFAULT_SIGNED_FETCH_HEADER)); + ConfigConstants.setSignedPreviewClaimUseValue(getProperty(properties, "signed.preview.claim.use", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_USE)); + ConfigConstants.setSignedPreviewUsePreviewValue(getProperty(properties, "signed.preview.use.preview", ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_PREVIEW)); + ConfigConstants.setSignedPreviewUseArchiveValue(getProperty(properties, "signed.preview.use.archive", ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_ARCHIVE)); + ConfigConstants.setSignedPreviewUseFetchValue(getProperty(properties, "signed.preview.use.fetch", ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_FETCH)); + ConfigConstants.setSignedPreviewUseRequiredValue(Boolean.parseBoolean(getProperty(properties, "signed.preview.use.required", ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_REQUIRED))); + ConfigConstants.setSignedPreviewClaimMapValue(getProperty(properties, "signed.preview.claim.map", ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_MAP)); + ConfigConstants.setSignedPreviewArchiveTtlValue(getProperty(properties, "signed.preview.archive.ttl", ConfigConstants.DEFAULT_SIGNED_PREVIEW_ARCHIVE_TTL)); + ConfigConstants.setSignedFetchTtlValue(getProperty(properties, "signed.fetch.ttl", ConfigConstants.DEFAULT_SIGNED_FETCH_TTL)); + ConfigConstants.setSignedFetchClaimUrlValue(getProperty(properties, "signed.fetch.claim.url", ConfigConstants.DEFAULT_SIGNED_FETCH_CLAIM_URL)); + ConfigConstants.setSignedFetchHeaderPrefixValue(getProperty(properties, "signed.fetch.header.prefix", ConfigConstants.DEFAULT_SIGNED_FETCH_HEADER_PREFIX)); + // 15. 视频转换配置 ConfigConstants.setMediaConvertMaxSizeValue(Integer.parseInt(getProperty(properties, "media.convert.max.size", ConfigConstants.DEFAULT_MEDIA_CONVERT_MAX_SIZE))); ConfigConstants.setMediaTimeoutEnabledValue(Boolean.parseBoolean(getProperty(properties, "media.timeout.enabled", ConfigConstants.DEFAULT_MEDIA_TIMEOUT_ENABLED))); diff --git a/server/src/main/java/cn/keking/config/WebConfig.java b/server/src/main/java/cn/keking/config/WebConfig.java index 404e688ef..14a940d6f 100644 --- a/server/src/main/java/cn/keking/config/WebConfig.java +++ b/server/src/main/java/cn/keking/config/WebConfig.java @@ -40,6 +40,20 @@ public FilterRegistrationBean getChinesePathFilter() { return registrationBean; } + @Bean + public FilterRegistrationBean getSignedPreviewFilter() { + Set filterUri = new HashSet<>(); + filterUri.add("/onlinePreview"); + filterUri.add("/picturesPreview"); + SignedPreviewFilter filter = new SignedPreviewFilter(); + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(filter); + registrationBean.setUrlPatterns(filterUri); + // 排在 ChinesePathFilter 之后,信任站点、信任目录、水印过滤器之前 + registrationBean.setOrder(15); + return registrationBean; + } + @Bean public FilterRegistrationBean getTrustHostFilter() { Set filterUri = new HashSet<>(); diff --git a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java index de817baaf..b74c953c1 100644 --- a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java @@ -9,6 +9,7 @@ import cn.keking.service.CompressFileReader; import cn.keking.utils.KkFileUtils; import cn.keking.utils.RarUtils; +import cn.keking.utils.SignedPreviewUtils; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.poi.EncryptedDocumentException; @@ -86,6 +87,11 @@ public String filePreviewHandle(String url, Model model, FileAttribute fileAttri logger.warn("Unable to read the previewed archive directory", e); return otherFilePreview.notSupportedFile(model, fileAttribute, "该压缩包目录无法读取!"); } + // 签名预览开启时,给页面一枚只对这个压缩包有效的令牌,点开包内文件时带上 + String archiveTokenQuery = SignedPreviewUtils.currentArchiveTokenQuery(fileTree); + if (StringUtils.hasText(archiveTokenQuery)) { + model.addAttribute("archiveTokenQuery", archiveTokenQuery); + } return COMPRESS_FILE_PREVIEW_PAGE; } } diff --git a/server/src/main/java/cn/keking/utils/DownloadUtils.java b/server/src/main/java/cn/keking/utils/DownloadUtils.java index bf9fdfbb0..ab11ed4d4 100644 --- a/server/src/main/java/cn/keking/utils/DownloadUtils.java +++ b/server/src/main/java/cn/keking/utils/DownloadUtils.java @@ -95,7 +95,7 @@ public static ReturnResponse downLoad(FileAttribute fileAttribute, Strin } } FileUtils.copyToFile(responseWrapper.getInputStream(), realFile); - }); + }, true); if (hasMimeError[0]) { response.setCode(1); diff --git a/server/src/main/java/cn/keking/utils/HttpRequestUtils.java b/server/src/main/java/cn/keking/utils/HttpRequestUtils.java index ccf75de30..283520244 100644 --- a/server/src/main/java/cn/keking/utils/HttpRequestUtils.java +++ b/server/src/main/java/cn/keking/utils/HttpRequestUtils.java @@ -187,7 +187,11 @@ private static void startIdleConnectionMonitor() { * 创建根据配置定制的HttpClient(支持复用) */ public static CloseableHttpClient createConfiguredHttpClient() throws Exception { - String cacheKey = buildHttpClientCacheKey(); + return createConfiguredHttpClient(ConfigConstants.isEnableRedirect()); + } + + private static CloseableHttpClient createConfiguredHttpClient(boolean enableRedirect) throws Exception { + String cacheKey = buildHttpClientCacheKey(enableRedirect); // 尝试从缓存获取 CloseableHttpClient cachedClient = httpClientCache.get(cacheKey); @@ -214,7 +218,7 @@ public static CloseableHttpClient createConfiguredHttpClient() throws Exception CloseableHttpClient httpClient = SslUtils.configureHttpClientBuilder( httpClientBuilder, ConfigConstants.isIgnoreSSL(), - ConfigConstants.isEnableRedirect() + enableRedirect ).build(); // 缓存HttpClient @@ -228,10 +232,10 @@ public static CloseableHttpClient createConfiguredHttpClient() throws Exception /** * 构建HttpClient缓存键 */ - private static String buildHttpClientCacheKey() { + private static String buildHttpClientCacheKey(boolean enableRedirect) { return String.format("ignoreSSL_%s_enableRedirect_%s", ConfigConstants.isIgnoreSSL(), - ConfigConstants.isEnableRedirect()); + enableRedirect); } /** @@ -261,14 +265,38 @@ private static RestTemplate getCachedRestTemplate(CloseableHttpClient httpClient */ public static void executeHttpRequest(java.net.URL url, CloseableHttpClient httpClient, FileAttribute fileAttribute, FileResponseHandler handler) throws Exception { + executeHttpRequest(url, httpClient, fileAttribute, handler, false); + } + + /** + * 执行HTTP请求(使用连接池) + * + * @param signFetch 是否按 signed.fetch.enabled 的配置附带取件令牌;只有 kkFileView 自己下载原文件时传 true + */ + public static void executeHttpRequest(java.net.URL url, CloseableHttpClient httpClient, + FileAttribute fileAttribute, FileResponseHandler handler, + boolean signFetch) throws Exception { + // 取件令牌只给原始地址:带令牌的这一次请求不自动跟随跳转,遇到跳转时在下面另发一次不带令牌的请求 + boolean signedFetch = signFetch && SignedPreviewUtils.isFetchSigningActive(); + if (signedFetch) { + httpClient = createConfiguredHttpClient(false); + } + java.net.URI requestUri = url.toURI(); + java.net.URI[] redirectTarget = new java.net.URI[1]; + // 获取缓存的RestTemplate RestTemplate restTemplate = getCachedRestTemplate(httpClient); String finalUrlStr = url.toString(); - RequestCallback requestCallback = createRequestCallback(finalUrlStr, fileAttribute); + RequestCallback requestCallback = createRequestCallback(finalUrlStr, fileAttribute, signFetch); try { - restTemplate.execute(url.toURI(), HttpMethod.GET, requestCallback, response -> { + restTemplate.execute(requestUri, HttpMethod.GET, requestCallback, response -> { + if (signedFetch && ConfigConstants.isEnableRedirect() && response.getStatusCode().is3xxRedirection() + && response.getHeaders().getLocation() != null) { + redirectTarget[0] = requestUri.resolve(response.getHeaders().getLocation()); + return null; + } FileResponseWrapper wrapper = new FileResponseWrapper(); wrapper.setInputStream(response.getBody()); wrapper.setContentType(WebUtils.headersType(response)); @@ -292,6 +320,9 @@ public static void executeHttpRequest(java.net.URL url, CloseableHttpClient http } return null; }); + if (redirectTarget[0] != null) { + executeHttpRequest(redirectTarget[0].toURL(), createConfiguredHttpClient(), fileAttribute, handler, false); + } } catch (Exception e) { // 如果是客户端中断连接,不再记录为错误 if (isClientAbortException(e)) { @@ -317,7 +348,7 @@ public static void executeHttpRequest(java.net.URL url, CloseableHttpClient http /** * 创建请求回调 */ - private static RequestCallback createRequestCallback(String finalUrlStr, FileAttribute fileAttribute) { + private static RequestCallback createRequestCallback(String finalUrlStr, FileAttribute fileAttribute, boolean signFetch) { return request -> { request.getHeaders().setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL)); WebUtils.applyBasicAuthHeaders(request.getHeaders(), finalUrlStr); @@ -334,6 +365,13 @@ private static RequestCallback createRequestCallback(String finalUrlStr, FileAtt ); proxyAuthorizationMap.forEach((key, value) -> request.getHeaders().set(key, value)); } + + // 取原文件时附带签名令牌(默认关闭),放在最后:请求头配成 Authorization 时, + // 上面的 basic 认证和转发来的请求头不会把它顶掉,以令牌为准 + String fetchToken = signFetch ? SignedPreviewUtils.issueFetchToken(request.getURI().toString()) : null; + if (fetchToken != null) { + request.getHeaders().set(ConfigConstants.getSignedFetchHeader(), SignedPreviewUtils.fetchHeaderValue(fetchToken)); + } }; } diff --git a/server/src/main/java/cn/keking/utils/JwtUtils.java b/server/src/main/java/cn/keking/utils/JwtUtils.java new file mode 100644 index 000000000..58ad22d87 --- /dev/null +++ b/server/src/main/java/cn/keking/utils/JwtUtils.java @@ -0,0 +1,150 @@ +package cn.keking.utils; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * 签名预览用到的 JWT 签发与校验(HMAC 一族:HS256、HS384、HS512)。 + * 只用 JDK 自带的 HMAC 和项目里已有的 Jackson,不引入新的依赖。 + */ +public final class JwtUtils { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + private JwtUtils() { + } + + /** + * 令牌没有通过校验 + */ + public static class JwtException extends Exception { + + private final boolean expired; + + public JwtException(String message) { + this(message, false); + } + + public JwtException(String message, boolean expired) { + super(message); + this.expired = expired; + } + + /** + * 是否因为过了有效期(其余情况都是令牌本身不对) + */ + public boolean isExpired() { + return expired; + } + } + + /** + * 签发令牌 + * + * @param claims 载荷 + * @param secret 密钥,按 UTF-8 字节使用 + * @param algorithm HS256、HS384 或 HS512 + * @return 三段式的 JWT + */ + public static String sign(Map claims, String secret, String algorithm) { + String macAlgorithm = macAlgorithm(algorithm); + if (macAlgorithm == null) { + throw new IllegalArgumentException("不支持的签名算法:" + algorithm); + } + try { + Map header = new LinkedHashMap<>(); + header.put("alg", algorithm.toUpperCase(Locale.ROOT)); + header.put("typ", "JWT"); + String signingInput = ENCODER.encodeToString(MAPPER.writeValueAsBytes(header)) + + "." + ENCODER.encodeToString(MAPPER.writeValueAsBytes(claims)); + return signingInput + "." + ENCODER.encodeToString(hmac(macAlgorithm, secret, signingInput)); + } catch (Exception e) { + throw new IllegalStateException("签发令牌失败", e); + } + } + + /** + * 校验令牌并取出载荷:令牌头里的算法必须就是配置的算法,签名必须对得上, + * 必须带数字形式的 exp 且没有过期;带了 nbf 时还不能早于生效时间。 + * + * @param token 三段式的 JWT + * @param secret 密钥,按 UTF-8 字节使用 + * @param algorithm HS256、HS384 或 HS512 + * @param leewaySeconds 比较时间时允许的时钟误差(秒) + * @return 载荷 + */ + public static Map verify(String token, String secret, String algorithm, long leewaySeconds) throws JwtException { + String macAlgorithm = macAlgorithm(algorithm); + if (macAlgorithm == null) { + throw new JwtException("不支持的签名算法:" + algorithm); + } + String[] parts = token == null ? new String[0] : token.split("\\.", -1); + if (parts.length != 3) { + throw new JwtException("令牌不是三段式的 JWT"); + } + Map header; + Map claims; + byte[] signature; + try { + header = MAPPER.readValue(DECODER.decode(parts[0]), MAP_TYPE); + claims = MAPPER.readValue(DECODER.decode(parts[1]), MAP_TYPE); + signature = DECODER.decode(parts[2]); + } catch (Exception e) { + throw new JwtException("令牌无法解析"); + } + if (header == null || claims == null || !algorithm.equalsIgnoreCase(String.valueOf(header.get("alg")))) { + throw new JwtException("令牌的签名算法与配置的不一致"); + } + byte[] expected; + try { + expected = hmac(macAlgorithm, secret, parts[0] + "." + parts[1]); + } catch (Exception e) { + throw new JwtException("无法计算签名"); + } + if (!MessageDigest.isEqual(expected, signature)) { + throw new JwtException("令牌的签名不正确"); + } + long now = System.currentTimeMillis() / 1000; + if (!(claims.get("exp") instanceof Number exp)) { + throw new JwtException("令牌没有带有效期 exp"); + } + if (now > exp.longValue() + leewaySeconds) { + throw new JwtException("令牌已过期", true); + } + if (claims.get("nbf") instanceof Number nbf && now + leewaySeconds < nbf.longValue()) { + throw new JwtException("令牌尚未生效"); + } + return claims; + } + + private static String macAlgorithm(String algorithm) { + if (algorithm == null) { + return null; + } + return switch (algorithm.toUpperCase(Locale.ROOT)) { + case "HS256" -> "HmacSHA256"; + case "HS384" -> "HmacSHA384"; + case "HS512" -> "HmacSHA512"; + default -> null; + }; + } + + private static byte[] hmac(String macAlgorithm, String secret, String signingInput) throws Exception { + Mac mac = Mac.getInstance(macAlgorithm); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), macAlgorithm)); + return mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/src/main/java/cn/keking/utils/SignedPreviewUtils.java b/server/src/main/java/cn/keking/utils/SignedPreviewUtils.java new file mode 100644 index 000000000..1dd16848e --- /dev/null +++ b/server/src/main/java/cn/keking/utils/SignedPreviewUtils.java @@ -0,0 +1,178 @@ +package cn.keking.utils; + +import cn.keking.config.ConfigConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 签名预览:按配置校验接入方签发的令牌,以及签发 kkFileView 自己用的两种令牌 + * (压缩包内文件、取原文件)。三种令牌用同一把密钥,靠载荷里的用途字段区分,互相不能顶替; + * 用途字段的名字和三种用途的值都可配,配得分不清时整套功能按配置有误处理。 + */ +public final class SignedPreviewUtils { + + private static final Logger logger = LoggerFactory.getLogger(SignedPreviewUtils.class); + + /** + * 校验通过后,令牌的载荷放在请求的这个属性里 + */ + public static final String CLAIMS_ATTRIBUTE = "signedPreviewClaims"; + + public static final String CLAIM_ARCHIVE_ROOT = "root"; + public static final String CLAIM_ARCHIVE_PREFIX = "prefix"; + + /** + * 除水印文字外的水印参数:令牌里的字段名缺省与请求参数名相同,可用 signed.preview.claim.map 改名 + */ + public static final List WATERMARK_PARAMS = List.of("watermarkXSpace", "watermarkYSpace", "watermarkFont", + "watermarkFontsize", "watermarkColor", "watermarkAlpha", "watermarkWidth", "watermarkHeight", "watermarkAngle"); + + private SignedPreviewUtils() { + } + + /** + * 是否配置了密钥(缺省值 false 表示没有配置) + */ + public static boolean isSecretConfigured() { + String secret = ConfigConstants.getSignedPreviewSecret(); + return StringUtils.hasText(secret) && !ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET.equalsIgnoreCase(secret.trim()); + } + + /** + * 用途字段的配置是否可用:字段名和三种用途的值都不为空,且三个值互不相同。 + * 不满足时入口拒绝全部请求、也不签发自己的令牌,三种令牌不会被当成同一种。 + */ + public static boolean isUseConfigValid() { + String preview = ConfigConstants.getSignedPreviewUsePreview(); + String archive = ConfigConstants.getSignedPreviewUseArchive(); + String fetch = ConfigConstants.getSignedPreviewUseFetch(); + return StringUtils.hasText(ConfigConstants.getSignedPreviewClaimUse()) + && StringUtils.hasText(preview) && StringUtils.hasText(archive) && StringUtils.hasText(fetch) + && !preview.equals(archive) && !preview.equals(fetch) && !archive.equals(fetch); + } + + /** + * 取原文件时这一次是否真的会带令牌:开关开着、配了密钥、用途配置可用 + */ + public static boolean isFetchSigningActive() { + return ConfigConstants.isSignedFetchEnabled() && isSecretConfigured() && isUseConfigValid(); + } + + /** + * 请求参数在令牌里对应的字段名(用于其余水印参数和 currentUrl) + */ + public static String claimName(String param) { + return ConfigConstants.getSignedPreviewClaimMap().getOrDefault(param, param); + } + + /** + * 按配置的密钥、算法、时钟误差校验令牌 + */ + public static Map verify(String token) throws JwtUtils.JwtException { + return JwtUtils.verify(token, ConfigConstants.getSignedPreviewSecret(), + ConfigConstants.getSignedPreviewAlgorithm(), ConfigConstants.getSignedPreviewLeeway()); + } + + /** + * 压缩包预览页拼包内文件链接时要追加的那一段(&参数名=令牌)。 + * 只有当前请求通过了签名校验才签发;签名预览没开、不在一次请求里、请求没有校验过时返回空串。 + * + * @param root 压缩包解开后的目录名 + */ + public static String currentArchiveTokenQuery(String root) { + if (!ConfigConstants.isSignedPreviewEnabled() || !isSecretConfigured() || !isUseConfigValid() || !StringUtils.hasText(root)) { + return ""; + } + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return ""; + } + Object verified = attributes.getAttribute(CLAIMS_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + Object baseUrl = attributes.getAttribute("baseUrl", RequestAttributes.SCOPE_REQUEST); + if (!(verified instanceof Map outerClaims) || !(baseUrl instanceof String)) { + return ""; + } + Map claims = new LinkedHashMap<>(); + claims.put(ConfigConstants.getSignedPreviewClaimUse(), ConfigConstants.getSignedPreviewUseArchive()); + claims.put(CLAIM_ARCHIVE_ROOT, root); + claims.put(CLAIM_ARCHIVE_PREFIX, baseUrl + root + "/"); + // 水印沿用打开压缩包时那枚令牌里的 + copyClaim(outerClaims, claims, ConfigConstants.getSignedPreviewClaimWatermark()); + WATERMARK_PARAMS.forEach(name -> copyClaim(outerClaims, claims, claimName(name))); + return "&" + URLEncoder.encode(ConfigConstants.getSignedPreviewTokenParam(), StandardCharsets.UTF_8) + "=" + + issue(claims, ConfigConstants.getSignedPreviewArchiveTtl()); + } + + /** + * 取原文件时附带的令牌;没开或没配密钥时返回 null。 + * + * @param url 本次请求的地址 + */ + public static String issueFetchToken(String url) { + if (!ConfigConstants.isSignedFetchEnabled()) { + return null; + } + if (!isFetchSigningActive()) { + logger.warn("已开启 signed.fetch.enabled 但没有配置 signed.preview.secret,或三种用途的值配置有误,取原文件时不带令牌"); + return null; + } + Map claims = new LinkedHashMap<>(); + claims.put(ConfigConstants.getSignedPreviewClaimUse(), ConfigConstants.getSignedPreviewUseFetch()); + putNested(claims, ConfigConstants.getSignedFetchClaimUrl(), url); + return issue(claims, ConfigConstants.getSignedFetchTtl()); + } + + /** + * 取件令牌放进请求头时的值:配了前缀就是「前缀 + 空格 + 令牌」 + */ + public static String fetchHeaderValue(String fetchToken) { + String prefix = ConfigConstants.getSignedFetchHeaderPrefix(); + return StringUtils.hasText(prefix) ? prefix + " " + fetchToken : fetchToken; + } + + /** + * 按点分路径把值放进载荷:a.b.c 放成 {"a":{"b":{"c":值}}};路径为空时放在 url 字段 + */ + @SuppressWarnings("unchecked") + private static void putNested(Map claims, String path, Object value) { + String[] segments = Arrays.stream(path == null ? new String[0] : path.split("\\.")) + .map(String::trim).filter(StringUtils::hasText).toArray(String[]::new); + if (segments.length == 0) { + segments = new String[]{ConfigConstants.DEFAULT_SIGNED_FETCH_CLAIM_URL}; + } + Map current = claims; + for (int i = 0; i < segments.length - 1; i++) { + Object next = current.get(segments[i]); + if (!(next instanceof Map)) { + next = new LinkedHashMap(); + current.put(segments[i], next); + } + current = (Map) next; + } + current.put(segments[segments.length - 1], value); + } + + private static String issue(Map claims, long ttlSeconds) { + long now = System.currentTimeMillis() / 1000; + claims.put("iat", now); + claims.put("exp", now + ttlSeconds); + return JwtUtils.sign(claims, ConfigConstants.getSignedPreviewSecret(), ConfigConstants.getSignedPreviewAlgorithm()); + } + + private static void copyClaim(Map from, Map to, String name) { + Object value = from.get(name); + if (value != null) { + to.put(name, value); + } + } +} diff --git a/server/src/main/java/cn/keking/web/filter/SignedPreviewFilter.java b/server/src/main/java/cn/keking/web/filter/SignedPreviewFilter.java new file mode 100644 index 000000000..f8093549c --- /dev/null +++ b/server/src/main/java/cn/keking/web/filter/SignedPreviewFilter.java @@ -0,0 +1,339 @@ +package cn.keking.web.filter; + +import cn.keking.config.ConfigConstants; +import cn.keking.utils.JwtUtils; +import cn.keking.utils.SignedPreviewUtils; +import cn.keking.utils.WebUtils; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 签名预览(signed.preview.enabled,默认关闭)。 + * 关闭时请求原样往后传;开启后预览入口只接受带有效签名令牌的请求,并按令牌重建请求参数: + * 文件地址、文件名、水印、是否强制重新转换取令牌里的,浏览器带的参数只留放行名单上的。 + * 重建后的参数里只有令牌给的那一个文件地址,后面的信任站点、信任目录过滤器照常对它做检查。 + */ +public class SignedPreviewFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(SignedPreviewFilter.class); + + private static final String PICTURES_PREVIEW_PATH = "/picturesPreview"; + private static final String PARAM_URL = "url"; + private static final String PARAM_URLS = "urls"; + private static final String PARAM_CURRENT_URL = "currentUrl"; + private static final String PARAM_WATERMARK = "watermarkTxt"; + private static final String PARAM_FORCE = "forceUpdatedCache"; + /** + * 由令牌决定的参数,写进放行名单也不从浏览器取 + */ + private static final Set TOKEN_OWNED_PARAMS = tokenOwnedParams(); + + private static final String MSG_MISSING = "预览链接缺少签名令牌,请回到原页面重新打开"; + private static final String MSG_EXPIRED = "预览链接已过期,请回到原页面重新打开"; + private static final String MSG_INVALID = "预览链接的签名令牌无效,请回到原页面重新打开"; + private static final String MSG_NO_SECRET = "已开启签名预览但未配置密钥,请联系管理员"; + private static final String MSG_BAD_CONFIG = "签名预览的令牌用途配置有误,请联系管理员"; + + /** + * 已经执行过「强制重新转换」的令牌(签名段 → 过期时间)。 + * 转换中的等待页会带着同一枚令牌整页刷新,每枚令牌只认一次,否则每刷新一次就重新转换一次。 + */ + private final Map forcedTokens = new ConcurrentHashMap<>(); + private String deniedView; + + @Override + public void init(FilterConfig filterConfig) { + try { + byte[] bytes = FileCopyUtils.copyToByteArray(new ClassPathResource("web/signedPreviewDenied.html").getInputStream()); + this.deniedView = new String(bytes, StandardCharsets.UTF_8); + } catch (IOException e) { + logger.error("加载signedPreviewDenied.html失败", e); + } + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (!ConfigConstants.isSignedPreviewEnabled()) { + chain.doFilter(request, response); + return; + } + HttpServletRequest httpRequest = (HttpServletRequest) request; + if (!SignedPreviewUtils.isSecretConfigured()) { + logger.error("已开启 signed.preview.enabled 但没有配置 signed.preview.secret,拒绝全部预览请求"); + deny(response, MSG_NO_SECRET); + return; + } + if (!SignedPreviewUtils.isUseConfigValid()) { + logger.error("signed.preview.use.preview / archive / fetch 三个值必须不为空且互不相同,拒绝全部预览请求"); + deny(response, MSG_BAD_CONFIG); + return; + } + String token = httpRequest.getParameter(ConfigConstants.getSignedPreviewTokenParam()); + if (!StringUtils.hasText(token)) { + deny(response, MSG_MISSING); + return; + } + Map claims; + Map parameters; + try { + claims = SignedPreviewUtils.verify(token); + parameters = rebuildParameters(httpRequest, token, claims); + } catch (JwtUtils.JwtException e) { + logger.warn("签名预览拒绝请求:{}", e.getMessage()); + deny(response, e.isExpired() ? MSG_EXPIRED : MSG_INVALID); + return; + } + request.setAttribute(SignedPreviewUtils.CLAIMS_ATTRIBUTE, claims); + chain.doFilter(new ParameterRequest(httpRequest, parameters), response); + } + + private Map rebuildParameters(HttpServletRequest request, String token, Map claims) throws JwtUtils.JwtException { + Map parameters = new LinkedHashMap<>(); + boolean picturesPreview = request.getRequestURI().endsWith(PICTURES_PREVIEW_PATH); + // 用途:没写时按配置决定算不算预览令牌;写了就必须正好是配置的那个值 + Object use = claims.get(ConfigConstants.getSignedPreviewClaimUse()); + boolean previewToken = use == null ? !ConfigConstants.isSignedPreviewUseRequired() + : ConfigConstants.getSignedPreviewUsePreview().equals(use); + if (previewToken) { + if (picturesPreview) { + parameters.put(PARAM_URLS, new String[]{base64(imageUrls(claims))}); + String currentUrl = text(claims.get(SignedPreviewUtils.claimName(PARAM_CURRENT_URL))); + if (currentUrl != null) { + parameters.put(PARAM_CURRENT_URL, new String[]{base64(currentUrl)}); + } + } else { + parameters.put(PARAM_URL, new String[]{base64(fileUrl(claims))}); + } + if (isTrue(claims.get(ConfigConstants.getSignedPreviewClaimForce())) && firstForce(token, claims)) { + parameters.put(PARAM_FORCE, new String[]{"true"}); + } + } else if (ConfigConstants.getSignedPreviewUseArchive().equals(use) && !picturesPreview) { + parameters.put(PARAM_URL, new String[]{archiveEntryUrl(request, claims)}); + } else { + throw new JwtUtils.JwtException("令牌的用途不是预览"); + } + String watermark = text(claims.get(ConfigConstants.getSignedPreviewClaimWatermark())); + if (watermark != null) { + parameters.put(PARAM_WATERMARK, new String[]{watermark}); + } + for (String name : SignedPreviewUtils.WATERMARK_PARAMS) { + String value = text(claims.get(SignedPreviewUtils.claimName(name))); + if (value != null) { + parameters.put(name, new String[]{value}); + } + } + String tokenParam = ConfigConstants.getSignedPreviewTokenParam(); + ConfigConstants.getSignedPreviewAllowParams().forEach((name, allowedValues) -> { + String[] values = request.getParameterValues(name); + if (values != null && !TOKEN_OWNED_PARAMS.contains(name) && !name.equals(tokenParam) + && isAllowedValue(values, allowedValues)) { + parameters.put(name, values); + } + }); + return parameters; + } + + /** + * 放行名单里限定了取值的参数:只能给一个值,且必须正好是列出的值之一;没限定的任意值 + */ + private static boolean isAllowedValue(String[] values, Set allowedValues) { + return allowedValues.isEmpty() || (values.length == 1 && allowedValues.contains(values[0])); + } + + /** + * 令牌里的文件地址;令牌带了文件名、地址里又没有 fullfilename 时,按现有约定把文件名接在地址后面 + */ + private String fileUrl(Map claims) throws JwtUtils.JwtException { + String url = text(claims.get(ConfigConstants.getSignedPreviewClaimUrl())); + if (!StringUtils.hasText(url)) { + throw new JwtUtils.JwtException("令牌里没有文件地址"); + } + String fileName = text(claims.get(ConfigConstants.getSignedPreviewClaimFilename())); + if (StringUtils.hasText(fileName) && !StringUtils.hasText(WebUtils.getUrlParameterReg(url, "fullfilename"))) { + url += (url.contains("?") ? "&" : "?") + "fullfilename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8); + } + return url; + } + + /** + * 多图预览的图片地址:令牌里可以是数组,也可以是用 | 分隔的字符串 + */ + private String imageUrls(Map claims) throws JwtUtils.JwtException { + Object urls = claims.get(ConfigConstants.getSignedPreviewClaimUrls()); + if (urls instanceof List list) { + List items = new ArrayList<>(); + for (Object item : list) { + items.add(text(item)); + } + urls = items.contains(null) ? null : String.join("|", items); + } + if (!(urls instanceof String joined) || !StringUtils.hasText(joined)) { + throw new JwtUtils.JwtException("令牌里没有图片地址"); + } + return joined; + } + + /** + * 压缩包令牌:包内文件的地址由压缩包预览页拼好、浏览器带来,这里只放行落在该压缩包目录下的那些。 + * 返回的是浏览器带来的原值,后面按原有流程解码。 + */ + private String archiveEntryUrl(HttpServletRequest request, Map claims) throws JwtUtils.JwtException { + String root = text(claims.get(SignedPreviewUtils.CLAIM_ARCHIVE_ROOT)); + String prefix = text(claims.get(SignedPreviewUtils.CLAIM_ARCHIVE_PREFIX)); + String[] values = request.getParameterValues(PARAM_URL); + String entryUrl = values == null || values.length != 1 ? null + : WebUtils.decodeBase64String(values[0], StandardCharsets.UTF_8); + if (!StringUtils.hasText(root) || !StringUtils.hasText(prefix) || entryUrl == null) { + throw new JwtUtils.JwtException("压缩包令牌或包内文件地址不完整"); + } + int query = entryUrl.indexOf('?'); + String location = query < 0 ? entryUrl : entryUrl.substring(0, query); + String entryPath = fullyDecoded(WebUtils.getUrlParameterReg(entryUrl, "kkCompressfilepath")); + boolean insideArchive = location.startsWith(prefix) + && isPlainRelativePath(fullyDecoded(location.substring(prefix.length()))) + && root.equals(WebUtils.getUrlParameterReg(entryUrl, "kkCompressfileKey")) + && entryPath != null && entryPath.startsWith(root + "/") + && isPlainRelativePath(entryPath.substring(root.length() + 1)); + if (!insideArchive) { + throw new JwtUtils.JwtException("包内文件地址不在令牌对应的压缩包下"); + } + return values[0]; + } + + /** + * 反复做 URL 解码直到不再变化(后面的流程对文件名可能解码不止一次) + */ + private static String fullyDecoded(String value) { + String current = value; + for (int i = 0; current != null && i < 8; i++) { + String decoded; + try { + decoded = URLDecoder.decode(current, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return current; + } + if (decoded.equals(current)) { + return current; + } + current = decoded; + } + return null; + } + + private static boolean isPlainRelativePath(String path) { + if (!StringUtils.hasText(path) || path.contains("\\")) { + return false; + } + for (String segment : path.split("/", -1)) { + if ("..".equals(segment)) { + return false; + } + } + return true; + } + + private boolean firstForce(String token, Map claims) { + long now = System.currentTimeMillis() / 1000; + forcedTokens.values().removeIf(expireAt -> expireAt < now); + long expireAt = ((Number) claims.get("exp")).longValue() + ConfigConstants.getSignedPreviewLeeway(); + return forcedTokens.putIfAbsent(token.substring(token.lastIndexOf('.') + 1), expireAt) == null; + } + + private static Set tokenOwnedParams() { + Set names = new HashSet<>(List.of(PARAM_URL, PARAM_URLS, PARAM_CURRENT_URL, "urlPath", "encryption", + PARAM_WATERMARK, PARAM_FORCE)); + names.addAll(SignedPreviewUtils.WATERMARK_PARAMS); + return names; + } + + private static String text(Object value) { + return value instanceof String || value instanceof Number || value instanceof Boolean ? value.toString() : null; + } + + private static boolean isTrue(Object value) { + return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(text(value)); + } + + private static String base64(String value) { + return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private void deny(ServletResponse response, String message) throws IOException { + if (response instanceof HttpServletResponse httpServletResponse) { + httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); + } + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("text/html;charset=UTF-8"); + String html = this.deniedView == null + ? "" + message + "" + : this.deniedView.replace("${message}", message); + response.getWriter().write(html); + response.getWriter().close(); + } + + @Override + public void destroy() { + + } + + /** + * 只暴露重建后的那一份请求参数 + */ + private static final class ParameterRequest extends HttpServletRequestWrapper { + + private final Map parameters; + + private ParameterRequest(HttpServletRequest request, Map parameters) { + super(request); + this.parameters = Collections.unmodifiableMap(parameters); + } + + @Override + public String getParameter(String name) { + String[] values = parameters.get(name); + return values == null || values.length == 0 ? null : values[0]; + } + + @Override + public Map getParameterMap() { + return parameters; + } + + @Override + public Enumeration getParameterNames() { + return Collections.enumeration(parameters.keySet()); + } + + @Override + public String[] getParameterValues(String name) { + return parameters.get(name); + } + } +} diff --git a/server/src/main/resources/web/compress.ftl b/server/src/main/resources/web/compress.ftl index 8be245d7a..cca6ad5b8 100644 --- a/server/src/main/resources/web/compress.ftl +++ b/server/src/main/resources/web/compress.ftl @@ -884,6 +884,9 @@ previewUrl += "&watermarkTxt=" + encodeURIComponent(keyword); } previewUrl += "&key=${kkkey}"; +<#if (archiveTokenQuery!)?has_content> + previewUrl += "${archiveTokenQuery?js_string}"; + return previewUrl; } diff --git a/server/src/main/resources/web/signedPreviewDenied.html b/server/src/main/resources/web/signedPreviewDenied.html new file mode 100644 index 000000000..5264258b1 --- /dev/null +++ b/server/src/main/resources/web/signedPreviewDenied.html @@ -0,0 +1,41 @@ + + + + + + + +
+ +

+ ${message} +

+
+ + diff --git a/server/src/test/java/cn/keking/SignedPreviewCustomFormatTests.java b/server/src/test/java/cn/keking/SignedPreviewCustomFormatTests.java new file mode 100644 index 000000000..d2478cca5 --- /dev/null +++ b/server/src/test/java/cn/keking/SignedPreviewCustomFormatTests.java @@ -0,0 +1,157 @@ +package cn.keking; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.model.ReturnResponse; +import cn.keking.utils.DownloadUtils; +import cn.keking.utils.WebUtils; +import cn.keking.web.filter.SignedPreviewFilter; +import com.sun.net.httpserver.HttpServer; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.keking.SignedPreviewTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 把签名预览整套配成另一种令牌格式(接入方已有自己的字段名、用途值、取件请求头写法), + * 从预览入口到下载原文件走一遍:接入方那一侧不用为 kkFileView 改签发和校验的写法。 + */ +class SignedPreviewCustomFormatTests { + + private HttpServer fileSource; + private final AtomicReference>> receivedHeaders = new AtomicReference<>(); + private Boolean previousIgnoreSsl; + private Boolean previousEnableRedirect; + + @BeforeEach + void setup() throws Exception { + previousIgnoreSsl = (Boolean) ReflectionTestUtils.getField(ConfigConstants.class, "ignoreSSL"); + previousEnableRedirect = (Boolean) ReflectionTestUtils.getField(ConfigConstants.class, "enableRedirect"); + ConfigConstants.setIgnoreSSLValue(false); + ConfigConstants.setEnableRedirectValue(false); + resetConfig(); + ConfigConstants.setSignedPreviewEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + // 预览令牌:参数名、字段名、用途值都是接入方自己的,且必须写用途 + ConfigConstants.setSignedPreviewTokenParamValue("accessToken"); + ConfigConstants.setSignedPreviewClaimUrlValue("fileUrl"); + ConfigConstants.setSignedPreviewClaimWatermarkValue("mark"); + ConfigConstants.setSignedPreviewClaimUseValue("kind"); + ConfigConstants.setSignedPreviewUsePreviewValue("view"); + ConfigConstants.setSignedPreviewUseArchiveValue("pack"); + ConfigConstants.setSignedPreviewUseFetchValue("pull"); + ConfigConstants.setSignedPreviewUseRequiredValue(true); + ConfigConstants.setSignedPreviewAllowParamsValue("officePreviewType=image|pdf,page"); + // 取件令牌:Authorization: Bearer <令牌>,地址在 payload.url,120 秒有效 + ConfigConstants.setSignedFetchEnabledValue(true); + ConfigConstants.setSignedFetchHeaderValue("Authorization"); + ConfigConstants.setSignedFetchHeaderPrefixValue("Bearer"); + ConfigConstants.setSignedFetchClaimUrlValue("payload.url"); + ConfigConstants.setSignedFetchTtlValue("120"); + + fileSource = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + fileSource.createContext("/", exchange -> { + receivedHeaders.set(Map.copyOf(exchange.getRequestHeaders())); + byte[] body = "content of the original file".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + fileSource.start(); + } + + @AfterEach + void restore() { + fileSource.stop(0); + ConfigConstants.setIgnoreSSLValue(previousIgnoreSsl); + ConfigConstants.setEnableRedirectValue(previousEnableRedirect); + resetConfig(); + } + + private HttpServletRequest throughFilter(MockHttpServletRequest request) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpServletRequest[] reached = new HttpServletRequest[1]; + new SignedPreviewFilter().doFilter(request, response, (req, res) -> reached[0] = (HttpServletRequest) req); + assertEquals(response.getStatus() == 200, reached[0] != null); + return reached[0]; + } + + private String receivedHeader(String name) { + return receivedHeaders.get().entrySet().stream().filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .map(entry -> entry.getValue().get(0)).findFirst().orElse(null); + } + + @Test + void previewAndFetchWorkEndToEndInAnotherTokenFormat() throws Exception { + String name = "custom-format-" + UUID.randomUUID() + ".txt"; + String fileUrl = "http://127.0.0.1:" + fileSource.getAddress().getPort() + "/doc/" + name; + + // 1. 预览入口:接入方格式的令牌通过,参数按令牌和带取值限定的放行名单重建 + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setParameter("accessToken", token("kind", "view", "fileUrl", fileUrl, "mark", "李四 0034", "exp", now() + 300)); + request.setParameter("url", base64("http://127.0.0.1:1/another.txt")); + request.setParameter("officePreviewType", "image"); + request.setParameter("page", "2"); + request.setParameter("highlightall", "true"); + HttpServletRequest reached = throughFilter(request); + assertNotNull(reached); + assertEquals(fileUrl, unbase64(reached.getParameter("url"))); + assertEquals("李四 0034", reached.getParameter("watermarkTxt")); + assertEquals("image", reached.getParameter("officePreviewType")); + assertEquals("2", reached.getParameter("page")); + assertNull(reached.getParameter("highlightall")); + + // 不写用途的、用缺省格式签的、放在缺省参数名里的,都不通过 + request.setParameter("accessToken", token("fileUrl", fileUrl, "exp", now() + 300)); + assertNull(throughFilter(request)); + request.setParameter("accessToken", token("use", "preview", "url", fileUrl, "exp", now() + 300)); + assertNull(throughFilter(request)); + request.removeParameter("accessToken"); + request.setParameter("token", token("kind", "view", "fileUrl", fileUrl, "exp", now() + 300)); + assertNull(throughFilter(request)); + // 值不在放行名单里的参数被丢掉,请求照常通过 + request.removeParameter("token"); + request.setParameter("accessToken", token("kind", "view", "fileUrl", fileUrl, "exp", now() + 300)); + request.setParameter("officePreviewType", "allImages"); + assertNull(throughFilter(request).getParameter("officePreviewType")); + + // 2. 取原文件:用入口交出来的地址去下载,文件来源收到的是它自己那种写法的请求头 + FileAttribute attribute = new FileAttribute(); + attribute.setSuffix("txt"); + attribute.setName(name); + attribute.setUrl(WebUtils.decodeUrl(reached.getParameter("url"), reached.getParameter("encryption"))); + ReturnResponse response = DownloadUtils.downLoad(attribute, name); + try { + assertTrue(response.isSuccess(), response.getMsg()); + assertEquals("content of the original file", Files.readString(new File(response.getContent()).toPath())); + assertNull(receivedHeader("kk-fetch-token")); + Map claims = verifiedBearer(receivedHeader("Authorization")); + assertEquals("pull", claims.get("kind")); + assertEquals(Map.of("url", fileUrl), claims.get("payload")); + assertNull(claims.get("url")); + assertNull(claims.get("use")); + assertEquals(120L, ((Number) claims.get("exp")).longValue() - ((Number) claims.get("iat")).longValue()); + } finally { + if (response.getContent() != null) { + new File(response.getContent()).delete(); + } + } + } +} diff --git a/server/src/test/java/cn/keking/SignedPreviewTestSupport.java b/server/src/test/java/cn/keking/SignedPreviewTestSupport.java new file mode 100644 index 000000000..5d290e323 --- /dev/null +++ b/server/src/test/java/cn/keking/SignedPreviewTestSupport.java @@ -0,0 +1,132 @@ +package cn.keking; + +import cn.keking.config.ConfigConstants; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 签名预览测试共用的小工具:令牌在这里按 JWT 规范独立签发和拆解,不经过被测代码, + * 这样被测的签发或校验写错时,测试不会跟着一起错。 + */ +public final class SignedPreviewTestSupport { + + public static final String SECRET = "unit-test-secret-0123456789abcdef"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private SignedPreviewTestSupport() { + } + + public static long now() { + return System.currentTimeMillis() / 1000; + } + + /** 载荷里依次给键、值;用默认密钥、HS256 签。 */ + public static String token(Object... keyValues) { + return sign("HS256", "HmacSHA256", SECRET, claims(keyValues)); + } + + public static Map claims(Object... keyValues) { + Map claims = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + claims.put((String) keyValues[i], keyValues[i + 1]); + } + return claims; + } + + /** 头里写的算法与真正用来算签名的算法分开给,方便造出两者不一致的令牌。 */ + public static String sign(String headerAlg, String macAlgorithm, String secret, Map claims) { + try { + String header = encode(("{\"alg\":\"" + headerAlg + "\",\"typ\":\"JWT\"}").getBytes(StandardCharsets.UTF_8)); + String payload = encode(MAPPER.writeValueAsBytes(claims)); + String signingInput = header + "." + payload; + if (macAlgorithm == null) { + return signingInput + "."; + } + Mac mac = Mac.getInstance(macAlgorithm); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), macAlgorithm)); + return signingInput + "." + encode(mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** 用默认密钥按 HS256 验签并取出载荷;签名对不上就抛错。 */ + @SuppressWarnings("unchecked") + public static Map verified(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new AssertionError("不是三段的 JWT:" + token); + } + Map header = MAPPER.readValue(Base64.getUrlDecoder().decode(parts[0]), Map.class); + if (!"HS256".equals(header.get("alg"))) { + throw new AssertionError("令牌头里的算法不是 HS256:" + header); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String expected = encode(mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8))); + if (!expected.equals(parts[2])) { + throw new AssertionError("签名与默认密钥对不上"); + } + return MAPPER.readValue(Base64.getUrlDecoder().decode(parts[1]), Map.class); + } catch (AssertionError e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** 取件请求头里可能带前缀(例如 Bearer),去掉前缀后验签取载荷。 */ + public static Map verifiedBearer(String headerValue) { + if (headerValue == null || !headerValue.startsWith("Bearer ")) { + throw new AssertionError("请求头的值不是 Bearer 加令牌:" + headerValue); + } + return verified(headerValue.substring("Bearer ".length())); + } + + public static String base64(String text) { + return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)); + } + + public static String unbase64(String text) { + return new String(Base64.getDecoder().decode(text), StandardCharsets.UTF_8); + } + + private static String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** 把签名预览的全部配置项恢复成缺省值(缺省即关闭)。 */ + public static void resetConfig() { + ConfigConstants.setSignedPreviewEnabledValue(Boolean.parseBoolean(ConfigConstants.DEFAULT_SIGNED_PREVIEW_ENABLED)); + ConfigConstants.setSignedPreviewSecretValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET); + ConfigConstants.setSignedPreviewAlgorithmValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_ALGORITHM); + ConfigConstants.setSignedPreviewTokenParamValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_TOKEN_PARAM); + ConfigConstants.setSignedPreviewClaimUrlValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_URL); + ConfigConstants.setSignedPreviewClaimUrlsValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_URLS); + ConfigConstants.setSignedPreviewClaimFilenameValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_FILENAME); + ConfigConstants.setSignedPreviewClaimWatermarkValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_WATERMARK); + ConfigConstants.setSignedPreviewClaimForceValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_FORCE); + ConfigConstants.setSignedPreviewAllowParamsValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_ALLOW_PARAMS); + ConfigConstants.setSignedPreviewTtlValue(Long.parseLong(ConfigConstants.DEFAULT_SIGNED_PREVIEW_TTL)); + ConfigConstants.setSignedPreviewLeewayValue(Long.parseLong(ConfigConstants.DEFAULT_SIGNED_PREVIEW_LEEWAY)); + ConfigConstants.setSignedFetchEnabledValue(Boolean.parseBoolean(ConfigConstants.DEFAULT_SIGNED_FETCH_ENABLED)); + ConfigConstants.setSignedFetchHeaderValue(ConfigConstants.DEFAULT_SIGNED_FETCH_HEADER); + ConfigConstants.setSignedPreviewClaimUseValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_USE); + ConfigConstants.setSignedPreviewUsePreviewValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_PREVIEW); + ConfigConstants.setSignedPreviewUseArchiveValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_ARCHIVE); + ConfigConstants.setSignedPreviewUseFetchValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_FETCH); + ConfigConstants.setSignedPreviewUseRequiredValue(Boolean.parseBoolean(ConfigConstants.DEFAULT_SIGNED_PREVIEW_USE_REQUIRED)); + ConfigConstants.setSignedPreviewClaimMapValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_CLAIM_MAP); + ConfigConstants.setSignedPreviewArchiveTtlValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_ARCHIVE_TTL); + ConfigConstants.setSignedFetchTtlValue(ConfigConstants.DEFAULT_SIGNED_FETCH_TTL); + ConfigConstants.setSignedFetchClaimUrlValue(ConfigConstants.DEFAULT_SIGNED_FETCH_CLAIM_URL); + ConfigConstants.setSignedFetchHeaderPrefixValue(ConfigConstants.DEFAULT_SIGNED_FETCH_HEADER_PREFIX); + } +} diff --git a/server/src/test/java/cn/keking/config/SignedPreviewConfigTests.java b/server/src/test/java/cn/keking/config/SignedPreviewConfigTests.java new file mode 100644 index 000000000..f84935fc4 --- /dev/null +++ b/server/src/test/java/cn/keking/config/SignedPreviewConfigTests.java @@ -0,0 +1,64 @@ +package cn.keking.config; + +import cn.keking.web.filter.SignedPreviewFilter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class SignedPreviewConfigTests { + + @ParameterizedTest + @ValueSource(strings = {"application.properties", "test.properties"}) + void shippedConfigKeepsSignedPreviewAndSignedFetchOff(String file) throws Exception { + String properties = new String(new ClassPathResource(file).getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(properties.contains("signed.preview.enabled = ${KK_SIGNED_PREVIEW_ENABLED:false}")); + assertTrue(properties.contains("signed.fetch.enabled = ${KK_SIGNED_FETCH_ENABLED:false}")); + assertTrue(properties.contains("signed.preview.secret = ${KK_SIGNED_PREVIEW_SECRET:false}")); + } + + @ParameterizedTest + @ValueSource(strings = {"application.properties", "test.properties"}) + void shippedConfigListsEveryOptionWithItsDefault(String file) throws Exception { + String properties = new String(new ClassPathResource(file).getInputStream().readAllBytes(), StandardCharsets.UTF_8); + for (String line : new String[]{ + "signed.preview.claim.use = ${KK_SIGNED_PREVIEW_CLAIM_USE:use}", + "signed.preview.use.preview = ${KK_SIGNED_PREVIEW_USE_PREVIEW:preview}", + "signed.preview.use.archive = ${KK_SIGNED_PREVIEW_USE_ARCHIVE:archive}", + "signed.preview.use.fetch = ${KK_SIGNED_PREVIEW_USE_FETCH:fetch}", + "signed.preview.use.required = ${KK_SIGNED_PREVIEW_USE_REQUIRED:false}", + "signed.preview.claim.map = ${KK_SIGNED_PREVIEW_CLAIM_MAP:default}", + "signed.preview.archive.ttl = ${KK_SIGNED_PREVIEW_ARCHIVE_TTL:default}", + "signed.fetch.ttl = ${KK_SIGNED_FETCH_TTL:default}", + "signed.fetch.claim.url = ${KK_SIGNED_FETCH_CLAIM_URL:url}", + "signed.fetch.header.prefix = ${KK_SIGNED_FETCH_HEADER_PREFIX:default}", + "signed.preview.allow.params = ${KK_SIGNED_PREVIEW_ALLOW_PARAMS:officePreviewType,tifPreviewType,page,highlightall,filePassword,key}"}) { + assertTrue(properties.contains(line + '\n'), line); + } + } + + @Test + void defaultsUsedWhenNothingIsConfiguredKeepBothSwitchesOff() { + assertEquals("false", ConfigConstants.DEFAULT_SIGNED_PREVIEW_ENABLED); + assertEquals("false", ConfigConstants.DEFAULT_SIGNED_FETCH_ENABLED); + } + + @Test + void signedPreviewFilterCoversBothPreviewEntriesAndRunsBeforeTheTrustFilters() { + WebConfig webConfig = new WebConfig(); + FilterRegistrationBean registration = webConfig.getSignedPreviewFilter(); + assertEquals(2, registration.getUrlPatterns().size()); + assertTrue(registration.getUrlPatterns().contains("/onlinePreview")); + assertTrue(registration.getUrlPatterns().contains("/picturesPreview")); + // 在统一字符集的过滤器之后,在信任站点、信任目录、水印三个过滤器之前 + assertTrue(registration.getOrder() > webConfig.getChinesePathFilter().getOrder()); + assertTrue(registration.getOrder() < webConfig.getTrustHostFilter().getOrder()); + assertTrue(registration.getOrder() < webConfig.getTrustDirFilter().getOrder()); + assertTrue(registration.getOrder() < webConfig.getWatermarkConfigFilter().getOrder()); + } +} diff --git a/server/src/test/java/cn/keking/utils/SignedFetchTests.java b/server/src/test/java/cn/keking/utils/SignedFetchTests.java new file mode 100644 index 000000000..f34fa6fac --- /dev/null +++ b/server/src/test/java/cn/keking/utils/SignedFetchTests.java @@ -0,0 +1,248 @@ +package cn.keking.utils; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.model.ReturnResponse; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.keking.SignedPreviewTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +class SignedFetchTests { + + private HttpServer server; + private final AtomicReference>> receivedHeaders = new AtomicReference<>(); + /** 按请求路径记下每次收到的请求头,用来分清跳转前后的两次请求。 */ + private final Map>> headersByPath = new ConcurrentHashMap<>(); + private Boolean previousIgnoreSsl; + private Boolean previousEnableRedirect; + + @BeforeEach + void setup() throws Exception { + previousIgnoreSsl = (Boolean) org.springframework.test.util.ReflectionTestUtils.getField(ConfigConstants.class, "ignoreSSL"); + previousEnableRedirect = (Boolean) org.springframework.test.util.ReflectionTestUtils.getField(ConfigConstants.class, "enableRedirect"); + ConfigConstants.setIgnoreSSLValue(false); + ConfigConstants.setEnableRedirectValue(false); + resetConfig(); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + ConfigConstants.setSignedFetchEnabledValue(true); + + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + receivedHeaders.set(Map.copyOf(exchange.getRequestHeaders())); + headersByPath.put(exchange.getRequestURI().getPath(), Map.copyOf(exchange.getRequestHeaders())); + if (exchange.getRequestURI().getPath().startsWith("/moved/")) { + exchange.getResponseHeaders().set("Location", "/target.txt"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + return; + } + byte[] body = "plain text".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void restore() { + server.stop(0); + ConfigConstants.setIgnoreSSLValue(previousIgnoreSsl); + ConfigConstants.setEnableRedirectValue(previousEnableRedirect); + resetConfig(); + } + + private String fileUrl(String path) { + return "http://127.0.0.1:" + server.getAddress().getPort() + path; + } + + /** 发一次请求,返回文件来源那一方在指定请求头里收到的值(没收到为 null)。 */ + private String fetch(String url, Boolean signFetch, String headerName) throws Exception { + receivedHeaders.set(null); + HttpRequestUtils.FileResponseHandler handler = wrapper -> wrapper.getInputStream().readAllBytes(); + if (signFetch == null) { + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), new FileAttribute(), handler); + } else { + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), new FileAttribute(), handler, signFetch); + } + return received(headerName); + } + + private String received(String headerName) { + return header(receivedHeaders.get(), headerName); + } + + private static String header(Map> headers, String headerName) { + assertNotNull(headers, "文件来源一方应当收到请求"); + return headers.entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(headerName)) + .map(entry -> entry.getValue().get(0)).findFirst().orElse(null); + } + + @Test + void fetchCarriesATokenTheFileSourceCanVerifyWithTheSameSecret() throws Exception { + ConfigConstants.setSignedPreviewTtlValue(120); + String url = fileUrl("/doc/report.txt?id=42"); + String header = fetch(url, true, "kk-fetch-token"); + assertNotNull(header, "开启后取原文件的请求应带上令牌"); + + Map claims = verified(header); + assertEquals("fetch", claims.get("use")); + assertEquals(url, claims.get("url"), "令牌里写的是这一次请求的地址"); + long exp = ((Number) claims.get("exp")).longValue(); + assertTrue(exp >= now() + 110 && exp <= now() + 125, "有效期按 signed.preview.ttl 算:" + (exp - now())); + } + + @Test + void fetchHeaderNameIsConfigurable() throws Exception { + ConfigConstants.setSignedFetchHeaderValue("X-Preview-Signature"); + String url = fileUrl("/a.txt"); + assertEquals(url, verified(fetch(url, true, "X-Preview-Signature")).get("url")); + assertNull(received("kk-fetch-token")); + } + + @Test + void fetchTokenCanCarryTheUrlInANestedField() throws Exception { + ConfigConstants.setSignedFetchClaimUrlValue("payload.source.url"); + String url = fileUrl("/a.txt"); + Map claims = verified(fetch(url, true, "kk-fetch-token")); + assertEquals(Map.of("source", Map.of("url", url)), claims.get("payload")); + assertNull(claims.get("url"), "配了嵌套位置之后,顶层不再放地址"); + assertEquals("fetch", claims.get("use")); + } + + @Test + void fetchHeaderValueCanCarryAPrefix() throws Exception { + ConfigConstants.setSignedFetchHeaderPrefixValue("Bearer"); + String url = fileUrl("/a.txt"); + assertEquals(url, verifiedBearer(fetch(url, true, "kk-fetch-token")).get("url")); + // 配置值两头的空格不影响,前缀与令牌之间固定一个空格 + ConfigConstants.setSignedFetchHeaderPrefixValue(" Bearer "); + assertEquals(url, verifiedBearer(fetch(url, true, "kk-fetch-token")).get("url")); + } + + @Test + void signedFetchOwnsTheAuthorizationHeaderWhenConfiguredToUseIt() throws Exception { + // 地址上的 basic 认证参数、转发的 kk-proxy-authorization 都会往 Authorization 上写 + String url = fileUrl("/a.txt?basic.name=alice&basic.pass=secret"); + FileAttribute attribute = new FileAttribute(); + attribute.setKkProxyAuthorization("{\"authorization\":\"Basic Zm9yd2FyZGVkOng=\",\"X-Forwarded-Extra\":\"kept\"}"); + HttpRequestUtils.FileResponseHandler handler = wrapper -> wrapper.getInputStream().readAllBytes(); + + // 对照:请求头是别的名字时,这些逻辑照旧,Authorization 上是转发来的那个 + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), attribute, handler, true); + assertEquals("Basic Zm9yd2FyZGVkOng=", received("Authorization")); + assertEquals(url, verified(received("kk-fetch-token")).get("url")); + + // 请求头配成 Authorization:以取件令牌为准,只有一个值;别的转发头不受影响 + ConfigConstants.setSignedFetchHeaderValue("Authorization"); + ConfigConstants.setSignedFetchHeaderPrefixValue("Bearer"); + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), attribute, handler, true); + assertEquals(1, receivedHeaders.get().entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase("Authorization")).mapToInt(entry -> entry.getValue().size()).sum()); + assertEquals(url, verifiedBearer(received("Authorization")).get("url")); + assertEquals("kept", received("X-Forwarded-Extra")); + + // 不签名的请求(代浏览器取文件)不受这两项配置影响 + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), attribute, handler, false); + assertEquals("Basic Zm9yd2FyZGVkOng=", received("Authorization")); + } + + @Test + void fetchIsUnsignedWhenSwitchedOffOrSecretMissing() throws Exception { + ConfigConstants.setSignedFetchEnabledValue(false); + assertNull(fetch(fileUrl("/a.txt"), true, "kk-fetch-token"), "默认关闭时不带"); + + // 与签名预览是两个各自独立的开关 + ConfigConstants.setSignedPreviewEnabledValue(true); + assertNull(fetch(fileUrl("/a.txt"), true, "kk-fetch-token")); + + ConfigConstants.setSignedFetchEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET); + assertNull(fetch(fileUrl("/a.txt"), true, "kk-fetch-token"), "没配密钥时不带"); + } + + @Test + void requestsMadeOnBehalfOfTheBrowserAreNeverSigned() throws Exception { + // /getCorsFile 用的是不带最后一个参数的旧方法 + assertNull(fetch(fileUrl("/a.txt"), null, "kk-fetch-token")); + assertNull(fetch(fileUrl("/a.txt"), false, "kk-fetch-token")); + } + + @Test + void redirectTargetDoesNotReceiveTheFetchToken() throws Exception { + ConfigConstants.setEnableRedirectValue(true); + String url = fileUrl("/moved/report.txt"); + AtomicReference body = new AtomicReference<>(); + HttpRequestUtils.executeHttpRequest(new URL(url), HttpRequestUtils.createConfiguredHttpClient(), new FileAttribute(), + wrapper -> body.set(new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)), true); + + assertEquals("plain text", body.get(), "跳转照常跟随,拿到的是跳转之后的内容"); + assertEquals(url, verified(header(headersByPath.get("/moved/report.txt"), "kk-fetch-token")).get("url"), + "原始地址那一方收到令牌"); + assertNotNull(headersByPath.get("/target.txt"), "跳转目标应当被请求到"); + assertNull(header(headersByPath.get("/target.txt"), "kk-fetch-token"), "令牌只给原始地址,不带到跳转目标"); + } + + @Test + void redirectHandlingIsUnchangedWhenFetchIsNotSigned() throws Exception { + // 取件签名关着:允许跳转时跟过去,不允许时把 3xx 的响应原样交给调用方,两种都不带令牌 + ConfigConstants.setSignedFetchEnabledValue(false); + for (boolean enableRedirect : new boolean[]{true, false}) { + ConfigConstants.setEnableRedirectValue(enableRedirect); + headersByPath.clear(); + AtomicReference body = new AtomicReference<>(); + HttpRequestUtils.executeHttpRequest(new URL(fileUrl("/moved/report.txt")), HttpRequestUtils.createConfiguredHttpClient(), + new FileAttribute(), wrapper -> body.set(new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)), true); + assertEquals(enableRedirect ? "plain text" : "", body.get()); + assertEquals(enableRedirect, headersByPath.containsKey("/target.txt")); + assertNull(header(headersByPath.get("/moved/report.txt"), "kk-fetch-token")); + } + } + + @Test + void signedFetchDoesNotFollowRedirectsWhenRedirectsAreDisabled() throws Exception { + ConfigConstants.setEnableRedirectValue(false); + AtomicReference body = new AtomicReference<>(); + HttpRequestUtils.executeHttpRequest(new URL(fileUrl("/moved/report.txt")), HttpRequestUtils.createConfiguredHttpClient(), + new FileAttribute(), wrapper -> body.set(new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8)), true); + assertEquals("", body.get(), "与不签名时一样,把 3xx 的响应原样交给调用方"); + assertFalse(headersByPath.containsKey("/target.txt")); + assertNotNull(header(headersByPath.get("/moved/report.txt"), "kk-fetch-token")); + } + + @Test + void downloadingTheOriginalFileSignsTheFetch() { + String name = "signed-fetch-" + UUID.randomUUID() + ".txt"; + String url = fileUrl("/doc/" + name); + FileAttribute attribute = new FileAttribute(); + attribute.setSuffix("txt"); + attribute.setName(name); + attribute.setUrl(url); + ReturnResponse response = DownloadUtils.downLoad(attribute, name); + try { + assertTrue(response.isSuccess(), response.getMsg()); + assertEquals(url, verified(received("kk-fetch-token")).get("url")); + } finally { + if (response.getContent() != null) { + new File(response.getContent()).delete(); + } + } + } +} diff --git a/server/src/test/java/cn/keking/web/filter/SignedPreviewArchiveTests.java b/server/src/test/java/cn/keking/web/filter/SignedPreviewArchiveTests.java new file mode 100644 index 000000000..db7c1d1ca --- /dev/null +++ b/server/src/test/java/cn/keking/web/filter/SignedPreviewArchiveTests.java @@ -0,0 +1,208 @@ +package cn.keking.web.filter; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.service.FileHandlerService; +import cn.keking.service.impl.CompressFilePreviewImpl; +import cn.keking.utils.SignedPreviewUtils; +import freemarker.template.Configuration; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.StringWriter; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static cn.keking.SignedPreviewTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +class SignedPreviewArchiveTests { + + private static final String BASE_URL = "https://preview.example/"; + private static final String ROOT = "archive.zip_"; + + @TempDir Path temp; + private String previousDir; + private Boolean previousCache; + private final SignedPreviewFilter filter = new SignedPreviewFilter(); + + @BeforeEach + void setup() throws Exception { + previousDir = ConfigConstants.getFileDir(); + previousCache = ConfigConstants.isCacheEnabled(); + ConfigConstants.setFileDirValue(temp.toString()); + ConfigConstants.setCacheEnabledValueValue(true); + Files.createDirectories(temp.resolve(ROOT + "/nested")); + Files.writeString(temp.resolve(ROOT + "/nested/visible.txt"), "visible"); + resetConfig(); + ConfigConstants.setSignedPreviewEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + } + + @AfterEach + void restore() { + RequestContextHolder.resetRequestAttributes(); + ConfigConstants.setFileDirValue(previousDir); + ConfigConstants.setCacheEnabledValueValue(previousCache); + resetConfig(); + } + + /** 模拟「压缩包的预览请求已经过了签名校验」:请求上挂着校验过的令牌内容和 baseUrl。 */ + private void withinVerifiedRequest(Map claims) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setAttribute("baseUrl", BASE_URL); + if (claims != null) { + request.setAttribute(SignedPreviewUtils.CLAIMS_ATTRIBUTE, claims); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private ExtendedModelMap archivePage() { + FileHandlerService handler = new FileHandlerService(null) { + @Override public String getConvertedFile(String name) { return ROOT; } + }; + FileAttribute file = new FileAttribute(); + file.setName("archive.zip"); + ExtendedModelMap model = new ExtendedModelMap(); + assertEquals("compress", new CompressFilePreviewImpl(handler, null, null) + .filePreviewHandle("https://files.example/archive.zip", model, file)); + return model; + } + + private String render(ExtendedModelMap model) throws Exception { + model.put("baseUrl", BASE_URL); + for (String name : new String[]{"watermarkXSpace", "watermarkYSpace", "watermarkAlpha", + "watermarkWidth", "watermarkHeight", "watermarkAngle"}) { model.put(name, "0"); } + Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); + cfg.setClassForTemplateLoading(getClass(), "/web"); + cfg.setClassicCompatible(true); + StringWriter output = new StringWriter(); + cfg.getTemplate("compress.ftl").process(model, output); + return output.toString(); + } + + /** 与 compress.ftl 的 buildPreviewUrl 拼法一致的包内文件地址。 */ + private static String entryUrl(String root, String id, String name) { + return BASE_URL + id + "?kkCompressfileKey=" + root + + "&kkCompressfilepath=" + URLEncoder.encode(id, StandardCharsets.UTF_8) + + "&fullfilename=" + URLEncoder.encode(name, StandardCharsets.UTF_8); + } + + private static String archiveToken(String root, long exp) { + return token("use", "archive", "root", root, "prefix", BASE_URL + root + "/", + "watermarkTxt", "张三 0012", "exp", exp); + } + + private HttpServletRequest open(String path, String token, String url) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", path); + request.setParameter("token", token); + request.setParameter(path.endsWith("picturesPreview") ? "urls" : "url", base64(url)); + request.setParameter("watermarkTxt", ""); + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpServletRequest[] reached = new HttpServletRequest[1]; + filter.doFilter(request, response, (req, res) -> reached[0] = (HttpServletRequest) req); + assertEquals(response.getStatus() == 200, reached[0] != null); + return reached[0]; + } + + @Test + void archivePageIsUnchangedWhenDisabled() throws Exception { + ConfigConstants.setSignedPreviewEnabledValue(false); + withinVerifiedRequest(claims("url", "https://files.example/archive.zip", "exp", now() + 300)); + ExtendedModelMap model = archivePage(); + assertNull(model.get("archiveTokenQuery")); + String page = render(model); + assertTrue(page.contains("previewUrl += \"&key="), "模板原有的拼接还在"); + assertFalse(page.contains("token="), "关闭时页面里不应出现令牌"); + } + + @Test + void archivePageRendersWithoutTokenOutsideAVerifiedRequest() throws Exception { + // 不在一次请求里 + assertNull(archivePage().get("archiveTokenQuery")); + // 在请求里,但这次请求没有经过签名校验 + withinVerifiedRequest(null); + ExtendedModelMap model = archivePage(); + assertNull(model.get("archiveTokenQuery")); + assertFalse(render(model).contains("token=")); + } + + @Test + void archivePageCarriesATokenBoundToThatArchive() throws Exception { + ConfigConstants.setSignedPreviewTtlValue(120); + withinVerifiedRequest(claims("url", "https://files.example/archive.zip", "watermarkTxt", "张三 0012", + "watermarkAlpha", 30, "forceUpdatedCache", true, "exp", now() + 300)); + ExtendedModelMap model = archivePage(); + String query = (String) model.get("archiveTokenQuery"); + assertNotNull(query, "开启后压缩包页面应拿到一枚令牌"); + assertTrue(query.startsWith("&token="), query); + + Map issued = verified(query.substring("&token=".length())); + assertEquals("archive", issued.get("use")); + assertEquals(ROOT, issued.get("root")); + assertEquals(BASE_URL + ROOT + "/", issued.get("prefix")); + assertEquals("张三 0012", issued.get("watermarkTxt")); + assertEquals(30, issued.get("watermarkAlpha")); + assertNull(issued.get("url"), "压缩包令牌里不带外层的文件地址"); + assertNull(issued.get("forceUpdatedCache")); + long exp = ((Number) issued.get("exp")).longValue(); + assertTrue(exp >= now() + 110 && exp <= now() + 125, "有效期按 signed.preview.ttl 算:" + (exp - now())); + + assertTrue(render(model).contains("previewUrl += \"" + query + "\";"), "模板拼包内文件链接时要带上令牌"); + } + + @Test + void tokenIssuedByTheArchivePageOpensItsOwnEntries() throws Exception { + withinVerifiedRequest(claims("url", "https://files.example/archive.zip", "watermarkTxt", "张三 0012", "exp", now() + 300)); + String query = (String) archivePage().get("archiveTokenQuery"); + assertNotNull(query); + String url = entryUrl(ROOT, ROOT + "/nested/visible.txt", "visible.txt"); + HttpServletRequest reached = open("/onlinePreview", query.substring("&token=".length()), url); + assertNotNull(reached, "本压缩包里的文件应放行"); + assertEquals(url, unbase64(reached.getParameter("url"))); + assertEquals("张三 0012", reached.getParameter("watermarkTxt"), "水印沿用打开压缩包时令牌里的"); + } + + @Test + void archiveTokenOnlyOpensEntriesOfItsOwnArchive() throws Exception { + String token = archiveToken(ROOT, now() + 300); + // 对照:本压缩包里的文件放行 + assertNotNull(open("/onlinePreview", token, entryUrl(ROOT, ROOT + "/nested/visible.txt", "visible.txt"))); + assertNotNull(open("/onlinePreview", token, entryUrl(ROOT, ROOT + "/名字 带空格.txt", "名字 带空格.txt"))); + + // 别的压缩包 + assertNull(open("/onlinePreview", token, entryUrl("other.zip_", "other.zip_/secret.txt", "secret.txt"))); + // 压缩包名对得上,路径却指到别处 + assertNull(open("/onlinePreview", token, entryUrl(ROOT, "other.zip_/secret.txt", "secret.txt"))); + assertNull(open("/onlinePreview", token, entryUrl(ROOT, ROOT + "/../other.zip_/secret.txt", "secret.txt"))); + assertNull(open("/onlinePreview", token, BASE_URL + ROOT + "/a.txt?kkCompressfileKey=" + ROOT + + "&kkCompressfilepath=" + ROOT + "%2F%2e%2e%2Fprivate.pdf&fullfilename=a.txt")); + assertNull(open("/onlinePreview", token, BASE_URL + ROOT + "/a.txt?kkCompressfileKey=" + ROOT + + "&kkCompressfilepath=" + ROOT + "%252F%252e%252e%252Fprivate.pdf&fullfilename=a.txt")); + assertNull(open("/onlinePreview", token, BASE_URL + ROOT + "/a.txt?kkCompressfileKey=" + ROOT + + "&kkCompressfilepath=" + ROOT + "%5C..%5Cprivate.pdf&fullfilename=a.txt")); + assertNull(open("/onlinePreview", token, BASE_URL + ROOT + "/../other.zip_/secret.txt?kkCompressfileKey=" + ROOT + + "&kkCompressfilepath=" + ROOT + "%2Fa.txt&fullfilename=a.txt")); + // 普通文件地址:不带压缩包参数的、带着压缩包参数但不在本服务的压缩包目录下的 + assertNull(open("/onlinePreview", token, "https://files.example/doc/report.docx")); + assertNull(open("/onlinePreview", token, "https://files.example/doc/report.docx?kkCompressfileKey=" + ROOT + + "&kkCompressfilepath=" + ROOT + "%2Fnested%2Fvisible.txt&fullfilename=visible.txt")); + assertNull(open("/onlinePreview", token, BASE_URL + "report.docx")); + // 多图预览不认压缩包令牌 + assertNull(open("/picturesPreview", token, BASE_URL + ROOT + "/nested/a.jpg")); + // 过期 + assertNull(open("/onlinePreview", archiveToken(ROOT, now() - 3600), + entryUrl(ROOT, ROOT + "/nested/visible.txt", "visible.txt"))); + } +} diff --git a/server/src/test/java/cn/keking/web/filter/SignedPreviewFilterTests.java b/server/src/test/java/cn/keking/web/filter/SignedPreviewFilterTests.java new file mode 100644 index 000000000..dd7729556 --- /dev/null +++ b/server/src/test/java/cn/keking/web/filter/SignedPreviewFilterTests.java @@ -0,0 +1,378 @@ +package cn.keking.web.filter; + +import cn.keking.config.ConfigConstants; +import cn.keking.utils.SignedPreviewUtils; +import cn.keking.utils.WebUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static cn.keking.SignedPreviewTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +class SignedPreviewFilterTests { + + private static final String FILE = "https://files.example/doc/report.docx"; + private static final String OTHER = "https://other.example/private/salary.xlsx"; + + private final SignedPreviewFilter filter = new SignedPreviewFilter(); + + /** 一次过滤的结果:状态码、提示页内容、放行到后面的那个请求(被拒时为 null)。 */ + private static final class Outcome { + int status; + String body; + HttpServletRequest reached; + } + + @BeforeEach + void setup() { + resetConfig(); + ConfigConstants.setSignedPreviewEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + } + + @AfterEach + void restore() { + resetConfig(); + ConfigConstants.setTrustHostValue("default"); + ConfigConstants.setNotTrustHostValue("default"); + } + + private Outcome run(MockHttpServletRequest request) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + Outcome outcome = new Outcome(); + filter.doFilter(request, response, (req, res) -> outcome.reached = (HttpServletRequest) req); + outcome.status = response.getStatus(); + outcome.body = response.getContentAsString(); + assertEquals(outcome.status == 200, outcome.reached != null, "只有放行的请求才是 200"); + return outcome; + } + + private MockHttpServletRequest preview(String token) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + if (token != null) { + request.setParameter("token", token); + } + return request; + } + + private static String urlOf(HttpServletRequest request) { + return unbase64(request.getParameter("url")); + } + + @Test + void disabledPassesTheUntouchedRequestWithOrWithoutToken() throws Exception { + ConfigConstants.setSignedPreviewEnabledValue(false); + String validToken = token("url", FILE, "watermarkTxt", "来自令牌", "exp", now() + 300); + for (String token : new String[]{null, "not-a-token", validToken}) { + MockHttpServletRequest request = preview(token); + request.setParameter("url", base64(OTHER)); + request.setParameter("watermarkTxt", "来自请求"); + request.setParameter("forceUpdatedCache", "true"); + request.setParameter("kkagent", "true"); + Outcome outcome = run(request); + assertEquals(200, outcome.status); + assertSame(request, outcome.reached, "关闭时请求应原样往后传,不换成别的对象"); + assertEquals(OTHER, urlOf(outcome.reached)); + assertEquals("来自请求", outcome.reached.getParameter("watermarkTxt")); + assertEquals("true", outcome.reached.getParameter("forceUpdatedCache")); + assertEquals("true", outcome.reached.getParameter("kkagent")); + assertEquals(token, outcome.reached.getParameter("token")); + assertNull(outcome.reached.getAttribute(SignedPreviewUtils.CLAIMS_ATTRIBUTE)); + } + } + + @Test + void enabledRejectsRequestWithoutToken() throws Exception { + MockHttpServletRequest request = preview(null); + request.setParameter("url", base64(FILE)); + Outcome outcome = run(request); + assertEquals(403, outcome.status); + assertTrue(outcome.body.contains("缺少签名令牌"), outcome.body); + + assertEquals(403, run(preview("")).status); + } + + @Test + void enabledAcceptsValidTokenAndExposesItsClaims() throws Exception { + Outcome outcome = run(preview(token("url", FILE, "exp", now() + 300))); + assertEquals(200, outcome.status); + assertEquals(FILE, urlOf(outcome.reached)); + Object claims = outcome.reached.getAttribute(SignedPreviewUtils.CLAIMS_ATTRIBUTE); + assertInstanceOf(Map.class, claims); + assertEquals(FILE, ((Map) claims).get("url")); + } + + @Test + void enabledRejectsExpiredToken() throws Exception { + Outcome outcome = run(preview(token("url", FILE, "exp", now() - 3600))); + assertEquals(403, outcome.status); + assertTrue(outcome.body.contains("已过期"), outcome.body); + } + + @Test + void leewayToleratesSmallClockDifferenceOnly() throws Exception { + String justExpired = token("url", FILE, "exp", now() - 10); + ConfigConstants.setSignedPreviewLeewayValue(30); + assertEquals(200, run(preview(justExpired)).status); + ConfigConstants.setSignedPreviewLeewayValue(0); + assertEquals(403, run(preview(justExpired)).status); + } + + @Test + void enabledRejectsTokenWithoutExpiry() throws Exception { + assertEquals(403, run(preview(token("url", FILE))).status); + assertEquals(403, run(preview(token("url", FILE, "exp", "never"))).status); + } + + @Test + void enabledRejectsWrongSignature() throws Exception { + Map claims = claims("url", FILE, "exp", now() + 300); + Outcome outcome = run(preview(sign("HS256", "HmacSHA256", "another-secret-0123456789abcdef", claims))); + assertEquals(403, outcome.status); + assertTrue(outcome.body.contains("签名令牌无效"), outcome.body); + + // 签名是对的,但载荷换成了别的文件 + String[] good = token("url", FILE, "exp", now() + 300).split("\\."); + String[] forged = token("url", OTHER, "exp", now() + 300).split("\\."); + assertEquals(403, run(preview(good[0] + "." + forged[1] + "." + good[2])).status); + + assertEquals(403, run(preview("not-a-token")).status); + assertEquals(403, run(preview("a.b.c")).status); + } + + @Test + void enabledRejectsTokenWhoseAlgorithmDiffersFromConfigured() throws Exception { + Map claims = claims("url", FILE, "exp", now() + 300); + // 不签名 + assertEquals(403, run(preview(sign("none", null, SECRET, claims))).status); + // 头里写 none,但带着一段按 HS256 算出来的签名 + assertEquals(403, run(preview(sign("none", "HmacSHA256", SECRET, claims))).status); + assertEquals(403, run(preview(sign("None", "HmacSHA256", SECRET, claims))).status); + // 密钥对、签名也对,但算法不是配置的那个 + assertEquals(403, run(preview(sign("HS512", "HmacSHA512", SECRET, claims))).status); + // 头里写 HS256,实际用 HS512 算 + assertEquals(403, run(preview(sign("HS256", "HmacSHA512", SECRET, claims))).status); + + // 对照:配置成 HS512 之后,HS512 的令牌通过,HS256 的反而不通过 + ConfigConstants.setSignedPreviewAlgorithmValue("HS512"); + assertEquals(200, run(preview(sign("HS512", "HmacSHA512", SECRET, claims))).status); + assertEquals(403, run(preview(sign("HS256", "HmacSHA256", SECRET, claims))).status); + } + + @Test + void enabledWithoutSecretRejectsEveryRequest() throws Exception { + ConfigConstants.setSignedPreviewSecretValue(ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET); + Map claims = claims("url", FILE, "exp", now() + 300); + Outcome outcome = run(preview(sign("HS256", "HmacSHA256", ConfigConstants.DEFAULT_SIGNED_PREVIEW_SECRET, claims))); + assertEquals(403, outcome.status); + assertTrue(outcome.body.contains("未配置密钥"), outcome.body); + + ConfigConstants.setSignedPreviewSecretValue(" "); + assertEquals(403, run(preview(sign("HS256", "HmacSHA256", " ", claims))).status); + } + + @Test + void tokenUrlWinsOverEverySourceTheBrowserSends() throws Exception { + MockHttpServletRequest request = preview(token("url", FILE, "exp", now() + 300)); + request.addParameter("url", base64(OTHER)); + request.addParameter("url", base64(OTHER)); + request.setParameter("urls", base64(OTHER)); + request.setParameter("currentUrl", base64(OTHER)); + request.setParameter("urlPath", base64(OTHER)); + request.setParameter("encryption", "aes"); + Outcome outcome = run(request); + assertEquals(200, outcome.status); + assertEquals(FILE, urlOf(outcome.reached)); + assertEquals(1, outcome.reached.getParameterValues("url").length); + assertNull(outcome.reached.getParameter("urls")); + assertNull(outcome.reached.getParameter("currentUrl")); + assertNull(outcome.reached.getParameter("urlPath")); + assertNull(outcome.reached.getParameter("encryption")); + // 后面的信任站点、信任目录两个过滤器就是用这个方法取地址的 + assertEquals(List.of(FILE), WebUtils.getSourceUrls(outcome.reached)); + } + + @Test + void trustHostCheckStillAppliesToTheTokenUrl() throws Exception { + ConfigConstants.setTrustHostValue("files.example"); + TrustHostFilter trustHostFilter = new TrustHostFilter(); + for (String[] expectation : new String[][]{{FILE, "200"}, {OTHER, "403"}}) { + MockHttpServletRequest request = preview(token("url", expectation[0], "exp", now() + 300)); + request.setParameter("url", base64(FILE)); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, (req, res) -> trustHostFilter.doFilter(req, res, (r, s) -> { })); + assertEquals(Integer.parseInt(expectation[1]), response.getStatus(), expectation[0]); + } + } + + @Test + void watermarkComesFromTokenNotFromRequest() throws Exception { + MockHttpServletRequest request = preview(token("url", FILE, "watermarkTxt", "张三 0012", + "watermarkAlpha", 30, "watermarkColor", "red", "exp", now() + 300)); + request.setParameter("watermarkTxt", ""); + request.setParameter("watermarkAlpha", "0"); + request.setParameter("watermarkFontsize", "1px"); + Outcome outcome = run(request); + assertEquals("张三 0012", outcome.reached.getParameter("watermarkTxt")); + assertEquals("30", outcome.reached.getParameter("watermarkAlpha")); + assertEquals("red", outcome.reached.getParameter("watermarkColor")); + assertNull(outcome.reached.getParameter("watermarkFontsize")); + + // 令牌里没写水印时,请求里带的水印同样不生效(回到配置文件里的缺省水印) + request = preview(token("url", FILE, "exp", now() + 300)); + request.setParameter("watermarkTxt", "自己加的"); + assertNull(run(request).reached.getParameter("watermarkTxt")); + } + + @Test + void forceUpdateComesFromTokenAndIsHonouredOncePerToken() throws Exception { + MockHttpServletRequest request = preview(token("url", FILE, "exp", now() + 300)); + request.setParameter("forceUpdatedCache", "true"); + assertNull(run(request).reached.getParameter("forceUpdatedCache")); + + String forcing = token("url", FILE, "forceUpdatedCache", true, "exp", now() + 300, "jti", "first"); + assertEquals("true", run(preview(forcing)).reached.getParameter("forceUpdatedCache")); + // 转换中的等待页会带着同一枚令牌整页刷新,第二次起不再强制重新转换 + assertNull(run(preview(forcing)).reached.getParameter("forceUpdatedCache")); + assertNull(run(preview(forcing)).reached.getParameter("forceUpdatedCache")); + + // 另一枚令牌不受影响;字符串形式的 true 也认 + String another = token("url", FILE, "forceUpdatedCache", "true", "exp", now() + 300, "jti", "second"); + assertEquals("true", run(preview(another)).reached.getParameter("forceUpdatedCache")); + String notForcing = token("url", FILE, "forceUpdatedCache", false, "exp", now() + 300); + assertNull(run(preview(notForcing)).reached.getParameter("forceUpdatedCache")); + } + + @Test + void onlyAllowListedBrowserParametersSurvive() throws Exception { + String token = token("url", FILE, "exp", now() + 300); + MockHttpServletRequest request = preview(token); + request.setParameter("officePreviewType", "image"); + request.setParameter("tifPreviewType", "jpg"); + request.setParameter("page", "3"); + request.setParameter("highlightall", "true"); + request.setParameter("filePassword", "123456"); + request.setParameter("key", "abc"); + request.setParameter("kkagent", "true"); + request.setParameter("usePasswordCache", "true"); + request.setParameter("anything", "else"); + Outcome outcome = run(request); + assertEquals(200, outcome.status); + Set expected = new TreeSet<>(Set.of("url", "officePreviewType", "tifPreviewType", "page", + "highlightall", "filePassword", "key")); + assertEquals(expected, new TreeSet<>(outcome.reached.getParameterMap().keySet())); + assertEquals(expected, new TreeSet<>(Collections.list(outcome.reached.getParameterNames()))); + assertEquals("image", outcome.reached.getParameter("officePreviewType")); + assertEquals("3", outcome.reached.getParameter("page")); + assertArrayEquals(new String[]{"123456"}, outcome.reached.getParameterValues("filePassword")); + assertNull(outcome.reached.getParameter("kkagent")); + assertNull(outcome.reached.getParameter("usePasswordCache")); + assertNull(outcome.reached.getParameter("anything")); + assertNull(outcome.reached.getParameter("token")); + + // 放行名单可配 + ConfigConstants.setSignedPreviewAllowParamsValue("page, kkagent"); + outcome = run(request); + assertEquals(new TreeSet<>(Set.of("url", "page", "kkagent")), new TreeSet<>(outcome.reached.getParameterMap().keySet())); + } + + @Test + void allowListCannotHandTokenOwnedParametersBackToTheBrowser() throws Exception { + ConfigConstants.setSignedPreviewAllowParamsValue( + "url,urls,currentUrl,urlPath,encryption,watermarkTxt,watermarkAlpha,forceUpdatedCache,token,page"); + MockHttpServletRequest request = preview(token("url", FILE, "exp", now() + 300)); + request.setParameter("url", base64(OTHER)); + request.setParameter("urlPath", base64(OTHER)); + request.setParameter("encryption", "aes"); + request.setParameter("watermarkTxt", "自己加的"); + request.setParameter("watermarkAlpha", "0"); + request.setParameter("forceUpdatedCache", "true"); + request.setParameter("page", "2"); + Outcome outcome = run(request); + assertEquals(new TreeSet<>(Set.of("url", "page")), new TreeSet<>(outcome.reached.getParameterMap().keySet())); + assertEquals(FILE, urlOf(outcome.reached)); + } + + @Test + void fileNameClaimIsCarriedInTheUrlTheSameWayAsToday() throws Exception { + String encodedName = URLEncoder.encode("年度 报告.docx", StandardCharsets.UTF_8); + Outcome outcome = run(preview(token("url", "https://files.example/download/42", + "fullfilename", "年度 报告.docx", "exp", now() + 300))); + assertEquals("https://files.example/download/42?fullfilename=" + encodedName, urlOf(outcome.reached)); + + outcome = run(preview(token("url", "https://files.example/download?id=42", + "fullfilename", "年度 报告.docx", "exp", now() + 300))); + assertEquals("https://files.example/download?id=42&fullfilename=" + encodedName, urlOf(outcome.reached)); + } + + @Test + void tokenParameterAndClaimNamesAreConfigurable() throws Exception { + ConfigConstants.setSignedPreviewTokenParamValue("ticket"); + ConfigConstants.setSignedPreviewClaimUrlValue("src"); + ConfigConstants.setSignedPreviewClaimFilenameValue("name"); + ConfigConstants.setSignedPreviewClaimWatermarkValue("mark"); + ConfigConstants.setSignedPreviewClaimForceValue("refresh"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setParameter("ticket", token("src", FILE, "name", "a.docx", "mark", "水印", "refresh", true, "exp", now() + 300)); + Outcome outcome = run(request); + assertEquals(200, outcome.status); + assertEquals(FILE + "?fullfilename=a.docx", urlOf(outcome.reached)); + assertEquals("水印", outcome.reached.getParameter("watermarkTxt")); + assertEquals("true", outcome.reached.getParameter("forceUpdatedCache")); + assertNull(outcome.reached.getParameter("ticket")); + + // 改名之后,缺省名字的参数和字段都不再认 + assertEquals(403, run(preview(token("src", FILE, "exp", now() + 300))).status); + request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setParameter("ticket", token("url", FILE, "exp", now() + 300)); + assertEquals(403, run(request).status); + } + + @Test + void picturesPreviewTakesItsImagesFromTheToken() throws Exception { + String a = "https://files.example/a.jpg"; + String b = "https://files.example/b.jpg"; + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/picturesPreview"); + request.setParameter("token", token("urls", List.of(a, b), "currentUrl", b, "exp", now() + 300)); + request.setParameter("urls", base64(OTHER)); + request.setParameter("currentUrl", base64(OTHER)); + Outcome outcome = run(request); + assertEquals(200, outcome.status); + assertEquals(a + "|" + b, unbase64(outcome.reached.getParameter("urls"))); + assertEquals(b, unbase64(outcome.reached.getParameter("currentUrl"))); + assertNull(outcome.reached.getParameter("url")); + assertEquals(List.of(b, a, b), WebUtils.getSourceUrls(outcome.reached)); + + // 也可以直接给用 | 分隔的字符串;不给 currentUrl 时请求里带的也不生效 + request.setParameter("token", token("urls", a + "|" + b, "exp", now() + 300)); + outcome = run(request); + assertEquals(a + "|" + b, unbase64(outcome.reached.getParameter("urls"))); + assertNull(outcome.reached.getParameter("currentUrl")); + + // 只有单个文件地址的令牌不能拿来开多图预览,反过来也一样 + request.setParameter("token", token("url", a, "exp", now() + 300)); + assertEquals(403, run(request).status); + assertEquals(403, run(preview(token("urls", List.of(a, b), "exp", now() + 300))).status); + } + + @Test + void tokensIssuedForOtherPurposesAreNotPreviewTokens() throws Exception { + assertEquals(200, run(preview(token("use", "preview", "url", FILE, "exp", now() + 300))).status); + // 取原文件时带的那种令牌里也有 url,不能反过来当预览令牌用 + assertEquals(403, run(preview(token("use", "fetch", "url", FILE, "exp", now() + 300))).status); + assertEquals(403, run(preview(token("use", "something-else", "url", FILE, "exp", now() + 300))).status); + } +} diff --git a/server/src/test/java/cn/keking/web/filter/SignedPreviewOptionsTests.java b/server/src/test/java/cn/keking/web/filter/SignedPreviewOptionsTests.java new file mode 100644 index 000000000..7aa83f000 --- /dev/null +++ b/server/src/test/java/cn/keking/web/filter/SignedPreviewOptionsTests.java @@ -0,0 +1,231 @@ +package cn.keking.web.filter; + +import cn.keking.config.ConfigConstants; +import cn.keking.utils.SignedPreviewUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.Map; + +import static cn.keking.SignedPreviewTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 签名预览里可以按接入方已有的令牌格式调整的那些配置项。 + */ +class SignedPreviewOptionsTests { + + private static final String FILE = "https://files.example/doc/report.docx"; + + private final SignedPreviewFilter filter = new SignedPreviewFilter(); + private String body; + + @BeforeEach + void setup() { + resetConfig(); + ConfigConstants.setSignedPreviewEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + } + + @AfterEach + void restore() { + RequestContextHolder.resetRequestAttributes(); + resetConfig(); + } + + /** 放行则返回到达后面的请求,被拒返回 null(提示页内容记在 body 里)。 */ + private HttpServletRequest run(MockHttpServletRequest request) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + HttpServletRequest[] reached = new HttpServletRequest[1]; + filter.doFilter(request, response, (req, res) -> reached[0] = (HttpServletRequest) req); + assertEquals(response.getStatus() == 200, reached[0] != null); + body = response.getContentAsString(); + return reached[0]; + } + + private MockHttpServletRequest preview(String token) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setParameter("token", token); + return request; + } + + /** 模拟一次已经通过签名校验的压缩包预览请求,取页面会拿到的那枚压缩包令牌的载荷。 */ + private Map issuedArchiveClaims(Map outerClaims) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/onlinePreview"); + request.setAttribute("baseUrl", "https://preview.example/"); + request.setAttribute(SignedPreviewUtils.CLAIMS_ATTRIBUTE, outerClaims); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + String query = SignedPreviewUtils.currentArchiveTokenQuery("archive.zip_"); + return query.isEmpty() ? null : verified(query.substring(query.indexOf('=') + 1)); + } + + @Test + void useClaimNameAndValuesAreConfigurable() throws Exception { + ConfigConstants.setSignedPreviewClaimUseValue("purpose"); + ConfigConstants.setSignedPreviewUsePreviewValue("doc-view"); + ConfigConstants.setSignedPreviewUseArchiveValue("doc-pack"); + ConfigConstants.setSignedPreviewUseFetchValue("doc-pull"); + + assertNotNull(run(preview(token("purpose", "doc-view", "url", FILE, "exp", now() + 300)))); + // 另两种用途的值、缺省的用途值,都不是预览令牌 + assertNull(run(preview(token("purpose", "doc-pull", "url", FILE, "exp", now() + 300)))); + assertNull(run(preview(token("purpose", "doc-pack", "url", FILE, "exp", now() + 300)))); + assertNull(run(preview(token("purpose", "preview", "url", FILE, "exp", now() + 300)))); + + // kkFileView 自己签发的两种令牌用的也是配置的字段名和值 + Map archive = issuedArchiveClaims(claims("purpose", "doc-view", "url", FILE, "exp", now() + 300)); + assertNotNull(archive); + assertEquals("doc-pack", archive.get("purpose")); + assertNull(archive.get("use")); + ConfigConstants.setSignedFetchEnabledValue(true); + Map fetch = verified(SignedPreviewUtils.issueFetchToken(FILE)); + assertEquals("doc-pull", fetch.get("purpose")); + assertNull(fetch.get("use")); + + // 签出来的压缩包令牌,入口按配置的用途值认得 + MockHttpServletRequest entry = preview(token("purpose", "doc-pack", "root", "archive.zip_", + "prefix", "https://preview.example/archive.zip_/", "exp", now() + 300)); + entry.setParameter("url", base64("https://preview.example/archive.zip_/a.txt?kkCompressfileKey=archive.zip_" + + "&kkCompressfilepath=archive.zip_%2Fa.txt&fullfilename=a.txt")); + assertNotNull(run(entry)); + } + + @Test + void previewTokenMustStateItsUseWhenRequired() throws Exception { + String withoutUse = token("url", FILE, "exp", now() + 300); + String withUse = token("use", "preview", "url", FILE, "exp", now() + 300); + // 缺省:不写用途也算预览令牌 + assertNotNull(run(preview(withoutUse))); + assertNotNull(run(preview(withUse))); + + ConfigConstants.setSignedPreviewUseRequiredValue(true); + assertNull(run(preview(withoutUse))); + assertTrue(body.contains("签名令牌无效"), body); + assertNotNull(run(preview(withUse))); + } + + @Test + void useValuesThatAreNotDistinctSwitchEverythingOffInsteadOfMixingTokens() throws Exception { + ConfigConstants.setSignedFetchEnabledValue(true); + String[][] badConfigs = {{"same", "same", "fetch"}, {"same", "archive", "same"}, {"preview", "same", "same"}, + {"", "archive", "fetch"}, {"preview", " ", "fetch"}}; + for (String[] values : badConfigs) { + ConfigConstants.setSignedPreviewUsePreviewValue(values[0]); + ConfigConstants.setSignedPreviewUseArchiveValue(values[1]); + ConfigConstants.setSignedPreviewUseFetchValue(values[2]); + String label = String.join("/", values); + + // 入口:带不带用途、带哪种用途都拒,提示配置有误 + assertNull(run(preview(token("url", FILE, "exp", now() + 300))), label); + assertTrue(body.contains("配置有误"), body); + assertNull(run(preview(token("use", "same", "url", FILE, "exp", now() + 300))), label); + // 自己的两种令牌都不签发 + assertNull(issuedArchiveClaims(claims("url", FILE, "exp", now() + 300)), label); + assertNull(SignedPreviewUtils.issueFetchToken(FILE), label); + } + // 用途字段名为空同样算配置有误 + resetConfig(); + ConfigConstants.setSignedPreviewEnabledValue(true); + ConfigConstants.setSignedPreviewSecretValue(SECRET); + ConfigConstants.setSignedPreviewClaimUseValue(""); + assertNull(run(preview(token("url", FILE, "exp", now() + 300)))); + assertTrue(body.contains("配置有误"), body); + } + + @Test + void claimMapRenamesTheRemainingWatermarkFieldsAndCurrentUrl() throws Exception { + ConfigConstants.setSignedPreviewClaimMapValue("watermarkAlpha:wmAlpha, watermarkColor:wmColor,currentUrl:cur"); + + HttpServletRequest reached = run(preview(token("url", FILE, "wmAlpha", 30, "wmColor", "red", + "watermarkAngle", 15, "exp", now() + 300))); + assertEquals("30", reached.getParameter("watermarkAlpha")); + assertEquals("red", reached.getParameter("watermarkColor")); + assertEquals("15", reached.getParameter("watermarkAngle"), "没写进映射表的仍是同名"); + + // 改了名之后,原来的名字不再认 + reached = run(preview(token("url", FILE, "watermarkAlpha", 40, "exp", now() + 300))); + assertNull(reached.getParameter("watermarkAlpha")); + + MockHttpServletRequest pictures = new MockHttpServletRequest("GET", "/picturesPreview"); + pictures.setParameter("token", token("urls", List.of("https://files.example/a.jpg", "https://files.example/b.jpg"), + "cur", "https://files.example/b.jpg", "exp", now() + 300)); + assertEquals("https://files.example/b.jpg", unbase64(run(pictures).getParameter("currentUrl"))); + + // 压缩包令牌沿用水印时用的也是映射后的字段名 + Map archive = issuedArchiveClaims(claims("url", FILE, "wmAlpha", 30, "exp", now() + 300)); + assertEquals(30, archive.get("wmAlpha")); + } + + @Test + void dedicatedClaimSettingsWinOverTheClaimMap() throws Exception { + // 文件地址、水印文字、强制重新转换各有单独的配置项,映射表里写了也不生效 + ConfigConstants.setSignedPreviewClaimMapValue("url:src,watermarkTxt:mark,forceUpdatedCache:refresh"); + HttpServletRequest reached = run(preview(token("url", FILE, "watermarkTxt", "水印", "mark", "别的", + "src", "https://other.example/x.docx", "refresh", true, "exp", now() + 300))); + assertEquals(FILE, unbase64(reached.getParameter("url"))); + assertEquals("水印", reached.getParameter("watermarkTxt")); + assertNull(reached.getParameter("forceUpdatedCache")); + + ConfigConstants.setSignedPreviewClaimWatermarkValue("label"); + reached = run(preview(token("url", FILE, "label", "单独配置的", "mark", "别的", "exp", now() + 300))); + assertEquals("单独配置的", reached.getParameter("watermarkTxt")); + } + + @Test + void allowListCanRestrictTheValuesOfAParameter() throws Exception { + ConfigConstants.setSignedPreviewAllowParamsValue("officePreviewType=image|pdf|html, page"); + String token = token("url", FILE, "exp", now() + 300); + + for (String allowed : new String[]{"image", "pdf", "html"}) { + MockHttpServletRequest request = preview(token); + request.setParameter("officePreviewType", allowed); + assertEquals(allowed, run(request).getParameter("officePreviewType")); + } + // 不在列出的值里:丢掉这个参数,请求本身照常放行 + for (String rejected : new String[]{"allImages", "Image", " image", "image ", "", "image|pdf"}) { + MockHttpServletRequest request = preview(token); + request.setParameter("officePreviewType", rejected); + request.setParameter("page", "any value 任意"); + HttpServletRequest reached = run(request); + assertNotNull(reached, rejected); + assertNull(reached.getParameter("officePreviewType"), rejected); + assertEquals("any value 任意", reached.getParameter("page")); + } + // 同一个参数给了多个值也丢掉,哪怕每个值都在名单里 + MockHttpServletRequest request = preview(token); + request.setParameter("officePreviewType", "image", "pdf"); + HttpServletRequest reached = run(request); + assertNotNull(reached); + assertNull(reached.getParameterValues("officePreviewType")); + } + + @Test + void archiveAndFetchTokensHaveTheirOwnTtlFallingBackToTheSharedOne() { + ConfigConstants.setSignedFetchEnabledValue(true); + ConfigConstants.setSignedPreviewTtlValue(600); + Map outer = claims("url", FILE, "exp", now() + 300); + + // 没单独配:都用 signed.preview.ttl + assertEquals(600, ttlOf(issuedArchiveClaims(outer))); + assertEquals(600, ttlOf(verified(SignedPreviewUtils.issueFetchToken(FILE)))); + + ConfigConstants.setSignedPreviewArchiveTtlValue("1800"); + assertEquals(1800, ttlOf(issuedArchiveClaims(outer))); + assertEquals(600, ttlOf(verified(SignedPreviewUtils.issueFetchToken(FILE)))); + + ConfigConstants.setSignedFetchTtlValue("120"); + assertEquals(1800, ttlOf(issuedArchiveClaims(outer))); + assertEquals(120, ttlOf(verified(SignedPreviewUtils.issueFetchToken(FILE)))); + } + + private static long ttlOf(Map claims) { + return ((Number) claims.get("exp")).longValue() - ((Number) claims.get("iat")).longValue(); + } +}