Search

Pre-Signed URL과 Signed URL로 S3 안전하게 사용하기

 들어가며

이미지나 동영상 등의 파일을 다루는 서비스라면 AWS의 Simple Storage Service인 S3를 사용하여 파일을 보관하고 AWS CloudFront의 Cloud Delivery Network를 사용하여 S3에서 보관하고 있는 파일을 Consumer에게 Serving 할 것입니다.
S3 Bucket의 보안을 위해선 버킷 자체에 모든 퍼블릭 엑세스를 차단하고 정책을 통해 특정 조건에 특정 권한을 허용 해주는 방식을 이용해야합니다.
위처럼 버킷 정책을 이용하면 특정 CDN에만 s3:GetObject의 권한을 허용해주는 등의 정책을 설정할 수 있습니다.
그런데
1.
s3:PutObject, s3:DeleteObject 권한을 특정 Consumer에게만 제공하고 싶다면?
2.
s3:GetObject 권한을 특정 Consumer에게만 특정 리소스를 제공한다거나 만료 시간을 설정하고 싶다면?
위의 두 가지 문제에 대해 S3를 모든 퍼블릭 엑세스 차단 상태로 안전하게 유지하면서 해결할 수 있는 방법을 고민하면서 읽으신다면 이 글에서 해답을 찾아가실 수 있을겁니다.

 도입 배경

기존 서비스에서 다루던 파일들은 5MB 이하의 가벼운 파일들이었고 CDN URL은 아무런 보안이 없던 상황이었습니다.
기능 개발 중 개인정보를 포함한 큰 용량의 파일을 다루게 되면서 발생할 수 있는 3가지 문제점을 찾았습니다.
1.
기존 서비스의 리소스가 크롤링에 노출되어 있다.
2.
개인정보에 해당하는 리소스에 타인이 엑세스할 수 있다.
3.
단순한 업로드를 위해 100MB 가까이 되는 대용량 파일을 서버에서 다루기엔 서버 메모리가 낭비된다.
1번의 경우 파일을 저장할 때 파일의 이름을 UUID를 사용한 고유한 값으로 변환한 후 저장하고 있기에 크롤링은 어려울 것이라 생각했지만 그래도 아무런 인증과정 없이 엑세스할 수 있다는 것이 우려되었습니다.
2번의 경우 기존처럼 CDN URL을 이용하는 것이 아닌 서버에서 검열한 파일을 byte[] 그대로 전달하려했다가 payload가 무식하게 길어지며 느려졌습니다.
3번의 경우 서버를 거치지 않고 Client의 컴퓨터 리소스를 사용하여 업로드를 할 방법이 강구되었습니다.
Pre-Signed URL과 Signed URL은 이 문제들을 해결하기에 가장 적합한 기술이었습니다.

 Pre-Signed URL이란?

Pre-Signed URL은 모든 퍼블릭 엑세스가 차단된 S3 Bucket에 Client가 직접적으로 Put, Delete 등의 엑세스를 할 수 있는 권한을 부여하는 URL입니다.
URL을 생성할 때 Bucket 이름, 리소스 Key, HTTP Method, 만료 날짜/시간을 지정하며 이 URL은 생성할 때 지정된 기간 동안만 유효합니다.

Pre-Signed URL 구조

https://bucket.s3.ap-northeast-2.amazonaws.com/bucket/path/file.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20231029T070655Z&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Expires=299&X-Amz-Credential=AccessKey%2F20231029%2FRegion%2Fs3%2Faws4_request&X-Amz-Signature=SignatureValue
HTML
복사
https://bucket.s3.ap-northeast-2.amazonaws.com
S3 Bucket의 URL
/bucket/path/file.jpg
엑세스 대상 리소스의 Key
X-Amz-Algorithm
서명 버전과 알고리즘을 식별하고 서명을 계산하는데 사용되는 값
X-Amz-Date
ISO 8601 형식의 날짜로 URL 생성 시간
X-Amz-SignedHeaders
서명을 계산하기 위해 요구되는 헤더 목록 (기본적으로 host 헤더 요구)
X-Amz-Expires
URL이 유효한 시간으로 단위는 초 (1~604800)
X-Amz-Credential
Access Key, 요청 날짜, Region, 서비스명
X-Amz-Signature
요청을 인증하기 위한 서명 값

 Signed URL이란?

Signed URL은 CloudFront로 배포되는 특정 리소스에 대한 엑세스를 제한하는 URL입니다.
특정 날짜가 지나면 엑세스를 불가능하게 하고 싶을 때, 특정 날짜 이후에 엑세스가 가능하게 하고 싶을 때, 특정 IP에서만 엑세스를 가능하게 하고 싶을 때 등에 사용합니다.

Signed URL의 구조

