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.fromInputStreamcần biết trướcsize. Nếu nhậnMultipartFile, bạn truyềnfile.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ùngs3.getObject(...)để lấyResponseInputStreamvà 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:
- 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.
- IAM policy: gắn vào user hoặc role, mô tả danh tính đó được làm gì.
- Bucket policy: gắn vào bucket, mô tả ai được làm gì trên bucket đó.
- 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:ListBucketlà 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
startsWithlà 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ạnghttp://localhost:4566/test-invoices/.... LocalStack cần dạng này vì nó không phân giải được subdomain kiểutest-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
AccessDenieddù đã cós3:GetObject: ARN thiếu/*, sai tên bucket, hoặc một bucket policyDenyđang chặn. Nếu bucket dùng SSE-KMS, role còn cần quyềnkms:Decrypttrên key KMS tương ứng.403thay vì404khi object không tồn tại: role thiếus3:ListBucket. Đây là hành vi đúng của S3, không phải bug.SignatureDoesNotMatchvới presigned PUT: client gửiContent-Typekhá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ớiBucketOwnerEnforced, 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
S3Clientmớ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.