From 98e0123ca48cc23f699b5c61598740fa6b8a320e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chmielowski?= Date: Wed, 5 Oct 2016 13:21:11 +0200 Subject: New api permissions framework --- src/acl.erl | 40 +-- src/ejabberd_access_permissions.erl | 527 ++++++++++++++++++++++++++++++++++++ src/ejabberd_admin.erl | 3 +- src/ejabberd_app.erl | 1 + src/ejabberd_commands.erl | 53 +++- src/ejabberd_config.erl | 3 +- src/ejabberd_ctl.erl | 13 +- src/ejabberd_oauth.erl | 25 ++ src/mod_http_api.erl | 195 +++++++------ 9 files changed, 724 insertions(+), 136 deletions(-) create mode 100644 src/ejabberd_access_permissions.erl (limited to 'src') diff --git a/src/acl.erl b/src/acl.erl index 349198182..1476081dd 100644 --- a/src/acl.erl +++ b/src/acl.erl @@ -36,7 +36,8 @@ acl_rule_verify/1, access_matches/3, transform_access_rules_config/1, parse_ip_netmask/1, - access_rules_validator/1, shaper_rules_validator/1]). + access_rules_validator/1, shaper_rules_validator/1, + normalize_spec/1, resolve_access/2]). -include("ejabberd.hrl"). -include("logger.hrl"). @@ -437,30 +438,35 @@ acl_rule_matches({node_glob, {UR, SR}}, #{usr := {U, S, _}}, _Host) -> acl_rule_matches(_ACL, _Data, _Host) -> false. --spec access_matches(atom()|list(), any(), global|binary()) -> any(). -access_matches(all, _Data, _Host) -> - allow; -access_matches(none, _Data, _Host) -> - deny; -access_matches(Name, Data, Host) when is_atom(Name) -> +resolve_access(all, _Host) -> + all; +resolve_access(none, _Host) -> + none; +resolve_access(Name, Host) when is_atom(Name) -> GAccess = mnesia:dirty_read(access, {Name, global}), LAccess = - if Host /= global -> mnesia:dirty_read(access, {Name, Host}); - true -> [] - end, + if Host /= global -> mnesia:dirty_read(access, {Name, Host}); + true -> [] + end, case GAccess ++ LAccess of [] -> - deny; + []; AccessList -> - Rules = lists:flatmap( + lists:flatmap( fun(#access{rules = Rs}) -> Rs - end, AccessList), - access_rules_matches(Rules, Data, Host) + end, AccessList) end; -access_matches(Rules, Data, Host) when is_list(Rules) -> - access_rules_matches(Rules, Data, Host). - +resolve_access(Rules, _Host) when is_list(Rules) -> + Rules. + +-spec access_matches(atom()|list(), any(), global|binary()) -> allow|deny. +access_matches(Rules, Data, Host) -> + case resolve_access(Rules, Host) of + all -> allow; + none -> deny; + RRules -> access_rules_matches(RRules, Data, Host) + end. -spec access_rules_matches(list(), any(), global|binary()) -> any(). diff --git a/src/ejabberd_access_permissions.erl b/src/ejabberd_access_permissions.erl new file mode 100644 index 000000000..f37de9a13 --- /dev/null +++ b/src/ejabberd_access_permissions.erl @@ -0,0 +1,527 @@ +%%%------------------------------------------------------------------- +%%% File : ejabberd_access_permissions.erl +%%% Author : Paweł Chmielowski +%%% Purpose : Administrative functions and commands +%%% Created : 7 Sep 2016 by Paweł Chmielowski +%%% +%%% +%%% 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(ejabberd_access_permissions). +-author("pawel@process-one.net"). + +-include("ejabberd_commands.hrl"). +-include("logger.hrl"). + +-behaviour(gen_server). +-behavior(ejabberd_config). + +%% API +-export([start_link/0, + parse_api_permissions/1, + can_access/2, + invalidate/0, + opt_type/1, + show_current_definitions/0, + register_permission_addon/2, + unregister_permission_addon/1]). + +%% gen_server callbacks +-export([init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3]). + +-define(SERVER, ?MODULE). + +-record(state, { + definitions = none, + fragments_generators = [] +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec can_access(atom(), map()) -> allow | deny. +can_access(Cmd, CallerInfo) -> + gen_server:call(?MODULE, {can_access, Cmd, CallerInfo}). + +-spec invalidate() -> ok. +invalidate() -> + gen_server:cast(?MODULE, invalidate). + +-spec register_permission_addon(atom(), fun()) -> ok. +register_permission_addon(Name, Fun) -> + gen_server:call(?MODULE, {register_config_fragment_generator, Name, Fun}). + +-spec unregister_permission_addon(atom()) -> ok. +unregister_permission_addon(Name) -> + gen_server:call(?MODULE, {unregister_config_fragment_generator, Name}). + +-spec show_current_definitions() -> any(). +show_current_definitions() -> + gen_server:call(?MODULE, show_current_definitions). + +%%-------------------------------------------------------------------- +%% @doc +%% Starts the server +%% +%% @end +%%-------------------------------------------------------------------- +-spec start_link() -> {ok, Pid :: pid()} | ignore | {error, Reason :: term()}. +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Initializes the server +%% +%% @spec init(Args) -> {ok, State} | +%% {ok, State, Timeout} | +%% ignore | +%% {stop, Reason} +%% @end +%%-------------------------------------------------------------------- +-spec init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore. +init([]) -> + {ok, #state{}}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling call messages +%% +%% @end +%%-------------------------------------------------------------------- +-spec handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}. +handle_call({can_access, Cmd, CallerInfo}, _From, State) -> + CallerModule = maps:get(caller_module, CallerInfo, none), + Host = maps:get(caller_host, CallerInfo, global), + {State2, Defs} = get_definitions(State), + Res = lists:foldl( + fun({Name, _} = Def, none) -> + case matches_definition(Def, Cmd, CallerModule, Host, CallerInfo) of + true -> + ?DEBUG("Command '~p' execution allowed by rule '~s' (CallerInfo=~p)", [Cmd, Name, CallerInfo]), + allow; + _ -> + none + end; + (_, Val) -> + Val + end, none, Defs), + Res2 = case Res of + allow -> allow; + _ -> + ?DEBUG("Command '~p' execution denied (CallerInfo=~p)", [Cmd, CallerInfo]), + deny + end, + {reply, Res2, State2}; +handle_call(show_current_definitions, _From, State) -> + {State2, Defs} = get_definitions(State), + {reply, Defs, State2}; +handle_call({register_config_fragment_generator, Name, Fun}, _From, #state{fragments_generators = Gens} = State) -> + NGens = lists:keystore(Name, 1, Gens, {Name, Fun}), + {reply, ok, State#state{fragments_generators = NGens}}; +handle_call({unregister_config_fragment_generator, Name}, _From, #state{fragments_generators = Gens} = State) -> + NGens = lists:keydelete(Name, 1, Gens), + {reply, ok, State#state{fragments_generators = NGens}}; +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling cast messages +%% +%% @end +%%-------------------------------------------------------------------- +-spec handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}. +handle_cast(invalidate, State) -> + {noreply, State#state{definitions = none}}; +handle_cast(_Request, State) -> + {noreply, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Handling all non call/cast messages +%% +%% @spec handle_info(Info, State) -> {noreply, State} | +%% {noreply, State, Timeout} | +%% {stop, Reason, State} +%% @end +%%-------------------------------------------------------------------- +-spec handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}. +handle_info(_Info, State) -> + {noreply, State}. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +%% +%% @spec terminate(Reason, State) -> void() +%% @end +%%-------------------------------------------------------------------- +-spec terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term(). +terminate(_Reason, _State) -> + ok. + +%%-------------------------------------------------------------------- +%% @private +%% @doc +%% Convert process state when code is changed +%% +%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState} +%% @end +%%-------------------------------------------------------------------- +-spec code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}. +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +get_definitions(#state{definitions = Defs, fragments_generators = Gens} = State) -> + DefaultOptions = [{<<"console commands">>, + [ejabberd_ctl], + [{acl, all}], + {all, none}}, + {<<"admin access">>, + [], + [{acl, admin}], + {all, [start, stop]}}], + NDefs = case Defs of + none -> + ApiPerms = ejabberd_config:get_option(api_permissions, fun(A) -> A end, DefaultOptions), + AllCommands = ejabberd_commands:get_commands_definition(), + Frags = lists:foldl( + fun({_Name, Generator}, Acc) -> + Acc ++ Generator() + end, [], Gens), + lists:map( + fun({Name, {From, Who, {Add, Del}}}) -> + Cmds = filter_commands_with_permissions(AllCommands, Add, Del), + {Name, {From, Who, Cmds}} + end, ApiPerms ++ Frags); + V -> + V + end, + {State#state{definitions = NDefs}, NDefs}. + +matches_definition({_Name, {From, Who, What}}, Cmd, Module, Host, CallerInfo) -> + case lists:member(Cmd, What) of + true -> + case From == [] orelse lists:member(Module, From) of + true -> + Scope = maps:get(scope, CallerInfo, none), + lists:any( + fun({access, Access}) when Scope == none -> + acl:access_matches(Access, CallerInfo, Host) == allow; + ({acl, _} = Acl) when Scope == none -> + acl:acl_rule_matches(Acl, CallerInfo, Host); + ({oauth, List}) when Scope /= none -> + lists:all( + fun({access, Access}) -> + acl:access_matches(Access, CallerInfo, Host) == allow; + ({acl, _} = Acl) -> + acl:acl_rule_matches(Acl, CallerInfo, Host); + ({scope, Scopes}) -> + ejabberd_oauth:scope_in_scope_list(Scope, Scopes) + end, List); + (_) -> + false + end, Who); + _ -> + false + end; + _ -> + false + end. + +filter_commands_with_permissions(AllCommands, Add, Del) -> + CommandsAdd = filter_commands_with_patterns(AllCommands, Add, []), + CommandsDel = filter_commands_with_patterns(CommandsAdd, Del, []), + lists:map(fun(#ejabberd_commands{name = N}) -> N end, + CommandsAdd -- CommandsDel). + +filter_commands_with_patterns([], _Patterns, Acc) -> + Acc; +filter_commands_with_patterns([C | CRest], Patterns, Acc) -> + case command_matches_patterns(C, Patterns) of + true -> + filter_commands_with_patterns(CRest, Patterns, [C | Acc]); + _ -> + filter_commands_with_patterns(CRest, Patterns, Acc) + end. + +command_matches_patterns(_, all) -> + true; +command_matches_patterns(_, none) -> + false; +command_matches_patterns(_, []) -> + false; +command_matches_patterns(#ejabberd_commands{tags = Tags} = C, [{tag, Tag} | Tail]) -> + case lists:member(Tag, Tags) of + true -> + true; + _ -> + command_matches_patterns(C, Tail) + end; +command_matches_patterns(#ejabberd_commands{name = Name}, [Name | _Tail]) -> + true; +command_matches_patterns(C, [_ | Tail]) -> + command_matches_patterns(C, Tail). + +%%%=================================================================== +%%% Options parsing code +%%%=================================================================== + +parse_api_permissions(Data) when is_list(Data) -> + throw({replace_with, [parse_api_permission(Name, Args) || {Name, Args} <- Data]}). + +parse_api_permission(Name, Args) -> + {From, Who, What} = case key_split(Args, [{from, []}, {who, none}, {what, []}]) of + {error, Msg} -> + report_error(<<"~s inside api_permission '~s' section">>, [Msg, Name]); + Val -> Val + end, + {Name, {parse_from(Name, From), parse_who(Name, Who, oauth), parse_what(Name, What)}}. + +parse_from(_Name, Module) when is_atom(Module) -> + [Module]; +parse_from(Name, Modules) when is_list(Modules) -> + lists:foreach(fun(Module) when is_atom(Module) -> + ok; + (Val) -> + report_error(<<"Invalid value '~p' used inside 'from' section for api_permission '~s'">>, + [Val, Name]) + end, Modules), + Modules; +parse_from(Name, Val) -> + report_error(<<"Invalid value '~p' used inside 'from' section for api_permission '~s'">>, + [Val, Name]). + +parse_who(Name, Atom, ParseOauth) when is_atom(Atom) -> + parse_who(Name, [Atom], ParseOauth); +parse_who(Name, Defs, ParseOauth) when is_list(Defs) -> + lists:map( + fun([{access, Val}]) -> + try acl:access_rules_validator(Val) of + Rule -> + {access, Rule} + catch + throw:{invalid_syntax, Msg} -> + report_error(<<"Invalid access rule: '~s' used inside 'who' section for api_permission '~s'">>, + [Msg, Name]); + throw:{replace_with, NVal} -> + {access, NVal}; + error:_ -> + report_error(<<"Invalid access rule '~p' used inside 'who' section for api_permission '~s'">>, + [Val, Name]) + end; + ([{oauth, OauthList}]) when is_list(OauthList) -> + case ParseOauth of + oauth -> + {oauth, parse_who(Name, lists:flatten(OauthList), scope)}; + scope -> + report_error(<<"Oauth rule can't be embeded inside other oauth rule in 'who' section for api_permission '~s'">>, + [Name]) + end; + ({scope, ScopeList}) -> + case ParseOauth of + oauth -> + report_error(<<"Scope can be included only inside oauth rule in 'who' section for api_permission '~s'">>, + [Name]); + scope -> + ScopeList2 = case ScopeList of + V when is_binary(V) -> [V]; + V2 when is_list(V2) -> V2; + V3 -> + report_error(<<"Invalid value for scope '~p' in 'who' section for api_permission '~s'">>, + [V3, Name]) + end, + {scope, ScopeList2} + end; + (Atom) when is_atom(Atom) -> + {acl, Atom}; + ([Other]) -> + try acl:normalize_spec(Other) of + Rule2 -> + {acl, Rule2} + catch + _:_ -> + report_error(<<"Invalid value '~p' used inside 'who' section for api_permission '~s'">>, + [Other, Name]) + end; + (Invalid) -> + report_error(<<"Invalid value '~p' used inside 'who' section for api_permission '~s'">>, + [Invalid, Name]) + end, Defs); +parse_who(Name, Val, _ParseOauth) -> + report_error(<<"Invalid value '~p' used inside 'who' section for api_permission '~s'">>, + [Val, Name]). + +parse_what(Name, Binary) when is_binary(Binary) -> + parse_what(Name, [Binary]); +parse_what(Name, Defs) when is_list(Defs) -> + {A, D} = lists:foldl( + fun(Def, {Add, Del}) -> + case parse_single_what(Def) of + {error, Err} -> + report_error(<<"~s used in value '~p' in 'what' section for api_permission '~s'">>, + [Err, Def, Name]); + all -> + {case Add of none -> none; _ -> all end, Del}; + {neg, all} -> + {none, all}; + {neg, Value} -> + {Add, case Del of L when is_list(L) -> [Value | L]; L2 -> L2 end}; + Value -> + {case Add of L when is_list(L) -> [Value | L]; L2 -> L2 end, Del} + end + end, {[], []}, Defs), + case {A, D} of + {[], _} -> + {none, all}; + {A2, []} -> + {A2, none}; + V -> + V + end; +parse_what(Name, Val) -> + report_error(<<"Invalid value '~p' used inside 'what' section for api_permission '~s'">>, + [Val, Name]). + +parse_single_what(<<"*">>) -> + all; +parse_single_what(<<"!*">>) -> + {neg, all}; +parse_single_what(<<"!", Rest/binary>>) -> + case parse_single_what(Rest) of + {neg, _} -> + {error, <<"Double negation">>}; + {error, _} = Err -> + Err; + V -> + {neg, V} + end; +parse_single_what(<<"[tag:", Rest/binary>>) -> + case binary:split(Rest, <<"]">>) of + [TagName, <<"">>] -> + case parse_single_what(TagName) of + {error, _} = Err -> + Err; + V when is_atom(V) -> + {tag, V}; + _ -> + {error, <<"Invalid tag">>} + end; + _ -> + {error, <<"Invalid tag">>} + end; +parse_single_what(Binary) when is_binary(Binary) -> + case is_valid_command_name(Binary) of + true -> + binary_to_atom(Binary, latin1); + _ -> + {error, <<"Invalid value">>} + end; +parse_single_what(_) -> + {error, <<"Invalid value">>}. + +is_valid_command_name(<<>>) -> + false; +is_valid_command_name(Val) -> + is_valid_command_name2(Val). + +is_valid_command_name2(<<>>) -> + true; +is_valid_command_name2(<>) when K >= $a andalso K =< $z orelse K == $_ -> + is_valid_command_name2(Rest); +is_valid_command_name2(_) -> + false. + +key_split(Args, Fields) -> + {_, Order1, Results1, Required1} = lists:foldl( + fun({Field, Default}, {Idx, Order, Results, Required}) -> + {Idx + 1, Order#{Field => Idx}, [Default | Results], Required}; + (Field, {Idx, Order, Results, Required}) -> + {Idx + 1, Order#{Field => Idx}, [none | Results], Required#{Field => 1}} + end, {1, #{}, [], #{}}, Fields), + key_split(Args, list_to_tuple(Results1), Order1, Required1, #{}). + +key_split([], _Results, _Order, Required, _Duplicates) when map_size(Required) > 0 -> + parse_error(<<"Missing fields '~s">>, [str:join(maps:keys(Required), <<", ">>)]); +key_split([], Results, _Order, _Required, _Duplicates) -> + Results; +key_split([{Arg, Value} | Rest], Results, Order, Required, Duplicates) -> + case maps:find(Arg, Order) of + {ok, Idx} -> + case maps:is_key(Arg, Duplicates) of + false -> + Results2 = setelement(Idx, Results, Value), + key_split(Rest, Results2, Order, maps:remove(Arg, Required), Duplicates#{Arg => 1}); + true -> + parse_error(<<"Duplicate field '~s'">>, [Arg]) + end; + _ -> + parse_error(<<"Unknown field '~s'">>, [Arg]) + end. + +report_error(Format, Args) -> + throw({invalid_syntax, iolist_to_binary(io_lib:format(Format, Args))}). + +parse_error(Format, Args) -> + {error, iolist_to_binary(io_lib:format(Format, Args))}. + +opt_type(api_permissions) -> + fun parse_api_permissions/1; +opt_type(_) -> + [api_permissions]. diff --git a/src/ejabberd_admin.erl b/src/ejabberd_admin.erl index ae2fac3e1..8622ea8d0 100644 --- a/src/ejabberd_admin.erl +++ b/src/ejabberd_admin.erl @@ -403,7 +403,8 @@ registered_vhosts() -> reload_config() -> ejabberd_config:reload_file(), acl:start(), - shaper:start(). + shaper:start(), + ejabberd_access_permissions:invalidate(). %%% %%% Cluster management diff --git a/src/ejabberd_app.erl b/src/ejabberd_app.erl index 33da45013..e4087142b 100644 --- a/src/ejabberd_app.erl +++ b/src/ejabberd_app.erl @@ -51,6 +51,7 @@ start(normal, _Args) -> db_init(), start(), translate:start(), + ejabberd_access_permissions:start_link(), ejabberd_ctl:init(), ejabberd_commands:init(), ejabberd_admin:start(), diff --git a/src/ejabberd_commands.erl b/src/ejabberd_commands.erl index d5649b2d7..173071d6f 100644 --- a/src/ejabberd_commands.erl +++ b/src/ejabberd_commands.erl @@ -218,23 +218,26 @@ get_command_format/1, get_command_format/2, get_command_format/3, - get_command_policy_and_scope/1, + get_command_policy_and_scope/1, get_command_definition/1, get_command_definition/2, get_tags_commands/0, get_tags_commands/1, - get_exposed_commands/0, + get_exposed_commands/0, register_commands/1, - unregister_commands/1, - expose_commands/1, + unregister_commands/1, + expose_commands/1, execute_command/2, execute_command/3, execute_command/4, execute_command/5, execute_command/6, - opt_type/1, - get_commands_spec/0 - ]). + opt_type/1, + get_commands_spec/0, + get_commands_definition/0, + get_commands_definition/1, + execute_command2/3, + execute_command2/4]). -include("ejabberd_commands.hrl"). -include("ejabberd.hrl"). @@ -280,7 +283,8 @@ init() -> {attributes, record_info(fields, ejabberd_commands)}, {type, bag}]), mnesia:add_table_copy(ejabberd_commands, node(), ram_copies), - register_commands(get_commands_spec()). + register_commands(get_commands_spec()), + ejabberd_access_permissions:register_permission_addon(?MODULE, fun permission_addon/0). -spec register_commands([ejabberd_commands()]) -> ok. @@ -296,7 +300,9 @@ register_commands(Commands) -> mnesia:dirty_write(Command) %% ?DEBUG("This command is already defined:~n~p", [Command]) end, - Commands). + Commands), + ejabberd_access_permissions:invalidate(), + ok. -spec unregister_commands([ejabberd_commands()]) -> ok. @@ -306,7 +312,9 @@ unregister_commands(Commands) -> fun(Command) -> mnesia:dirty_delete_object(Command) end, - Commands). + Commands), + ejabberd_access_permissions:invalidate(), + ok. %% @doc Expose command through ejabberd ReST API. %% Pass a list of command names or policy to expose. @@ -427,6 +435,9 @@ get_command_definition(Name, Version) -> _E -> throw({error, unknown_command}) end. +get_commands_definition() -> + get_commands_definition(?DEFAULT_VERSION). + -spec get_commands_definition(integer()) -> [ejabberd_commands()]. % @doc Returns all commands for a given API version @@ -448,6 +459,18 @@ get_commands_definition(Version) -> end, lists:foldl(F, [], L). +execute_command2(Name, Arguments, CallerInfo) -> + execute_command(Name, Arguments, CallerInfo, ?DEFAULT_VERSION). + +execute_command2(Name, Arguments, CallerInfo, Version) -> + Command = get_command_definition(Name, Version), + case ejabberd_access_permissions:can_access(Name, CallerInfo) of + allow -> + do_execute_command(Command, Arguments); + _ -> + throw({error, access_rules_unauthorized}) + end. + %% @spec (Name::atom(), Arguments) -> ResultTerm %% where %% Arguments = [any()] @@ -811,6 +834,8 @@ is_admin(_Name, admin, _Extra) -> true; is_admin(_Name, {_User, _Server, _, false}, _Extra) -> false; +is_admin(_Name, Map, _extra) when is_map(Map) -> + true; is_admin(Name, Auth, Extra) -> {ACLInfo, Server} = case Auth of {U, S, _, _} -> @@ -832,6 +857,14 @@ is_admin(Name, Auth, Extra) -> deny -> false end. +permission_addon() -> + [{<<"'commands' option compatibility shim">>, + {[], + [{access, ejabberd_config:get_option(commands_admin_access, + fun(V) -> V end, + none)}], + {get_exposed_commands(), []}}}]. + opt_type(commands_admin_access) -> fun acl:access_rules_validator/1; opt_type(commands) -> fun(V) when is_list(V) -> V end; diff --git a/src/ejabberd_config.erl b/src/ejabberd_config.erl index 6ca6a40a8..517f2fd2f 100644 --- a/src/ejabberd_config.erl +++ b/src/ejabberd_config.erl @@ -178,7 +178,8 @@ read_file(File, Opts) -> -spec load_file(string()) -> ok. load_file(File) -> - State = read_file(File), + State0 = read_file(File), + State = validate_opts(State0), set_opts(State). -spec reload_file() -> ok. diff --git a/src/ejabberd_ctl.erl b/src/ejabberd_ctl.erl index d52b55cf9..a96a28016 100644 --- a/src/ejabberd_ctl.erl +++ b/src/ejabberd_ctl.erl @@ -321,10 +321,15 @@ call_command([CmdString | Args], Auth, AccessCommands, Version) -> {ArgsFormat, ResultFormat} -> case (catch format_args(Args, ArgsFormat)) of ArgsFormatted when is_list(ArgsFormatted) -> - Result = ejabberd_commands:execute_command(AccessCommands, - Auth, Command, - ArgsFormatted, - Version), + CI = case Auth of + {U, S, _, _} -> #{usr => {U, S, <<"">>}, caller_host => S}; + _ -> #{} + end, + CI2 = CI#{caller_module => ?MODULE}, + Result = ejabberd_commands:execute_command2(Command, + ArgsFormatted, + CI2, + Version), format_result(Result, ResultFormat); {'EXIT', {function_clause,[{lists,zip,[A1, A2], _} | _]}} -> {NumCompa, TextCompa} = diff --git a/src/ejabberd_oauth.erl b/src/ejabberd_oauth.erl index 4541190ad..d11548c22 100644 --- a/src/ejabberd_oauth.erl +++ b/src/ejabberd_oauth.erl @@ -42,8 +42,10 @@ associate_access_code/3, associate_access_token/3, associate_refresh_token/3, + check_token/1, check_token/4, check_token/2, + scope_in_scope_list/2, process/2, opt_type/1]). @@ -305,6 +307,29 @@ associate_refresh_token(_RefreshToken, _Context, AppContext) -> %put(?REFRESH_TOKEN_TABLE, RefreshToken, Context), {ok, AppContext}. +scope_in_scope_list(Scope, ScopeList) -> + TokenScopeSet = oauth2_priv_set:new(Scope), + lists:any(fun(Scope2) -> + oauth2_priv_set:is_member(Scope2, TokenScopeSet) end, + ScopeList). + +check_token(Token) -> + case lookup(Token) of + {ok, #oauth_token{us = US, + scope = TokenScope, + expire = Expire}} -> + {MegaSecs, Secs, _} = os:timestamp(), + TS = 1000000 * MegaSecs + Secs, + if + Expire > TS -> + {ok, US, TokenScope}; + true -> + {false, expired} + end; + _ -> + {false, not_found} + end. + check_token(User, Server, ScopeList, Token) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), diff --git a/src/mod_http_api.erl b/src/mod_http_api.erl index 7a95f8c6f..491383769 100644 --- a/src/mod_http_api.erl +++ b/src/mod_http_api.erl @@ -118,9 +118,11 @@ %% ------------------- start(_Host, _Opts) -> + ejabberd_access_permissions:register_permission_addon(?MODULE, fun permission_addon/0), ok. stop(_Host) -> + ejabberd_access_permissions:unregister_permission_addon(?MODULE), ok. depends(_Host, _Opts) -> @@ -130,76 +132,39 @@ depends(_Host, _Opts) -> %% basic auth %% ---------- -check_permissions(Request, Command) -> - case catch binary_to_existing_atom(Command, utf8) of - Call when is_atom(Call) -> - {ok, CommandPolicy, Scope} = ejabberd_commands:get_command_policy_and_scope(Call), - check_permissions2(Request, Call, CommandPolicy, Scope); - _ -> - json_error(404, 40, <<"Endpoint not found.">>) - end. - -check_permissions2(#request{auth = HTTPAuth, headers = Headers}, Call, _, ScopeList) - when HTTPAuth /= undefined -> - Admin = - case lists:keysearch(<<"X-Admin">>, 1, Headers) of - {value, {_, <<"true">>}} -> true; - _ -> false - end, - Auth = - case HTTPAuth of - {SJID, Pass} -> - case jid:from_string(SJID) of - #jid{user = User, server = Server} -> - case ejabberd_auth:check_password(User, <<"">>, Server, Pass) of - true -> {ok, {User, Server, Pass, Admin}}; - false -> false - end; - _ -> - false - end; - {oauth, Token, _} -> - case oauth_check_token(ScopeList, Token) of - {ok, user, {User, Server}} -> - {ok, {User, Server, {oauth, Token}, Admin}}; - {false, Reason} -> - {false, Reason} - end; - _ -> - false - end, - case Auth of - {ok, A} -> {allowed, Call, A}; - {false, no_matching_scope} -> outofscope_response(); - _ -> unauthorized_response() +extract_auth(#request{auth = HTTPAuth, ip = {IP, _}}) -> + Info = case HTTPAuth of + {SJID, Pass} -> + case jid:from_string(SJID) of + #jid{luser = User, lserver = Server} -> + case ejabberd_auth:check_password(User, <<"">>, Server, Pass) of + true -> + #{usr => {User, Server, <<"">>}, caller_server => Server}; + false -> + {error, invalid_auth} + end; + _ -> + {error, invalid_auth} + end; + {oauth, Token, _} -> + case ejabberd_oauth:check_token(Token) of + {ok, {U, S}, Scope} -> + #{usr => {U, S, <<"">>}, oauth_scope => Scope, caller_server => S}; + {false, Reason} -> + {error, Reason} + end; + _ -> + #{} + end, + case Info of + Map when is_map(Map) -> + Map#{caller_module => ?MODULE, ip => IP}; + _ -> + ?DEBUG("Invalid auth data: ~p", [Info]), + Info end; -check_permissions2(_Request, Call, open, _Scope) -> - {allowed, Call, noauth}; -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), - Res = acl:match_rule(global, Access, IP), - case Res of - all -> - {allowed, Call, admin}; - [all] -> - {allowed, Call, admin}; - allow -> - {allowed, Call, admin}; - Commands when is_list(Commands) -> - case lists:member(Call, Commands) of - true -> {allowed, Call, admin}; - _ -> outofscope_response() - end; - _E -> - {allowed, Call, noauth} - end; -check_permissions2(_Request, _Call, _Policy, _Scope) -> - unauthorized_response(). - -oauth_check_token(ScopeList, Token) when is_list(ScopeList) -> - ejabberd_oauth:check_token(ScopeList, Token). +extract_auth(#request{ip = IP}) -> + #{ip => IP, caller_module => ?MODULE}. %% ------------------ %% command processing @@ -210,19 +175,12 @@ oauth_check_token(ScopeList, Token) when is_list(ScopeList) -> process(_, #request{method = 'POST', data = <<>>}) -> ?DEBUG("Bad Request: no data", []), badrequest_response(<<"Missing POST data">>); -process([Call], #request{method = 'POST', data = Data, ip = {IP, _} = IPPort} = Req) -> +process([Call], #request{method = 'POST', data = Data, ip = IPPort} = Req) -> Version = get_api_version(Req), try Args = extract_args(Data), log(Call, Args, IPPort), - case check_permissions(Req, Call) of - {allowed, Cmd, Auth} -> - Result = handle(Cmd, Auth, Args, Version, IP), - json_format(Result); - %% Warning: check_permission direcly formats 401 reply if not authorized - ErrorResponse -> - ErrorResponse - end + perform_call(Call, Args, Req, Version) catch %% TODO We need to refactor to remove redundant error return formatting throw:{error, unknown_command} -> @@ -234,7 +192,7 @@ process([Call], #request{method = 'POST', data = Data, ip = {IP, _} = IPPort} = ?DEBUG("Bad Request: ~p ~p", [_Error, erlang:get_stacktrace()]), badrequest_response() end; -process([Call], #request{method = 'GET', q = Data, ip = IP} = Req) -> +process([Call], #request{method = 'GET', q = Data, ip = {IP, _}} = Req) -> Version = get_api_version(Req), try Args = case Data of @@ -242,14 +200,7 @@ process([Call], #request{method = 'GET', q = Data, ip = IP} = Req) -> _ -> Data end, log(Call, Args, IP), - case check_permissions(Req, Call) of - {allowed, Cmd, Auth} -> - Result = handle(Cmd, Auth, Args, Version, IP), - json_format(Result); - %% Warning: check_permission direcly formats 401 reply if not authorized - ErrorResponse -> - ErrorResponse - end + perform_call(Call, Args, Req, Version) catch %% TODO We need to refactor to remove redundant error return formatting throw:{error, unknown_command} -> @@ -267,6 +218,22 @@ process(_Path, Request) -> ?DEBUG("Bad Request: no handler ~p", [Request]), json_error(400, 40, <<"Missing command name.">>). +perform_call(Command, Args, Req, Version) -> + case catch binary_to_existing_atom(Command, utf8) of + Call when is_atom(Call) -> + case extract_auth(Req) of + {error, expired} -> invalid_token_response(); + {error, not_found} -> invalid_token_response(); + {error, invalid_auth} -> unauthorized_response(); + {error, _} -> unauthorized_response(); + Auth when is_map(Auth) -> + Result = handle(Call, Auth, Args, Version), + json_format(Result) + end; + _ -> + json_error(404, 40, <<"Endpoint not found.">>) + end. + %% Be tolerant to make API more easily usable from command-line pipe. extract_args(<<"\n">>) -> []; extract_args(Data) -> @@ -298,7 +265,7 @@ get_api_version([]) -> %% TODO Check accept types of request before decided format of reply. % generic ejabberd command handler -handle(Call, Auth, Args, Version, IP) when is_atom(Call), is_list(Args) -> +handle(Call, Auth, Args, Version) when is_atom(Call), is_list(Args) -> case ejabberd_commands:get_command_format(Call, Auth, Version) of {ArgsSpec, _} when is_list(ArgsSpec) -> Args2 = [{jlib:binary_to_atom(Key), Value} || {Key, Value} <- Args], @@ -315,7 +282,7 @@ handle(Call, Auth, Args, Version, IP) when is_atom(Call), is_list(Args) -> [{Key, undefined}|Acc] end, [], ArgsSpec), try - handle2(Call, Auth, match(Args2, Spec), Version, IP) + handle2(Call, Auth, match(Args2, Spec), Version) catch throw:not_found -> {404, <<"not_found">>}; throw:{not_found, Why} when is_atom(Why) -> @@ -354,10 +321,15 @@ handle(Call, Auth, Args, Version, IP) when is_atom(Call), is_list(Args) -> {400, <<"Error">>} end. -handle2(Call, Auth, Args, Version, IP) when is_atom(Call), is_list(Args) -> +handle2(Call, Auth, Args, Version) when is_atom(Call), is_list(Args) -> {ArgsF, _ResultF} = ejabberd_commands:get_command_format(Call, Auth, Version), ArgsFormatted = format_args(Args, ArgsF), - ejabberd_command(Auth, Call, ArgsFormatted, Version, IP). + case ejabberd_commands:execute_command2(Call, ArgsFormatted, Auth, Version) of + {error, Error} -> + throw(Error); + Res -> + format_command_result(Call, Auth, Res, Version) + end. get_elem_delete(A, L) -> case proplists:get_all_values(A, L) of @@ -456,18 +428,6 @@ process_unicode_codepoints(Str) -> match(Args, Spec) -> [{Key, proplists:get_value(Key, Args, Default)} || {Key, Default} <- Spec]. -ejabberd_command(Auth, Cmd, Args, Version, IP) -> - Access = case Auth of - admin -> []; - _ -> undefined - end, - case ejabberd_commands:execute_command(Access, Auth, Cmd, Args, Version, #{ip => IP}) of - {error, Error} -> - throw(Error); - Res -> - format_command_result(Cmd, Auth, Res, Version) - end. - format_command_result(Cmd, Auth, Result, Version) -> {_, ResultFormat} = ejabberd_commands:get_command_format(Cmd, Auth, Version), case {ResultFormat, Result} of @@ -538,6 +498,9 @@ format_error_result(_ErrorAtom, Code, Msg) -> {500, Code, iolist_to_binary(Msg)}. unauthorized_response() -> + json_error(401, 10, <<"You are not authorized to call this command.">>). + +invalid_token_response() -> json_error(401, 10, <<"Oauth Token is invalid or expired.">>). outofscope_response() -> @@ -571,5 +534,31 @@ log(Call, Args, {Addr, Port}) -> log(Call, Args, IP) -> ?INFO_MSG("API call ~s ~p (~p)", [Call, Args, IP]). +permission_addon() -> + Access = gen_mod:get_module_opt(global, ?MODULE, admin_ip_access, + fun(V) -> V end, + none), + Rules = acl:resolve_access(Access, global), + R = lists:filtermap( + fun({V, AclRules}) when V == all; V == [all]; V == [allow]; V == allow -> + {true, {[{allow, AclRules}], {[<<"*">>], []}}}; + ({List, AclRules}) when is_list(List) -> + {true, {[{allow, AclRules}], {List, []}}}; + (_) -> + false + end, Rules), + case R of + [] -> + none; + _ -> + {_, Res} = lists:foldl( + fun({R2, L2}, {Idx, Acc}) -> + {Idx+1, [{<<"'mod_http_api admin_ip_access' option compatibility shim ", + (integer_to_binary(Idx))/binary>>, + {[?MODULE], [{access, R2}], L2}} | Acc]} + end, {1, []}, R), + Res + end. + mod_opt_type(admin_ip_access) -> fun acl:access_rules_validator/1; mod_opt_type(_) -> [admin_ip_access]. -- cgit v1.2.3