环境:SpringBoot3.4.2
1. 简介
保护文件下载是后端系统中的常见需求。静态 URL 可能会被用户收藏、被网络爬虫抓取,或者在文件本应不再可用后仍被长期分享。为了避免这种情况,可以使用 Spring Boot 生成短时间内过期或通过令牌来授予访问权限的下载链接。这样既能保护敏感文件,又能根据动态规则决定谁可以下载文件以及下载的有效期。
安全下载链接的工作原理
你肯定不希望将所有文件都存储在静态路径下,也不希望文件因 URL 泄露而处于公开状态。相反,你可以创建有时效性或基于令牌的链接,这些链接会通过你自己的逻辑进行处理。这样一来,每个文件请求都会经过权限检查,由你来决定哪些请求应该被允许。
短期有效的链接可以保护你的文件,并为你提供更大的灵活性。你可以让链接针对特定用户、设定下载次数限制,或者在几分钟后使其过期。
为何应避免使用静态 URL
像 /download/SpringBoot实战案例200讲.pdf 这样的直接文件路径直观简洁可直接使用。你只需将文件放在磁盘上,映射一个静态资源路径,浏览器就可以无需额外操作直接获取文件。但问题是,一旦静态 URL 启用,它将一直保持可访问状态。用户可以分享它,网络爬虫可以抓取它,而且你无法实现 "此文件只能下载十分钟" 或 "只有这个用户可以查看它"。如下示例映射目录:
@Component
public class DownloadConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/download/**")
.addResourceLocations("file:///d:/images/") ;
}
}
如上示例,将本地磁盘目录d:/images内的所有文件及子目录下的文件进行了暴露通过/download前缀就可以直接访问。
如上即便我们针对 /download 访问路径进行了身份验证,如果 URL 始终不变,风险仍然存在。任何拥有正确请求头的用户都可以下载。因此,最好完全避免使用公开的基于文件的路径。可以将用户引导至类似 /download/pack666 的链接,其中 pack666 是一个唯一令牌,仅在短时间内有效。这样可以隐藏文件位置,并且所有下载规则都由你掌控。
接下来,我们将详细的设计一个安全的文件下载功能更。
2.实战案例
2.1 生成令牌
每个下载链接都应绑定一个包含足够信息以验证访问权限的令牌。这通常意味着需要存储文件名、过期时间戳,以及可选的用户或会话信息。
public class DownloadToken {
private final String token;
private final String fileName;
private final Instant expiresAt;
private final String username;
public DownloadToken(String fileName, Duration validFor, String username) {
this.token = UUID.randomUUID().toString();
this.fileName = fileName;
this.expiresAt = Instant.now().plus(validFor);
this.username = username;
}
// getters
}
设计一个存储和获取token的接口。对于小型应用我们可以将token直接存储在内存中。对大型应用或是对性能有要求的可以采用redis外部存储。
public interface TokenStore {
void store(String token, DownloadToken downloadToken) ;
DownloadToken getToken(String token) ;
void removeToken(String token) ;
}
基于内存的实现
public class MemoryTokenStore implements TokenStore {
private final static Map<String, DownloadToken> tokens = new ConcurrentHashMap<>() ;
@Override
public void store(String token, DownloadToken downloadToken) {
tokens.putIfAbsent(token, downloadToken) ;
}
@Override
public DownloadToken getToken(String token) {
return tokens.get(token) ;
}
@Override
public void removeToken(String token) {
tokens.remove(token) ;
}
}
创建下载链接
当用户请求下载时,将会创建一个token并返回一个临时链接:
@RestController
@RequestMapping("/download")
public class DownloadController {
private final TokenStore tokenStore ;
public DownloadController(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
@GetMapping("/request-download")
public ResponseEntity<String> createDownloadLink(@RequestParam String fileName) {
DownloadToken token = new DownloadToken(fileName, Duration.ofMinutes(5), "pack") ;
tokenStore.store(token.getToken(), token) ;
String link = "/download/" + token.getToken();
return ResponseEntity.ok(link);
}
}
先通过 /download/request-downoad 生成下载链接。
2.2 处理下载
当用户点击链接时,你的应用程序会查找令牌、检查其是否过期,如果一切正常,则提供文件。这样可以确保访问的动态性和安全性。只有令牌有效时,才会继续处理请求。
@GetMapping("/{token}")
public ResponseEntity<Resource> download(@PathVariable String token) {
DownloadToken stored = tokenStore.getToken(token) ;
if (stored == null || stored.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
Path filePath = Paths.get("d:/images", stored.getFileName()) ;
Resource file = new FileSystemResource(filePath);
if (!file.exists()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file);
}
上述检查会防止过期或不存在的令牌被使用。只有令牌仍然有效时,才会提供文件。
如果希望链接是一次性的,那么你可以如下操作:
// 用完后直接删除
tokenStore.removeToken(token) ;
2.3 防止路径遍历
文件下载中最常见的错误之一是允许用户输入无限制地驱动文件路径。这可能导致目录遍历攻击,即用户尝试使用 ../ 等序列或注入绝对路径来访问预期目录之外的文件。
如果不对从请求中直接获取的文件名进行清理,并将其附加到基础路径上,这是非常危险的。相反,应谨慎解析路径,并始终检查结果是否位于受信任的位置内。
在上面的生成链接接口中,我们传入 ../123.tx文件名(该文件位于d:/根目录下)
成功下载到了images目录外的文件,这是非常危险的安全漏洞。
以下代码展示了如何验证最终解析的路径是否位于 images 文件夹内:
@GetMapping("/request-download")
public ResponseEntity<String> createDownloadLink(@RequestParam String fileName) {
DownloadToken token = new DownloadToken(fileName, Duration.ofMinutes(5), "pack");
tokenStore.store(token.getToken(), token);
// 验证文件目录
Path baseDir = Paths.get("d:/images").toAbsolutePath().normalize();
Path requestedPath = baseDir.resolve(fileName).normalize();
if (!requestedPath.startsWith(baseDir)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
String link = "/download/" + token.getToken();
return ResponseEntity.ok(link);
}
上面代码中使用了 normalize() 方法可以防止用户偷偷加入目录跳转。如果用户尝试发送 ../../etc/passwd,则生成的路径将位于允许的目录之外,你的检查将捕获它。
如上修改后,我们再次访问 /request-download 接口:
防止了目录跳转问题。
2.4 从内存或外部生成下载文件
并非所有文件都存储在磁盘上。有时,你会实时生成文件、从云存储中提取文件,或者传输本地文件系统中不存在的二进制数据。在这些情况下,你的逻辑变化不大,但提供内容的方式会有所不同。
Spring Boot 提供了多种方式将文件内容作为响应发送。如果你有一个作为字节数组的文件在内存中,可以直接使用下载头返回它:
@GetMapping("/download-mem/{token}")
public ResponseEntity<byte[]> downloadFromMemory(@PathVariable String token) {
DownloadToken downloadToken = tokenStore.getToken(token) ;
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
byte[] content = createFileStream(downloadToken.getFileName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadToken.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(content.length)
.body(content);
}
private byte[] createFileStream(String fileName) {
try {
return StreamUtils.copyToByteArray(new FileInputStream(new File("d:/images/" + fileName))) ;
} catch (Exception e) {
throw new RuntimeException("文件错误") ;
}
}
这种方法在生成 PDF 或 Excel 文件等文档,或从 S3 或 Blob Storage 等服务中提取内容并直接读取到内存中时非常有效。
在处理大文件或流式源时,可以使用 InputStreamResource 来避免一次性将整个文件加载到内存中:
@GetMapping("/download-stream/{token}")
public ResponseEntity<Resource> streamDownload(@PathVariable String token) throws IOException {
DownloadToken downloadToken = tokenStore.getToken(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
InputStream stream = fetchFromCloudStorage(downloadToken.getFileName());
Resource resource = new InputStreamResource(stream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadToken.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
关于大文件的下载,更多知识请查看下面文章:
告别内存溢出!Spring StreamingResponseBody 三大实战案例,性能提升100%
2.5 身份认证
并非所有下载都应对任何拥有有效令牌的用户开放。有时,令牌会与特定用户绑定,需要强制执行这一额外的限制层。否则,用户可能会与他人共享令牌,而下载仍然有效。
如果你的项目中使用了Spring Security 那么你可以使用 @AuthenticationPrincipal 注解访问当前用户。你可以将其与令牌中存储的任何元数据进行比较,如果不匹配,则阻止访问。
@GetMapping("/download-secure/{token}")
public ResponseEntity<Resource> downloadWithUserCheck(@PathVariable String token,
@AuthenticationPrincipal UserDetails currentUser) {
DownloadToken downloadToken = tokenStore.get(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
if (!downloadToken.getUsername().equals(currentUser.getUsername())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Path filePath = Paths.get("files", downloadToken.getFileName()).normalize();
Resource file = new FileSystemResource(filePath);
if (!file.exists()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"")
.body(file);
}
这增加了第二个检查,将下载token与已认证用户绑定。如果用户不匹配,则在访问文件之前拒绝请求。
你可以进一步扩展此功能,将令牌与角色、过期时间或客户端 IP 绑定。