S3 trong Spring Boot: lưu file an toàn và phân quyền đúng cách

1. Tổng quan

Amazon S3 là lựa chọn phổ biến để lưu file như hóa đơn, ảnh đại diện hay file đính kèm. Phần upload thường chỉ mất vài dòng code. Phần khó hơn là phân quyền: ai được đọc, ai được ghi, và làm sao chia sẻ file mà không mở bucket ra public.

Trong bài này, bạn sẽ dùng AWS SDK for Java v2 trong Spring Boot 3.x để lưu và đọc file. Sau đó, bạn sẽ cấu hình quyền bằng IAM policy và bucket policy, rồi dùng presigned URL để chia sẻ file có thời hạn. Cuối bài có một bài test chạy với LocalStack.

2. Thiết lập dự án

2.1. Dependency

Bạn nên dùng BOM của AWS SDK để các module luôn cùng phiên bản:

<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>

Module s3 đã có sẵn cả S3Client lẫn S3Presigner, nên bạn không cần thêm module nào khác.

2.2. Cấu hình bean

Khai báo region và tên bucket trong application.properties:

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

Tiếp theo, tạo hai bean S3Client và S3Presigner:

@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();
    }
}

Đoạn code trên không khai báo credentials. SDK sẽ dùng DefaultCredentialsProvider và lần lượt tìm credentials trong biến môi trường, file ~/.aws/credentials, rồi IAM role của EC2/ECS/EKS. Trên môi trường production, bạn nên để ứng dụng chạy bằng IAM role.

Lưu ý: Đừng hard-code access key trong code hay trong application.properties. Key bị lộ qua Git là một trong những sự cố AWS phổ biến nhất.

3. Lưu và đọc file

Ví dụ trong bài là một dịch vụ lưu hóa đơn PDF cho khách hàng. Mỗi file nằm dưới prefix theo customerId. Cách đặt key này giúp bạn kiểm tra quyền sở hữu dễ hơn ở mục 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));
    }
}

Các điểm chính trong đoạn code:

  • Key có dạng invoices/42/7f3c...pdf. Phần UUID giúp tránh trùng tên. Bạn cũng không đưa tên file người dùng vào key, nên tránh được ký tự lạ và path traversal.
  • contentType được lưu cùng object. Khi trình duyệt tải file qua presigned URL, nó sẽ hiển thị PDF đúng cách.
  • serverSideEncryption(AES256) yêu cầu S3 mã hóa file khi lưu (SSE-S3). Hiện nay S3 bật mặc định chế độ này. Ghi rõ trong request vẫn hữu ích nếu bucket policy bắt buộc header mã hóa.
  • RequestBody.fromInputStream cần biết trước size. Nếu nhận MultipartFile, bạn truyền file.getInputStream() và file.getSize().
  • getObjectAsBytes đọc toàn bộ file vào bộ nhớ. Cách này chỉ phù hợp với file nhỏ.

Lưu ý: Với file lớn (hàng trăm MB), đừng dùng getObjectAsBytes. Hãy dùng s3.getObject(...) để lấy ResponseInputStream và stream thẳng ra response. Một cách khác là để client tải trực tiếp qua presigned URL (mục 5).

4. Mô hình phân quyền của S3

Một request tới S3 chỉ được chấp nhận khi qua được nhiều lớp kiểm tra:

  1. Block Public Access: công tắc ở cấp account và bucket. Khi bật, mọi policy hay ACL cấp quyền public đều bị vô hiệu.
  2. IAM policy: gắn vào user hoặc role, mô tả danh tính đó được làm gì.
  3. Bucket policy: gắn vào bucket, mô tả ai được làm gì trên bucket đó.
  4. ACL: cơ chế cũ. Bucket mới mặc định dùng Object Ownership BucketOwnerEnforced, tức là ACL bị tắt.

Quy tắc đánh giá rất đơn giản: một Deny tường minh ở bất kỳ đâu luôn thắng. Nếu không có Deny, request cần ít nhất một Allow phù hợp.

Khuyến nghị cho hầu hết ứng dụng:

  • Giữ Block Public Access bật ở cả bốn tùy chọn.
  • Giữ ACL tắt (BucketOwnerEnforced).
  • Cấp quyền cho ứng dụng qua IAM role.
  • Dùng bucket policy cho các ràng buộc chung, ví dụ bắt buộc TLS.

4.1. IAM policy theo nguyên tắc least privilege

