Tim Fletcher 17 лет назад
Сommit
edfc243cea
10 измененных файлов с 436 добавлено и 0 удалено
  1. 1 0
      .gitignore
  2. 1 0
      EMakefile
  3. 22 0
      License.txt
  4. 21 0
      Makefile
  5. 61 0
      README.txt
  6. 67 0
      src/oauth.erl
  7. 16 0
      src/oauth_consumer.erl
  8. 81 0
      src/oauth_request.erl
  9. 122 0
      src/oauth_test.erl
  10. 44 0
      src/oauth_util.erl

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+*.beam

+ 1 - 0
EMakefile

@@ -0,0 +1 @@
+{"src/*", [debug_info, {outdir, "ebin"}, {i, "include"}]}.

+ 22 - 0
License.txt

@@ -0,0 +1,22 @@
+Copyright (c) 2008 Tim Fletcher <http://tfletcher.com/>
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.

+ 21 - 0
Makefile

@@ -0,0 +1,21 @@
+SHELL=/bin/sh
+
+EFLAGS=-pa ebin -pa ../erlang-fmt/ebin
+
+all: compile
+
+compile: clean
+	test -d ebin || mkdir ebin
+	erl $(EFLAGS) -make
+
+clean:
+	rm -rf ebin erl_crash.dump
+
+test: compile
+	erl $(EFLAGS) -noshell -eval 'crypto:start(), oauth_test:all(), c:q().'
+
+termie: compile
+	erl $(EFLAGS) -noshell -eval 'crypto:start(), inets:start(), oauth_test:termie(), c:q().'
+
+i: compile
+	erl $(EFLAGS) -eval 'crypto:start(), inets:start().'

+ 61 - 0
README.txt

