From ac6f701033a42e0c81b220e78a29b0f94f8c2f99 Mon Sep 17 00:00:00 2001 From: Alexey Shchepin Date: Tue, 27 Sep 2016 05:57:14 +0300 Subject: [PATCH] Add http_p1.erl, rest.erl, and oauth2 ReST backend for tokens. --- src/ejabberd_app.erl | 1 + src/ejabberd_oauth_rest.erl | 98 ++++++++++ src/http_p1.erl | 358 ++++++++++++++++++++++++++++++++++++ src/rest.erl | 181 ++++++++++++++++++ 4 files changed, 638 insertions(+) create mode 100644 src/ejabberd_oauth_rest.erl create mode 100644 src/http_p1.erl create mode 100644 src/rest.erl diff --git a/src/ejabberd_app.erl b/src/ejabberd_app.erl index 890ab6f90..33da45013 100644 --- a/src/ejabberd_app.erl +++ b/src/ejabberd_app.erl @@ -225,6 +225,7 @@ start_apps() -> ejabberd:start_app(fast_tls), ejabberd:start_app(fast_xml), ejabberd:start_app(stringprep), + http_p1:start(), ejabberd:start_app(cache_tab). opt_type(net_ticktime) -> diff --git a/src/ejabberd_oauth_rest.erl b/src/ejabberd_oauth_rest.erl new file mode 100644 index 000000000..aadb97084 --- /dev/null +++ b/src/ejabberd_oauth_rest.erl @@ -0,0 +1,98 @@ +%%%------------------------------------------------------------------- +%%% File : ejabberd_oauth_rest.erl +%%% Author : Alexey Shchepin +%%% Purpose : OAUTH2 REST backend +%%% Created : 26 Jul 2016 by Alexey Shchepin +%%% +%%% +%%% ejabberd, Copyright (C) 2002-2016 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License +%%% along with this program; if not, write to the Free Software +%%% Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA +%%% 02111-1307 USA +%%% +%%%------------------------------------------------------------------- + +-module(ejabberd_oauth_rest). + +-export([init/0, + store/1, + lookup/1, + clean/1, + opt_type/1]). + +-include("ejabberd.hrl"). +-include("ejabberd_oauth.hrl"). +-include("logger.hrl"). +-include("jlib.hrl"). + +init() -> + rest:start(?MYNAME), + ok. + +store(R) -> + Path = path(<<"store">>), + %% Retry 2 times, with a backoff of 500millisec + {User, Server} = R#oauth_token.us, + SJID = jid:to_string({User, Server, <<"">>}), + case rest:with_retry( + post, + [?MYNAME, Path, [], + {[{<<"token">>, R#oauth_token.token}, + {<<"user">>, SJID}, + {<<"scope">>, R#oauth_token.scope}, + {<<"expire">>, R#oauth_token.expire} + ]}], 2, 500) of + {ok, Code, _} when Code == 200 orelse Code == 201 -> + ok; + Err -> + ?ERROR_MSG("failed to store oauth record ~p: ~p", [R, Err]), + {error, Err} + end. + +lookup(Token) -> + Path = path(<<"lookup">>), + case rest:with_retry(post, [?MYNAME, Path, [], + {[{<<"token">>, Token}]}], + 2, 500) of + {ok, 200, {Data}} -> + SJID = proplists:get_value(<<"user">>, Data, <<>>), + JID = jid:from_string(SJID), + US = {JID#jid.luser, JID#jid.lserver}, + Scope = proplists:get_value(<<"scope">>, Data, []), + Expire = proplists:get_value(<<"expire">>, Data, 0), + #oauth_token{token = Token, + us = US, + scope = Scope, + expire = Expire}; + {ok, 404, _Resp} -> + false; + Other -> + ?ERROR_MSG("Unexpected response for oauth lookup: ~p", [Other]), + {error, rest_failed} + end. + +clean(_TS) -> + ok. + +path(Path) -> + Base = ejabberd_config:get_option(ext_api_path_oauth, + fun(X) -> iolist_to_binary(X) end, + <<"/oauth">>), + <>. + + +opt_type(ext_api_path_oauth) -> + fun (X) -> iolist_to_binary(X) end; +opt_type(_) -> [ext_api_path_oauth]. diff --git a/src/http_p1.erl b/src/http_p1.erl new file mode 100644 index 000000000..6ede758f2 --- /dev/null +++ b/src/http_p1.erl @@ -0,0 +1,358 @@ +%%%---------------------------------------------------------------------- +%%% File : http_p1.erl +%%% Author : Emilio Bustos +%%% Purpose : Provide a common API for inets / lhttpc / ibrowse +%%% Created : 29 Jul 2010 by Emilio Bustos +%%% +%%% +%%% ejabberd, Copyright (C) 2002-2016 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%---------------------------------------------------------------------- + +-module(http_p1). + +-author('ebustos@process-one.net'). + +-export([start/0, stop/0, get/1, get/2, post/2, post/3, + request/3, request/4, request/5, + get_pool_size/0, set_pool_size/1]). + +-include("logger.hrl"). + +% -define(USE_INETS, 1). +-define(USE_LHTTPC, 1). +% -define(USE_IBROWSE, 1). +% inets used as default if none specified + +-ifdef(USE_IBROWSE). + +start() -> + ejabberd:start_app(ibrowse). + +stop() -> + application:stop(ibrowse). + +request(Method, URL, Hdrs, Body, Opts) -> + TimeOut = proplists:get_value(timeout, Opts, infinity), + Options = [{inactivity_timeout, TimeOut} + | proplists:delete(timeout, Opts)], + case ibrowse:send_req(URL, Hdrs, Method, Body, Options) + of + {ok, Status, Headers, Response} -> + {ok, jlib:binary_to_integer(Status), Headers, + Response}; + {error, Reason} -> {error, Reason} + end. + +get_pool_size() -> + application:get_env(ibrowse, default_max_sessions, 10). + +set_pool_size(Size) -> + application:set_env(ibrowse, default_max_sessions, Size). + +-else. + +-ifdef(USE_LHTTPC). + +start() -> + ejabberd:start_app(lhttpc). + +stop() -> + application:stop(lhttpc). + +request(Method, URL, Hdrs, Body, Opts) -> + {[TO, SO], Rest} = proplists:split(Opts, [timeout, socket_options]), + TimeOut = proplists:get_value(timeout, TO, infinity), + SockOpt = proplists:get_value(socket_options, SO, []), + Options = [{connect_options, SockOpt} | Rest], + Result = lhttpc:request(URL, Method, Hdrs, Body, TimeOut, Options), + ?DEBUG("HTTP request -> response:~n" + "** Method = ~p~n" + "** URI = ~s~n" + "** Body = ~s~n" + "** Hdrs = ~p~n" + "** Timeout = ~p~n" + "** Options = ~p~n" + "** Response = ~p", + [Method, URL, Body, Hdrs, TimeOut, Options, Result]), + case Result of + {ok, {{Status, _Reason}, Headers, Response}} -> + {ok, Status, Headers, (Response)}; + {error, Reason} -> {error, Reason} + end. + +get_pool_size() -> + Opts = proplists:get_value(lhttpc_manager, lhttpc_manager:list_pools()), + proplists:get_value(max_pool_size,Opts). + +set_pool_size(Size) -> + lhttpc_manager:set_max_pool_size(lhttpc_manager, Size). + +-else. + +start() -> + ejabberd:start_app(inets). + +stop() -> + application:stop(inets). + +to_list(Str) when is_binary(Str) -> + binary_to_list(Str); +to_list(Str) -> + Str. + +request(Method, URLRaw, HdrsRaw, Body, Opts) -> + Hdrs = lists:map(fun({N, V}) -> + {to_list(N), to_list(V)} + end, HdrsRaw), + URL = to_list(URLRaw), + + Request = case Method of + get -> {URL, Hdrs}; + head -> {URL, Hdrs}; + delete -> {URL, Hdrs}; + _ -> % post, etc. + {URL, Hdrs, + to_list(proplists:get_value(<<"content-type">>, HdrsRaw, [])), + Body} + end, + Options = case proplists:get_value(timeout, Opts, + infinity) + of + infinity -> proplists:delete(timeout, Opts); + _ -> Opts + end, + case httpc:request(Method, Request, Options, []) of + {ok, {{_, Status, _}, Headers, Response}} -> + {ok, Status, Headers, Response}; + {error, Reason} -> {error, Reason} + end. + +get_pool_size() -> + {ok, Size} = httpc:get_option(max_sessions), + Size. + +set_pool_size(Size) -> + httpc:set_option(max_sessions, Size). + +-endif. + +-endif. + +-type({header, + {type, 63, tuple, + [{type, 63, union, + [{type, 63, string, []}, {type, 63, atom, []}]}, + {type, 63, string, []}]}, + []}). + +-type({headers, + {type, 64, list, [{type, 64, header, []}]}, []}). + +-type({option, + {type, 67, union, + [{type, 67, tuple, + [{atom, 67, connect_timeout}, {type, 67, timeout, []}]}, + {type, 68, tuple, + [{atom, 68, timeout}, {type, 68, timeout, []}]}, + {type, 70, tuple, + [{atom, 70, send_retry}, + {type, 70, non_neg_integer, []}]}, + {type, 71, tuple, + [{atom, 71, partial_upload}, + {type, 71, union, + [{type, 71, non_neg_integer, []}, + {atom, 71, infinity}]}]}, + {type, 72, tuple, + [{atom, 72, partial_download}, {type, 72, pid, []}, + {type, 72, union, + [{type, 72, non_neg_integer, []}, + {atom, 72, infinity}]}]}]}, + []}). + +-type({options, + {type, 74, list, [{type, 74, option, []}]}, []}). + +-type({result, + {type, 76, union, + [{type, 76, tuple, + [{atom, 76, ok}, + {type, 76, tuple, + [{type, 76, tuple, + [{type, 76, pos_integer, []}, {type, 76, string, []}]}, + {type, 76, headers, []}, {type, 76, string, []}]}]}, + {type, 77, tuple, + [{atom, 77, error}, {type, 77, atom, []}]}]}, + []}). + +%% @spec (URL) -> Result +%% URL = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a GET request. +%% Would be the same as calling `request(get, URL, [])', +%% that is {@link request/3} with an empty header list. +%% @end +%% @see request/3 +-spec get(string()) -> result(). +get(URL) -> request(get, URL, []). + +%% @spec (URL, Hdrs) -> Result +%% URL = string() +%% Hdrs = [{Header, Value}] +%% Header = string() +%% Value = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a GET request. +%% Would be the same as calling `request(get, URL, Hdrs)'. +%% @end +%% @see request/3 +-spec get(string(), headers()) -> result(). +get(URL, Hdrs) -> request(get, URL, Hdrs). + +%% @spec (URL, RequestBody) -> Result +%% URL = string() +%% RequestBody = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a POST request with form data. +%% Would be the same as calling +%% `request(post, URL, [{"content-type", "x-www-form-urlencoded"}], Body)'. +%% @end +%% @see request/4 +-spec post(string(), string()) -> result(). +post(URL, Body) -> + request(post, URL, + [{<<"content-type">>, <<"x-www-form-urlencoded">>}], + Body). + +%% @spec (URL, Hdrs, RequestBody) -> Result +%% URL = string() +%% Hdrs = [{Header, Value}] +%% Header = string() +%% Value = string() +%% RequestBody = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a POST request. +%% Would be the same as calling +%% `request(post, URL, Hdrs, Body)'. +%% @end +%% @see request/4 +-spec post(string(), headers(), string()) -> result(). +post(URL, Hdrs, Body) -> + NewHdrs = case [X + || {X, _} <- Hdrs, + str:to_lower(X) == <<"content-type">>] + of + [] -> + [{<<"content-type">>, <<"x-www-form-urlencoded">>} + | Hdrs]; + _ -> Hdrs + end, + request(post, URL, NewHdrs, Body). + +%% @spec (Method, URL, Hdrs) -> Result +%% Method = atom() +%% URL = string() +%% Hdrs = [{Header, Value}] +%% Header = string() +%% Value = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a request without a body. +%% Would be the same as calling `request(Method, URL, Hdrs, [], [])', +%% that is {@link request/5} with an empty body. +%% @end +%% @see request/5 +-spec request(atom(), string(), headers()) -> result(). +request(Method, URL, Hdrs) -> + request(Method, URL, Hdrs, [], []). + +%% @spec (Method, URL, Hdrs, RequestBody) -> Result +%% Method = atom() +%% URL = string() +%% Hdrs = [{Header, Value}] +%% Header = string() +%% Value = string() +%% RequestBody = string() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a request with a body. +%% Would be the same as calling +%% `request(Method, URL, Hdrs, Body, [])', that is {@link request/5} +%% with no options. +%% @end +%% @see request/5 +-spec request(atom(), string(), headers(), string()) -> result(). +request(Method, URL, Hdrs, Body) -> + request(Method, URL, Hdrs, Body, []). + +%% @spec (Method, URL, Hdrs, RequestBody, Options) -> Result +%% Method = atom() +%% URL = string() +%% Hdrs = [{Header, Value}] +%% Header = string() +%% Value = string() +%% RequestBody = string() +%% Options = [Option] +%% Option = {timeout, Milliseconds | infinity} | +%% {connect_timeout, Milliseconds | infinity} | +%% {socket_options, [term()]} | + +%% Milliseconds = integer() +%% Result = {ok, StatusCode, Hdrs, ResponseBody} +%% | {error, Reason} +%% StatusCode = integer() +%% ResponseBody = string() +%% Reason = connection_closed | connect_timeout | timeout +%% @doc Sends a request with a body. +%% Would be the same as calling +%% `request(Method, URL, Hdrs, Body, [])', that is {@link request/5} +%% with no options. +%% @end +%% @see request/5 +-spec request(atom(), string(), headers(), string(), options()) -> result(). + +% ibrowse {response_format, response_format()} | +% Options - [option()] +% Option - {sync, boolean()} | {stream, StreamTo} | {body_format, body_format()} | {full_result, +% boolean()} | {headers_as_is, boolean()} +%body_format() = string() | binary() +% The body_format option is only valid for the synchronous request and the default is string. +% When making an asynchronous request the body will always be received as a binary. +% lhttpc: always binary + diff --git a/src/rest.erl b/src/rest.erl new file mode 100644 index 000000000..01b04f66a --- /dev/null +++ b/src/rest.erl @@ -0,0 +1,181 @@ +%%%---------------------------------------------------------------------- +%%% File : rest.erl +%%% Author : Christophe Romain +%%% Purpose : Generic REST client +%%% Created : 16 Oct 2014 by Christophe Romain +%%% +%%% +%%% ejabberd, Copyright (C) 2002-2016 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%---------------------------------------------------------------------- + +-module(rest). + +-behaviour(ejabberd_config). + +-export([start/1, stop/1, get/2, get/3, post/4, delete/2, + request/6, with_retry/4, opt_type/1]). + +-include("logger.hrl"). + +-define(HTTP_TIMEOUT, 10000). +-define(CONNECT_TIMEOUT, 8000). + +start(Host) -> + http_p1:start(), + Pool_size = + ejabberd_config:get_option({ext_api_http_pool_size, Host}, + fun(X) when is_integer(X), X > 0-> + X + end, + 100), + http_p1:set_pool_size(Pool_size). + +stop(_Host) -> + ok. + +with_retry(Method, Args, MaxRetries, Backoff) -> + with_retry(Method, Args, 0, MaxRetries, Backoff). +with_retry(Method, Args, Retries, MaxRetries, Backoff) -> + case apply(?MODULE, Method, Args) of + %% Only retry on timeout errors + {error, {http_error,{error,Error}}} + when Retries < MaxRetries + andalso (Error == 'timeout' orelse Error == 'connect_timeout') -> + timer:sleep(round(math:pow(2, Retries)) * Backoff), + with_retry(Method, Args, Retries+1, MaxRetries, Backoff); + Result -> + Result + end. + +get(Server, Path) -> + request(Server, get, Path, [], "application/json", <<>>). +get(Server, Path, Params) -> + request(Server, get, Path, Params, "application/json", <<>>). + +delete(Server, Path) -> + request(Server, delete, Path, [], "application/json", <<>>). + +post(Server, Path, Params, Content) -> + Data = case catch jiffy:encode(Content) of + {'EXIT', Reason} -> + ?ERROR_MSG("HTTP content encodage failed:~n" + "** Content = ~p~n" + "** Err = ~p", + [Content, Reason]), + <<>>; + Encoded -> + Encoded + end, + request(Server, post, Path, Params, "application/json", Data). + +request(Server, Method, Path, Params, Mime, Data) -> + URI = url(Server, Path, Params), + Opts = [{connect_timeout, ?CONNECT_TIMEOUT}, + {timeout, ?HTTP_TIMEOUT}], + Hdrs = [{"connection", "keep-alive"}, + {"content-type", Mime}, + {"User-Agent", "ejabberd"}], + Begin = os:timestamp(), + Result = case catch http_p1:request(Method, URI, Hdrs, Data, Opts) of + {ok, Code, _, <<>>} -> + {ok, Code, []}; + {ok, Code, _, <<" ">>} -> + {ok, Code, []}; + {ok, Code, _, <<"\r\n">>} -> + {ok, Code, []}; + {ok, Code, _, Body} -> + try jiffy:decode(Body) of + JSon -> + {ok, Code, JSon} + catch + _:Error -> + ?ERROR_MSG("HTTP response decode failed:~n" + "** URI = ~s~n" + "** Body = ~p~n" + "** Err = ~p", + [URI, Body, Error]), + {error, {invalid_json, Body}} + end; + {error, Reason} -> + ?ERROR_MSG("HTTP request failed:~n" + "** URI = ~s~n" + "** Err = ~p", + [URI, Reason]), + {error, {http_error, {error, Reason}}}; + {'EXIT', Reason} -> + ?ERROR_MSG("HTTP request failed:~n" + "** URI = ~s~n" + "** Err = ~p", + [URI, Reason]), + {error, {http_error, {error, Reason}}} + end, + ejabberd_hooks:run(backend_api_call, Server, [Server, Method, Path]), + case Result of + {ok, _, _} -> + End = os:timestamp(), + Elapsed = timer:now_diff(End, Begin) div 1000, %% time in ms + ejabberd_hooks:run(backend_api_response_time, Server, + [Server, Method, Path, Elapsed]); + {error, {http_error,{error,timeout}}} -> + ejabberd_hooks:run(backend_api_timeout, Server, + [Server, Method, Path]); + {error, {http_error,{error,connect_timeout}}} -> + ejabberd_hooks:run(backend_api_timeout, Server, + [Server, Method, Path]); + {error, _} -> + ejabberd_hooks:run(backend_api_error, Server, + [Server, Method, Path]) + end, + Result. + +%%%---------------------------------------------------------------------- +%%% HTTP helpers +%%%---------------------------------------------------------------------- + +base_url(Server, Path) -> + Tail = case iolist_to_binary(Path) of + <<$/, Ok/binary>> -> Ok; + Ok -> Ok + end, + case Tail of + <<"http", _Url/binary>> -> Tail; + _ -> + Base = ejabberd_config:get_option({ext_api_url, Server}, + fun(X) -> + iolist_to_binary(X) + end, + <<"http://localhost/api">>), + <> + end. + +url(Server, Path, []) -> + binary_to_list(base_url(Server, Path)); +url(Server, Path, Params) -> + Base = base_url(Server, Path), + [<<$&, ParHead/binary>> | ParTail] = + [<<"&", (iolist_to_binary(Key))/binary, "=", + (ejabberd_http:url_encode(Value))/binary>> + || {Key, Value} <- Params], + Tail = iolist_to_binary([ParHead | ParTail]), + binary_to_list(<>). + +opt_type(ext_api_http_pool_size) -> + fun (X) when is_integer(X), X > 0 -> X end; +opt_type(ext_api_url) -> + fun (X) -> iolist_to_binary(X) end; +opt_type(_) -> [ext_api_http_pool_size, ext_api_url].