Amazon Web Services 한국 블로그

AWS API 호출하기 (2) – Amazon S3 객체에 대한 미리 선언된(pre-signed) URL 생성하기

이번 블로그 포스팅은 지난 번에 올렸던 AWS API 호출하기(개론편)의 후속편입니다. 아마도 개론편 블로그를 끝까지 읽으시고도 애매모호 하셨을 것으로 생각됩니다. 실제로 HTTP/HTTPS API 호출 요청을 만들어 보면서 전체적인 흐름을 파악하고 이해를 돕도록 하겠습니다.  

‘어떤 예제가 간단하면서도 실제 업무에 도움이 될 수 있을까?’ 많이 고민하였는데요. 제목에서 추측하셨겠지만, Amazon S3 버킷에 있는 객체를 특정 시간 내에만 유효하게 공유할 수 있는 미리 선언된(pre-signed) URL을 만들어 보도록 하겠습니다. 물론 이곳을 참조해서, Java, .NET 그리고 Ruby용 SDK를 이용해 미리 선언된 URL을 쉽게 만들 수 있습니다. 또한 AWS Explorer나 기타 상용 도구에서도 이런 기능을 이용할 수 있습니다.

이번 블로그 포스팅에서는 bash 쉘 스크립트를 이용해서 이 기능을 지원하는 HTTP/HTTPS API 호출 요청을 직접 만들어 보도록 하겠습니다.

예제 쉘 스크립트는 미리 선언된 URL을 만드는데 서명 버전 4를 사용하였습니다. Amazon S3 경우 2014년 1월 30일 이후에 생성된 리전에서는 서명 버전 V2는 더 이상 지원하지 않습니다. 따라서 2016년 1월 7일 개설된 서울 리전도 서명 버전 V4 만 지원합니다.

1. 사전 준비 사항
스크립트를 작성하기 위해서는 openssl(1) 버전 1 또는 그 이상이 필요합니다. 서명키를 만들 때 키값이 문자열이 아닌 이진키값을 입력으로 받아야 하기 때문에 버전 1 이상이 필요합니다. 버전 확인은 아래와 같이 하시고, 예제와 같이 0.9.8zg 버전이면 이곳에서 소스 코드를 받아 설치하시면 됩니다. 설치 방법은 이 블로그 범위를 벗어나기 때문에 따로 설명드리지는 않겠습니다.

$ openssl version
OpenSSL 0.9.8zg 14 July 2015

그리고 사용자 신원 정보와 리전 정보를 얻기 위해서 환경 변수 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 및 AWS_DEFAULT_REGION 을 이용하였습니다. 설정 방법은 이곳이나 아래를 참조하시면 됩니다. AWS 계정에 대한 중요한 신원 정보를 스크립트 코드 내에 포함시키는 것은 위험합니다. 따라서 사용 환경에 설정하는 것을 권장합니다.

$ export AWS_ACCESS_KEY_ID=<YOUR_ACCESS_KEY_ID>
$ export AWS_SECRET_ACCESS_KEY=<YOUR_SECRET_ACCESS_KEY_ID>
$ export AWS_DEFAULT_REGION=<AWS_REGION>

로케일 언어 설정은 UTF-8인코딩으로 설정합니다. 이 로케일 설정이 여러분의 작업 환경과 다르다면 bash 쉘 스크립트 내에 포함시켜도 무방합니다.

$ export  export LC_ALL=ko_kr.UTF-8
$ export LANG=ko_kr.UTF-8

2. 스크립트 작성하기
위 선행 작업이 완료되었다면, 이제 스크립트를 작성할 준비가 되었습니다.  Amazon S3 API 호출 요청은 Amazon S3 API Reference를 기본적으로 참조하였으며, 미리 선언된 URL을 만들기 위한 전반적인 작업 순서는 아래와 같습니다:

  1. 표준 요청(Canonical Request) 형식으로 메시지 내용들을 정렬하기
  2. 서명하기 위한 문자열(String To Sign) 만들기
  3. AWS 서명 버전 4(Signature) 계산하기
  4. 생성한 서명 정보를 HTTP/HTTPS API 요청에 추가하기

