%-*-Mode:erlang;coding:utf-8;tab-width:4;c-basic-offset:4;indent-tabs-mode:()-*- % ex: set ft=erlang fenc=utf-8 sts=4 ts=4 sw=4 et nomod: %%% %%%------------------------------------------------------------------------ %%% @doc %%% ==CloudI OAuth v1.0 Service== %%% Provide OAuth v1.0 support as a CloudI service based on RFC 5849 %%% (http://tools.ietf.org/html/rfc5849). %%% @end %%% %%% MIT License %%% %%% Copyright (c) 2014-2022 Michael Truog %%% %%% 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. %%% %%% @author Michael Truog %%% @copyright 2014-2022 Michael Truog %%% @version 2.0.5 {@date} {@time} %%%------------------------------------------------------------------------ -module(cloudi_service_oauth1). -author('mjtruog at protonmail dot com'). -behaviour(cloudi_service). %% external interface %% cloudi_service callbacks -export([cloudi_service_init/4, cloudi_service_handle_request/11, cloudi_service_handle_info/3, cloudi_service_terminate/3]). -include_lib("cloudi_core/include/cloudi_logger.hrl"). -define(DEFAULT_DATABASE_TYPE, pgsql). -define(DEFAULT_DATABASE, undefined). -define(DEFAULT_URL_HOST, "http://0.0.0.0:8000"). % see below: % - must not end with a "/" % from http://tools.ietf.org/html/rfc5849#section-3.4.1.2 % - must be lowercase % - must include port number if not 80 % - must match Host HTTP request header value -define(DEFAULT_AUTHENTICATION, undefined). % see below: % either an anonymous function or a {module(), atom()} tuple % (to reference a function), i.e.: % fun((RequestHeaders :: cloudi_service:key_values(), % Request :: binary(), % Timeout :: cloudi_service:timeout_value_milliseconds()) -> % Valid :: boolean()) % can be provided as {Module, FunctionName} % for checking the user's current authentication status before % using the OAuth authorize endpoint % (if this value is set to undefined, no function checks the user's % authentication in this service, so that needs to have been done % by the service sending to the OAuth authorize endpoint) -define(DEFAULT_TOKENS_CLEAN, 300). % seconds (how often to delete) -define(DEFAULT_TOKEN_REQUEST_LENGTH, 12). % characters minimum -define(DEFAULT_TOKEN_REQUEST_SECRET_LENGTH, 12). % characters minimum -define(DEFAULT_TOKEN_REQUEST_EXPIRATION, 300). % seconds -define(DEFAULT_TOKEN_ACCESS_LENGTH, 12). % characters minimum -define(DEFAULT_TOKEN_ACCESS_SECRET_LENGTH, 12). % characters minimum -define(DEFAULT_TOKEN_ACCESS_EXPIRATION, 300). % seconds -define(DEFAULT_VERIFIER_LENGTH, 12). % characters minimum -define(DEFAULT_DEBUG_DB, false). % see below: % debug db data with consumer key from % http://tools.ietf.org/html/rfc5849#section-1.2 -define(DEFAULT_DEBUG, false). % see below: % debug with raw data from % http://tools.ietf.org/html/rfc5849#section-1.2 -record(state, { database_module :: module(), database :: cloudi_service:service_name(), url_host :: string(), host :: binary(), authentication_f :: fun((cloudi_service:key_values(), binary(), cloudi_service:timeout_value_milliseconds()) -> boolean()) | undefined, token_request_bytes :: pos_integer(), token_request_secret_bytes :: pos_integer(), token_request_expiration :: pos_integer(), token_access_bytes :: pos_integer(), token_access_secret_bytes :: pos_integer(), token_access_expiration :: pos_integer(), verifier_bytes :: pos_integer(), debug_db :: boolean(), debug :: boolean(), prefix_length :: pos_integer() }). % for features specific to Erlang/OTP version 21.x (and later versions) -ifdef(ERLANG_OTP_VERSION_19). -else. -ifdef(ERLANG_OTP_VERSION_20). -else. -ifdef(OTP_RELEASE). -define(ERLANG_OTP_VERSION_21_FEATURES, true). -else. -error("Erlang/OTP version invalid"). -endif. -endif. -endif. % Get the stacktrace in a way that is backwards compatible -ifdef(ERLANG_OTP_VERSION_21_FEATURES). -define(STACKTRACE(ErrorType, Error, ErrorStackTrace), ErrorType:Error:ErrorStackTrace ->). -else. -define(STACKTRACE(ErrorType, Error, ErrorStackTrace), ErrorType:Error -> ErrorStackTrace = erlang:get_stacktrace(),). -endif. %%%------------------------------------------------------------------------ %%% External interface functions %%%------------------------------------------------------------------------ %%%------------------------------------------------------------------------ %%% Callback functions from cloudi_service %%%------------------------------------------------------------------------ cloudi_service_init(Args, Prefix, _Timeout, Dispatcher) -> Defaults = [ {database_type, ?DEFAULT_DATABASE_TYPE}, {database, ?DEFAULT_DATABASE}, {url_host, ?DEFAULT_URL_HOST}, {authentication, ?DEFAULT_AUTHENTICATION}, {tokens_clean, ?DEFAULT_TOKENS_CLEAN}, {token_request_length, ?DEFAULT_TOKEN_REQUEST_LENGTH}, {token_request_secret_length, ?DEFAULT_TOKEN_REQUEST_SECRET_LENGTH}, {token_request_expiration, ?DEFAULT_TOKEN_REQUEST_EXPIRATION}, {token_access_length, ?DEFAULT_TOKEN_ACCESS_LENGTH}, {token_access_secret_length, ?DEFAULT_TOKEN_ACCESS_SECRET_LENGTH}, {token_access_expiration, ?DEFAULT_TOKEN_ACCESS_EXPIRATION}, {verifier_length, ?DEFAULT_VERIFIER_LENGTH}, {debug_db, ?DEFAULT_DEBUG_DB}, {debug, ?DEFAULT_DEBUG}], [DatabaseType, Database, URLHost, Authentication0, TokensClean, TokenRequestLength, TokenRequestSecretLength, TokenRequestExpiration, TokenAccessLength, TokenAccessSecretLength, TokenAccessExpiration, VerifierLength, DebugDB0, Debug ] = cloudi_proplists:take_values(Defaults, Args), cloudi_service:self(Dispatcher) ! initialize, % db initialize DatabaseModule = if DatabaseType =:= pgsql -> cloudi_service_oauth1_db_pgsql; DatabaseType =:= riak -> cloudi_service_oauth1_db_riak end, true = (is_list(Database) andalso is_integer(hd(Database))), true = (is_list(URLHost) andalso is_integer(hd(URLHost))), Host = case URLHost of "https://" ++ HostValue -> HostValue; "http://" ++ HostValue -> HostValue end, Authentication1 = case Authentication0 of {AuthenticationModule, AuthenticationFunction} when is_atom(AuthenticationModule), is_atom(AuthenticationFunction) -> {file, _} = code:is_loaded(AuthenticationModule), fun(AuthenticationRequestHeaders, AuthenticationRequest, AuthenticationTimeout) -> AuthenticationModule: AuthenticationFunction(AuthenticationRequestHeaders, AuthenticationRequest, AuthenticationTimeout) end; _ when is_function(Authentication0, 3) -> Authentication0; undefined -> undefined end, if TokensClean =:= undefined -> ok; is_integer(TokensClean), TokensClean > 0, TokensClean =< 4294967 -> _ = erlang:send_after(TokensClean * 1000, cloudi_service:self(Dispatcher), {tokens_clean, TokensClean}), ok end, true = (is_integer(TokenRequestLength) andalso (TokenRequestLength > 0)), true = (is_integer(TokenRequestSecretLength) andalso (TokenRequestSecretLength > 0)), true = (is_integer(TokenRequestExpiration) andalso (TokenRequestExpiration > 0)), true = (is_integer(TokenAccessLength) andalso (TokenAccessLength > 0)), true = (is_integer(TokenAccessSecretLength) andalso (TokenAccessSecretLength > 0)), true = (is_integer(TokenAccessExpiration) andalso (TokenAccessExpiration > 0)), true = (is_integer(VerifierLength) andalso (VerifierLength > 0)), DebugDB1 = if Debug =:= true -> true; Debug =:= false -> true = is_boolean(DebugDB0), DebugDB0 end, false = cloudi_service_name:pattern(Prefix), % endpoints based on http://tools.ietf.org/html/rfc5849#section-1.2 ok = cloudi_service:subscribe(Dispatcher, "initiate/post"), ok = cloudi_service:subscribe(Dispatcher, "authorize/get"), ok = cloudi_service:subscribe(Dispatcher, "token/post"), % revoke an access token ok = cloudi_service:subscribe(Dispatcher, "token/delete"), % proxy verify requests through ok = cloudi_service:subscribe(Dispatcher, "verify*"), {ok, #state{database_module = DatabaseModule, database = Database, url_host = URLHost, host = erlang:list_to_binary(Host), authentication_f = Authentication1, token_request_bytes = token_length_to_bytes(TokenRequestLength), token_request_secret_bytes = token_length_to_bytes(TokenRequestSecretLength), token_request_expiration = TokenRequestExpiration, token_access_bytes = token_length_to_bytes(TokenAccessLength), token_access_secret_bytes = token_length_to_bytes(TokenAccessSecretLength), token_access_expiration = TokenAccessExpiration, verifier_bytes = token_length_to_bytes(VerifierLength), debug_db = DebugDB1, debug = Debug, prefix_length = erlang:length(Prefix)}}. cloudi_service_handle_request(_RequestType, Name, Pattern, RequestInfo, Request, Timeout, _Priority, _TransId, _Source, #state{prefix_length = PrefixLength} = State, Dispatcher) -> Suffix = lists:nthtail(PrefixLength, Pattern), RequestHeaders = cloudi_request_info:key_value_parse(RequestInfo), request(Suffix, Name, Pattern, RequestHeaders, Request, Timeout, State, Dispatcher). cloudi_service_handle_info(initialize, #state{database_module = DatabaseModule, database = Database, debug_db = DebugDB} = State, Dispatcher) -> ok = DatabaseModule:initialize(Dispatcher, Database, DebugDB), {noreply, State}; cloudi_service_handle_info({tokens_clean, TokensClean} = Request, #state{database_module = DatabaseModule, database = Database} = State, Dispatcher) -> ok = DatabaseModule:tokens_clean(Dispatcher, Database), erlang:send_after(TokensClean * 1000, cloudi_service:self(Dispatcher), Request), {noreply, State}; cloudi_service_handle_info(Request, State, _Dispatcher) -> {stop, cloudi_string:format("Unknown info \"~w\"", [Request]), State}. cloudi_service_terminate(_Reason, _Timeout, _State) -> ok. %%%------------------------------------------------------------------------ %%% Private functions %%%------------------------------------------------------------------------ request("initiate/post", _Name, _Pattern, RequestHeaders, Request, Timeout, #state{url_host = URLHost, host = Host} = State, Dispatcher) -> Method = "POST", case url(Method, RequestHeaders, URLHost, Host, Request) of {ok, URL} -> case oauth_parameters(Method, RequestHeaders, Request) of {ok, Params} -> request_initiate(Method, URL, Params, Timeout, State, Dispatcher); {error, _} -> response_error_authorization_type(State) end; {error, _} -> response_error_authorization_type(State) end; request("authorize/get", _Name, _Pattern, RequestHeaders, RequestQS, Timeout, #state{url_host = URLHost, host = Host, authentication_f = AuthenticationF} = State, Dispatcher) -> Method = "GET", case url(Method, RequestHeaders, URLHost, Host, RequestQS) of {ok, _} -> Authenticated = if AuthenticationF =:= undefined -> true; is_function(AuthenticationF) -> AuthenticationF(RequestHeaders, RequestQS, Timeout) end, if Authenticated =:= true -> case lists:keyfind(<<"oauth_token">>, 1, RequestQS) of {_, TokenRequest} -> request_authorize(RequestQS, TokenRequest, Timeout, State, Dispatcher); false -> response_error_authorization_type(State) end; Authenticated =:= false -> response_error_authorization_type(State) end; {error, _} -> response_error_authorization_type(State) end; request("token/post", _Name, _Pattern, RequestHeaders, Request, Timeout, #state{url_host = URLHost, host = Host} = State, Dispatcher) -> Method = "POST", case url(Method, RequestHeaders, URLHost, Host, Request) of {ok, URL} -> case oauth_parameters(Method, RequestHeaders, Request) of {ok, Params} -> request_token(Method, URL, Params, Timeout, State, Dispatcher); {error, _} -> response_error_authorization_type(State) end; {error, _} -> response_error_authorization_type(State) end; request("token/delete", _Name, _Pattern, RequestHeaders, Request, Timeout, #state{url_host = URLHost, host = Host} = State, Dispatcher) -> Method = "DELETE", case url(Method, RequestHeaders, URLHost, Host, Request) of {ok, URL} -> case oauth_parameters(Method, RequestHeaders, Request) of {ok, Params} -> request_delete(Method, URL, Params, Timeout, State, Dispatcher); {error, _} -> response_error_authorization_type(State) end; {error, _} -> response_error_authorization_type(State) end; request("verify*", Name, Pattern, RequestHeaders, Request, Timeout, #state{url_host = URLHost, host = Host} = State, Dispatcher) -> [NextName] = cloudi_service_name:parse(Name, Pattern), Method = cloudi_string:uppercase(cloudi_string:afterr($/, NextName)), case url(Method, RequestHeaders, URLHost, Host, Request) of {ok, URL} -> case oauth_parameters(Method, RequestHeaders, Request) of {ok, Params} -> request_access(Method, URL, Params, NextName, RequestHeaders, Request, Timeout, State, Dispatcher); {error, _} -> response_error_authorization_type(State) end; {error, _} -> response_error_authorization_type(State) end. url(Method, RequestHeaders, URLHost, Host, Request) -> {ok, URLPath} = cloudi_key_value:find(<<"url-path">>, RequestHeaders), case cloudi_key_value:find(<<"host">>, RequestHeaders) of {ok, Host} when Method == "GET" -> {ok, URLHost ++ erlang:binary_to_list(URLPath) ++ "?" ++ erlang:binary_to_list(cow_qs:qs(Request))}; {ok, Host} -> {ok, URLHost ++ erlang:binary_to_list(URLPath)}; {ok, HostInvalid} -> % from http://tools.ietf.org/html/rfc5849#section-3.4.1.2 ?LOG_WARN("host invalid: ~s", [HostInvalid]), {error, host_invalid}; error -> {error, host_missing} end. oauth_parameters(Method, RequestHeaders, Request) -> QS = if Method == "GET" -> [{erlang:binary_to_list(K), erlang:binary_to_list(V)} || {K, V} <- Request]; true -> [] end, case cloudi_key_value:find(<<"authorization">>, RequestHeaders) of {ok, <>} when Scheme == <<"OAuth ">> -> case cloudi_service_oauth1_parse:authorization(OAuth) of [_ | _] = Params -> % from http://tools.ietf.org/html/rfc5849#section-3.2 case lists:keyfind("oauth_version", 1, Params) of {_, "1.0"} -> {ok, QS ++ Params}; {_, _} -> {error, oauth_version}; false -> {ok, QS ++ Params} end; {error, badarg} = Error -> Error end; {ok, _} -> {error, not_oauth}; error -> {error, not_found} end. -spec oauth_verify(Signature :: string(), Method :: string(), URL :: string(), Params :: list({string(), string()}), Consumer :: {string(), string(), plaintext | hmac_sha1 | rsa_sha1}, TokenSecret :: string()) -> boolean(). oauth_verify(Signature, Method, URL, Params, {_, _, _} = Consumer, TokenSecret) -> try cloudi_service_oauth1_data:verify(Signature, Method, URL, Params, Consumer, TokenSecret) of true -> true; false -> % invalid oauth_signature false catch ?STACKTRACE(ErrorType, Error, ErrorStackTrace) % implementation error? ?LOG_ERROR("oauth failed ~p ~p~n ~p", [ErrorType, Error, ErrorStackTrace]), false end. initiate_input_verify(Realm, ConsumerKey, "PLAINTEXT" = SignatureMethod, Signature, Timestamp, NonceRequest, Params, _Timeout, _State, _Dispatcher) -> {ok, {Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest}, Params}; initiate_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest, Params, Timeout, #state{database_module = DatabaseModule, database = Database}, Dispatcher) -> % check to make sure nonce/timestamp/token (if present) have not % been used before for "HMAC-SHA1" or "RSA-SHA1" signature methods % from http://tools.ietf.org/html/rfc5849#section-3.2 % (Timestamp is client-side time, so that is ignored. Just verifying % all the nonces for a single access token are different) case DatabaseModule:token_request_check(Dispatcher, Database, Realm, ConsumerKey, NonceRequest, Timeout) of ok -> {ok, {Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest}, Params}; {error, Reason} = Error -> ?LOG_ERROR("request token check error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), Error end. initiate_input_check(Realm, ConsumerKey, "PLAINTEXT" = SignatureMethod, Signature, Params, Timeout, State, Dispatcher) -> % from http://tools.ietf.org/html/rfc5849#section-3.1 Timestamp = null, NonceRequest = null, initiate_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest, Params, Timeout, State, Dispatcher); initiate_input_check(Realm, ConsumerKey, SignatureMethod, Signature, Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_timestamp", 1, Params) of {_, Timestamp} -> case lists:keyfind("oauth_nonce", 1, Params) of {_, NonceRequest} -> initiate_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest, Params, Timeout, State, Dispatcher); false -> {error, nonce_missing} end; false -> {error, timestamp_missing} end. initiate_input(Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_consumer_key", 1, Params) of {_, ConsumerKey} -> case lists:keyfind("oauth_signature_method", 1, Params) of {_, SignatureMethod} -> case clean_input(Params) of {ok, Signature, Realm, NewParams} -> initiate_input_check(Realm, ConsumerKey, SignatureMethod, Signature, NewParams, Timeout, State, Dispatcher); {error, _} = Error -> Error end; false -> {error, signature_method_missing} end; false -> {error, consumer_key_missing} end. request_initiate_store(_Realm, _ConsumerKey, _SignatureMethod, _ClientSharedSecret, _Timestamp, _NonceRequest, _Callback, _Timeout, #state{debug = true} = State, _Dispatcher) -> % example from http://tools.ietf.org/html/rfc5849#section-1.2 {reply, [{<<"content-type">>, <<"application/x-www-form-urlencoded">>}], <<"oauth_token=hh5s93j4hdidpola&oauth_token_secret=hdhd0244k9j7ao03&" "oauth_callback_confirmed=true">>, State}; request_initiate_store(Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, Callback, Timeout, #state{database_module = DatabaseModule, database = Database, token_request_expiration = TokenRequestExpiration} = State, Dispatcher) -> % provide request token and secret TokenRequest = token_request(State), TokenRequestSecret = token_request_secret(State), {CallbackURL, CallbackQS} = cloudi_string:splitl($?, Callback, input), case DatabaseModule:token_request_store(Dispatcher, Database, Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, TokenRequest, TokenRequestSecret, CallbackURL, CallbackQS, TokenRequestExpiration, Timeout) of ok -> L = ["oauth_token=", TokenRequest, "&oauth_token_secret=", TokenRequestSecret, "&oauth_callback_confirmed=true"], {reply, [{<<"content-type">>, <<"application/x-www-form-urlencoded">>}], erlang:iolist_to_binary(L), State}; {error, Reason} -> ?LOG_ERROR("token request store error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), response_error(400, [], State) end. request_initiate_verify(Signature, Method, URL, Params, {_, ClientSharedSecret, _} = Consumer, Realm, ConsumerKey, SignatureMethod, Timestamp, NonceRequest, CallbackRegex, Timeout, State, Dispatcher) -> % from http://tools.ietf.org/html/rfc5849#section-2.1 % (n.b., http://tools.ietf.org/html/rfc5849#section-3.2) case lists:keyfind("oauth_callback", 1, Params) of {_, Callback} -> case re:run(Callback, CallbackRegex) of {match, _} -> case oauth_verify(Signature, Method, URL, Params, Consumer, "") of true -> request_initiate_store(Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, Callback, Timeout, State, Dispatcher); false -> response_error_authorization_type(State) end; nomatch -> response_error_authorization_type(State) end; false -> response_error(400, [], State) end. signature_method_select("PLAINTEXT", {PLAINTEXT, _, _}) -> if PLAINTEXT =:= null -> {error, signature_method_plaintext_invalid}; true -> {ok, {erlang:binary_to_list(PLAINTEXT), plaintext}} end; signature_method_select("HMAC-SHA1", {_, HMAC_SHA1, _}) -> if HMAC_SHA1 =:= null -> {error, signature_method_hmac_sha1_invalid}; true -> {ok, {erlang:binary_to_list(HMAC_SHA1), hmac_sha1}} end; signature_method_select("RSA-SHA1", {_, _, RSA_SHA1}) -> if RSA_SHA1 =:= null -> {error, signature_method_rsa_sha1_invalid}; true -> % keep the certificate contents as binary data {ok, {RSA_SHA1, rsa_sha1}} end; signature_method_select(_, {_, _, _}) -> {error, signature_method_unsupported}. request_initiate(Method, URL, Params, Timeout, #state{database_module = DatabaseModule, database = Database} = State, Dispatcher) -> % similar to: oauth_mochiweb_server:serve_oauth_request_token/1 % (from the https://github.com/tim/erlang-oauth-examples repository) % (n.b., http://tools.ietf.org/html/rfc5849#section-3.2) case initiate_input(Params, Timeout, State, Dispatcher) of {ok, {Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceRequest}, NewParams} -> case DatabaseModule:signature_methods(Dispatcher, Database, Realm, ConsumerKey, Timeout) of {ok, {_, _, _} = SignatureMethods, CallbackRegex} -> case signature_method_select(SignatureMethod, SignatureMethods) of {ok, {ClientSharedSecret, SignatureMethodType}} -> Consumer = {ConsumerKey, ClientSharedSecret, SignatureMethodType}, request_initiate_verify(Signature, Method, URL, NewParams, Consumer, Realm, ConsumerKey, SignatureMethod, Timestamp, NonceRequest, CallbackRegex, Timeout, State, Dispatcher); {error, signature_method_unsupported} -> response_error(400, [], State); {error, Reason} -> % signature method invalid ?LOG_ERROR("signature method error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), response_error_authorization_type(State) end; {error, not_found} -> response_error_authorization_type(State); {error, Reason} -> % unable to get signature methods ?LOG_ERROR("signature methods error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), response_error(400, [], State) end; {error, nonce_exists} -> response_error_authorization_type(State); {error, _} -> % missing parameters response_error(400, [], State) end. callback_merge_qs_filter_callback(CallbackQS) -> callback_merge_qs_filter_callback(CallbackQS, []). callback_merge_qs_filter_callback([], Output) -> lists:reverse(Output); callback_merge_qs_filter_callback([{<>, _} | CallbackQS], Output) when Prefix == <<"oauth_">> -> callback_merge_qs_filter_callback(CallbackQS, Output); callback_merge_qs_filter_callback([Entry | CallbackQS], Output) -> callback_merge_qs_filter_callback(CallbackQS, [Entry | Output]). callback_merge_qs_filter_request(RequestQS, CallbackQS) -> callback_merge_qs_filter_request(RequestQS, [], CallbackQS). callback_merge_qs_filter_request([], Output, _) -> lists:reverse(Output); callback_merge_qs_filter_request([{<<"oauth_token">>, _} = Entry | RequestQS], Output, CallbackQS) -> callback_merge_qs_filter_request(RequestQS, [Entry | Output], CallbackQS); callback_merge_qs_filter_request([{<>, _} | RequestQS], Output, CallbackQS) when Prefix == <<"oauth_">> -> callback_merge_qs_filter_request(RequestQS, Output, CallbackQS); callback_merge_qs_filter_request([{K, _} = Entry | RequestQS], Output, CallbackQS) -> case lists:keyfind(K, 1, CallbackQS) of {_, _} -> % should not overwrite a callback qs value callback_merge_qs_filter_request(RequestQS, Output, CallbackQS); false -> callback_merge_qs_filter_request(RequestQS, [Entry | Output], CallbackQS) end. callback_merge(CallbackURL, CallbackQS, RequestQS, Verifier) -> NewCallbackQS = callback_merge_qs_filter_callback(CallbackQS), NewRequestQS = callback_merge_qs_filter_request(RequestQS, CallbackQS), QS = cow_qs:qs(NewCallbackQS ++ NewRequestQS ++ [{<<"oauth_verifier">>, Verifier}]), <>. request_authorize([{<<"oauth_token">>, <<"hh5s93j4hdidpola">>}] = RequestQS, <<"hh5s93j4hdidpola">>, _Timeout, #state{debug = true} = State, _Dispatcher) -> % example from http://tools.ietf.org/html/rfc5849#section-1.2 Verifier = <<"hfdp7dh39dks9884">>, CallbackURL = <<"http://printer.example.com/ready">>, Callback = callback_merge(CallbackURL, [], RequestQS, Verifier), response_redirect(Callback, State); request_authorize(RequestQS, TokenRequest, Timeout, #state{database_module = DatabaseModule, database = Database} = State, Dispatcher) -> % based on http://tools.ietf.org/html/rfc5849#section-2.2 Verifier = erlang:list_to_binary(verifier(State)), case DatabaseModule:token_request_find(Dispatcher, Database, TokenRequest, Timeout) of {ok, <<"oob">>, <<"">>} -> % based on http://tools.ietf.org/html/rfc5849#section-2.1 case DatabaseModule:token_request_update(Dispatcher, Database, TokenRequest, Verifier, Timeout) of ok -> {reply, [{<<"content-type">>, <<"application/x-www-form-urlencoded">>}], <<(<<"oauth_token=">>)/binary, TokenRequest/binary, $&, (<<"oauth_verifier=">>)/binary, Verifier/binary>>, State}; {error, Reason} -> ?LOG_ERROR("token request update error (~p): ~p", [TokenRequest, Reason]), response_error(400, [], State) end; {ok, CallbackURL, CallbackQS} -> Callback = callback_merge(CallbackURL, cow_qs:parse_qs(CallbackQS), RequestQS, Verifier), case DatabaseModule:token_request_update(Dispatcher, Database, TokenRequest, Verifier, Timeout) of ok -> response_redirect(Callback, State); {error, Reason} -> ?LOG_ERROR("token request update error (~p): ~p", [TokenRequest, Reason]), response_error(400, [], State) end; {error, Reason} -> ?LOG_WARN("token request find error (~p): ~p", [TokenRequest, Reason]), response_error_authorization_type(State) end. token_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceAccess, "hh5s93j4hdidpola" = TokenRequest, _Verifier, Params, _Timeout, #state{debug = true}, _Dispatcher) -> % example from http://tools.ietf.org/html/rfc5849#section-1.2 {ok, {Realm, ConsumerKey, SignatureMethod, "kd94hf93k423kf44", Signature, Timestamp, "wIjqoS", NonceAccess, TokenRequest, "hdhd0244k9j7ao03"}, Params}; token_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceAccess, TokenRequest, Verifier, Params, Timeout, #state{database_module = DatabaseModule, database = Database}, Dispatcher) -> case DatabaseModule:token_request_verify(Dispatcher, Database, Realm, ConsumerKey, SignatureMethod, Timestamp, NonceAccess, TokenRequest, Verifier, Timeout) of {ok, ClientSharedSecret, NonceRequest, TokenRequestSecret} -> {ok, {Realm, ConsumerKey, SignatureMethod, erlang:binary_to_list(ClientSharedSecret), Signature, Timestamp, erlang:binary_to_list(NonceRequest), NonceAccess, TokenRequest, erlang:binary_to_list(TokenRequestSecret)}, Params}; {error, Reason} = Error -> ?LOG_ERROR("request token check error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), Error end. token_input_check(Realm, ConsumerKey, "PLAINTEXT" = SignatureMethod, Signature, TokenRequest, Verifier, Params, Timeout, State, Dispatcher) -> % from http://tools.ietf.org/html/rfc5849#section-3.1 Timestamp = null, NonceAccess = null, token_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceAccess, TokenRequest, Verifier, Params, Timeout, State, Dispatcher); token_input_check(Realm, ConsumerKey, SignatureMethod, Signature, TokenRequest, Verifier, Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_timestamp", 1, Params) of {_, Timestamp} -> case lists:keyfind("oauth_nonce", 1, Params) of {_, NonceAccess} -> token_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, NonceAccess, TokenRequest, Verifier, Params, Timeout, State, Dispatcher); false -> {error, nonce_missing} end; false -> {error, timestamp_missing} end. token_input(Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_consumer_key", 1, Params) of {_, ConsumerKey} -> case lists:keyfind("oauth_signature_method", 1, Params) of {_, SignatureMethod} -> case lists:keyfind("oauth_token", 1, Params) of {_, TokenRequest} -> case lists:keyfind("oauth_verifier", 1, Params) of {_, Verifier} -> case clean_input(Params) of {ok, Signature, Realm, NewParams} -> token_input_check(Realm, ConsumerKey, SignatureMethod, Signature, TokenRequest, Verifier, NewParams, Timeout, State, Dispatcher); {error, _} = Error -> Error end; false -> {error, verifier_missing} end; false -> {error, token_missing} end; false -> {error, signature_method_missing} end; false -> {error, consumer_key_missing} end. request_token_access(_Realm, _ConsumerKey, _SignatureMethod, _ClientSharedSecret, _Timestamp, _NonceRequest, _NonceAccess, _TokenRequest, _Timeout, #state{debug = true} = State, _Dispatcher) -> % example from http://tools.ietf.org/html/rfc5849#section-1.2 {reply, [{<<"content-type">>, <<"application/x-www-form-urlencoded">>}], <<"oauth_token=nnch734d00sl2jdk&oauth_token_secret=pfkkdhi9sl3r4s00">>, State}; request_token_access(Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, NonceAccess, TokenRequest, Timeout, #state{database_module = DatabaseModule, database = Database, token_access_expiration = TokenAccessExpiration} = State, Dispatcher) -> TokenAccess = token_access(State), TokenAccessSecret = token_access_secret(State), case DatabaseModule:token_access_store(Dispatcher, Database, Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, NonceAccess, TokenAccess, TokenAccessSecret, TokenAccessExpiration, Timeout) of ok -> % ignore delete failure if it occurs % (can not use a transaction due to using an extended query, to % avoid SQL injection problems, if a failure does occur, % the expiration of the request token can still happen based on % time and the access token can still be used instead) DatabaseModule:token_request_delete(Dispatcher, Database, TokenRequest, Timeout), L = ["oauth_token=", TokenAccess, "&oauth_token_secret=", TokenAccessSecret], {reply, [{<<"content-type">>, <<"application/x-www-form-urlencoded">>}], erlang:iolist_to_binary(L), State}; {error, Reason} -> ?LOG_ERROR("token access store error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), response_error(400, [], State) end. request_token(Method, URL, Params, Timeout, #state{} = State, Dispatcher) -> % similar to: oauth_mochiweb_server:serve_oauth_access_token/1 % (from the https://github.com/tim/erlang-oauth-examples repository) % (n.b., http://tools.ietf.org/html/rfc5849#section-3.2) case token_input(Params, Timeout, State, Dispatcher) of {ok, {Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Signature, Timestamp, NonceRequest, NonceAccess, TokenRequest, TokenRequestSecret}, NewParams} -> SignatureMethodType = if SignatureMethod == "PLAINTEXT" -> plaintext; SignatureMethod == "HMAC-SHA1" -> hmac_sha1; SignatureMethod == "RSA-SHA1" -> rsa_sha1 end, Consumer = {ConsumerKey, ClientSharedSecret, SignatureMethodType}, case oauth_verify(Signature, Method, URL, NewParams, Consumer, TokenRequestSecret) of true -> request_token_access(Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Timestamp, NonceRequest, NonceAccess, TokenRequest, Timeout, State, Dispatcher); false -> response_error_authorization_type(State) end; {error, not_found} -> response_error_authorization_type(State); {error, _} -> % missing parameters response_error(400, [], State) end. access_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, Nonce, "nnch734d00sl2jdk" = TokenAccess, Params, _Timeout, #state{debug = true}, _Dispatcher) -> % example from http://tools.ietf.org/html/rfc5849#section-1.2 {ok, {Realm, ConsumerKey, SignatureMethod, "kd94hf93k423kf44", Signature, Timestamp, Nonce, TokenAccess, "pfkkdhi9sl3r4s00"}, Params}; access_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, Nonce, TokenAccess, Params, Timeout, #state{database_module = DatabaseModule, database = Database}, Dispatcher) -> case DatabaseModule:token_access_verify(Dispatcher, Database, Realm, ConsumerKey, SignatureMethod, Timestamp, Nonce, TokenAccess, Timeout) of {ok, ClientSharedSecret, TokenAccessSecret} -> {ok, {Realm, ConsumerKey, SignatureMethod, erlang:binary_to_list(ClientSharedSecret), Signature, Timestamp, Nonce, TokenAccess, erlang:binary_to_list(TokenAccessSecret)}, Params}; {error, Reason} = Error -> ?LOG_ERROR("access token check error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), Error end. access_input_check(Realm, ConsumerKey, "PLAINTEXT" = SignatureMethod, Signature, TokenAccess, Params, Timeout, State, Dispatcher) -> % from http://tools.ietf.org/html/rfc5849#section-3.1 Timestamp = null, Nonce = null, access_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, Nonce, TokenAccess, Params, Timeout, State, Dispatcher); access_input_check(Realm, ConsumerKey, SignatureMethod, Signature, TokenAccess, Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_timestamp", 1, Params) of {_, Timestamp} -> case lists:keyfind("oauth_nonce", 1, Params) of {_, Nonce} -> access_input_verify(Realm, ConsumerKey, SignatureMethod, Signature, Timestamp, Nonce, TokenAccess, Params, Timeout, State, Dispatcher); false -> {error, nonce_missing} end; false -> {error, timestamp_missing} end. access_input(Params, Timeout, State, Dispatcher) -> case lists:keyfind("oauth_consumer_key", 1, Params) of {_, ConsumerKey} -> case lists:keyfind("oauth_signature_method", 1, Params) of {_, SignatureMethod} -> case lists:keyfind("oauth_token", 1, Params) of {_, TokenAccess} -> case clean_input(Params) of {ok, Signature, Realm, NewParams} -> access_input_check(Realm, ConsumerKey, SignatureMethod, Signature, TokenAccess, NewParams, Timeout, State, Dispatcher); {error, _} = Error -> Error end; false -> {error, token_missing} end; false -> {error, signature_method_missing} end; false -> {error, consumer_key_missing} end. request_delete(Method, URL, Params, Timeout, #state{database_module = DatabaseModule, database = Database} = State, Dispatcher) -> case access_input(Params, Timeout, State, Dispatcher) of {ok, {Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Signature, _Timestamp, _Nonce, TokenAccess, TokenAccessSecret}, NewParams} -> SignatureMethodType = if SignatureMethod == "PLAINTEXT" -> plaintext; SignatureMethod == "HMAC-SHA1" -> hmac_sha1; SignatureMethod == "RSA-SHA1" -> rsa_sha1 end, Consumer = {ConsumerKey, ClientSharedSecret, SignatureMethodType}, case oauth_verify(Signature, Method, URL, NewParams, Consumer, TokenAccessSecret) of true -> % OAuth verification of the access token was successful, % delete the access token case DatabaseModule:token_request_delete(Dispatcher, Database, TokenAccess, Timeout) of ok -> {reply, [], <<>>, State}; {error, Reason} -> ?LOG_ERROR("access token delete error (~p, ~p): ~p", [Realm, ConsumerKey, Reason]), response_error(400, [], State) end; false -> response_error_authorization_type(State) end; {error, not_found} -> response_error_authorization_type(State); {error, _} -> % missing parameters response_error(400, [], State) end. request_access(Method, URL, Params, NextName, RequestHeaders, Request, Timeout, #state{} = State, Dispatcher) -> case access_input(Params, Timeout, State, Dispatcher) of {ok, {_Realm, ConsumerKey, SignatureMethod, ClientSharedSecret, Signature, _Timestamp, _Nonce, _TokenAccess, TokenAccessSecret}, NewParams} -> SignatureMethodType = if SignatureMethod == "PLAINTEXT" -> plaintext; SignatureMethod == "HMAC-SHA1" -> hmac_sha1; SignatureMethod == "RSA-SHA1" -> rsa_sha1 end, Consumer = {ConsumerKey, ClientSharedSecret, SignatureMethodType}, case oauth_verify(Signature, Method, URL, NewParams, Consumer, TokenAccessSecret) of true -> % OAuth verification of the access token was successful, % forward the service request to a different service {forward, NextName, RequestHeaders, Request, State}; false -> response_error_authorization_type(State) end; {error, not_found} -> response_error_authorization_type(State); {error, _} -> % missing parameters response_error(400, [], State) end. response_redirect(Callback, State) -> % from http://tools.ietf.org/html/rfc5849#section-2.2 {reply, [{<<"status">>, <<"302">>}, % 302 instead of 303 or 307 for compatibility {<<"location">>, Callback}], <<>>, State}. response_error_authorization_type(State) -> % from http://tools.ietf.org/html/rfc2617#section-1.2 response_error(401, [{<<"www-authenticate">>, <<"OAuth">>}], State). response_error(StatusCode, ResponseHeaders, State) when is_integer(StatusCode), StatusCode >= 400, StatusCode =< 599 -> {reply, [{<<"status">>, erlang:integer_to_binary(StatusCode)} | ResponseHeaders], <<>>, State}. clean_input(Params) -> case lists:keytake("oauth_signature", 1, Params) of {value, {_, Signature}, NextParams} -> case lists:keytake("realm", 1, NextParams) of {value, {_, Realm}, NewParams} -> {ok, Signature, Realm, NewParams}; false -> {ok, Signature, null, NextParams} end; false -> {error, signature_missing} end. token_i_to_list(I) when is_integer(I), I > 0 -> token_i_to_list([], I). token_i_to_list(L, I) when I < 62 -> [token_i_to_alphanum(I) | L]; token_i_to_list(L, I) -> token_i_to_list([token_i_to_alphanum(I rem 62) | L], I div 62). -compile({inline, [{token_i_to_alphanum,1}]}). % modulus 62 token_i_to_alphanum(I) when 0 =< I, I =< 9 -> I + $0; token_i_to_alphanum(I) when 10 =< I, I =< 35 -> (I - 10) + $a; token_i_to_alphanum(I) when 36 =< I, I =< 61 -> (I - 36) + $A. -spec token(Random :: binary()) -> string(). token(Random) when is_binary(Random) -> % avoids percent encoding problems by using only alphanum characters token_i_to_list(binary:decode_unsigned(Random, big)). token_length_to_bytes(Length) -> % will always have at least Length characters assuming typical randomness % (Length+1 will be created if an extra byte is necessary for the % last character) erlang:round((Length * (math:log(62) / math:log(2))) / 8). token_request(#state{token_request_bytes = N}) -> token(crypto:strong_rand_bytes(N)). token_request_secret(#state{token_request_secret_bytes = N}) -> token(crypto:strong_rand_bytes(N)). token_access(#state{token_access_bytes = N}) -> token(crypto:strong_rand_bytes(N)). token_access_secret(#state{token_access_secret_bytes = N}) -> token(crypto:strong_rand_bytes(N)). verifier(#state{verifier_bytes = N}) -> token(crypto:strong_rand_bytes(N)).