Spring Boot 中的 S3:安全存储文件和正确权限管理

1. 概述

Amazon S3 是存储发票、头像或附件等文件的常见选择。上传部分通常只需几行代码。难的部分是权限管理:谁能读,谁能写,以及如何在不公开 bucket 的情况下共享文件。

在本文中,你将在 Spring Boot 3.x 中使用 AWS SDK for Java v2 来存储和读取文件。然后,你将通过 IAM policy 和 bucket policy 配置权限,然后使用 presigned URL 来共享有期限的文件。文末有使用 LocalStack 运行的测试。

2. 项目设置

2.1. 依赖项

建议使用 AWS SDK 的 BOM 来保持各个模块版本一致:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>bom</artifactId>
            <version>2.31.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>s3</artifactId>
    </dependency>
</dependencies>

s3 模块已经包含了 S3Client 和 S3Presigner,所以你不需要添加其他模块。

2.2. Bean 配置

在 application.properties 中声明 region 和 bucket 名称:

app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod

接下来,创建 S3Client 和 S3Presigner 两个 bean:

@Configuration
public class S3Config {

    @Bean
    S3Client s3Client(@Value("${app.s3.region}") String region) {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean
    S3Presigner s3Presigner(@Value("${app.s3.region}") String region) {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

上面的代码没有声明 credentials。SDK 将使用 DefaultCredentialsProvider,依次在环境变量、~/.aws/credentials 文件,然后是 EC2/ECS/EKS 的 IAM role 中查找 credentials。在生产环境中,你应该让应用程序使用 IAM role 运行。

注意: 不要在代码或 application.properties 中硬编码 access key。通过 Git 泄露密钥是最常见的 AWS 事故之一。

3. 保存和读取文件

本文中的示例是为客户存储 PDF 发票的服务。每个文件都存储在以 customerId 为前缀的路径下。这种设置密钥的方式可以帮助你更容易地在第 5 部分检查所有权。

@Service
public class InvoiceStorageService {

    private final S3Client s3;
    private final S3Presigner presigner;
    private final String bucket;

    public InvoiceStorageService(S3Client s3, S3Presigner presigner,
                                 @Value("${app.s3.bucket}") String bucket) {
        this.s3 = s3;
        this.presigner = presigner;
        this.bucket = bucket;
    }

    public String upload(long customerId, InputStream content, long size) {
        String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("application/pdf")
                .serverSideEncryption(ServerSideEncryption.AES256)
                .build();

        s3.putObject(request, RequestBody.fromInputStream(content, size));
        return key;
    }

    public byte[] download(String key) {
        return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asByteArray();
    }

    public void delete(String key) {
        s3.deleteObject(b -> b.bucket(bucket).key(key));
    }
}

代码中的主要要点:

  • 密钥的格式为 invoices/42/7f3c...pdf。UUID 部分有助于避免名称冲突。你也不会将用户文件名放入密钥中,这样可以避免奇怪的字符和路径遍历问题。
  • contentType 与对象一起存储。当浏览器通过 presigned URL 下载文件时,它将正确显示 PDF。
  • serverSideEncryption(AES256) 要求 S3 在存储文件时加密(SSE-S3)。目前 S3 默认启用此模式。在 request 中明确指定它仍然很有用,如果 bucket policy 强制要求加密头。
  • RequestBody.fromInputStream 需要事先知道 size。如果接收 MultipartFile,你传入 file.getInputStream() 和 file.getSize()。
  • getObjectAsBytes 将整个文件读入内存。这种方法仅适用于小文件。

注意: 对于大文件(数百 MB),不要使用 getObjectAsBytes。使用 s3.getObject(...) 获取 ResponseInputStream 并直接流式传输到响应。另一种方法是让客户端直接通过 presigned URL 下载(第 5 部分)。

4. S3 的权限模型

只有当请求通过多个检查层时,对 S3 的请求才会被接受:

  1. Block Public Access:账户和 bucket 级别的开关。启用时,任何将权限授予公众的 policy 或 ACL 都会被禁用。
  2. IAM policy:附加到用户或角色,描述该身份可以执行的操作。
  3. Bucket policy:附加到 bucket,描述谁可以对该 bucket 执行什么操作。
  4. ACL:旧机制。新 bucket 默认使用 Object Ownership BucketOwnerEnforced,这意味着 ACL 被禁用。

评估规则很简单:任何地方的显式 Deny 总是会胜出。如果没有 Deny,请求至少需要一个匹配的 Allow。

对大多数应用程序的建议:

  • 在所有四个选项中保持 Block Public Access 启用。
  • 保持 ACL 禁用(BucketOwnerEnforced)。
  • 通过 IAM role 授予应用程序权限。
  • 对于通用约束(例如强制 TLS),使用 bucket policy。

4.1. 最小权限原则的 IAM policy

应用程序的角色只需要读取、写入和删除 invoices/ 前缀下的对象:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvoiceObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::nonla-invoices-prod/invoices/*"
    },
    {
      "Sid": "ListInvoicePrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::nonla-invoices-prod",
      "Condition": {
        "StringLike": { "s3:prefix": "invoices/*" }
      }
    }
  ]
}

请注意两种不同的 ARN 格式:

  • 对象上的操作使用末尾带有 /* 的 ARN。
  • s3:ListBucket 是对 bucket 的操作,所以使用不带 /* 的 ARN。

混淆这两种 ARN 是导致 AccessDenied 错误最常见的原因。还有一个细节:如果缺少 s3:ListBucket,当对象不存在时,S3 将返回 403 而不是 404。这是 S3 的主动机制,有助于防止泄露“这个密钥是否存在”的信息。

4.2. 强制 TLS 的 Bucket policy

Bucket policy 适合设置应用于所有调用者(包括管理员)的规则:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::nonla-invoices-prod",
        "arn:aws:s3:::nonla-invoices-prod/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

此 Statement 是 Deny,所以所有普通 HTTP 请求都会被阻止。没有其他 Allow 可以覆盖它。SDK v2 默认使用 HTTPS,所以你的应用程序不会受到影响。

5. 使用 presigned URL 共享文件

客户需要下载发票,但你不想公开 bucket 或通过服务器流式传输每个文件。Presigned URL 正好解决了这个问题。这是一个使用应用程序凭证签名的 URL,只能在短时间内使用。

向 InvoiceStorageService 添加以下两个方法:

public URL presignDownload(long customerId, String key, Duration ttl) {
    if (!key.startsWith("invoices/%d/".formatted(customerId))) {
        throw new ForbiddenFileAccessException(customerId, key);
    }

    GetObjectPresignRequest request = GetObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .getObjectRequest(b -> b.bucket(bucket).key(key))
            .build();

    return presigner.presignGetObject(request).url();
}

public URL presignUpload(long customerId, Duration ttl) {
    String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

    PutObjectPresignRequest request = PutObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .putObjectRequest(b -> b.bucket(bucket).key(key).contentType("application/pdf"))
            .build();

    return presigner.presignPutObject(request).url();
}

简单的 exception 类:

public class ForbiddenFileAccessException extends RuntimeException {
    public ForbiddenFileAccessException(long customerId, String key) {
        super("Customer %d cannot access %s".formatted(customerId, key));
    }
}

代码的工作方式:

  • startsWith 检查行是 应用程序层的权限管理。S3 只知道你的应用程序有权读取所有 invoices/*。S3 不知道客户 42 不能看客户 43 的发票。如果删除这一行,任何人都可以猜测密钥并下载其他人的文件。
  • signatureDuration 为 URL 设置有效期。对于下载文件,几分钟通常就足够了。
  • 对于 presigned PUT,客户端必须发送正确的 Content-Type: application/pdf 头。否则,签名将不匹配。

生成的 URL 的形式为:

https://nonla-invoices-prod.s3.ap-southeast-1.amazonaws.com/invoices/42/7f3c...pdf
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Date=20261013T080000Z
  &X-Amz-Expires=300
  &X-Amz-Credential=...
  &X-Amz-Signature=...

客户端使用 presigned PUT 直接上传到 S3:

curl -X PUT -H "Content-Type: application/pdf" \
     --upload-file invoice.pdf "<presigned-url>"

注意: Presigned URL 携带签名人的权限。如果应用程序的 IAM role 失去 s3:GetObject 权限,已发出的 URL 也将失效。最大有效期为 7 天。使用 IAM role 的临时凭证签名时,URL 的失效时间会更早,与 role 会话同时结束(通常几小时)。不要将 presigned URL 保存到数据库以供长期使用。保存密钥,每次需要时生成新的 URL。

6. 使用 LocalStack 进行测试

添加 Testcontainers 以在 Docker 中运行模拟的 S3:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.x 已经管理 Testcontainers 的版本,所以你不需要指定 version。

@Testcontainers
class InvoiceStorageServiceTest {

    @Container
    static LocalStackContainer localstack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
                    .withServices(LocalStackContainer.Service.S3);

    static InvoiceStorageService service;

    @BeforeAll
    static void setUp() {
        var credentials = StaticCredentialsProvider.create(
                AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()));
        var region = Region.of(localstack.getRegion());

        S3Client s3 = S3Client.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .forcePathStyle(true)
                .build();

        S3Presigner presigner = S3Presigner.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .build();

        s3.createBucket(b -> b.bucket("test-invoices"));
        service = new InvoiceStorageService(s3, presigner, "test-invoices");
    }

    @Test
    void givenUploadedInvoice_whenDownload_thenContentIsSame() {
        byte[] pdf = "%PDF-1.7 fake invoice".getBytes(StandardCharsets.UTF_8);

        String key = service.upload(42L, new ByteArrayInputStream(pdf), pdf.length);

        assertThat(key).startsWith("invoices/42/").endsWith(".pdf");
        assertThat(service.download(key)).isEqualTo(pdf);
    }

    @Test
    void givenOwnKey_whenPresign_thenUrlHasExpiry() {
        URL url = service.presignDownload(42L, "invoices/42/a.pdf", Duration.ofMinutes(5));

        assertThat(url.getQuery()).contains("X-Amz-Expires=300");
    }

    @Test
    void givenOtherCustomersKey_whenPresign_thenForbidden() {
        assertThatThrownBy(() ->
                service.presignDownload(42L, "invoices/43/b.pdf", Duration.ofMinutes(5)))
                .isInstanceOf(ForbiddenFileAccessException.class);
    }
}

需要注意的几个细节:

  • forcePathStyle(true) 允许以 http://localhost:4566/test-invoices/... 的形式访问 bucket。LocalStack 需要这种形式,因为它无法解析 test-invoices.localhost 这样的子域。
  • 第三个测试从安全角度来说最重要。它确保客户 42 不能获取客户 43 文件的 URL。

LocalStack 免费版不评估 IAM policy。因此,你应该使用 IAM Policy Simulator 或在 staging 环境中验证真实 policy。

7. 常见错误

  • AccessDenied 尽管有 s3:GetObject:ARN 缺少 /*,bucket 名称错误,或 bucket policy Deny 正在阻止。如果 bucket 使用 SSE-KMS,role 还需要对应 KMS 密钥的 kms:Decrypt 权限。
  • 对象不存在时返回 403 而不是 404:role 缺少 s3:ListBucket。这是 S3 的正确行为,不是 bug。
  • presigned PUT 的 SignatureDoesNotMatch:客户端发送的 Content-Type 与签名时不同,或 presigner 配置的 region 与 bucket 的 region 不同。
  • 使用 ACL public-read 开放公共访问:在 BucketOwnerEnforced 下,带有 ACL 的请求会被拒绝。如果确实需要公开内容(例如产品图像),请在 bucket 前面放置 CloudFront 并使用 Origin Access Control。不要禁用 Block Public Access。
  • 为每个请求创建新的 S3Client:client 维护连接池,应该共享使用。按照第 2.2 部分所示,将其声明为 singleton bean。

注意: S3 不是文件系统。你没有“rename”或“append”操作。重命名意味着复制到新密钥然后删除旧密钥。如果需要连续追加到一个文件,S3 不是合适的选择。

8. 结论

在本文中,你使用 Spring Boot 中的 AWS SDK v2 在 S3 上上传、下载和删除文件,密钥按 customerId 设计。你还按多个层次配置了权限:

  • Block Public Access 和 ACL 禁用 以保持 bucket 私密。
  • 为应用程序 role 的 最小权限原则的 IAM policy。
  • 用于通用规则(如强制 TLS)的 Bucket policy。
  • 在发出 presigned URL 前的 应用程序层所有权检查。

最重要的一点是:S3 只知道你的应用程序可以做什么。哪个用户可以查看哪个文件仍然是你编写的代码的责任。这也是为什么你应该有测试覆盖用户访问他人文件的情况。

awss3spring-bootiamjava