위 서명 계산 절차를 구체적이고 이해하기 쉽게 도식화해 놓은 것이 아래 다이어그램이며, 이 순서를 따라서 스크립스틀 작성하였습니다. 그리고 각 항목에대한 구체적인 설명은 이곳을 참조하시면 많은 도움을 얻을 수 있습니다.

아래는 위 절차에 따라 생성된 미리 선언된 URL의 한 예입니다. 가독성을 높이기 위해 URL에 줄바꿈 문자를 추가했습니다. 실제로는 한 줄로 표현되어야 합니다.

https://BUCKETURL/test.txt?
X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=<YOUR_ACCESS_KEY_ID>/20160115/ap-northeast-2/s3/aws4_request&
&X-Amz-Date=20160115T000000Z
&X-Amz-Expires=86400
&X-Amz-SignedHeaders=host
&X-Amz-Signature=<SIGNATURE_VALUE>

그리고 URL에서 X-Amz-Credential 헤더의 값도 가독성을 위해서 “/”문자를 사용해서 표현하였습니다. 실제로는 %2F로 인코딩되어서 아래와 같이 사용되어야 합니다.

&X-Amz-Credential=<YOUR_ACCESS_KEY_ID>%2F20160115%2Fap-northeast-2%2Fs3%2Faws4_request

URL내에 인증 정보를 제공하기 위해, 다음과 같은 다양한 쿼리 매개변수들이 포함되어 있습니다.

매개변수 설명
X-Amz-Algorithm 서명 버전과 알고리즘을 식별하고, 서명을 계산하는데 사용. 서명 버전 4를 위해서 “AWS4-HMAC-SHA256” 로 설정
X-Amz-Credential 액세스 키 ID와 범위 정보(요청 날짜, 사용하는 리전, 서비스 명). 리전 명은 리전 및 엔드포인트에서 확인 가능
X-Amz-Date 날짜는 ISO 8601형식. 예: 20160115T000000Z
X-Amz-Expires 미리 선언된 URL이 유효한 시간 주기. 초단위. 정수 값. 최소 1에서 최대 604800 (7일) 예: 86400 (24시간)
X-Amz-SignedHeaders 서명을 계산하기 위해 사용되어지는 헤더 목록. HTTP host 헤더가 요구됨
X-Amz-Signature 요청을 인증하기 위한 서명

주의: X-Amz-Signature 헤더를 제외한 모든 쿼리 매개변수들은 표준 쿼리 문자열(Canonical Query String)에 포함시킵니다. 표준 헤더(Canonical Headers)는 HTTP host 헤더를 포함해야 합니다. 미리 사인된 URL을 만들 때 실제 페이로드에 대해 알 수 없기 때문에 표준 요청(Canonical Request)에 있는 페이로드 해시는 포함 안 시킵니다. 대신 “UNSIGNED-PAYLOAD” 문자열을 사용합니다.

이를 바탕으로 만든 완성된 스크립트는 아래와 같습니다. Bash 쉘 스크립트에 대한 블로그 포스팅이 아니기 때문에 bash 쉘 문구에 대해서는 따로 설명하지 않겠습니다.

#!/bin/bash
#
export PATH="/usr/local/ssl/bin:$PATH"
urlEncode() {
  LINE="$1"
  LENGTH="${#LINE}"
  I=0
  while [ $I -lt $LENGTH ]
    do
    C="${LINE:I:1}"
  case $C in
    [a-zA-Z0-9.~_-]) printf "$C" ;;
    *) printf '%%%02X' "'$C" ;;
  esac
    let I=I+1
  done
}

getHexaDecimalString() {
  read LINE
  LENGTH="${#LINE}"
  I=0
  while [ $I -lt $LENGTH ]
    do
    C="${LINE:I:1}"
   printf '%2x' "'$C"
   let I=I+1
  done
}

