S3 in Spring Boot: Dateien sicher speichern und richtig berechtigen
1. Übersicht
Amazon S3 ist eine beliebte Wahl zum Speichern von Dateien wie Rechnungen, Profilfotos oder Anhänge. Das Hochladen dauert normalerweise nur ein paar Zeilen Code. Der schwierigere Teil ist die Berechtigung: Wer darf lesen, wer darf schreiben, und wie teilt man Dateien, ohne den Bucket öffentlich zu machen?
In diesem Artikel werden Sie AWS SDK for Java v2 in Spring Boot 3.x verwenden, um Dateien zu speichern und zu lesen. Anschließend konfigurieren Sie die Berechtigungen mit IAM Policy und Bucket Policy und verwenden dann presigned URLs zum zeitlich begrenzten Teilen von Dateien. Am Ende des Artikels finden Sie einen Test, der mit LocalStack ausgeführt wird.
2. Projektsetup
2.1. Dependencies
Sie sollten die BOM des AWS SDK verwenden, um sicherzustellen, dass die Module immer die gleiche Version haben:
<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>
Das Modul s3 enthält bereits sowohl S3Client als auch S3Presigner, daher benötigen Sie keine zusätzlichen Module.
2.2. Bean-Konfiguration
Deklarieren Sie Region und Bucket-Namen in application.properties:
app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod
Erstellen Sie anschließend zwei Beans S3Client und 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();
}
}
Der obige Code deklariert keine Credentials. Das SDK verwendet DefaultCredentialsProvider und sucht nacheinander nach Credentials in Umgebungsvariablen, in der Datei ~/.aws/credentials und dann in der IAM-Rolle von EC2/ECS/EKS. In der Produktionsumgebung sollten Sie die Anwendung mit einer IAM-Rolle ausführen.
Hinweis: Hardcodieren Sie keinen Access Key in den Code oder in
application.properties. Ein über Git geleckter Key ist eines der häufigsten AWS-Probleme.
3. Dateien speichern und laden
Das Beispiel in diesem Artikel ist ein Service zum Speichern von PDF-Rechnungen für Kunden. Jede Datei liegt unter einem Präfix basierend auf customerId. Diese Art der Schlüsselbenennung erleichtert die Überprüfung des Eigentums in Abschnitt 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));
}
}
Die wichtigsten Punkte im Code:
- Der Schlüssel hat die Form
invoices/42/7f3c...pdf. Der UUID-Teil vermeidet Namenskollisionen. Sie verschieben auch nicht den Dateinamen des Benutzers in den Schlüssel, um merkwürdige Zeichen und Path-Traversal zu vermeiden. contentTypewird zusammen mit dem Object gespeichert. Wenn der Browser die Datei über eine vorsignierte URL herunterlädt, wird das PDF korrekt angezeigt.serverSideEncryption(AES256)fordert S3 auf, die Datei beim Speichern zu verschlüsseln (SSE-S3). S3 hat diesen Modus standardmäßig aktiviert. Es ist dennoch sinnvoll, es explizit in der Anfrage anzugeben, wenn die Bucket-Policy einen Verschlüsselungs-Header erzwingt.RequestBody.fromInputStreammuss die Größesizevorher kennen. Wenn SieMultipartFileerhalten, übergeben Siefile.getInputStream()undfile.getSize().getObjectAsBytesliest die gesamte Datei in den Speicher. Dieses Verfahren ist nur für kleine Dateien geeignet.
Hinweis: Bei großen Dateien (Hunderte MB) verwenden Sie nicht
getObjectAsBytes. Verwenden Sie stattdessens3.getObject(...), umResponseInputStreamzu erhalten und direkt in die Response zu streamen. Eine Alternative ist, den Client direkt über eine vorsignierte URL zu laden (Abschnitt 5).
4. S3-Berechtigungsmodell
Ein Request an S3 wird nur akzeptiert, wenn er mehrere Kontrollebenen passiert:
- Block Public Access: Schalter auf Account- und Bucket-Ebene. Wenn aktiviert, werden alle Policies und ACLs, die öffentliche Berechtigungen gewähren, unwirksam.
- IAM Policy: an Benutzer oder Rolle angehängt, beschreibt, was diese Identität tun darf.
- Bucket Policy: an den Bucket angehängt, beschreibt, wer was mit dem Bucket tun darf.
- ACL: älterer Mechanismus. Neue Buckets verwenden standardmäßig Object Ownership
BucketOwnerEnforced, d. h. ACLs sind deaktiviert.
Die Evaluierungsregel ist sehr einfach: ein explizites Deny an beliebiger Stelle gewinnt immer. Wenn es kein Deny gibt, benötigt der Request mindestens ein passendes Allow.
Empfehlungen für die meisten Anwendungen:
- Halten Sie Block Public Access in allen vier Optionen aktiviert.
- Halten Sie ACLs deaktiviert (
BucketOwnerEnforced). - Gewähren Sie der Anwendung Berechtigungen über IAM Role.
- Verwenden Sie Bucket Policy für allgemeine Beschränkungen, z. B. erzwungenes TLS.
4.1. IAM Policy nach dem Prinzip der minimalen Berechtigung
Die Role der Anwendung muss nur Objects unter dem Präfix invoices/ lesen, schreiben und löschen:
{
"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/*" }
}
}
]
}
Beachten Sie die zwei verschiedenen ARN-Formate:
- Operationen auf Objects verwenden ARNs mit
/*am Ende. s3:ListBucketist eine Operation auf dem Bucket und sollte daher einen ARN ohne/*verwenden.
Das Verwechseln dieser beiden ARN-Formate ist die häufigste Ursache für AccessDenied-Fehler. Ein weiteres Detail: Fehlt s3:ListBucket, gibt S3 403 zurück, wenn das Object nicht existiert, anstatt 404. Dies ist ein bewusstes S3-Mechanismus, um nicht offenzulegen, ob ein Schlüssel existiert.
4.2. Bucket Policy zur erzwungenen Nutzung von TLS
Bucket Policies sind gut geeignet, um Regeln festzulegen, die für alle Aufrufer gelten, einschließlich Administratoren:
{
"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" }
}
}
]
}
Diese Statement ist ein Deny, daher werden alle HTTP-Requests normalerweise blockiert. Keine andere Allow kann sie überschreiben. SDK v2 verwendet standardmäßig bereits HTTPS, daher ist Ihre Anwendung nicht betroffen.
5. Dateien mit vorsignierter URL teilen
Kunden müssen Rechnungen herunterladen, aber Sie möchten den Bucket nicht öffentlich machen oder alle Dateien durch den Server streamen. Vorsignierte URLs lösen genau dieses Problem. Dies ist eine URL, die mit den Credentials der Anwendung signiert wurde und nur in einem kurzen Zeitfenster verwendet werden kann.
Fügen Sie zu InvoiceStorageService die folgenden zwei Methoden hinzu:
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();
}
Eine einfache Exception-Klasse:
public class ForbiddenFileAccessException extends RuntimeException {
public ForbiddenFileAccessException(long customerId, String key) {
super("Customer %d cannot access %s".formatted(customerId, key));
}
}
So funktioniert der Code:
- Die
startsWith-Überprüfung ist die Berechtigung auf Anwendungsebene. S3 kennt nur, dass Ihre Anwendung alleinvoices/*lesen darf. S3 weiß nicht, dass Kunde 42 die Rechnungen von Kunde 43 nicht sehen darf. Wenn Sie diese Zeile weglassen, kann jeder, der den Schlüssel errät, die Datei eines anderen abrufen. signatureDurationsetzt die Gültigkeitsdauer der URL. Für heruntergeladene Dateien sind normalerweise ein paar Minuten ausreichend.- Bei vorsigniertem PUT muss der Client den korrekten Header
Content-Type: application/pdfsenden. Andernfalls stimmt die Signatur nicht überein.
Die generierte URL hat die Form:
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=...
Der Client lädt direkt zu S3 mit vorsigniertem PUT hoch:
curl -X PUT -H "Content-Type: application/pdf" \
--upload-file invoice.pdf "<presigned-url>"
Hinweis: Vorsignierte URLs tragen die Berechtigungen des Unterzeichners. Wenn die IAM-Rolle der Anwendung die
s3:GetObject-Berechtigung verliert, werden auch die bereits ausgegebenen URLs ungültig. Die maximale Gültigkeitsdauer beträgt 7 Tage. Wenn Sie mit temporären Credentials einer IAM-Rolle signieren, expires die URL sogar früher, zusammen mit der Session der Rolle (normalerweise ein paar Stunden). Speichern Sie vorsignierte URLs nicht langfristig in der Datenbank. Speichern Sie stattdessen den Schlüssel und generieren Sie jedes Mal eine neue URL, wenn Sie sie benötigen.
6. Test mit LocalStack
Fügen Sie Testcontainers hinzu, um einen S3-Emulator in Docker auszuführen:
<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 verwaltet bereits die Version von Testcontainers, daher müssen Sie version nicht angeben.
@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);
}
}
Ein paar beachtenswerte Details:
forcePathStyle(true)ermöglicht den Zugriff auf den Bucket in der Formhttp://localhost:4566/test-invoices/.... LocalStack benötigt dieses Format, da es Subdomains wietest-invoices.localhostnicht auflösen kann.- Der dritte Test ist der wichtigste aus Sicherheitssicht. Er stellt sicher, dass Kunde 42 keine URL für Dateien von Kunde 43 anfordern kann.
LocalStack bewertet IAM-Policies in der kostenlosen Version nicht. Daher sollten Sie Ihre Policies mit dem IAM Policy Simulator oder in einer Staging-Umgebung überprüfen.
7. Häufige Fehler
AccessDeniedtrotzs3:GetObject: Der ARN fehlt/*, der Bucket-Name ist falsch, oder eine Bucket-PolicyDenyblockiert. Wenn der Bucket SSE-KMS verwendet, benötigt die Rolle auch die Berechtigungkms:Decryptfür den entsprechenden KMS-Schlüssel.403anstelle von404, wenn das Object nicht existiert: Die Rolle fehlts3:ListBucket. Dies ist korrektes S3-Verhalten, kein Bug.SignatureDoesNotMatchbei vorsigniertem PUT: Der Client sendet einen anderenContent-Typeals zum Zeitpunkt der Signatur, oder der Presigner ist mit einer anderen Region konfiguriert als das Bucket.- Öffentlich machen mit ACL
public-read: MitBucketOwnerEnforcedwerden Requests mit ACLs abgelehnt. Wenn Sie wirklich öffentliche Inhalte benötigen, z. B. Produktfotos, stellen Sie CloudFront vor den Bucket und verwenden Sie Origin Access Control. Deaktivieren Sie nicht Block Public Access. - Für jeden Request einen neuen
S3Clienterstellen: Der Client unterhält einen Connection Pool und sollte gemeinsam genutzt werden. Deklarieren Sie ihn als Singleton-Bean wie in Abschnitt 2.2.
Hinweis: S3 ist kein Dateisystem. Es gibt keine Operationen zum „Umbenennen” oder „Anhängen”. Umbenennen bedeutet, auf einen neuen Schlüssel zu kopieren und den alten zu löschen. Wenn Sie fortlaufend an eine Datei anhängen müssen, ist S3 nicht die richtige Wahl.
8. Fazit
In diesem Artikel haben Sie AWS SDK v2 in Spring Boot verwendet, um Dateien zu S3 hochzuladen, herunterzuladen und zu löschen, mit Schlüsseln, die nach customerId strukturiert sind. Sie haben auch Berechtigungen auf mehreren Ebenen konfiguriert:
- Block Public Access und deaktivierte ACLs, um den Bucket immer privat zu halten.
- IAM Policy mit minimalen Berechtigungen für die Anwendungsrolle.
- Bucket Policy für allgemeine Regeln wie erzwungenes TLS.
- Überprüfung der Berechtigung auf Anwendungsebene vor der Ausstellung vorsignierter URLs.
Der wichtigste Punkt: S3 kennt nur, was Ihre Anwendung tun darf. Welcher Benutzer welche Dateien sehen darf, liegt in der Verantwortung Ihres Codes. Deshalb sollten Sie Tests haben, die den Fall abdecken, dass ein Benutzer auf Dateien eines anderen zugreift.