https://example.cloudfront.net/exmaple.jpg?Policy=eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vZGV2Y2RuLmdvY2hvLWJhY2suY29tL2NvbXBhbmllcy8xMDIyL2xvZ28ucG5nIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjkyODc1Mjk1fSwiSXBBZGRyZXNzIjp7IkFXUzpTb3VyY2VJcCI6IjAuMC4wLjAvMCJ9LCJEYXRlR3JlYXRlclRoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTY5Mjc4ODg5NX19fV19&Signature=fABMv0XTCesYBg9QfWviX~Rqeee9bs2gID68c5FaF~J4lVHmXjBNe9YbC7MbfUcVeR8nLr6v0epVQnSrZOd4gsai~~uB7wE5tSGC-tHgx3KJ78mGPPRHS0xS3jsg0EyhAKhgteOjFE96EMsDPmdLkiWy07oe3PMIn6vfxKLqTyL1G413CaBFz5S5esHtTb3uQ6sKsDJg416Yxmy6oUeJuo0WQrj7M6Qn8LYCtbR5Rmo37mywHwpBOB8SREIMVo05HT2RNreoOprmH21J-820l5RZtH2TeBvlti3MZt48xMIWZkqNiApQC9RS889kA34OtUgDM4Ajojr04mgKe1HQPA__&Key-Pair-Id=Key-Pair-ID
HTML
복사
https://example.cloudfront.net
CloudFront의 URL
example.jpg
CloudFront를 통해 배포되는 파일
Policy
리소스 사용 제한을 정의하는 정책의 내용을 Base64로 인코딩한 값
+ → - , = → _ , / → ~
Signature
리소스 사용 제한을 정의하는 정책의 내용을 ClondFront 개인 키(Private Key)로 서명한 해시 값을 Base64로 인코딩한 값
+ → - , = → _ , / → ~
Key-Pair-Id
CloudFront 전용 키 쌍(Key Pair)의 엑세스 키(Access Key)

Signed URL의 Policy

Canned Policy
resource 1개의 사용을 제한합니다.
특정 날짜가 지나면 엑세스를 불가능하게 하는 기능만 사용할 수 있습니다.
Policy가 쿼리 파라미터에 포함되지 않아 URL의 길이가 짧습니다.
Custom Policy
resource 여러 개의 사용을 제한합니다.
특정 날짜가 지나면 엑세스를 불가능하게 하는 기능, 특정 날짜가 지나야 엑세스를 가능하게 하는 기능, 특정 IP만 엑세스가 가능하게 하는 기능을 사용할 수 있습니다.
Policy가 쿼리 파라미터에 포함되어 URL의 길이가 깁니다.

Signed URL의 Policy 구조

{ "Statement": [ { "Resource": "https://example.cloudfront.net/example.jpg", "Condition": { "IpAddress": { "AWS:SourceIp": "0.0.0.0/0" }, "DateLessThan": { "AWS:EpochTime": 1693290740 }, "DateGreaterThan": { "AWS:EpochTime": 1693204340 } } } ] }
JSON
복사
Resource
resource의 경로입니다.
http:// 또는 https:// 로 시작해야하고 와일드카드(*)를 사용할 수 있습니다.
쿼리 파라미터를 붙일 수도 있습니다.
Condition
Signed URL이 동작할 조건을 지정합니다.
IpAddress
특정 IP 주소에서 resource에 엑세스하도록 하는 조건입니다.
CIDR 형식으로 IP 대역을 지정할 수도 있습니다.
DateLessThan
특정 날짜와 시간 이전에만 resource에 엑세스할 수 있도록 하는 조건입니다.
AWS:EpochTime으로 설정하며 값은 UTC 형식입니다.
DateGreaterThan
특정 날짜와 시간 이후에 resource에 엑세스할 수 있도록 하는 조건입니다.
AWS:EpochTime으로 설정하며 값은 UTC 형식입니다.

 Pre-Signed URL 구현

S3 설정

우선 S3 버킷의 모든 퍼블릭 엑세스를 차단 해두어야 합니다.

라이브러리 추가

//AWS SDK implementation 'com.amazonaws:aws-java-sdk:1.12.533'
Java
복사
build.gradle에 Pre-Signed URL을 위해 필요한 AWS SDK 라이브러리를 추가해주었습니다.

코드 작성

