S3 dans Spring Boot : sauvegarder les fichiers en sécurité
1. Aperçu
Amazon S3 est un choix populaire pour stocker des fichiers tels que les factures, les images de profil ou les pièces jointes. La partie upload ne demande généralement que quelques lignes de code. La partie plus difficile est la gestion des permissions : qui peut lire, qui peut écrire, et comment partager des fichiers sans rendre le bucket public.
Dans cet article, vous utiliserez AWS SDK for Java v2 dans Spring Boot 3.x pour sauvegarder et lire des fichiers. Ensuite, vous configurerez les permissions avec les policies IAM et bucket, puis utiliserez des URL présignées pour partager des fichiers avec une durée d’expiration. La fin de l’article contient un test qui s’exécute avec LocalStack.
2. Configuration du projet
2.1. Dépendances
Vous devriez utiliser le BOM d’AWS SDK pour que les modules aient toujours la même version :
<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>
Le module s3 contient déjà S3Client et S3Presigner, donc vous n’avez besoin d’ajouter aucun autre module.
2.2. Configuration du bean
Déclarez la région et le nom du bucket dans application.properties :
app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod
Ensuite, créez deux beans S3Client et 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();
}
}
Le code ci-dessus ne déclare pas de credentials. Le SDK utilisera DefaultCredentialsProvider et cherchera successivement les credentials dans les variables d’environnement, le fichier ~/.aws/credentials, puis le rôle IAM de EC2/ECS/EKS. En environnement de production, vous devriez laisser l’application s’exécuter avec un rôle IAM.
Note : Ne mettez pas en dur les clés d’accès dans le code ou dans
application.properties. Les clés exposées via Git sont l’une des erreurs AWS les plus courantes.
3. Sauvegarder et lire des fichiers
L’exemple dans cet article est un service qui sauvegarde les factures PDF pour les clients. Chaque fichier se trouve sous un préfixe basé sur customerId. Cette façon de définir la clé vous aide à vérifier la propriété plus facilement à la section 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));
}
}
Points clés dans le code :
- La clé a la forme
invoices/42/7f3c...pdf. La partie UUID aide à éviter les doublons. Vous ne mettez pas non plus le nom de fichier de l’utilisateur dans la clé, ce qui évite les caractères étranges et la traversée de répertoires. contentTypeest sauvegardé avec l’objet. Quand le navigateur télécharge le fichier via une URL présignée, il affichera le PDF correctement.serverSideEncryption(AES256)demande à S3 de chiffrer le fichier lors de la sauvegarde (SSE-S3). S3 active ce mode par défaut aujourd’hui. L’indiquer explicitement dans la requête est toujours utile si la policy du bucket impose un en-tête de chiffrement.RequestBody.fromInputStreama besoin de connaîtresizeà l’avance. Si vous recevezMultipartFile, vous passezfile.getInputStream()etfile.getSize().getObjectAsByteslit le fichier entier en mémoire. Cette approche ne convient qu’aux petits fichiers.
Note : Pour les gros fichiers (des centaines de Mo), n’utilisez pas
getObjectAsBytes. Utilisez plutôts3.getObject(...)pour obtenirResponseInputStreamet la transmettre directement en réponse. Une autre approche consiste à laisser le client télécharger directement via une URL présignée (section 5).
4. Modèle de permissions de S3
Une requête vers S3 n’est acceptée que si elle passe plusieurs couches de contrôle :
- Block Public Access : un interrupteur au niveau du compte et du bucket. Quand il est activé, toute policy ou ACL accordant des permissions publiques est neutralisée.
- IAM policy : attachée à un utilisateur ou rôle, décrivant ce que cette identité peut faire.
- Bucket policy : attachée au bucket, décrivant qui peut faire quoi sur ce bucket.
- ACL : ancien mécanisme. Les nouveaux buckets utilisent par défaut Object Ownership
BucketOwnerEnforced, ce qui signifie que l’ACL est désactivée.
La règle d’évaluation est très simple : un Deny explicite n’importe où gagne toujours. S’il n’y a pas de Deny, la requête a besoin d’au moins un Allow correspondant.
Recommandations pour la plupart des applications :
- Gardez Block Public Access activé sur les quatre options.
- Gardez ACL désactivée (
BucketOwnerEnforced). - Accordez les permissions à l’application via un rôle IAM.
- Utilisez bucket policy pour les contraintes générales, comme forcer TLS.
4.1. IAM policy selon le principe du moindre privilège
Le rôle de l’application n’a besoin que de lire, écrire et supprimer des objets sous le préfixe 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/*" }
}
}
]
}
Faites attention aux deux formes d’ARN différentes :
- Les opérations sur les objets utilisent un ARN avec
/*à la fin. s3:ListBucketest une opération sur le bucket, donc utilisez un ARN sans/*.
Confondre ces deux ARN est la cause la plus courante des erreurs AccessDenied. Un autre détail : si s3:ListBucket est manquant, quand un objet n’existe pas, S3 retournera 403 au lieu de 404. C’est un mécanisme intentionnel de S3 pour ne pas révéler si “cette clé existe ou non”.
4.2. Bucket policy imposant TLS
La bucket policy est appropriée pour établir des règles qui s’appliquent à tous les appelants, y compris les administrateurs :
{
"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" }
}
}
]
}
Cette statement est un Deny, donc toutes les requêtes en HTTP ordinaire sont bloquées. Aucun autre Allow ne peut l’annuler. Le SDK v2 utilise HTTPS par défaut, donc votre application n’est pas affectée.
5. Partager des fichiers via des URL présignées
Le client a besoin de télécharger la facture, mais vous ne voulez pas ouvrir le bucket au public ni streamer tous les fichiers via le serveur. Les URL présignées résolvront exactement ce problème. C’est une URL signée avec les credentials de l’application et ne peut être utilisée que pendant une courte période.
Ajoutez à InvoiceStorageService les deux méthodes suivantes :
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();
}
La classe d’exception simple :
public class ForbiddenFileAccessException extends RuntimeException {
public ForbiddenFileAccessException(long customerId, String key) {
super("Customer %d cannot access %s".formatted(customerId, key));
}
}
Voici comment le code fonctionne :
- La ligne de vérification
startsWithest une autorisation au niveau applicatif. S3 sait seulement que votre application a le droit de lire l’ensembleinvoices/*. S3 ne sait pas que le client 42 ne peut pas voir les factures du client 43. Si vous omettez cette ligne, quiconque peut deviner la clé et télécharger le fichier d’un autre. signatureDurationdéfinit la durée d’expiration de l’URL. Pour les téléchargements de fichiers, quelques minutes suffisent généralement.- Avec presigned PUT, le client doit envoyer l’en-tête correct
Content-Type: application/pdf. Sinon, la signature ne correspondra pas.
L’URL générée a la forme :
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=...
Le client upload directement vers S3 via presigned PUT :
curl -X PUT -H "Content-Type: application/pdf" \
--upload-file invoice.pdf "<presigned-url>"
Note : Les URL présignées portent les permissions de celui qui les signe. Si le rôle IAM de l’application perd l’autorisation
s3:GetObject, l’URL déjà émise devient inutilisable. La durée maximale est de 7 jours. Quand vous signez avec les credentials temporaires d’un rôle IAM, l’URL expire plus tôt, en même temps que la session du rôle (généralement quelques heures). Ne sauvegardez pas les URL présignées dans la base de données pour une utilisation à long terme. Sauvegardez la clé et générez une nouvelle URL à chaque fois.
6. Tests avec LocalStack
Ajoutez Testcontainers pour exécuter un S3 simulé dans 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 gère déjà la version de Testcontainers, donc vous n’avez pas besoin de spécifier 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);
}
}
Quelques détails à noter :
forcePathStyle(true)permet d’accéder au bucket sous la formehttp://localhost:4566/test-invoices/.... LocalStack a besoin de cela car il ne peut pas résoudre les sous-domaines du typetest-invoices.localhost.- Le troisième test est le plus important du point de vue sécurité. Il s’assure que le client 42 ne peut pas obtenir une URL pour le fichier du client 43.
LocalStack n’évalue pas les policies IAM dans la version gratuite. Par conséquent, vous devriez tester les vraies policies avec l’IAM Policy Simulator ou dans un environnement de staging.
7. Erreurs courantes
AccessDeniedmalgré la présence des3:GetObject: ARN manquant/*, nom de bucket incorrect, ou une bucket policyDenybloque. Si le bucket utilise SSE-KMS, le rôle a également besoin de la permissionkms:Decryptsur la clé KMS correspondante.403au lieu de404quand l’objet n’existe pas : le rôle manques3:ListBucket. C’est le comportement correct de S3, pas un bug.SignatureDoesNotMatchavec presigned PUT : le client envoie unContent-Typedifférent de celui utilisé lors de la signature, ou le presigner est configuré avec une région différente de celle du bucket.- Rendre public avec l’ACL
public-read: avecBucketOwnerEnforced, les requêtes avec ACL sont rejetées. Si vous avez vraiment besoin de contenu public, par exemple des images de produits, placez CloudFront devant le bucket et utilisez Origin Access Control. Ne désactivez pas Block Public Access. - Créer un nouveau
S3Clientpour chaque requête : le client gère un pool de connexions et devrait être réutilisé. Déclarez-le comme un bean singleton comme à la section 2.2.
Note : S3 n’est pas un système de fichiers. Vous n’avez pas d’opération “rename” ou “append”. Renommer signifie copier vers une nouvelle clé puis supprimer l’ancienne. Si vous avez besoin d’écrire en continu dans un fichier, S3 n’est pas le bon choix.
8. Conclusion
Dans cet article, vous avez utilisé AWS SDK v2 dans Spring Boot pour uploader, télécharger et supprimer des fichiers sur S3, avec des clés structurées selon customerId. Vous avez également configuré les permissions sur plusieurs niveaux :
- Block Public Access et ACL désactivée pour que le bucket reste privé.
- IAM policy du moindre privilège pour le rôle de l’application.
- Bucket policy pour les règles générales comme forcer TLS.
- Vérification de la propriété au niveau applicatif avant de délivrer les URL présignées.
Point à retenir : S3 sait seulement ce que votre application peut faire. C’est votre code qui détermine quel utilisateur peut accéder à quel fichier. C’est pourquoi vous devriez avoir des tests qui couvrent le cas où un utilisateur tente d’accéder au fichier d’un autre.