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 的请求才会被接受:
- Block Public Access:账户和 bucket 级别的开关。启用时,任何将权限授予公众的 policy 或 ACL 都会被禁用。
- IAM policy:附加到用户或角色,描述该身份可以执行的操作。
- Bucket policy:附加到 bucket,描述谁可以对该 bucket 执行什么操作。
- 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 policyDeny正在阻止。如果 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 只知道你的应用程序可以做什么。哪个用户可以查看哪个文件仍然是你编写的代码的责任。这也是为什么你应该有测试覆盖用户访问他人文件的情况。