@Configuration public class AwsConfig { @Value("${cloud.aws.region.static}") String region; @Value("${cloud.aws.s3.access-key}") String accessKeyForS3; @Value("${cloud.aws.s3.secret-key}") String secretKeyForS3; @Bean public AmazonS3 amazonS3() { return AmazonS3ClientBuilder .standard() .withRegion(region) .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyForS3, secretKeyForS3))) .build(); } }
Java
복사
가장 먼저 AWS SDK 라이브러리의 인증 정보를 입력해주기 위해 AwsConfig 클래스를 추가한 후 @Configuration과 @Bean을 통해 인증 정보를 가진 AmazonS3를 빈으로 등록해줍니다.
Access Key나 Secret Key는 하드코딩으로 인해 노출되지 않도록 @Value를 이용해 properties에서 읽어오는 방식을 사용하였습니다.
public String getPreSignedUrl(String dir, String fileName) { Date date = new Date(); long time = date.getTime(); time += 1000 * 60 * 5; date.setTime(time); GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, dir + "/" + encodeKorToUrl(fileName)) .withMethod(HttpMethod.PUT) .withContentType(MediaTypeFactory.getMediaType(encodeKorToUrl(fileName)).orElseThrow(() -> new CustomException(ErrorCode.INVALID_EXTENSION)).toString()) .withExpiration(date); return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); }
Java
복사
S3Util 클래스를 주입만 하면 사용할 수 있도록 해당 클래스 안에 메서드를 추가하였습니다.
메서드 인자로 넘겨받은 dir, filtName은 리소스가 S3에서 어디에 위치할지에 대한 Key에 사용됩니다.
GeneratePresignedUrlRequest는 amazonS3를 사용하여 Pre-Signed URL을 생성하기 위해 사용되고 generatePresignedUrl() 메서드에 건네주면 응답으로 Pre-Signed URL을 받을 수 있습니다.
Request를 생성하는 과정에서 Method나 Expiration 등을 지정하여 URL에 대한 정책을 설정할 수 있습니다.

 Signed URL 구현

RSA Key Pair 생성

openssl genrsa -out private_key.pem 2048
JSON
복사
먼저 openssl을 사용하여 길이가 2048비트인 RSA 키 쌍을 생성해줍니다.
openssl rsa -pubout -in private_key.pem -out public_key.pem
JSON
복사
생성한 RSA 키 쌍에서 공개 키를 추출합니다.
openssl pkcs8 -topk8 -nocrypt -in private_key.pem -inform PEM -out private_key.der -outform DER
JSON
복사
Java를 사용하는 경우 개인 키를 DER 형식으로 변환해야합니다.
하지만 BouncyCastle 라이브러리를 사용하는 경우 DER 형식으로 변환 없이 PEM으로도 사용 가능합니다.

S3 & CloudFront설정

우선 S3 버킷의 모든 퍼블릭 엑세스를 차단 해두어야 합니다.
퍼블릭 엑세스가 차단된 S3 버킷은 버킷 정책을 통해 엑세스 포인트를 열어둡니다.
위 사진은 CloudFront를 통한 엑세스를 허용하는 정책을 나타냅니다.
그리고 S3 버킷에 엑세스할 CloudFront를 만들어줍니다.
만들어진 CloudFront의 동작 탭에 보면 경로 패턴에 대한 동작들을 설정할 수 있습니다.
기본값인 모든 경로에 대한 동작이 정의되어있고 특정 경로에 대해 Signed URL을 사용하고 싶다면 동작을 생성하시면 됩니다.
여기서 Signed URL을 사용하기 위한 설정은 뷰어 엑세스 제한입니다.
Yes를 선택한 후 키 그룹을 추가해줘야하는데 아래에 키 그룹 생성을 눌러서 키 쌍을 등록해야합니다.
앞서 생성했던 공개 키를 cat public_key.pem 커맨드를 통해 확인하여 등록해줍니다.
등록한 공개 키로 키 그룹을 생성해줍니다.

라이브러리 추가

//AWS SDK implementation 'com.amazonaws:aws-java-sdk:1.12.533' //Bouncy Castle Provider implementation 'org.bouncycastle:bcpkix-jdk15on:1.70'
Java
복사
build.gradle에 사용할 AWS SDK 라이브러리와 Bouncy Castle의 라이브러리를 추가해줍니다.

코드 작성

