Skip to content

Commit

Permalink
Issue #122: Add support for generating S3 Presigned Urls
Browse files Browse the repository at this point in the history
  • Loading branch information
onno-vos-dev committed Feb 12, 2023
1 parent 90a0071 commit 569ddf6
Show file tree
Hide file tree
Showing 2 changed files with 154 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/aws_request.erl
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
, request/2
]).

%% Exported for aws_s3_presigned_url
-export([ credential_scope/3
, canonical_headers/1
, signing_key/4
, signed_headers/1
, split_url/1
, string_to_sign/3
]).

-include_lib("hackney/include/hackney_lib.hrl").

%%====================================================================
Expand Down
145 changes: 145 additions & 0 deletions src/aws_s3_presigned_url.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
%%%% @doc
%%%
%%% Allows generating either a get or put presigned s3 url.
%%% This can be used by external clients such as cURL to access the object in question.
%%%
%%% See:
%%% - https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
%%% - https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
-module(aws_s3_presigned_url).

-export([ make_presigned_v4_url/5
]).

-include_lib("hackney/include/hackney_lib.hrl").

%%====================================================================
%% API
%%====================================================================
-spec make_presigned_v4_url(map(), get | put, integer(), binary(), binary()) -> {ok, binary()}.
make_presigned_v4_url(Client0, Method, ExpireSeconds, Bucket, Key) ->
MethodBin = aws_request:method_to_binary(Method),
Path = ["/", aws_util:encode_uri(Bucket), "/", aws_util:encode_multi_segment_uri(Key), ""],
Client = Client0#{service => <<"s3">>},
SecurityToken = maps:get(token, Client),
AccessKeyID = maps:get(access_key_id, Client),
Region = maps:get(region, Client),
Host = build_host(<<"s3">>, Client, Bucket),
{BaseHost, URL0} = build_url(Host, Path, Client, Bucket),
Now = calendar:universal_time(),
ShortDate = list_to_binary(ec_date:format("Ymd", Now)),
Credential = make_credential(AccessKeyID, ShortDate, Region),
Headers = [{<<"Host">>, Host},
{<<"X-Amz-Algorithm">>, <<"AWS4-HMAC-SHA256">>},
{<<"X-Amz-Credential">>, Credential},
{<<"X-Amz-Date">>, list_to_binary(ec_date:format("YmdTHisZ", Now))},
{<<"X-Amz-Expires">>, integer_to_binary(ExpireSeconds)},
{<<"X-Amz-Security-Token">>, SecurityToken},
{<<"X-Amz-SignedHeaders">>, <<"host">>}
],
Signature = sign_request(Client, MethodBin, BaseHost, aws_request:add_query(URL0, Headers), [{<<"Host">>, BaseHost}], Now, <<"UNSIGNED-PAYLOAD">>),
FinalHeaders = lists:keysort(1, [{<<"X-Amz-Signature">>, Signature} | Headers]),
{ok, aws_request:add_query(URL0, FinalHeaders)}.

