使用Spring Boot 原始的文件下载功能,告别下载风险!
安全文件下载系统设计与实现
- 系统概述
本文介绍了一种基于Spring Boot的安全文件下载系统,该系统通过动态生成的令牌来保护文件下载,避免了静态URL带来的安全隐患。系统主要特点包括:
动态令牌机制:每个下载链接都有时效性
安全验证:检查令牌有效性、用户权限和文件路径
灵活存储:支持内存存储和外部存储(如Redis)
多种文件来源:支持本地文件、内存文件和流式文件
- 核心组件设计
2.1 DownloadToken类
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;}public boolean isExpired() {return Instant.now().isAfter(expiresAt);}// Getterspublic String getToken() { return token; }public String getFileName() { return fileName; }public Instant getExpiresAt() { return expiresAt; }public String getUsername() { return username; }
}
2.2 TokenStore接口及实现
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<>();@Overridepublic void store(String token, DownloadToken downloadToken) {tokens.putIfAbsent(token, downloadToken);}@Overridepublic DownloadToken getToken(String token) {return tokens.get(token);}@Overridepublic void removeToken(String token) {tokens.remove(token);}
}
- 控制器实现
3.1 生成下载链接
@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) {// 验证文件路径安全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();}DownloadToken token = new DownloadToken(fileName, Duration.ofMinutes(5), "pack");tokenStore.store(token.getToken(), token);String link = "/download/" + token.getToken();return ResponseEntity.ok(link);}
}
3.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 baseDir = Paths.get("d:/images").toAbsolutePath().normalize();Path filePath = baseDir.resolve(stored.getFileName()).normalize();if (!filePath.startsWith(baseDir)) {return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();}Resource file = new FileSystemResource(filePath);if (!file.exists()) {return ResponseEntity.status(HttpStatus.NOT_FOUND).build();}// 一次性令牌可在此处移除// tokenStore.removeToken(token);return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"").contentType(MediaType.APPLICATION_OCTET_STREAM).body(file);
}
- 高级功能实现
4.1 内存文件下载
@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);
}
4.2 流式文件下载
@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);
}
4.3 安全认证下载
@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);
}
- 安全注意事项
路径遍历防护:始终验证最终文件路径是否在允许的目录内
Path baseDir = Paths.get("d:/images").toAbsolutePath().normalize();
Path requestedPath = baseDir.resolve(fileName).normalize();
if (!requestedPath.startsWith(baseDir)) {// 拒绝请求
}
令牌有效期:为每个令牌设置合理的有效期
new DownloadToken(fileName, Duration.ofMinutes(5), "pack");
用户绑定:将下载令牌与特定用户绑定,防止令牌共享
一次性令牌:对于高敏感文件,可使用后立即删除令牌
6. 扩展建议
存储优化:对于生产环境,建议使用Redis等外部存储代替内存存储
下载统计:记录下载次数、用户等信息用于审计
速率限制:防止用户频繁生成下载链接
内容加密:对于高敏感文件,可考虑在传输前加密