醋醋百科网

Good Luck To You!

Spring Boot 动态生成签名 URL:私有文件安全访问方案

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时

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言