%%====================================================================
%% Internal functions
%%====================================================================
-spec build_host(<<_:16>>, #{'region':=_, 'service':=<<_:16>>, _=>_}, _) -> any().
build_host(_EndpointPrefix, #{region := <<"local">>, endpoint := Endpoint}, undefined) ->
Endpoint;
build_host(_EndpointPrefix, #{region := <<"local">>, endpoint := Endpoint}, _Bucket) ->
<<Endpoint/binary>>;
build_host(_EndpointPrefix, #{region := <<"local">>}, undefined) ->
"localhost";
build_host(_EndpointPrefix, #{region := <<"local">>}, _Bucket) ->
<<"localhost">>;
build_host(EndpointPrefix, #{region := Region, endpoint := Endpoint}, undefined) ->
aws_util:binary_join([EndpointPrefix, Region, Endpoint], <<".">>);
build_host(EndpointPrefix, #{region := Region, endpoint := Endpoint}, Bucket) ->
aws_util:binary_join([Bucket, EndpointPrefix, Region, Endpoint], <<".">>).

-spec build_url(binary() | maybe_improper_list(binary() | maybe_improper_list(any(), binary() | []) | byte(), binary() | []), [binary() | maybe_improper_list(any(), binary() | []) | byte(), ...], #{'region':=_, 'service':=<<_:16>>, _=>_}, _) -> {binary(), _}.
build_url(Host0, Path0, Client, Bucket) ->
Proto = maps:get(proto, Client),
%% Mocks are notoriously bad with host-style requests, just skip it and use path-style for anything local
%% At some points once the mocks catch up, we should remove this ugly hack...
Host1 = erlang:iolist_to_binary(Host0),
IsLocalHost = maps:get(region, Client) =:= <<"local">>,
Path = erlang:iolist_to_binary(Path0),
Host = case Bucket of
_ when not IsLocalHost andalso Bucket =/= undefined ->
erlang:iolist_to_binary(string:replace(Host1, <<Bucket/binary, ".">>, <<"">>, all));
_ ->
Host1
end,
Port = maps:get(port, Client),
{Host, aws_util:binary_join([Proto, <<"://">>, Host, <<":">>, Port, Path], <<"">>)}.

make_credential(AccessKeyID, ShortDate, Region) ->
<<AccessKeyID/binary, "/", ShortDate/binary, "/", Region/binary, "/", "s3/aws4_request">>.

%% Generate headers with an AWS signature version 4 for the specified
%% request.
sign_request(Client, Method, Host, URL, Headers, Now, Body) ->
AccessKeyID = maps:get(access_key_id, Client),
SecretAccessKey = maps:get(secret_access_key, Client),
Region = maps:get(region, Client),
Service = maps:get(service, Client),
Token = maps:get(token, Client, undefined),
sign_request(AccessKeyID, SecretAccessKey, Region, Service, Token, Now,
Method, Host, URL, Headers, Body).

%% Generate headers with an AWS signature version 4 for the specified
%% request using the specified time when generating signatures.
sign_request(_AccessKeyID, SecretAccessKey, Region, Service, _Token, Now,
Method, Host, URL, Headers0, Body) ->
LongDate = list_to_binary(ec_date:format("YmdTHisZ", Now)),
ShortDate = list_to_binary(ec_date:format("Ymd", Now)),
CanonicalRequest = canonical_request(Method, Host, URL, Headers0, Body),
HashedCanonicalRequest = aws_util:sha256_hexdigest(CanonicalRequest),
CredentialScope = aws_request:credential_scope(ShortDate, Region, Service),
SigningKey = aws_request:signing_key(SecretAccessKey, ShortDate, Region, Service),
StringToSign = aws_request:string_to_sign(LongDate, CredentialScope,
HashedCanonicalRequest),
aws_util:hmac_sha256_hexdigest(SigningKey, StringToSign).

%% Process and merge request values into a canonical request for AWS
%% signature version 4.
canonical_request(Method, _Host, URL, Headers, _Body) ->
{CanonicalURL, CanonicalQueryString} = aws_request:split_url(URL),
CanonicalHeaders = aws_request:canonical_headers(Headers),
SignedHeaders = aws_request:signed_headers(Headers),
aws_util:binary_join([Method, CanonicalURL, CanonicalQueryString,
CanonicalHeaders, SignedHeaders, <<"UNSIGNED-PAYLOAD">>],
<<"\n">>).

%%====================================================================
%% Unit tests
%%====================================================================

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
presigned_url_test() ->
Client = aws_client:make_temporary_client(<<"AccessKeyID">>, <<"SecretAccessKey">>,
<<"Token">>, <<"eu-west-1">>),
{ok, Url} = aws_s3_presigned_url:make_presigned_v4_url(Client, put, 3600, <<"bucket">>, <<"key">>),
HackneyUrl = hackney_url:parse_url(Url),
ParsedQs = hackney_url:parse_qs(HackneyUrl#hackney_url.qs),
Credential = proplists:get_value(<<"X-Amz-Credential">>, ParsedQs),
[AccessKeyId, _ShortDate, Region, Service, Request] = binary:split(Credential, <<"/">>, [global]),
?assertEqual(https, HackneyUrl#hackney_url.scheme),
?assertEqual(443, HackneyUrl#hackney_url.port),
?assertEqual("s3.eu-west-1.amazonaws.com", HackneyUrl#hackney_url.host),
?assertEqual(<<"/bucket/key">>, HackneyUrl#hackney_url.path),
?assertEqual(8, length(ParsedQs)),
?assertEqual(<<"AccessKeyID">>, AccessKeyId),
?assertEqual(<<"eu-west-1">>, Region),
?assertEqual(<<"s3">>, Service),
?assertEqual(<<"aws4_request">>, Request),
?assertEqual(<<"bucket.s3.eu-west-1.amazonaws.com">>, proplists:get_value(<<"Host">>, ParsedQs)),
?assertEqual(<<"AWS4-HMAC-SHA256">>, proplists:get_value(<<"X-Amz-Algorithm">>, ParsedQs)),
?assertEqual(<<"3600">>, proplists:get_value(<<"X-Amz-Expires">>, ParsedQs)),
?assertEqual(<<"Token">>, proplists:get_value(<<"X-Amz-Security-Token">>, ParsedQs)),
?assertEqual(<<"host">>, proplists:get_value(<<"X-Amz-SignedHeaders">>, ParsedQs)).

-endif.

0 comments on commit 569ddf6

Please sign in to comment.