別怕泄露!」Spring Boot 秒生成簽名 URL,輕松搞定私有文件安全訪問!
在現(xiàn)代應用系統(tǒng)中,文件訪問是幾乎繞不開的功能點。無論是用戶上傳的頭像、合同 PDF,還是后臺生成的報表文件,系統(tǒng)都需要考慮如何在保證 安全 的前提下,實現(xiàn) 便捷訪問。
僅依賴用戶身份認證有時并不足夠,因為某些場景下,我們需要給外部系統(tǒng)或臨時用戶開放有限時間的訪問權(quán)限,而不可能為其建立長期有效的賬號和密碼。此時,簽名 URL(Signed URL)便成為最佳選擇。
簽名 URL 具備以下兩個關鍵特征:
- 帶有過期時間:一旦時間到期,鏈接自動失效,避免長期暴露。
- 包含數(shù)字簽名:只有服務端能生成正確的簽名,客戶端無法偽造,確保鏈接可信。
結(jié)合 Spring Boot 提供的靈活配置與加密工具,我們可以非常高效地實現(xiàn)這一機制。本文將帶你逐步完成從配置、簽名生成、文件驗證到前端測試頁面的完整流程。
簽名 URL 基礎機制
簽名 URL 的設計思路
簽名 URL 的本質(zhì),是將 請求方法、資源路徑、過期時間 等核心信息組合后,通過 加密簽名算法(如 HMAC-SHA256)計算出校驗值。 只有在簽名校驗通過、并且未過期時,才能訪問對應的私有文件。
這種設計有兩個顯著優(yōu)點:
- 無需額外賬號體系:直接通過 URL 控制訪問權(quán)限。
- 輕量安全:過期時間 + 簽名雙重保護,有效防止鏈接被篡改或長期傳播。
簽名 URL 的結(jié)構(gòu)
簽名 URL 的樣子和普通 HTTP 鏈接差不多,只是附帶了額外參數(shù):
https://oss.example.com/photos/architecture.png?expires=1755990064&sign=sefxxfx關鍵參數(shù)解釋:
expires:Unix 時間戳,表示鏈接過期時間。sign:加密簽名,確保鏈接未被篡改。
服務器端會在收到請求時:
- 檢查當前時間是否超過
expires; - 使用同樣的算法重新計算簽名,和
sign對比。
Spring Boot 實戰(zhàn)
下面我們基于 Spring Boot 來實現(xiàn)簽名 URL 生成與驗證
src/main/java/com/icoderoad/security/signurl
├── config
│ └── LinkProperties.java
├── util
│ └── SignatureUtil.java
├── service
│ └── LinkService.java
└── controller
└── FileAccessController.java配置類
package com.icoderoad.security.signurl.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "pack.app")
public class LinkProperties {
private String secretKey;
private String algs;
private long lifetimeSeconds;
private String method;
private String accessPath;
// getters & setters
}application.yml 配置:
pack:
app:
algs: HmacSHA256
lifetime-seconds: 1800
method: get
secret-key: aaaabbbbccccdddd
accessPath: /files簽名工具類
package com.icoderoad.security.signurl.util;
import com.icoderoad.security.signurl.config.LinkProperties;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Component
public class SignatureUtil {
private final LinkProperties linkProperties;
private final byte[] secret;
public SignatureUtil(LinkProperties linkProperties) {
this.linkProperties = linkProperties;
this.secret = linkProperties.getSecretKey().getBytes(StandardCharsets.UTF_8);
}
public String signPath(String method, String path, long expires) throws Exception {
String data = method + "|" + path + "|" + expires;
String HMAC = linkProperties.getAlgs();
Mac mac = Mac.getInstance(HMAC);
mac.init(new SecretKeySpec(secret, HMAC));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
}簽名 URL 服務類
package com.icoderoad.security.signurl.service;
import com.icoderoad.security.signurl.config.LinkProperties;
import com.icoderoad.security.signurl.util.SignatureUtil;
import org.springframework.stereotype.Service;
import java.time.ZonedDateTime;
@Service
public class LinkService {
private final SignatureUtil signatureUtil;
private final LinkProperties linkProperties;
public LinkService(SignatureUtil signatureUtil, LinkProperties linkProperties) {
this.signatureUtil = signatureUtil;
this.linkProperties = linkProperties;
}
public String generateLink(String filePath) throws Exception {
String canonicalPath = filePath.startsWith("/") ? filePath : "/" + filePath;
long expiresAt = ZonedDateTime.now()
.plusSeconds(linkProperties.getLifetimeSeconds())
.toEpochSecond();
String signature = signatureUtil.signPath(linkProperties.getMethod(), canonicalPath, expiresAt);
return String.format("/%s%s?expires=%d&sign=%s",
linkProperties.getAccessPath().replaceFirst("^/", ""),
canonicalPath, expiresAt, signature);
}
}文件訪問控制器
package com.icoderoad.security.signurl.controller;
import com.icoderoad.security.signurl.config.LinkProperties;
import com.icoderoad.security.signurl.service.LinkService;
import com.icoderoad.security.signurl.util.SignatureUtil;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.file.*;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
@Controller
@RequestMapping("${pack.app.accessPath:/files}")
public class FileAccessController {
private final SignatureUtil signatureUtil;
private final LinkService linkService;
private final LinkProperties linkProperties;
public FileAccessController(SignatureUtil signatureUtil, LinkService linkService,
LinkProperties linkProperties) {
this.signatureUtil = signatureUtil;
this.linkService = linkService;
this.linkProperties = linkProperties;
}
/** 展示頁面,生成文件簽名鏈接 */
@GetMapping("")
public String generateLinksForDirectory(Model model) throws Exception {
String directoryPath = "/opt/data/images";
List<String> links = new ArrayList<>();
Path dirPath = Paths.get(directoryPath);
if (Files.exists(dirPath) && Files.isDirectory(dirPath)) {
Files.list(dirPath).filter(Files::isRegularFile).forEach(file -> {
try {
String relativePath = dirPath.relativize(file).toString().replace("\\", "/");
links.add("http://localhost:8080" + linkService.generateLink(relativePath));
} catch (Exception e) {
e.printStackTrace();
}
});
}
model.addAttribute("links", links);
return "preview";
}
/** 訪問文件接口 */
@GetMapping("/{*path}")
public void fetchFile(@PathVariable("path") String path,
@RequestParam long expires,
@RequestParam String sign,
HttpServletResponse response) throws Exception {
long now = Instant.now().getEpochSecond();
if (now >= expires) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "鏈接已過期");
return;
}
String expected = signatureUtil.signPath(linkProperties.getMethod(), path, expires);
byte[] expectedBytes = Base64.getUrlDecoder().decode(expected);
byte[] providedBytes = Base64.getUrlDecoder().decode(sign);
if (!MessageDigest.isEqual(expectedBytes, providedBytes)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "無效鏈接");
return;
}
Path filePath = Paths.get("/opt/data/images/", path).normalize();
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
String contentType = determineContentType(path);
response.setContentType(contentType);
Files.copy(resource.getFile().toPath(), response.getOutputStream());
response.getOutputStream().flush();
}
private String determineContentType(String path) {
if (path == null || !path.contains(".")) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String extension = path.substring(path.lastIndexOf(".") + 1).toLowerCase();
return switch (extension) {
case "png" -> MediaType.IMAGE_PNG_VALUE;
case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE;
case "pdf" -> MediaType.APPLICATION_PDF_VALUE;
case "txt" -> MediaType.TEXT_PLAIN_VALUE;
case "html" -> MediaType.TEXT_HTML_VALUE;
default -> MediaType.APPLICATION_OCTET_STREAM_VALUE;
};
}
}前端頁面(Thymeleaf + Bootstrap)
src/main/resources/templates/preview.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>簽名 URL 文件預覽</title>
<link rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
<h2 class="mb-4 text-center">簽名 URL 文件訪問測試</h2>
<div class="card shadow-sm p-4">
<h5 class="mb-3">生成的文件鏈接:</h5>
<ul class="list-group">
<li th:each="link : ${links}" class="list-group-item d-flex justify-content-between align-items-center">
<span th:text="${link}"></span>
<a th:href="${link}" class="btn btn-primary btn-sm" target="_blank">訪問</a>
</li>
</ul>
</div>
</div>
</body>
</html>效果:
- 頁面會列出
/opt/data/images目錄下的所有文件簽名 URL; - 點擊右側(cè)按鈕即可直接測試訪問。
測試流程
- 在
/opt/data/images放入若干文件(jpg/png/pdf/txt)。 - 啟動 Spring Boot 項目。
- 瀏覽器訪問:
http://localhost:8080/files頁面會展示簽名 URL 列表,點擊即可驗證訪問是否成功。
結(jié)論
通過本文完整實戰(zhàn),我們實現(xiàn)了 簽名 URL 的后端生成 + 前端預覽:
- 后端負責安全計算簽名、校驗過期時間,保證文件訪問的合規(guī)性;
- 前端通過 Thymeleaf + Bootstrap 渲染文件列表,用戶可以一鍵點擊測試。
這種方式既簡潔又高效,尤其適合需要 臨時文件分享 的業(yè)務場景,比如:
- 生成臨時下載地址
- 限時訪問合同、賬單、報表
- 文件分享的安全保護
未來如果你要在生產(chǎn)環(huán)境結(jié)合 OSS/S3/CDN,只需要替換文件存儲目錄和 URL 生成規(guī)則即可無縫擴展。





