private PrivateKey getPrivateKey() { try(InputStream ips = new ClassPathResource(privateKeyPath).getInputStream()) { byte[] bytes = IOUtils.toByteArray(ips); String privateKey = new String(bytes); Reader pemReader = new StringReader(privateKey); PEMParser pemParser = new PEMParser(pemReader); JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); PrivateKeyInfo object = PrivateKeyInfo.getInstance(pemParser.readObject()); return converter.getPrivateKey(object); } catch (Exception e) { throw new CustomException(ErrorCode.FAIL_GET_PRIVATEKEY); } }
Java
복사
가장 처음 만들었던 개인 키는 프로젝트에 잘 넣어둔 후 .gitignore에 추가해 노출되지 않도록 조심합니다.
그 후 위 코드를 통해 개인 키를 읽어 PrivateKey 타입의 객체로 가져옵니다.
public String getSignedUrl(String url) { String dir = url.substring(url.indexOf("/", 8), url.lastIndexOf("/")); String fileName = url.substring(url.lastIndexOf("/") + 1); String resourcePath = "https://" + distributionDomain + dir + "/" + encodeKorToUrl(fileName); Date dateGreaterThan = ServiceUtils.parseIso8601Date(LocalDateTime.now().minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant().toString()); Date dateLessThan = ServiceUtils.parseIso8601Date(LocalDateTime.now().plusSeconds(86400).atZone(ZoneId.systemDefault()).toInstant().toString()); String ipRange = "0.0.0.0/0"; String customPolicyForSignedUrl = CloudFrontUrlSigner.buildCustomPolicyForSignedUrl(resourcePath, dateLessThan, ipRange, dateGreaterThan); PrivateKey privateKey = getPrivateKey(); return CloudFrontUrlSigner.getSignedURLWithCustomPolicy(resourcePath, keyPairId, privateKey, customPolicyForSignedUrl); }
Java
복사
위 메서드는 Original CDN URL을 받아 서명을 한 후 Signed CDN URL을 리턴하는 메서드입니다.
resourcePath, ipRange, dateGreaterThan, dateLessThan을 통해 CustomPolicy를 만들 수 있습니다.
@Slf4j @Component @RequiredArgsConstructor public class SignedUrlFilter implements Filter { private final S3Util s3Util; private final String[] uriPatterns = {"/v*/users/*/resume-profile", "/v*/managers/*/requests", "/v*/jds/*/applicants"}; @Override public void init(FilterConfig filterConfig) throws ServletException { Filter.super.init(filterConfig); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String requestURI = httpServletRequest.getRequestURI(); if (PatternMatchUtils.simpleMatch(uriPatterns, requestURI)) { ContentCachingResponseWrapper wrapperedResponse = new ContentCachingResponseWrapper((HttpServletResponse) response); chain.doFilter(request, wrapperedResponse); byte[] contentAsByteArray = wrapperedResponse.getContentAsByteArray(); String content = new String(contentAsByteArray, Charsets.UTF_8); Pattern cdnUrl = Pattern.compile("https://cloudfront.com[\\w./?=&-]+"); StringBuilder modifiedResponse = new StringBuilder(); Matcher matcher = cdnUrl.matcher(content); while (matcher.find()) { String originalUrl = matcher.group(); String signedUrl = s3Util.getSignedUrl(originalUrl); matcher.appendReplacement(modifiedResponse, Matcher.quoteReplacement(signedUrl)); } matcher.appendTail(modifiedResponse); response.getOutputStream().write(modifiedResponse.toString().getBytes()); } else { chain.doFilter(request, response); } } @Override public void destroy() { Filter.super.destroy(); } }
Java
복사
Signed URL을 적용하기 위해선 특정 URI를 가진 API 응답의 Original CDN URL을 Signed CDN URL로 바꿔주는 작업이 필요하였습니다.
그래서 Spring Context보다 더 앞단에 있으면서 Tomcat으로부터 통하는 모든 HTTP Request와 Response를 거치는 Spring Filter를 추가하였습니다.
이 때 중요한 부분은 HttpServletRequest나 Response는 한 번 꺼내보면 값이 사라진다는 점입니다.
그래서 HTTP Response의 내용을 담아둘 수 있는 ContentCachingResponseWrapper라는 객체로 감싸주었습니다.
이제 Matcher를 이용해 Response Body에서 Original CDN URL을 찾고 Signed CDN URL로 교체해준 후 doFilter()를 통해 나머지 FilterChain을 통과하게끔 하였습니다.

 확인

Pre-Signed URL

Pre-Signed URL 인증을 위한 파라미터를 제거한 후 요청을 보내면 403 Forbidden 응답이 오는걸 확인할 수 있습니다.
인증에 필요한 모든 파라미터를 붙여 다시 요청을 보내면 200 OK 응답이 오는걸 확인할 수 있습니다.
또한 S3에서 Pre-Signed URL 생성 시 지정한 위치로 리소스가 잘 저장된걸 확인할 수 있습니다.

Signed URL

CloudFront의 설정이 뷰어 엑세스 제한으로 되어 있는 상태에서 Policy, Signature, Key-Pair-Id 쿼리 파라미터가 빠지면 403 Forbidden 응답이 내려가는걸 확인할 수 있었습니다.
서버측에서 서명한 URL대로 CDN URL 뒤에 Policy, Signature, Key-Pair-Id 쿼리 파라미터를 붙여서 요청하면 리소스에 정상적으로 엑세스할 수 있는 것을 확인할 수 있습니다.