@@ -0,0 +1,61 @@
+============
+erlang-oauth
+============
+
+
+What is this?
+-------------
+
+An Erlang wrapper around the OAuth protocol.
+
+
+What is OAuth?
+--------------
+
+An "open protocol to allow secure API authentication in a simple and standard
+method from desktop and web applications". See http://oauth.net/ for more info.
+
+
+What do I need?
+---------------
+
+Erlang, and erlang-fmt (http://tfletcher.com/dev/erlang-fmt).
+
+The Makefile assumes that erlang-fmt is contained in the parent directory of
+this one, so you might want to edit the Makefile if you have it elsewhere.
+
+
+How do I use it?
+----------------
+
+The crypto and inets applications needs to be running, and---as it's easy to
+forget---all the code needs to be compiled. A typical authentication flow
+would be similar to the following:
+
+  ConsumerKey = "key",
+
+  ConsumerSecret = "secret",
+
+  SignatureMethod = "HMAC-SHA1",
+
+  Consumer = oauth_consumer:new(ConsumerKey, ConsumerSecret, SignatureMethod),
+
+  {ok, RequestTokens} = oauth:tokens(oauth:get(RequestTokenURL, Consumer)),
+
+  % If necessary, direct user to the Service Provider,
+  % with Token = proplists:get_value(oauth_token, RequestTokens).
+
+  {ok, AccessTokens} = oauth:tokens(oauth:get(AccessTokenURL, Consumer, RequestTokens)),
+
+  oauth:get(ProtectedResourceURL, Consumer, AccessTokens, ExtraParams).
+
+
+Calling oauth:get or oauth:post returns an HTTP response tuple, as returned
+from http:request/4. Type "make termie", or look at oauth_test:termie/0 for
+a working example. Thanks Andy!
+
+
+Who can I contact if I have another question?
+---------------------------------------------
+
+Tim Fletcher (http://tfletcher.com/).

+ 67 - 0
src/oauth.erl

@@ -0,0 +1,67 @@
+-module(oauth).
+
+-export([get/2, get/3, get/4]).
+-export([post/2, post/3, post/4]).
+-export([tokens/1]).
+-export([params_from_string/1]).
+
+
+get(URL, Consumer) ->
+  fetch({get, URL, []}, Consumer).
+
+get(URL, Consumer, {oauth_tokens, Tokens}) ->
+  fetch({get, URL, []}, Consumer, Tokens);
+get(URL, Consumer, Params) ->
+  fetch({get, URL, Params}, Consumer, []).
+
+get(URL, Consumer, {oauth_tokens, Tokens}, Params) ->
+  fetch({get, URL, Params}, Consumer, Tokens).
+
+post(URL, Consumer) ->
+  fetch({post, URL, []}, Consumer).
+
+post(URL, Consumer, {oauth_tokens, Tokens}) ->
+  fetch({post, URL, []}, Consumer, Tokens);
+post(URL, Consumer, Params) ->
+  fetch({post, URL, Params}, Consumer, []).
+
+post(URL, Consumer, {oauth_tokens, Tokens}, Params) ->
+  fetch({post, URL, Params}, Consumer, Tokens).
+
+tokens({ok, {_,_,Data}}) ->
+  {ok, {oauth_tokens, params_from_string(Data)}};
+tokens(Term) ->
+  Term.
+
+fetch({Method, URL, Params}, Consumer) ->
+  fetch({Method, URL, Params}, Consumer, []).
+
+fetch({Method, URL, Params}, Consumer, Tokens) ->
+  SignedURL = oauth_request:url(Method, URL, Params, Consumer, Tokens),
+  http:request(Method, {SignedURL, _Headers=[]}, [], []).
+
+params_from_string(Data) ->
+  lists:map(fun param_from_string/1, explode($&, Data)).
+
+param_from_string(Data) when is_list(Data) ->
+  param_from_string(break_at($=, Data));
+param_from_string({K, V}) ->
+  {list_to_atom(oauth_util:percent_decode(K)), oauth_util:percent_decode(V)}.
+
+explode(_Sep, []) ->
+  [];
+explode(Sep, Chars) ->
+  explode(Sep, break_at(Sep, Chars), []).
+
+explode(_Sep, {Param, []}, Params) ->
+  lists:reverse([Param|Params]);
+explode(Sep, {Param, Etc}, Params) ->
+  explode(Sep, break_at(Sep, Etc), [Param|Params]).
+
+break_at(Sep, Chars) ->
+  case lists:splitwith(fun(C) -> C =/= Sep end, Chars) of
+    Result={_, []} ->
+      Result;
+    {Before, [Sep|After]} ->
+      {Before, After}
+  end.

+ 16 - 0
src/oauth_consumer.erl

@@ -0,0 +1,16 @@
+-module(oauth_consumer).
+
+-compile(export_all).
+
+
+new(Key, Secret, SignatureMethod) ->
+  {oauth_consumer, Key, Secret, SignatureMethod}.
+
+key(Consumer) ->
+  element(2, Consumer).
+
+secret(Consumer) ->
+  element(3, Consumer).
+
+signature_method(Consumer) ->
+  element(4, Consumer).

+ 81 - 0
src/oauth_request.erl

@@ -0,0 +1,81 @@
+-module(oauth_request).
+
+-export([url/5]).
+
+% for testing:
+-export([plaintext_signature/2]).
+-export([hmac_sha1_signature/3]).
+-export([hmac_sha1_base_string/3]).
+-export([hmac_sha1_normalize/1]).
+
+-import(fmt, [sprintf/2, percent_encode/1]).
+-import(lists, [map/2]).
+-import(oauth_util, [implode/2]).
+
+
+url(Method, URL, ExtraParams, Consumer, []) ->
+  Params = oauth_params(Consumer, ExtraParams),
+  signed_url(Method, URL, Params, Consumer, _TokenSecret="");
+url(Method, URL, ExtraParams, Consumer, Tokens) ->
+  Token = proplists:lookup(oauth_token, Tokens),
+  TokenSecret = proplists:get_value(oauth_token_secret, Tokens),
+  Params = [Token|oauth_params(Consumer, ExtraParams)],
+  signed_url(Method, URL, Params, Consumer, TokenSecret).
+
+oauth_params(Consumer, ExtraParams) ->
+  proplists_merge([
+    {oauth_consumer_key, oauth_consumer:key(Consumer)},
+    {oauth_signature_method, oauth_consumer:signature_method(Consumer)},
+    {oauth_timestamp, oauth_util:unix_timestamp()},
+    {oauth_nonce, oauth_util:nonce()},
+    {oauth_version, "1.0"}
+  ], ExtraParams).
+
+proplists_merge({K,V}, Merged) ->
+  case proplists:is_defined(K, Merged) of
+    true ->
+      Merged;
+    false ->
+      [{K,V}|Merged]
+  end;
+proplists_merge(A, B) ->
+  lists:foldl(fun proplists_merge/2, A, B).
+
+signed_url(Method, URL, Params, Consumer, TokenSecret) ->
+  Signature = signature(Method, URL, Params, Consumer, TokenSecret),
+  sprintf("%s?%s", [URL, params_to_string([{oauth_signature, Signature}|Params])]).
+
+signature(Method, URL, Params, Consumer, TokenSecret) ->
+  ConsumerSecret = oauth_consumer:secret(Consumer),
+  case signature_method(Params) of
+    "PLAINTEXT" ->
+      plaintext_signature(ConsumerSecret, TokenSecret);
+    "HMAC-SHA1" ->
+      MethodString = string:to_upper(atom_to_list(Method)),
+      BaseString = hmac_sha1_base_string(MethodString, URL, Params),
+      hmac_sha1_signature(BaseString, ConsumerSecret, TokenSecret)
+  end.
+
+signature_method(Params) ->
+  proplists:get_value(oauth_signature_method, Params).
+
+plaintext_signature(ConsumerSecret, TokenSecret) ->
+  percent_encode(sprintf("%s&%s", [percent_encode(ConsumerSecret), percent_encode(TokenSecret)])).
+
+hmac_sha1_signature(BaseString, ConsumerSecret, TokenSecret) ->
+  base64:encode_to_string(crypto:sha_mac(hmac_sha1_key(ConsumerSecret, TokenSecret), BaseString)).
+
+hmac_sha1_key(ConsumerSecret, TokenSecret) ->
+  sprintf("%s&%s", [percent_encode(ConsumerSecret), percent_encode(TokenSecret)]).
+
+hmac_sha1_base_string(MethodString, URL, Params) ->
+  implode($&, map(fun fmt:percent_encode/1, [MethodString, URL, hmac_sha1_normalize(Params)])).
+
+hmac_sha1_normalize(Params) ->
+  params_to_string(lists:sort(fun({K,X},{K,Y}) -> X < Y; ({A,_},{B,_}) -> A < B end, Params)).
+
+params_to_string(Params) ->
+  implode($&, map(fun param_to_string/1, Params)).
+
+param_to_string({K,V}) ->
+  sprintf("%s=%s", [percent_encode(K), percent_encode(V)]).

+ 122 - 0
src/oauth_test.erl

@@ -0,0 +1,122 @@
+-module(oauth_test).
+
+-compile(export_all).
+
+
+all() ->
+  lists:all(fun(F) ->
+    io:format("~s:~s~n", [?MODULE, F]),
+    apply(?MODULE, F, [])
+  end, [
+    params_from_string,
+    plaintext_signature,
+    hmac_sha1_normalize,
+    hmac_sha1_base_string,
+    hmac_sha1_signature
+  ]),
+  ok.
+
+params_from_string() ->
+  % cf. http://oauth.net/core/1.0/#response_parameters (5.3)
+  should_be_equal([{oauth_token, "ab3cd9j4ks73hf7g"}, {oauth_token_secret, "xyz4992k83j47x0b"}],
+  oauth:params_from_string("oauth_token=ab3cd9j4ks73hf7g&oauth_token_secret=xyz4992k83j47x0b")).
+
+plaintext_signature() ->
+  % cf. http://oauth.net/core/1.0/#rfc.section.9.4.1
+  ConsumerSecret="djr9rjt0jd78jf88",
+  lists:all(fun({TokenSecret, Expected}) ->
+    Actual = oauth_request:plaintext_signature(ConsumerSecret, TokenSecret),
+    should_be_equal(Expected, Actual)
+  end, [
+    {"jjd999tj88uiths3","djr9rjt0jd78jf88%26jjd999tj88uiths3"},
+    {"jjd99$tj88uiths3","djr9rjt0jd78jf88%26jjd99%2524tj88uiths3"},
+    {"", "djr9rjt0jd78jf88%26"}
+  ]).
+
+hmac_sha1_normalize() ->
+  % cf. http://wiki.oauth.net/TestCases
+  lists:all(fun({Params, Expected}) ->
+    should_be_equal(Expected, oauth_request:hmac_sha1_normalize(Params))
+  end, [
+    {[{name,undefined}], "name="},
+    {[{a,b}], "a=b"},
+    {[{a,b},{c,d}], "a=b&c=d"},
+    {[{a,"x!y"},{a,"x y"}], "a=x%20y&a=x%21y"},
+    {[{"x!y",a},{x,a}], "x=a&x%21y=a"}
+  ]).
+
+hmac_sha1_base_string() ->
+  % cf. http://wiki.oauth.net/TestCases
+  lists:all(fun({MethodString, URL, Params, Expected}) ->
+    Actual = oauth_request:hmac_sha1_base_string(MethodString, URL, Params),
+    should_be_equal(Expected, Actual)
+  end, [
+    {"GET", "http://example.com", [
+      {n,v}
+    ],
+      "GET&http%3A%2F%2Fexample.com&n%3Dv"
+    },
+    {"POST", "https://photos.example.net/request_token", [
+      {oauth_version, "1.0"},
+      {oauth_consumer_key, "dpf43f3p2l4k3l03"},
+      {oauth_timestamp, "1191242090"},
+      {oauth_nonce, "hsu94j3884jdopsl"},
+      {oauth_signature_method, "PLAINTEXT"}
+    ],
+      "POST&https%3A%2F%2Fphotos.example.net%2Frequest_token&oauth_consumer_key" ++
+      "%3Ddpf43f3p2l4k3l03%26oauth_nonce%3Dhsu94j3884jdopsl%26oauth_signature_method" ++
+      "%3DPLAINTEXT%26oauth_timestamp%3D1191242090%26oauth_version%3D1.0"
+    },
+    {"GET", "http://photos.example.net/photos", [
+      {file, "vacation.jpg"},
+      {size, "original"},
+      {oauth_version, "1.0"},
+      {oauth_consumer_key, "dpf43f3p2l4k3l03"},
+      {oauth_token, "nnch734d00sl2jdk"},
+      {oauth_timestamp, "1191242096"},
+      {oauth_nonce, "kllo9940pd9333jh"},
+      {oauth_signature_method, "HMAC-SHA1"}
+    ],
+      "GET&http%3A%2F%2Fphotos.example.net%2Fphotos&file%3Dvacation.jpg%26" ++
+      "oauth_consumer_key%3Ddpf43f3p2l4k3l03%26oauth_nonce%3Dkllo9940pd9333jh%26" ++
+      "oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1191242096%26" ++
+      "oauth_token%3Dnnch734d00sl2jdk%26oauth_version%3D1.0%26size%3Doriginal"
+    }
+  ]).
+
+hmac_sha1_signature() ->
+  % cf. http://wiki.oauth.net/TestCases
+  lists:all(fun({Expected, ConsumerSecret, TokenSecret, BaseString}) ->
+    Actual = oauth_request:hmac_sha1_signature(BaseString, ConsumerSecret, TokenSecret),
+    should_be_equal(Expected, Actual)
+  end, [
+    {"egQqG5AJep5sJ7anhXju1unge2I=", "cs", "", "bs"},
+    {"VZVjXceV7JgPq/dOTnNmEfO0Fv8=", "cs", "ts", "bs"},
+    {"tR3+Ty81lMeYAr/Fid0kMTYa/WM=", "kd94hf93k423kf44", "pfkkdhi9sl3r4s00",
+      "GET&http%3A%2F%2Fphotos.example.net%2Fphotos&file%3Dvacation.jpg%26" ++
+      "oauth_consumer_key%3Ddpf43f3p2l4k3l03%26oauth_nonce%3Dkllo9940pd9333jh%26" ++
+      "oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1191242096%26" ++
+      "oauth_token%3Dnnch734d00sl2jdk%26oauth_version%3D1.0%26size%3Doriginal"
+    }
+  ]).
+
+termie() ->
+  % cf. http://term.ie/oauth/example/
+  Consumer = oauth_consumer:new("key", "secret", "HMAC-SHA1"),
+  RequestTokenURL = "http://term.ie/oauth/example/request_token.php",
+  AccessTokenURL = "http://term.ie/oauth/example/access_token.php",
+  EchoURL = "http://term.ie/oauth/example/echo_api.php",
+  EchoParams = [{bar, "baz"}, {method, "foo"}],
+  {ok, Tokens} = tee(oauth:tokens(oauth:get(RequestTokenURL, Consumer))),  
+  {ok, AccessTokens} = tee(oauth:tokens(oauth:get(AccessTokenURL, Consumer, Tokens))),
+  {ok, {_,_,Data}} = tee(oauth:get(EchoURL, Consumer, AccessTokens, EchoParams)),
+  should_be_equal(lists:keysort(1, EchoParams), lists:keysort(1, oauth:params_from_string(Data))).
+
+tee(X) ->
+  io:format("~p~n", [X]), X.
+
+should_be_equal(X, X) ->
+  true;
+should_be_equal(X, Y) ->
+  io:format("~p (expected) is not equal to ~p~n", [X, Y]),
+  false.

+ 44 - 0
src/oauth_util.erl

@@ -0,0 +1,44 @@
+-module(oauth_util).
+
+-compile(export_all).
+
+-define(is_uppercase_alpha(C), C >= $A, C =< $Z).
+-define(is_lowercase_alpha(C), C >= $a, C =< $z).
+-define(is_alpha(C), ?is_uppercase_alpha(C); ?is_lowercase_alpha(C)).
+-define(is_digit(C), C >= $0, C =< $9).
+-define(is_alphanumeric(C), ?is_alpha(C); ?is_digit(C)).
+-define(is_unreserved(C), ?is_alphanumeric(C); C =:= $-; C =:= $_; C =:= $.; C =:= $~).
+-define(is_hex(C), ?is_digit(C); C >= $A, C =< $F).
+
+
+unix_timestamp() ->
+  unix_timestamp(calendar:universal_time()).
+
+unix_timestamp(DateTime) ->
+  calendar:datetime_to_gregorian_seconds(DateTime) - unix_epoch().
+
+unix_epoch() ->
+  calendar:datetime_to_gregorian_seconds({{1970,1,1},{00,00,00}}).
+
+nonce() ->
+  base64:encode_to_string(crypto:rand_bytes(32)). % cf. ruby-oauth
+
+implode(Sep, Strings) when is_list(Strings) ->
+  implode(Sep, Strings, []).
+
+implode(_Sep, [], Imploded) ->
+  lists:flatten(lists:reverse(Imploded));
+implode(Sep, [String|Strings], []) ->
+  implode(Sep, Strings, [String]);
+implode(Sep, [String|Strings], Imploded) ->
+  implode(Sep, Strings, [String,Sep|Imploded]).
+
+percent_decode(Chars) when is_list(Chars) ->
+  percent_decode(Chars, []).
+
+percent_decode([], Decoded) ->
+  lists:reverse(Decoded);
+percent_decode([$%,A,B|Etc], Decoded) when ?is_hex(A), ?is_hex(B) ->
+  percent_decode(Etc, [erlang:list_to_integer([A,B], 16)|Decoded]);
+percent_decode([C|Etc], Decoded) when ?is_unreserved(C) ->
+  percent_decode(Etc, [C|Decoded]).