diff --git a/include/ejabberd_commands.hrl b/include/ejabberd_commands.hrl index 81be06dc36e..2b4eca581b6 100644 --- a/include/ejabberd_commands.hrl +++ b/include/ejabberd_commands.hrl @@ -26,6 +26,8 @@ {tuple, [rterm()]} | {list, rterm()} | rescode | restuple. +-type oauth_scope() :: atom(). + -record(ejabberd_commands, {name :: atom(), tags = [] :: [atom()] | '_' | '$2', diff --git a/src/ejabberd_commands.erl b/src/ejabberd_commands.erl index 9d41f50c223..075ff35cfc2 100644 --- a/src/ejabberd_commands.erl +++ b/src/ejabberd_commands.erl @@ -218,7 +218,7 @@ get_command_format/1, get_command_format/2, get_command_format/3, - get_command_policy/1, + get_command_policy_and_scope/1, get_command_definition/1, get_command_definition/2, get_tags_commands/0, @@ -366,17 +366,23 @@ get_command_format(Name, Auth, Version) -> {Args, Result} end. --spec get_command_policy(atom()) -> {ok, open|user|admin|restricted} | {error, command_not_found}. +-spec get_command_policy_and_scope(atom()) -> {ok, open|user|admin|restricted, [oauth_scope()]} | {error, command_not_found}. %% @doc return command policy. -get_command_policy(Name) -> +get_command_policy_and_scope(Name) -> case get_command_definition(Name) of - #ejabberd_commands{policy = Policy} -> - {ok, Policy}; + #ejabberd_commands{policy = Policy} = Cmd -> + {ok, Policy, cmd_scope(Cmd)}; command_not_found -> {error, command_not_found} end. +%% The oauth scopes for a command are the command name itself, +%% also might include either 'ejabberd:user' or 'ejabberd:admin' +cmd_scope(#ejabberd_commands{policy = Policy, name = Name}) -> + [erlang:atom_to_binary(Name,utf8)] ++ [<<"ejabberd:user">> || Policy == user] ++ [<<"ejabberd:admin">> || Policy == admin]. + + -spec get_command_definition(atom()) -> ejabberd_commands(). %% @doc Get the definition record of a command. @@ -627,8 +633,8 @@ check_access_commands(AccessCommands, Auth, Method, Command1, Arguments, CallerI check_auth(_Command, noauth) -> no_auth_provided; check_auth(Command, {User, Server, {oauth, Token}, _}) -> - Scope = erlang:atom_to_binary(Command#ejabberd_commands.name, utf8), - case ejabberd_oauth:check_token(User, Server, Scope, Token) of + ScopeList = cmd_scope(Command), + case ejabberd_oauth:check_token(User, Server, ScopeList, Token) of true -> {ok, User, Server}; false -> diff --git a/src/ejabberd_oauth.erl b/src/ejabberd_oauth.erl index 246bac127cb..0397571b281 100644 --- a/src/ejabberd_oauth.erl +++ b/src/ejabberd_oauth.erl @@ -90,7 +90,7 @@ start() -> get_commands_spec() -> [ #ejabberd_commands{name = oauth_issue_token, tags = [oauth], - desc = "Issue an oauth token. Available scopes are the ones usable by ejabberd admins", + desc = "Issue an oauth token for the given jid", module = ?MODULE, function = oauth_issue_token, args = [{jid, string},{scopes, string}], policy = restricted, @@ -106,11 +106,11 @@ get_commands_spec() -> result = {tokens, {list, {token, {tuple, [{token, string}, {user, string}, {scope, string}, {expires_in, string}]}}}} }, #ejabberd_commands{name = oauth_list_scopes, tags = [oauth], - desc = "List scopes that can be granted to tokens generated through the command line", + desc = "List scopes that can be granted to tokens generated through the command line, together with the commands they allow", module = ?MODULE, function = oauth_list_scopes, args = [], policy = restricted, - result = {scopes, {list, {scope, string}}} + result = {scopes, {list, {scope, {tuple, [{scope, string}, {commands, string}]}}}} }, #ejabberd_commands{name = oauth_revoke_token, tags = [oauth], desc = "Revoke authorization for a token", @@ -153,7 +153,7 @@ oauth_revoke_token(Token) -> oauth_list_tokens(). oauth_list_scopes() -> - get_cmd_scopes(). + [ {Scope, string:join([atom_to_list(Cmd) || Cmd <- Cmds], ",")} || {Scope, Cmds} <- dict:to_list(get_cmd_scopes())]. @@ -240,7 +240,7 @@ authenticate_client(Client, Ctx) -> {ok, {Ctx, {client, Client}}}. verify_resowner_scope({user, _User, _Server}, Scope, Ctx) -> Cmds = ejabberd_commands:get_commands(), - Cmds1 = [sasl_auth | Cmds], + Cmds1 = ['ejabberd:user', 'ejabberd:admin', sasl_auth | Cmds], RegisteredScope = [atom_to_binary(C, utf8) || C <- Cmds1], case oauth2_priv_set:is_subset(oauth2_priv_set:new(Scope), oauth2_priv_set:new(RegisteredScope)) of @@ -254,17 +254,27 @@ verify_resowner_scope(_, _, _) -> get_cmd_scopes() -> - Cmds = lists:filter(fun(Cmd) -> case ejabberd_commands:get_command_policy(Cmd) of - {ok, Policy} when Policy =/= restricted -> true; - _ -> false - end end, - ejabberd_commands:get_commands()), - [atom_to_binary(C, utf8) || C <- Cmds]. + ScopeMap = lists:foldl(fun(Cmd, Accum) -> + case ejabberd_commands:get_command_policy_and_scope(Cmd) of + {ok, Policy, Scopes} when Policy =/= restricted -> + lists:foldl(fun(Scope, Accum2) -> + dict:append(Scope, Cmd, Accum2) + end, Accum, Scopes); + _ -> Accum + end end, dict:new(), ejabberd_commands:get_commands()), + ScopeMap. + + %Scps = lists:flatmap(fun(Cmd) -> case ejabberd_commands:get_command_policy_and_scope(Cmd) of + % {ok, Policy, Scopes} when Policy =/= restricted -> Scopes; + % _ -> [] + % end end, + % ejabberd_commands:get_commands()), + %lists:usort(Scps). %% This is callback for oauth tokens generated through the command line. Only open and admin commands are %% made available. verify_client_scope({client, ejabberd_ctl}, Scope, Ctx) -> - RegisteredScope = get_cmd_scopes(), + RegisteredScope = dict:fetch_keys(get_cmd_scopes()), case oauth2_priv_set:is_subset(oauth2_priv_set:new(Scope), oauth2_priv_set:new(RegisteredScope)) of true -> @@ -299,7 +309,7 @@ associate_refresh_token(_RefreshToken, _Context, AppContext) -> {ok, AppContext}. -check_token(User, Server, Scope, Token) -> +check_token(User, Server, ScopeList, Token) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), case catch mnesia:dirty_read(oauth_token, Token) of @@ -308,23 +318,25 @@ check_token(User, Server, Scope, Token) -> expire = Expire}] -> {MegaSecs, Secs, _} = os:timestamp(), TS = 1000000 * MegaSecs + Secs, - oauth2_priv_set:is_member( - Scope, oauth2_priv_set:new(TokenScope)) andalso - Expire > TS; + TokenScopeSet = oauth2_priv_set:new(TokenScope), + lists:any(fun(Scope) -> + oauth2_priv_set:is_member(Scope, TokenScopeSet) end, + ScopeList) andalso Expire > TS; _ -> false end. -check_token(Scope, Token) -> +check_token(ScopeList, Token) -> case catch mnesia:dirty_read(oauth_token, Token) of [#oauth_token{us = US, scope = TokenScope, expire = Expire}] -> {MegaSecs, Secs, _} = os:timestamp(), TS = 1000000 * MegaSecs + Secs, - case oauth2_priv_set:is_member( - Scope, oauth2_priv_set:new(TokenScope)) andalso - Expire > TS of + TokenScopeSet = oauth2_priv_set:new(TokenScope), + case lists:any(fun(Scope) -> + oauth2_priv_set:is_member(Scope, TokenScopeSet) end, + ScopeList) andalso Expire > TS of true -> {ok, user, US}; false -> false end; diff --git a/src/mod_http_api.erl b/src/mod_http_api.erl index 1b4aa502ba6..f6621c09f22 100644 --- a/src/mod_http_api.erl +++ b/src/mod_http_api.erl @@ -133,13 +133,13 @@ depends(_Host, _Opts) -> check_permissions(Request, Command) -> case catch binary_to_existing_atom(Command, utf8) of Call when is_atom(Call) -> - {ok, CommandPolicy} = ejabberd_commands:get_command_policy(Call), - check_permissions2(Request, Call, CommandPolicy); + {ok, CommandPolicy, Scope} = ejabberd_commands:get_command_policy_and_scope(Call), + check_permissions2(Request, Call, CommandPolicy, Scope); _ -> unauthorized_response() end. -check_permissions2(#request{auth = HTTPAuth, headers = Headers}, Call, _) +check_permissions2(#request{auth = HTTPAuth, headers = Headers}, Call, _, ScopeList) when HTTPAuth /= undefined -> Admin = case lists:keysearch(<<"X-Admin">>, 1, Headers) of @@ -159,7 +159,7 @@ check_permissions2(#request{auth = HTTPAuth, headers = Headers}, Call, _) false end; {oauth, Token, _} -> - case oauth_check_token(Call, Token) of + case oauth_check_token(ScopeList, Token) of {ok, user, {User, Server}} -> {ok, {User, Server, {oauth, Token}, Admin}}; false -> @@ -172,9 +172,9 @@ check_permissions2(#request{auth = HTTPAuth, headers = Headers}, Call, _) {ok, A} -> {allowed, Call, A}; _ -> unauthorized_response() end; -check_permissions2(_Request, Call, open) -> +check_permissions2(_Request, Call, open, _Scope) -> {allowed, Call, noauth}; -check_permissions2(#request{ip={IP, _Port}}, Call, _Policy) -> +check_permissions2(#request{ip={IP, _Port}}, Call, _Policy, _Scope) -> Access = gen_mod:get_module_opt(global, ?MODULE, admin_ip_access, fun(V) -> V end, none), @@ -194,13 +194,11 @@ check_permissions2(#request{ip={IP, _Port}}, Call, _Policy) -> _E -> {allowed, Call, noauth} end; -check_permissions2(_Request, _Call, _Policy) -> +check_permissions2(_Request, _Call, _Policy, _Scope) -> unauthorized_response(). -oauth_check_token(Scope, Token) when is_atom(Scope) -> - oauth_check_token(atom_to_binary(Scope, utf8), Token); -oauth_check_token(Scope, Token) -> - ejabberd_oauth:check_token(Scope, Token). +oauth_check_token(ScopeList, Token) when is_list(ScopeList) -> + ejabberd_oauth:check_token(ScopeList, Token). %% ------------------ %% command processing diff --git a/test/mod_http_api_mock_test.exs b/test/mod_http_api_mock_test.exs index 47b1fe94ae1..db8761887f1 100644 --- a/test/mod_http_api_mock_test.exs +++ b/test/mod_http_api_mock_test.exs @@ -70,8 +70,8 @@ defmodule ModHttpApiMockTest do fn (@acommand, {@user, @domain, @userpass, false}, @version) -> {[], {:res, :rescode}} end) - :meck.expect(:ejabberd_commands, :get_command_policy, - fn (@acommand) -> {:ok, :user} end) + :meck.expect(:ejabberd_commands, :get_command_policy_and_scope, + fn (@acommand) -> {:ok, :user, [:erlang.atom_to_binary(@acommand,:utf8)]} end) :meck.expect(:ejabberd_commands, :get_commands, fn () -> [@acommand] end) :meck.expect(:ejabberd_commands, :execute_command, @@ -123,8 +123,8 @@ defmodule ModHttpApiMockTest do fn (@acommand, {@user, @domain, {:oauth, _token}, false}, @version) -> {[], {:res, :rescode}} end) - :meck.expect(:ejabberd_commands, :get_command_policy, - fn (@acommand) -> {:ok, :user} end) + :meck.expect(:ejabberd_commands, :get_command_policy_and_scope, + fn (@acommand) -> {:ok, :user, [:erlang.atom_to_binary(@acommand,:utf8), "ejabberd:user"]} end) :meck.expect(:ejabberd_commands, :get_commands, fn () -> [@acommand] end) :meck.expect(:ejabberd_commands, :execute_command, @@ -134,7 +134,7 @@ defmodule ModHttpApiMockTest do end) - # Correct OAuth call + # Correct OAuth call using specific scope token = EjabberdOauthMock.get_token @user, @domain, @command req = request(method: :GET, path: ["api", @command], @@ -147,6 +147,19 @@ defmodule ModHttpApiMockTest do assert 200 == elem(result, 0) # HTTP code assert "0" == elem(result, 2) # command result + # Correct OAuth call using specific ejabberd:user scope + token = EjabberdOauthMock.get_token @user, @domain, "ejabberd:user" + req = request(method: :GET, + path: ["api", @command], + q: [nokey: ""], + # OAuth + auth: {:oauth, token, []}, + ip: {{127,0,0,1},60000}, + host: @domain) + result = :mod_http_api.process([@command], req) + assert 200 == elem(result, 0) # HTTP code + assert "0" == elem(result, 2) # command result + # Wrong OAuth token req = request(method: :GET, path: ["api", @command], @@ -184,8 +197,8 @@ defmodule ModHttpApiMockTest do result = :mod_http_api.process([@command], req) assert 401 == elem(result, 0) # HTTP code - # Check that the command was executed only once - assert 1 == + # Check that the command was executed twice + assert 2 == :meck.num_calls(:ejabberd_commands, :execute_command, :_) assert :meck.validate :ejabberd_auth