From 8f7fa3894902ed2181b45723388ee9087b92c18a Mon Sep 17 00:00:00 2001 From: Alexey Shchepin Date: Fri, 27 Sep 2019 20:36:35 +0300 Subject: [PATCH] Support OAUTH client authentication --- include/ejabberd_oauth.hrl | 7 ++ sql/lite.sql | 7 ++ sql/mysql.sql | 7 ++ sql/pg.sql | 7 ++ src/ejabberd_oauth.erl | 158 +++++++++++++++++++++++++--------- src/ejabberd_oauth_mnesia.erl | 21 +++++ src/ejabberd_oauth_rest.erl | 51 ++++++++++- src/ejabberd_oauth_sql.erl | 48 ++++++++++- src/ejabberd_option.erl | 8 ++ src/ejabberd_options.erl | 3 + 10 files changed, 274 insertions(+), 43 deletions(-) diff --git a/include/ejabberd_oauth.hrl b/include/ejabberd_oauth.hrl index 51e77636f..9254da1e5 100644 --- a/include/ejabberd_oauth.hrl +++ b/include/ejabberd_oauth.hrl @@ -24,3 +24,10 @@ scope = [] :: [binary()] | '_', expire :: integer() | '$1' | '_' }). + +-record(oauth_client, { + client = <<"">> :: binary() | '_', + secret = <<"">> :: binary() | '_', + grant_type = password :: password | '_', + options :: [any()] | '_' + }). diff --git a/sql/lite.sql b/sql/lite.sql index e8755029c..c77922c20 100644 --- a/sql/lite.sql +++ b/sql/lite.sql @@ -338,6 +338,13 @@ CREATE TABLE oauth_token ( expire bigint NOT NULL ); +CREATE TABLE oauth_client ( + client text PRIMARY KEY, + secret text NOT NULL, + grant_type text NOT NULL, + options text NOT NULL +); + CREATE TABLE route ( domain text NOT NULL, server_host text NOT NULL, diff --git a/sql/mysql.sql b/sql/mysql.sql index a05f8c86c..7f415a2e4 100644 --- a/sql/mysql.sql +++ b/sql/mysql.sql @@ -354,6 +354,13 @@ CREATE TABLE oauth_token ( expire bigint NOT NULL ) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE TABLE oauth_client ( + client varchar(191) NOT NULL PRIMARY KEY, + secret text NOT NULL, + grant_type text NOT NULL, + options text NOT NULL +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE TABLE route ( domain text NOT NULL, server_host text NOT NULL, diff --git a/sql/pg.sql b/sql/pg.sql index eae98d3f0..0f87fd5d8 100644 --- a/sql/pg.sql +++ b/sql/pg.sql @@ -358,6 +358,13 @@ CREATE TABLE oauth_token ( CREATE UNIQUE INDEX i_oauth_token_token ON oauth_token USING btree (token); +CREATE TABLE oauth_client ( + client text PRIMARY KEY, + secret text NOT NULL, + grant_type text NOT NULL, + options text NOT NULL +); + CREATE TABLE route ( domain text NOT NULL, server_host text NOT NULL, diff --git a/src/ejabberd_oauth.erl b/src/ejabberd_oauth.erl index d9b16c70e..4060b4b7b 100644 --- a/src/ejabberd_oauth.erl +++ b/src/ejabberd_oauth.erl @@ -49,7 +49,8 @@ verify_resowner_scope/3]). -export([get_commands_spec/0, - oauth_issue_token/3, oauth_list_tokens/0, oauth_revoke_token/1]). + oauth_issue_token/3, oauth_list_tokens/0, oauth_revoke_token/1, + oauth_add_client/3, oauth_remove_client/1]). -include("xmpp.hrl"). -include("logger.hrl"). @@ -97,6 +98,22 @@ get_commands_spec() -> policy = restricted, result = {tokens, {list, {token, {tuple, [{token, string}, {user, string}, {scope, string}, {expires_in, string}]}}}}, result_desc = "List of remaining tokens" + }, + #ejabberd_commands{name = oauth_add_client, tags = [oauth], + desc = "Add OAUTH client_id", + module = ?MODULE, function = oauth_add_client, + args = [{client_id, binary}, + {secret, binary}, + {grant_type, binary}], + policy = restricted, + result = {res, restuple} + }, + #ejabberd_commands{name = oauth_remove_client, tags = [oauth], + desc = "Remove OAUTH client_id", + module = ?MODULE, function = oauth_remove_client, + args = [{client_id, binary}], + policy = restricted, + result = {res, restuple} } ]. @@ -129,6 +146,24 @@ oauth_revoke_token(Token) -> ok = mnesia:dirty_delete(oauth_token, list_to_binary(Token)), oauth_list_tokens(). +oauth_add_client(Client, Secret, SGrantType) -> + case SGrantType of + <<"password">> -> + DBMod = get_db_backend(), + DBMod:store_client(#oauth_client{client = Client, + secret = Secret, + grant_type = password, + options = []}), + {ok, []}; + _ -> + {error, "Unsupported grant type"} + end. + +oauth_remove_client(Client) -> + DBMod = get_db_backend(), + DBMod:remove_client(Client), + {ok, []}. + config_reloaded() -> DBMod = get_db_backend(), case init_cache(DBMod) of @@ -535,48 +570,89 @@ process(_Handlers, end; process(_Handlers, #request{method = 'POST', q = Q, lang = _Lang, + auth = HTTPAuth, path = [_, <<"token">>]}) -> - case proplists:get_value(<<"grant_type">>, Q, <<"">>) of - <<"password">> -> - SScope = proplists:get_value(<<"scope">>, Q, <<"">>), - StringJID = proplists:get_value(<<"username">>, Q, <<"">>), - #jid{user = Username, server = Server} = jid:decode(StringJID), - Password = proplists:get_value(<<"password">>, Q, <<"">>), - Scope = str:tokens(SScope, <<" ">>), - TTL = proplists:get_value(<<"ttl">>, Q, <<"">>), - ExpiresIn = case TTL of - <<>> -> undefined; - _ -> binary_to_integer(TTL) + Access = + case ejabberd_option:oauth_client_id_check() of + allow -> + case proplists:get_value(<<"grant_type">>, Q, <<"">>) of + <<"password">> -> + password; + _ -> + unsupported_grant_type + end; + deny -> + deny; + db -> + {ClientID, Secret} = + case HTTPAuth of + {ClientID1, Secret1} -> + {ClientID1, Secret1}; + _ -> + ClientID1 = proplists:get_value( + <<"client_id">>, Q, <<"">>), + Secret1 = proplists:get_value( + <<"client_secret">>, Q, <<"">>), + {ClientID1, Secret1} end, - case oauth2:authorize_password({Username, Server}, - Scope, - {password, Password}) of - {ok, {_AppContext, Authorization}} -> - {ok, {_AppContext2, Response}} = - oauth2:issue_token(Authorization, [{expiry_time, ExpiresIn} || ExpiresIn /= undefined ]), - {ok, AccessToken} = oauth2_response:access_token(Response), - {ok, Type} = oauth2_response:token_type(Response), - %%Ugly: workardound to return the correct expirity time, given than oauth2 lib doesn't really have - %%per-case expirity time. - Expires = case ExpiresIn of - undefined -> - {ok, Ex} = oauth2_response:expires_in(Response), - Ex; - _ -> - ExpiresIn - end, - {ok, VerifiedScope} = oauth2_response:scope(Response), - json_response(200, {[ - {<<"access_token">>, AccessToken}, - {<<"token_type">>, Type}, - {<<"scope">>, str:join(VerifiedScope, <<" ">>)}, - {<<"expires_in">>, Expires}]}); - {error, Error} when is_atom(Error) -> - json_error(400, <<"invalid_grant">>, Error) - end; - _OtherGrantType -> - json_error(400, <<"unsupported_grant_type">>, unsupported_grant_type) - end; + DBMod = get_db_backend(), + case DBMod:lookup_client(ClientID) of + {ok, #oauth_client{secret = Secret} = Client} -> + case proplists:get_value(<<"grant_type">>, Q, <<"">>) of + <<"password">> when + Client#oauth_client.grant_type == password -> + password; + _ -> + unsupported_grant_type + end; + _ -> + deny + end + end, + case Access of + password -> + SScope = proplists:get_value(<<"scope">>, Q, <<"">>), + StringJID = proplists:get_value(<<"username">>, Q, <<"">>), + #jid{user = Username, server = Server} = jid:decode(StringJID), + Password = proplists:get_value(<<"password">>, Q, <<"">>), + Scope = str:tokens(SScope, <<" ">>), + TTL = proplists:get_value(<<"ttl">>, Q, <<"">>), + ExpiresIn = case TTL of + <<>> -> undefined; + _ -> binary_to_integer(TTL) + end, + case oauth2:authorize_password({Username, Server}, + Scope, + {password, Password}) of + {ok, {_AppContext, Authorization}} -> + {ok, {_AppContext2, Response}} = + oauth2:issue_token(Authorization, [{expiry_time, ExpiresIn} || ExpiresIn /= undefined ]), + {ok, AccessToken} = oauth2_response:access_token(Response), + {ok, Type} = oauth2_response:token_type(Response), + %%Ugly: workardound to return the correct expirity time, given than oauth2 lib doesn't really have + %%per-case expirity time. + Expires = case ExpiresIn of + undefined -> + {ok, Ex} = oauth2_response:expires_in(Response), + Ex; + _ -> + ExpiresIn + end, + {ok, VerifiedScope} = oauth2_response:scope(Response), + json_response(200, {[ + {<<"access_token">>, AccessToken}, + {<<"token_type">>, Type}, + {<<"scope">>, str:join(VerifiedScope, <<" ">>)}, + {<<"expires_in">>, Expires}]}); + {error, Error} when is_atom(Error) -> + json_error(400, <<"invalid_grant">>, Error) + end; + unsupported_grant_type -> + json_error(400, <<"unsupported_grant_type">>, + unsupported_grant_type); + deny -> + ejabberd_web:error(not_allowed) + end; process(_Handlers, _Request) -> ejabberd_web:error(not_found). diff --git a/src/ejabberd_oauth_mnesia.erl b/src/ejabberd_oauth_mnesia.erl index dcc70505c..de851f1ea 100644 --- a/src/ejabberd_oauth_mnesia.erl +++ b/src/ejabberd_oauth_mnesia.erl @@ -31,6 +31,9 @@ store/1, lookup/1, clean/1, + lookup_client/1, + store_client/1, + remove_client/1, use_cache/0]). -include("ejabberd_oauth.hrl"). @@ -40,6 +43,10 @@ init() -> [{disc_only_copies, [node()]}, {attributes, record_info(fields, oauth_token)}]), + ejabberd_mnesia:create(?MODULE, oauth_client, + [{disc_copies, [node()]}, + {attributes, + record_info(fields, oauth_client)}]), ok. use_cache() -> @@ -71,3 +78,17 @@ clean(TS) -> lists:foreach(fun mnesia:delete_object/1, Ts) end, mnesia:async_dirty(F). + +lookup_client(ClientID) -> + case catch mnesia:dirty_read(oauth_client, ClientID) of + [R] -> + {ok, R}; + _ -> + error + end. + +remove_client(ClientID) -> + mnesia:dirty_delete(oauth_client, ClientID). + +store_client(R) -> + mnesia:dirty_write(R). diff --git a/src/ejabberd_oauth_rest.erl b/src/ejabberd_oauth_rest.erl index 8ebfecf5a..b15fc904b 100644 --- a/src/ejabberd_oauth_rest.erl +++ b/src/ejabberd_oauth_rest.erl @@ -30,7 +30,9 @@ -export([init/0, store/1, lookup/1, - clean/1]). + clean/1, + lookup_client/1, + store_client/1]). -include("ejabberd_oauth.hrl"). -include("logger.hrl"). @@ -88,3 +90,50 @@ clean(_TS) -> path(Path) -> Base = ejabberd_option:ext_api_path_oauth(), <>. + +store_client(#oauth_client{client = Client, + secret = Secret, + grant_type = GrantType} = R) -> + Path = path(<<"store_client">>), + %% Retry 2 times, with a backoff of 500millisec + SGrantType = + case GrantType of + password -> <<"password">> + end, + case rest:with_retry( + post, + [ejabberd_config:get_myname(), Path, [], + {[{<<"client">>, Client}, + {<<"secret">>, Secret}, + {<<"grant_type">>, SGrantType}, + {<<"options">>, []} + ]}], 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, db_failure} + end. + +lookup_client(Client) -> + Path = path(<<"lookup_client">>), + case rest:with_retry(post, [ejabberd_config:get_myname(), Path, [], + {[{<<"client">>, Client}]}], + 2, 500) of + {ok, 200, {Data}} -> + Secret = proplists:get_value(<<"secret">>, Data, <<>>), + SGrantType = proplists:get_value(<<"grant_type">>, Data, <<>>), + GrantType = + case SGrantType of + <<"password">> -> password + end, + {ok, #oauth_client{client = Client, + secret = Secret, + grant_type = GrantType, + options = []}}; + {ok, 404, _Resp} -> + error; + Other -> + ?ERROR_MSG("Unexpected response for oauth lookup: ~p", [Other]), + error + end. diff --git a/src/ejabberd_oauth_sql.erl b/src/ejabberd_oauth_sql.erl index 8ce2bc574..724017af4 100644 --- a/src/ejabberd_oauth_sql.erl +++ b/src/ejabberd_oauth_sql.erl @@ -30,7 +30,10 @@ -export([init/0, store/1, lookup/1, - clean/1]). + clean/1, + lookup_client/1, + store_client/1, + remove_client/1]). -include("ejabberd_oauth.hrl"). -include("ejabberd_sql_pt.hrl"). @@ -80,3 +83,46 @@ clean(TS) -> ejabberd_config:get_myname(), ?SQL("delete from oauth_token where expire < %(TS)d")). +lookup_client(Client) -> + case ejabberd_sql:sql_query( + ejabberd_config:get_myname(), + ?SQL("select @(secret)s, @(grant_type)s" + " from oauth_client where client=%(Client)s")) of + {selected, [{Secret, SGrantType}]} -> + GrantType = + case SGrantType of + <<"password">> -> password + end, + {ok, #oauth_client{client = Client, + secret = Secret, + grant_type = GrantType, + options = []}}; + _ -> + error + end. + +store_client(#oauth_client{client = Client, + secret = Secret, + grant_type = GrantType}) -> + SGrantType = + case GrantType of + password -> <<"password">> + end, + SOptions = <<"">>, + case ?SQL_UPSERT( + ejabberd_config:get_myname(), + "oauth_client", + ["!client=%(Client)s", + "secret=%(Secret)s", + "grant_type=%(SGrantType)s", + "options=%(SOptions)s"]) of + ok -> + ok; + _ -> + {error, db_failure} + end. + +remove_client(Client) -> + ejabberd_sql:sql_query( + ejabberd_config:get_myname(), + ?SQL("delete from oauth_client where client=%(Client)s")). diff --git a/src/ejabberd_option.erl b/src/ejabberd_option.erl index e559db20f..94e79d80d 100644 --- a/src/ejabberd_option.erl +++ b/src/ejabberd_option.erl @@ -83,6 +83,7 @@ -export([oauth_cache_life_time/0]). -export([oauth_cache_missed/0]). -export([oauth_cache_size/0]). +-export([oauth_client_id_check/0, oauth_client_id_check/1]). -export([oauth_db_type/0]). -export([oauth_expire/0]). -export([oauth_use_cache/0]). @@ -620,6 +621,13 @@ oauth_cache_missed() -> oauth_cache_size() -> ejabberd_config:get_option({oauth_cache_size, global}). +-spec oauth_client_id_check() -> 'allow' | 'db' | 'deny'. +oauth_client_id_check() -> + oauth_client_id_check(global). +-spec oauth_client_id_check(global | binary()) -> 'allow' | 'db' | 'deny'. +oauth_client_id_check(Host) -> + ejabberd_config:get_option({oauth_client_id_check, Host}). + -spec oauth_db_type() -> atom(). oauth_db_type() -> ejabberd_config:get_option({oauth_db_type, global}). diff --git a/src/ejabberd_options.erl b/src/ejabberd_options.erl index 8468d181b..7bfa06ee6 100644 --- a/src/ejabberd_options.erl +++ b/src/ejabberd_options.erl @@ -234,6 +234,8 @@ opt_type(oauth_expire) -> econf:non_neg_int(); opt_type(oauth_use_cache) -> econf:bool(); +opt_type(oauth_client_id_check) -> + econf:enum([allow, deny, db]); opt_type(oom_killer) -> econf:bool(); opt_type(oom_queue) -> @@ -546,6 +548,7 @@ options() -> {oauth_expire, 4294967}, {oauth_use_cache, fun(Host) -> ejabberd_config:get_option({use_cache, Host}) end}, + {oauth_client_id_check, allow}, {oom_killer, true}, {oom_queue, 10000}, {oom_watermark, 80},