Role của ứng dụng chỉ cần đọc, ghi và xóa object dưới prefix 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/*" }
      }
    }
  ]
}

Hãy để ý hai dạng ARN khác nhau:

  • Thao tác trên object dùng ARN có /* ở cuối.
  • s3:ListBucket là thao tác trên bucket, nên dùng ARN không có /*.

Nhầm hai ARN này là nguyên nhân phổ biến nhất của lỗi AccessDenied. Còn một chi tiết nữa: nếu thiếu s3:ListBucket, khi object không tồn tại S3 sẽ trả 403 thay vì 404. Đây là cơ chế chủ động của S3, giúp không làm lộ thông tin “key này có tồn tại hay không”.

4.2. Bucket policy bắt buộc TLS

Bucket policy phù hợp để đặt các luật áp dụng cho mọi người gọi, kể cả admin:

{
  "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 này là Deny, nên mọi request qua HTTP thường đều bị chặn. Không có Allow nào khác có thể ghi đè nó. SDK v2 mặc định đã dùng HTTPS, nên ứng dụng của bạn không bị ảnh hưởng.

5. Chia sẻ file bằng presigned URL

Khách hàng cần tải hóa đơn, nhưng bạn không muốn mở bucket public hay stream mọi file qua server. Presigned URL giải quyết đúng việc này. Đây là một URL đã được ký bằng credentials của ứng dụng và chỉ dùng được trong một khoảng thời gian ngắn.

Thêm vào InvoiceStorageService hai phương thức sau:

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();
}

Lớp exception đơn giản:

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

Cách hoạt động của đoạn code:

  • Dòng kiểm tra startsWith là lớp phân quyền ở tầng ứng dụng. S3 chỉ biết ứng dụng của bạn có quyền đọc toàn bộ invoices/*. S3 không biết khách hàng 42 không được xem hóa đơn của khách hàng 43. Nếu bỏ dòng này, ai đoán được key là tải được file của người khác.
  • signatureDuration đặt thời hạn cho URL. Với file tải về, vài phút thường là đủ.
  • Với presigned PUT, client phải gửi đúng header Content-Type: application/pdf. Nếu không, chữ ký sẽ không khớp.

URL sinh ra có dạng:

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=...

Client upload trực tiếp lên S3 bằng presigned PUT:

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

Lưu ý: Presigned URL mang quyền của người ký. Nếu IAM role của ứng dụng mất quyền s3:GetObject, URL đã phát ra cũng hết hiệu lực. Thời hạn tối đa là 7 ngày. Khi ký bằng credentials tạm thời của IAM role, URL còn hết hạn sớm hơn, cùng lúc với session của role (thường vài giờ). Đừng lưu presigned URL vào database để dùng lâu dài. Hãy lưu key và sinh URL mới mỗi lần cần.

6. Kiểm thử với LocalStack

Thêm Testcontainers để chạy một S3 giả lập trong Docker:

<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 đã quản lý phiên bản Testcontainers, nên bạn không cần ghi 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);
    }
}

Một vài chi tiết đáng chú ý:

  • forcePathStyle(true) cho phép truy cập bucket theo dạng http://localhost:4566/test-invoices/.... LocalStack cần dạng này vì nó không phân giải được subdomain kiểu test-invoices.localhost.
  • Test thứ ba quan trọng nhất về mặt bảo mật. Nó đảm bảo khách hàng 42 không thể xin URL cho file của khách hàng 43.

LocalStack không đánh giá IAM policy ở bản miễn phí. Vì vậy, bạn nên kiểm tra policy thật bằng IAM Policy Simulator hoặc trên một môi trường staging.

7. Lỗi hay gặp

  • AccessDenied dù đã có s3:GetObject: ARN thiếu /*, sai tên bucket, hoặc một bucket policy Deny đang chặn. Nếu bucket dùng SSE-KMS, role còn cần quyền kms:Decrypt trên key KMS tương ứng.
  • 403 thay vì 404 khi object không tồn tại: role thiếu s3:ListBucket. Đây là hành vi đúng của S3, không phải bug.
  • SignatureDoesNotMatch với presigned PUT: client gửi Content-Type khác với lúc ký, hoặc presigner được cấu hình region khác với region của bucket.
  • Mở public bằng ACL public-read: với BucketOwnerEnforced, request có ACL sẽ bị từ chối. Nếu thật sự cần nội dung public, ví dụ ảnh sản phẩm, hãy đặt CloudFront phía trước bucket và dùng Origin Access Control. Đừng tắt Block Public Access.
  • Tạo S3Client mới cho mỗi request: client giữ connection pool và nên dùng chung. Hãy khai báo nó là singleton bean như ở mục 2.2.

Lưu ý: S3 không phải file system. Bạn không có thao tác “rename” hay “append”. Đổi tên nghĩa là copy sang key mới rồi xóa key cũ. Nếu cần ghi nối liên tục vào một file, S3 không phải lựa chọn phù hợp.

8. Kết luận

Trong bài này, bạn đã dùng AWS SDK v2 trong Spring Boot để upload, download và xóa file trên S3, với key được thiết kế theo customerId. Bạn cũng đã cấu hình quyền theo nhiều lớp:

  • Block Public Access và ACL tắt để bucket luôn private.
  • IAM policy least privilege cho role của ứng dụng.
  • Bucket policy cho các luật chung như bắt buộc TLS.
  • Kiểm tra quyền sở hữu ở tầng ứng dụng trước khi phát presigned URL.

Điểm cần nhớ nhất: S3 chỉ biết ứng dụng của bạn được làm gì. Việc người dùng nào được xem file nào vẫn là trách nhiệm của code bạn viết. Đó cũng là lý do bạn nên có test bao phủ trường hợp người dùng truy cập file của người khác.

awss3spring-bootiamjava