1. 简介
在应用系统中,涉及文件访问是极为常见的需求。安全地访问私有文件,通常并非仅依靠身份验证就能实现。有时,应用程序需要为用户或外部系统提供一种短期访问的途径,而无需授予长期有效的凭证,此时签名 URL 便是一种理想的解决方案。
签名 URL 带有数字签名,这一签名能够证明该链接是由受信任的服务器生成的,同时链接中还包含了过期时间。这样的设计组合,既能在保护其他所有内容安全的前提下,实现文件的限时共享。
在技术实现层面,Spring Boot 为开发人员提供了便捷的工具,可用于生成签名 URL,并能通过加密检查和基于时间的验证来强制执行相关规则,确保文件访问的安全性与合规性。
1.1 签名URL机制
签名URL平衡安全与临时访问,将文件链接转为可数学验证形式。它融合资源路径、过期时间,以加密签名锁定。无正确签名,链接无效,适用于私有文件,无需新账户或长密码。
1.2 签名URL结构
签名URL是带额外查询参数的HTTP链接,含过期时间、签名参数,其余似普通路径。这种设计既方便传播,又能让服务器获取足够信息进行验证。如下示例:
https://oss.pack.com/photos/architecture.png?expires=1755990064115&sign=xxxoooaaabbbcccddd
服务端通过对比当前时间与expires值,判断链接是否有效。sign签名字段含加密校验,由服务端生成,访问时校验该签名是未被篡改的。
接下来,我们将安全文件访问功能。
2.实战案例
2.1 基本配置
我们将签名算法,有效期等信息都进行可配置化。
@Component
@ConfigurationProperties(prefix = "pack.app")
public class LinkProperties {
/*密钥*/
private String secretKey ;
/*算法*/
private String algs ;
/*有效期*/
private long lifetimeSeconds ;
/*请求方法(GET,POST,...)*/
private String method ;
/**访问路径URL*/
private String accessPath ;
// getters ,setters
}
配置文件
pack:
app:
algs: HmacSHA256
lifetime-seconds: 1800
method: get
secret-key: aaaabbbbccccdddd
accessPath: /files
2.2 生成签名工具类
@Component
public class SignatureUtil {
private final LinkProperties linkProperties ;
private final byte[] secret ;
public SignatureUtil(LinkProperties linkProperties) {
this.linkProperties = linkProperties ;
this.secret = this.linkProperties.getSecretKey().getBytes(StandardCharsets.UTF_8) ;
}
public String signPath(String method, String path, long expires) throws Exception {
// 组织签名数据(注意使用了 "|" 分隔符,你也可以使用其它分隔符,就是为了区分不同部分数据)
String data = method + "|" + path + "|" + expires;
// 使用带密钥的HASH算法HMAC
final String HMAC = this.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);
}
}
签名数据使用了 "|" 分隔符,其实完全没有必要,这里仅仅是为了区分数据而已实际无意义。
2.3 生成签名URL
@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(this.linkProperties.getLifetimeSeconds())
.toEpochSecond();
String signature = signatureUtil.signPath(this.linkProperties.getMethod(), canonicalPath, expiresAt);
return String.format("/%s%s?expires=%d&sign=%s", this.linkProperties.getAccessPath(),
canonicalPath, expiresAt, signature);
}
}
用于生成带签名和过期时间的文件URL。它通过SignatureUtil对文件路径和过期时间进行签名,返回格式如如下:
/files/ar.png?expires=1756081608&sign=3FwZxHFpxJrPvGyfcGuJiaCH2zkSDoCLzhZhZtkk5qo
2.4 生成签名 & 访问接口
@RestController
@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 ResponseEntity<List<String>> generateLinksForDirectory() throws Exception {
String directoryPath = "d:/images/photos" ;
List<String> links = new ArrayList<>();
Path dirPath = Paths.get(directoryPath);
if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) {
return ResponseEntity.badRequest().body(links);
}
// 遍历目录下的所有文件
Files.list(dirPath).filter(Files::isRegularFile).forEach(file -> {
try {
// 计算相对路径(相对于目录)
String relativePath = dirPath.relativize(file).toString().replace("\\", "/");
links.add(String.format("http://localhost:8080%s",
this.linkService.generateLink(relativePath))) ;
} catch (Exception e) {
// 记录日志或跳过错误文件
e.printStackTrace();
}
});
return ResponseEntity.ok(links);
}
@GetMapping("/{*path}")
public void fetchFile(@PathVariable("path") String path, @RequestParam long expires, @RequestParam String sign,
HttpServletResponse response) throws Exception {
long now = Instant.now().getEpochSecond();
// 1.校验是否过期
if (now >= expires) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "链接已过期");
return;
}
// 2.验证签名
String canonicalPath = path;
String expected = signatureUtil.signPath(this.linkProperties.getMethod(), canonicalPath, expires);
try {
byte[] expectedBytes = Base64.getUrlDecoder().decode(expected);
byte[] providedBytes = Base64.getUrlDecoder().decode(sign);
if (!MessageDigest.isEqual(expectedBytes, providedBytes)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无效链接");
return;
}
} catch (IllegalArgumentException e) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "非法访问: " + e.getMessage());
return;
}
// 3.如果签名验证通过,读取文件并写入响应
try {
Path filePath = Paths.get("d:/images/photos/", path).normalize() ;
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
// 4.设置响应头(Content-Type、Content-Disposition 等)
String contentType = determineContentType(path); // 自定义方法,根据文件扩展名返回 MIME 类型
response.setContentType(contentType);
// 5.将文件内容写入响应输出流
Files.copy(resource.getFile().toPath(), response.getOutputStream());
response.getOutputStream().flush();
} catch (IOException e) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "服务器错误: " + e.getMessage());
}
}
/**根据文件扩展名确定 Content-Type*/
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;
} ;
}
}
访问接口:{*path} 说明:
* 表示贪婪匹配(Greedy Path Variable)
它会捕获 从 / 开始的所有后续路径(包括 / 和子路径),而不仅仅是单个路径段。如下例如:
- 请求 /files/a/b/c.jpg → path 的值是 files/a/b/c.jpg
- 请求 /test → path 的值是 test
关于@PathVariable更多使用,请查看下面这篇文章:
别不信@PathVariable你真不会用
2.5 测试
我们将如下目录文件生成访问URL:
访问:
http://localhost:8080/files
任意访问上面的链接
访问过去的URL时