getSignatureKey() {
  SECRET_KEY=$1
  DATESTAMP=$2
  REGIONNAME=$3
  SERVICENAME=$4
  STRING_TO_SIGN=$5

  HEX_KEY=$(echo -n "AWS4${SECRET_KEY}" | getHexaDecimalString)
  HEX_KEY=$(echo -n "${DATESTAMP}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${HEX_KEY})
  HEX_KEY=$(echo -n "${REGIONNAME}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${HEX_KEY#* })
  HEX_KEY=$(echo -n "${SERVICENAME}" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${HEX_KEY#* })
  SIGNING_KEY=$(echo -n "aws4_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${HEX_KEY#* })

  SIGNATURE=$(echo -en "${STRING_TO_SIGN}" | openssl dgst -binary -hex -sha256 -mac HMAC -macopt hexkey:${SIGNING_KEY#* })
  echo "${SIGNATURE#* }"
} 

getHexaHash() {
  PAYLOAD="$@"
  HASH=$(echo -n "${PAYLOAD}" | openssl dgst -sha256)
  echo  "${HASH#* }"
}

### Main ###
if [ -z $AWS_DEFAULT_REGION ] || [ -z $AWS_SECRET_ACCESS_KEY ] || [ -z $AWS_ACCESS_KEY_ID ]
then
  echo "Please set $AWS_DEFAULT_REGION, $AWS_SECRET_ACCESS_KEY, and $AWS_ACCESS_KEY_ID environment variables"
exit 1
fi
SK="$AWS_SECRET_ACCESS_KEY"
AK="$AWS_ACCESS_KEY_ID"
REGION="$AWS_DEFAULT_REGION"

[ $# -ne 6 ] && exit 2
 
while getopts ":b:k:e:" OPT; do
  case $OPT in
        b)
         BUCKET=$OPTARG
         ;;
        k)
         S3KEY=$OPTARG
         ;;
        e)
         EXPIRES=$OPTARG
         ;;
       *)
   echo "Invalid option: -$OPTARG" >&2
   exit 3
   ;;
   esac
done

SERVICENAME="s3"
HOST="${BUCKET}.${SERVICENAME}.amazonaws.com"
ENDPOINT="http://${BUCKET}.${SERVICENAME}.amazonaws.com"

# step 1. Create a Canonical request
AMZ_DATE=$(date -u +%Y%m%dT%H%M%SZ)
DATESTAMP=$(date -u +%Y%m%d)
AMZ_EXPIRES=$((${EXPIRES}*60))   # minute -> second

HTTPMETHOD="GET"
CANONICAL_URI="/${S3KEY}"
#
CANONICAL_HEADERS="host:${HOST}\n"
SIGNED_HEADERS="host"
PAYLOAD_HASH="UNSIGNED-PAYLOAD"
#
ALGORITHM="AWS4-HMAC-SHA256"
CREDENTIAL_SCOPE="${DATESTAMP}/${REGION}/${SERVICENAME}/aws4_request"
#
CANONICAL_QUERYSTRING="X-Amz-Algorithm=${ALGORITHM}"
CANONICAL_QUERYSTRING="${CANONICAL_QUERYSTRING}&X-Amz-Credential=$(urlEncode "${AK}/${CREDENTIAL_SCOPE}")"
CANONICAL_QUERYSTRING="${CANONICAL_QUERYSTRING}&X-Amz-Date=${AMZ_DATE}"
CANONICAL_QUERYSTRING="${CANONICAL_QUERYSTRING}&X-Amz-Expires=${AMZ_EXPIRES}"
CANONICAL_QUERYSTRING="${CANONICAL_QUERYSTRING}&X-Amz-SignedHeaders=${SIGNED_HEADERS}"
CANONICAL_REQUEST="${HTTPMETHOD}\n${CANONICAL_URI}\n${CANONICAL_QUERYSTRING}\n${CANONICAL_HEADERS}\n${SIGNED_HEADERS}\n${PAYLOAD_HASH}"

# step 2. String To Sign
STRING_TO_SIGN="${ALGORITHM}\n${AMZ_DATE}\n${CREDENTIAL_SCOPE}\n$(getHexaHash "$(echo -e "${CANONICAL_REQUEST}")")"

# step 3. Signature
SIGNATURE="$(getSignatureKey $SK $DATESTAMP $REGION $SERVICENAME $STRING_TO_SIGN)"

# step 4.  Create a request URL
CANONICAL_QUERYSTRING="${CANONICAL_QUERYSTRING}&X-Amz-Signature=${SIGNATURE}"

echo "request_url = ${ENDPOINT}/${S3KEY}?${CANONICAL_QUERYSTRING}"

스크립트의 간결성을 유지하기 위해서 대부분의 에러 처리 루틴을 배제하였습니다. 따라서 범용적으로 사용하기 위해서는 적절한 에러 처리 루틴들이 추가되어야 하고 충분한 테스트를 거친 후 사용하시길 권장합니다.

아래는 이 스크립트를 이용해서 한 시간동안만 버킷 examplebucket에 포함된 오브젝트test.txt를 다른 사람에게 공유하기 위해 미리 사인된 URL을 만든 예입니다.

$ ./apis3.sh -b examplebucket -k test.txt -e 60
request_url = http://BUCKETURL/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<YOUR_ACCESS_KEY_ID>%2F20160115%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Date=20160115T130732Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=d0578b9fe721c9964765f2ab6bf6f8a1fc7c4966956785688b881783b661bdfd

이 URL은 생성된 시간부터 60분간 유효하며, 공유하고 싶은 임의의 사람에게 전달하면 됩니다. 그리고 공유된 링크를 클릭하거나 curl(1) 명령어로 실행하게 되면,  해당 API 호출 요청이Amazon S3 엔드포인트로 전달되고 적절히 처리됩니다.

아래는 curl(1) 를 이용해서 오브젝트를 성공적으로 다운로드한 예입니다.

$ curl -o "test.txt" "http://BUCKETURL/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<YOUR_ACCESS_KEY_ID>%2F20160115%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Date=20160115T130732Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=d0578b9fe721c9964765f2ab6bf6f8a1fc7c4966956785688b881783b661bdfd"
% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
Dload  Upload   Total   Spent    Left  Speed
100  3619  100  3619    0     0   6305      0 --:--:-- --:--:-- --:--:--  6304

그리고 아래는 시간이 초과되어 다운로드가 실패한 예입니다.

$ curl -o "test.txt" "http://BUCKETURL/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<YOUR_ACCESS_KEY_ID>%2F20160115%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Date=20160115T130732Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=d0578b9fe721c9964765f2ab6bf6f8a1fc7c4966956785688b881783b661bdfd"
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Request has expired</Message><X-Amz-Expires>60</X-Amz-Expires><Expires>2016-01-15T140732:52Z</Expires><ServerTime>2016-01-18T20:13:55Z</ServerTime><RequestId>F865CB514FF4C92A</RequestId><HostId>f6Bv6Jtht91bSqt8OPsxirsrHZkWs7Qcc4kNYjZ2tJbPUmt8BW9Wv1hAUY2YFDKqSpEvFr8PLbI=</HostId></Error>

지금까지 총 2회의 포스팅을 통해서 AWS API 호출 요청을 어떻게 만들 수 있는지 살펴보았습니다. 특히 실전편에서 제공된 예는 많은 사용자들이 문의하신 내용이기도 합니다. 이번 글을 통해서 앞으로AWS API 호출을 활용하실 수 있고,  S3 오브젝트를 안전하게 공유하는데 조금이라도 도움이 되었으면 합니다.
본 글은 아마존웹서비스 코리아의 솔루션즈 아키텍트가 국내 고객을 위해 전해 드리는 AWS 활용 기술 팁을 보내드리는 코너로서, 이번 글은 박철수 솔루션즈 아키텍트께서 작성해주셨습니다.