别怕泄露!」Spring Boot 秒生成签名 URL,轻松搞定私有文件安全访问!

签名 URL 的本质,是将 请求方法、资源路径、过期时间 等核心信息组合后,通过 加密签名算法(如 HMAC-SHA256)计算出校验值。 只有在签名校验通过、并且未过期时,才能访问对应的私有文件。

在现代应用系统中,文件访问是几乎绕不开的功能点。无论是用户上传的头像、合同 PDF,还是后台生成的报表文件,系统都需要考虑如何在保证 安全 的前提下,实现 便捷访问。

仅依赖用户身份认证有时并不足够,因为某些场景下,我们需要给外部系统或临时用户开放有限时间的访问权限,而不可能为其建立长期有效的账号和密码。此时,签名 URL(Signed URL)便成为最佳选择。

签名 URL 具备以下两个关键特征:

  1. 带有过期时间:一旦时间到期,链接自动失效,避免长期暴露。
  2. 包含数字签名:只有服务端能生成正确的签名,客户端无法伪造,确保链接可信。

结合 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:加密签名,确保链接未被篡改。

    服务器端会在收到请求时:

    1. 检查当前时间是否超过 expires
    2. 使用同样的算法重新计算签名,和 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;
    • 点击右侧按钮即可直接测试访问。
    测试流程
    1. 在 /opt/data/images 放入若干文件(jpg/png/pdf/txt)。
    2. 启动 Spring Boot 项目。
    3. 浏览器访问:
    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大模型商业化落地方案

      因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

      作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

      评论
      添加红包

      请填写红包祝福语或标题

      红包个数最小为10个

      红包金额最低5元

      当前余额3.43前往充值 >
      需支付:10.00
      成就一亿技术人!
      领取后你会自动成为博主和红包主的粉丝 规则
      hope_wisdom
      发出的红包
      实付
      使用余额支付
      点击重新获取
      扫码支付
      钱包余额 0

      抵扣说明:

      1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
      2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

      余额充值