4
votes

I want to store user profile pictures in an S3 bucket, but keep these images private. In order to do this, I am creating a presigned url whenever the image is required. However, this creates a unique url each time which means the image will never be cached by the browser and I'll end up paying a lot more in GET requests.

Here's an example of my code to generate the url, I'm using Laravel:

$s3 = \Storage::disk('s3');
$client = $s3->getDriver()->getAdapter()->getClient();
$expiry = new \DateTime('2017-07-25');

$command = $client->getCommand('GetObject', [
    'Bucket' => \Config::get('filesystems.disks.s3.bucket'),
    'Key'    => $key
]);

$request = $client->createPresignedRequest($command, $expiry);

return (string) $request->getUri();

I thought that by specifying a datetime rather a unit of time that it would create the same url but it actually adds the number of seconds remaining to the url, here's an example:

xxxx.s3.eu-west-2.amazonaws.com/profile-pics/92323.png?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AXXXXXXXXXXX%2Feu-west-2%2Fs3%2Faws4_request&X-Amz-Date=20170720T112123Z&X-Amz-SignedHeaders=host&X-Amz-Expires=391117&X-Amz-Signature=XXXXXXXXX

Is it possible to generate a repeatable presigned request url so that an image may be cached by the users browser?

4

4 Answers

1
votes

Maybe a late reply, but I'll add my approach for the benefit of people reading this in future.

To force the browser cache to kick in, it's important to generate same exact url every time until you specifically want the browser to reload content from the server. Unfortunately the presigner provided in the sdk, relies on current timestamp leading to a new url every time.

This example is in Java but it can easily be extended to other languages

The GetObjectRequest builder(used to create the presigned url) allows overriding configuration. We can supply a custom signer to modify its behaviour

AwsRequestOverrideConfiguration.builder()
    .signer(new CustomAwsS3V4Signer())
    .credentialsProvider(<You may need to provide a custom credential provider 
here>)))
.build())

GetObjectRequest getObjectRequest =
    GetObjectRequest.builder()
            .bucket(getUserBucket())
            .key(key)
            .responseCacheControl("max-age="+(TimeUnit.DAYS.toSeconds(7)+ defaultIfNull(version,0L)))
            .overrideConfiguration(overrideConfig)
            .build();

public class CustomAwsS3V4Signer implements Presigner, Signer
{
    private final AwsS3V4Signer awsSigner;

    public CustomAwsS3V4Signer()
    {
        awsSigner = AwsS3V4Signer.create();
    }

@Override
public SdkHttpFullRequest presign(SdkHttpFullRequest request, ExecutionAttributes executionAttributes)
{
    Instant baselineInstant = Instant.now().truncatedTo(ChronoUnit.DAYS);

    executionAttributes.putAttribute(AwsSignerExecutionAttribute.PRESIGNER_EXPIRATION,
            baselineInstant.plus(3, ChronoUnit.DAYS));

Here we override the signing clock to simulate a fixed time which ultimately results in consistent expiry and signature in the url until a certain date in future:

    Aws4PresignerParams.Builder builder = Aws4PresignerParams.builder()
            .signingClockOverride(Clock.fixed(baselineInstant, ZoneId.of("UTC")));

    Aws4PresignerParams signingParams =
            extractPresignerParams(builder, executionAttributes).build();

    return awsSigner.presign(request, signingParams);
    }
}

More details are available here:

https://murf.ai/resources/creating-cache-friendly-presigned-s3-urls-using-v4signer-q1bbqgk

0
votes

Rather than using the presigned URL mechanism perhaps you could add an authenticated endpoint to your application and within said endpoint retrieve the image? Using this URL in your img tags and such. This endpoint could cache the image and provide the appropriate response headers for the browser to cache the image too.

0
votes

Similar to the concept of @Aragorn but this is more complete code. This is Java again though. Also since my app is multi-region I had to put in the region properties.

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.signer.Signer;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

import javax.annotation.PostConstruct;
import javax.validation.constraints.NotNull;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

@Component
@Slf4j
public class S3Operations {

    @Autowired
    private Signer awsSigner;

    private final Map<Region, S3Presigner> presignerMap = new ConcurrentHashMap<>();

    private S3Presigner buildPresignerForRegion(
      AwsCredentialsProvider credentialsProvider,
      Region region) {

        return S3Presigner.builder()
            .credentialsProvider(credentialsProvider)
            .region(region)
            .build();

    }


