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-82. 스크립트 작성하기
 위 선행 작업이 완료되었다면, 이제 스크립트를 작성할 준비가 되었습니다.  Amazon S3 API 호출 요청은 Amazon S3 API Reference를 기본적으로 참조하였으며, 미리 선언된 URL을 만들기 위한 전반적인 작업 순서는 아래와 같습니다:
- 표준 요청(Canonical Request) 형식으로 메시지 내용들을 정렬하기
- 서명하기 위한 문자열(String To Sign) 만들기
- AWS 서명 버전 4(Signature) 계산하기
- 생성한 서명 정보를 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_requestURL내에 인증 정보를 제공하기 위해, 다음과 같은 다양한 쿼리 매개변수들이 포함되어 있습니다.
| 매개변수 | 설명 | 
| 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 활용 기술 팁을 보내드리는 코너로서, 이번 글은 박철수 솔루션즈 아키텍트께서 작성해주셨습니다.
 