签名 URL 的本质,是将 请求方法、资源路径、过期时间 等核心信息组合后,通过 加密签名算法(如 HMAC-SHA256)计算出校验值。 只有在签名校验通过、并且未过期时,才能访问对应的私有文件。
在现代应用系统中,文件访问是几乎绕不开的功能点。无论是用户上传的头像、合同 PDF,还是后台生成的报表文件,系统都需要考虑如何在保证 安全 的前提下,实现 便捷访问。
仅依赖用户身份认证有时并不足够,因为某些场景下,我们需要给外部系统或临时用户开放有限时间的访问权限,而不可能为其建立长期有效的账号和密码。此时,签名 URL(Signed URL)便成为最佳选择。
签名 URL 具备以下两个关键特征:
- 带有过期时间:一旦时间到期,链接自动失效,避免长期暴露。
- 包含数字签名:只有服务端能生成正确的签名,客户端无法伪造,确保链接可信。
结合 Spring Boot 提供的灵活配置与加密工具,我们可以非常高效地实现这一机制。本文将带你逐步完成从配置、签名生成、文件验证到前端测试页面的完整流程。
签名 URL 基础机制
签名 URL 的设计思路
签名 URL 的本质,是将 请求方法、资源路径、过期时间 等核心信息组合后,通过 加密签名算法(如 HMAC-SHA256)计算出校验值。 只有在签名校验通过、并且未过期时,才能访问对应的私有文件。
这种设计有两个显著优点:
- 无需额外账号体系:直接通过 URL 控制访问权限。
- 轻量安全:过期时间 + 签名双重保护,有效防止链接被篡改或长期传播。
签名 URL 的结构
签名 URL 的样子和普通 HTTP 链接差不多,只是附带了额外参数:
https://oss.example.com/photos/architecture.png?expires=1755990064&sign=sefxxfx
关键参数解释:
expires:Unix 时间戳,表示链接过期时间。sign:加密签名,确保链接未被篡改。
服务器端会在收到请求时:
- 检查当前时间是否超过
expires; - 使用同样的算法重新计算签名,和
sign对比。
Spring Boot 实战
下面我们基于 Spring Boot 来实现签名 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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" 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; - 点击右侧按钮即可直接测试访问。
测试流程
- 在
/opt/data/images放入若干文件(jpg/png/pdf/txt)。 - 启动 Spring Boot 项目。
- 浏览器访问:
http://localhost:8080/files
页面会展示签名 URL 列表,点击即可验证访问是否成功。
结论
通过本文完整实战,我们实现了 签名 URL 的后端生成 + 前端预览:
- 后端负责安全计算签名、校验过期时间,保证文件访问的合规性;
- 前端通过 Thymeleaf + Bootstrap 渲染文件列表,用户可以一键点击测试。
这种方式既简洁又高效,尤其适合需要 临时文件分享 的业务场景,比如:
- 生成临时下载地址
- 限时访问合同、账单、报表
- 文件分享的安全保护
未来如果你要在生产环境结合 OSS/S3/CDN,只需要替换文件存储目录和 URL 生成规则即可无缝扩展。
AI大模型学习福利
作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。
一、全套AGI大模型学习路线
AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取
二、640套AI大模型报告合集
这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
三、AI大模型经典PDF籍
随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
四、AI大模型商业化落地方案

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量
3万+

被折叠的 条评论
为什么被折叠?