    /**
     * Convert an S3 URI to a normal HTTPS URI that expires.
     *
     * @param s3Uri S3 URI (e.g. s3://bucketname/ArchieTest/フェニックス.jpg)
     * @return https URI
     */
    @SneakyThrows
    public URI getExpiringUri(final URI s3Uri) {

        final GetObjectRequest getObjectRequest =
            GetObjectRequest.builder()
                .bucket(s3Uri.getHost())
                .key(s3Uri.getPath().substring(1))
                .overrideConfiguration(builder -> builder.signer(awsSigner))
                .build();

        final Region bucketRegion = bucketRegionMap.computeIfAbsent(s3Uri.getHost(),
            bucketName -> {
                final GetBucketLocationRequest getBucketLocationRequest = GetBucketLocationRequest.builder()
                    .bucket(bucketName)
                    .build();

                return Region.of(s3Client.getBucketLocation(getBucketLocationRequest).locationConstraint().toString());
            });

        final GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
            .signatureDuration(Duration.ofSeconds(0)) // required, but ignored
            .getObjectRequest(getObjectRequest)
            .build();

        return presignerMap.computeIfAbsent(bucketRegion, this::buildPresignerForRegion).presignGetObject(getObjectPresignRequest).url().toURI();

    }

For CustomAwsSigner which is injected above. The key difference being I throw an unsupported operation exception.

import org.jetbrains.annotations.TestOnly;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.signer.AwsS3V4Signer;
import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute;
import software.amazon.awssdk.auth.signer.params.Aws4PresignerParams;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.signer.Presigner;
import software.amazon.awssdk.core.signer.Signer;
import software.amazon.awssdk.http.SdkHttpFullRequest;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;

/**
 * This is a custom signer where the expiration is preset to a 5 minute block within an hour.
 * This must only be used for presigning.
 */
@Component
public class CustomAwsSigner implements Signer, Presigner {
    private final AwsS3V4Signer theSigner = AwsS3V4Signer.create();

    /**
     * This is the clip time for the expiration.  This should be divisible into 60.
     */
    @Value("${aws.s3.clipTimeInMinutes:5}")
    private long clipTimeInMinutes;

    @Value("${aws.s3.expirationInSeconds:3600}")
    private long expirationInSeconds;

    /**
     * Computes the base time as the processing time to the floor of nearest clip block.
     *
     * @param processingDateTime processing date time
     * @return base time
     */
    @TestOnly
    public ZonedDateTime computeBaseTime(final ZonedDateTime processingDateTime) {

        return processingDateTime
            .truncatedTo(ChronoUnit.MINUTES)
            .with(temporal -> temporal.with(ChronoField.MINUTE_OF_HOUR, temporal.get(ChronoField.MINUTE_OF_HOUR) / clipTimeInMinutes * clipTimeInMinutes));

    }

    @Override
    public SdkHttpFullRequest presign(final SdkHttpFullRequest request, final ExecutionAttributes executionAttributes) {

        final Instant baselineInstant = computeBaseTime(ZonedDateTime.now()).toInstant();

        final Aws4PresignerParams signingParams = Aws4PresignerParams.builder()
            .awsCredentials(executionAttributes.getAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS))
            .signingName(executionAttributes.getAttribute(AwsSignerExecutionAttribute.SERVICE_SIGNING_NAME))
            .signingRegion(executionAttributes.getAttribute(AwsSignerExecutionAttribute.SIGNING_REGION))
            .signingClockOverride(Clock.fixed(baselineInstant, ZoneId.of("UTC")))
            .expirationTime(baselineInstant.plus(expirationInSeconds, ChronoUnit.SECONDS))
            .build();
        return theSigner.presign(request, signingParams);

    }

    @Override
    public SdkHttpFullRequest sign(final SdkHttpFullRequest request, final ExecutionAttributes executionAttributes) {

        throw new UnsupportedOperationException("this class is only used for presigning");

    }
}
0
votes

Here's a solution in python I came up with after following this post. It uses the freezegun library to manipulate the time to make the signature the same over a given period.

import time
import datetime

import boto3
from freezegun import freezetime


S3_CLIENT = boto3.client("s3")

SEVEN_DAYS_IN_SECONDS = 604800
MAX_EXPIRES_SECONDS = SEVEN_DAYS_IN_SECONDS



def get_presigned_get_url(bucket: str, key: str, expires_in_seconds: int = MAX_EXPIRES_SECONDS) -> str:
        current_timestamp = int(time.time())
        truncated_timestamp = current_timestamp - (current_timestamp % expires_in_seconds)
        with freeze_time(datetime.datetime.fromtimestamp(truncated_timestamp)):
            presigned_url = S3_CLIENT.generate_presigned_url(
                ClientMethod="get_object",
                Params={
                    "Bucket": bucket,
                    "Key": key,
                    "ResponseCacheControl": f"private, max-age={expires_in_seconds}, immutable",
                },
                ExpiresIn=expires_in_seconds,
                HttpMethod="GET",
            )
        return presigned_url