diff --git a/apps/ejabberd/include/mod_muc_room.hrl b/apps/ejabberd/include/mod_muc_room.hrl index bdd0e5e6ec8..cfddeefa9fd 100644 --- a/apps/ejabberd/include/mod_muc_room.hrl +++ b/apps/ejabberd/include/mod_muc_room.hrl @@ -86,7 +86,9 @@ just_created = false :: boolean(), activity = treap:empty() :: treap:treap(), room_shaper :: shaper:shaper(), - room_queue = queue:new() + room_queue = queue:new(), + http_auth_pool = none :: none | mongoose_http_client:pool(), + http_auth_pids = [] :: [pid()] }). -record(muc_online_users, { diff --git a/apps/ejabberd/src/ejabberd_app.erl b/apps/ejabberd/src/ejabberd_app.erl index 700269831f9..9064df54618 100644 --- a/apps/ejabberd/src/ejabberd_app.erl +++ b/apps/ejabberd/src/ejabberd_app.erl @@ -59,6 +59,7 @@ start(normal, _Args) -> {ok, _} = Sup = ejabberd_sup:start_link(), ejabberd_rdbms:start(), mongoose_riak:start(), + mongoose_http_client:start(), ejabberd_auth:start(), cyrsasl:start(), %% Profiling diff --git a/apps/ejabberd/src/ejabberd_config.erl b/apps/ejabberd/src/ejabberd_config.erl index 94571c77656..63e7be48ae9 100644 --- a/apps/ejabberd/src/ejabberd_config.erl +++ b/apps/ejabberd/src/ejabberd_config.erl @@ -572,6 +572,8 @@ process_term(Term, State) -> add_option(max_fsm_queue, N, State); {sasl_mechanisms, Mechanisms} -> add_option(sasl_mechanisms, Mechanisms, State); + {http_connections, HttpConnections} -> + add_option(http_connections, HttpConnections, State); {_Opt, _Val} -> lists:foldl(fun(Host, S) -> process_host_term(Term, Host, S) end, State, State#state.hosts) diff --git a/apps/ejabberd/src/mod_muc.erl b/apps/ejabberd/src/mod_muc.erl index f7d173d384c..33224805660 100644 --- a/apps/ejabberd/src/mod_muc.erl +++ b/apps/ejabberd/src/mod_muc.erl @@ -112,7 +112,8 @@ access, history_size :: integer(), default_room_opts :: list(), - room_shaper :: shaper:shaper() + room_shaper :: shaper:shaper(), + http_auth_pool :: mongoose_http_client:pool() }). -type state() :: #state{}. @@ -284,16 +285,21 @@ init([Host, Opts]) -> AccessCreate = gen_mod:get_opt(access_create, Opts, all), AccessAdmin = gen_mod:get_opt(access_admin, Opts, none), AccessPersistent = gen_mod:get_opt(access_persistent, Opts, all), + HttpAuthPool = case gen_mod:get_opt(http_auth_pool, Opts, none) of + none -> none; + PoolName -> mongoose_http_client:get_pool(PoolName) + end, HistorySize = gen_mod:get_opt(history_size, Opts, 20), DefRoomOpts = gen_mod:get_opt(default_room_options, Opts, []), RoomShaper = gen_mod:get_opt(room_shaper, Opts, none), State = #state{host = MyHost, - server_host = Host, - access = {Access, AccessCreate, AccessAdmin, AccessPersistent}, - default_room_opts = DefRoomOpts, - history_size = HistorySize, - room_shaper = RoomShaper}, + server_host = Host, + access = {Access, AccessCreate, AccessAdmin, AccessPersistent}, + default_room_opts = DefRoomOpts, + history_size = HistorySize, + room_shaper = RoomShaper, + http_auth_pool = HttpAuthPool}, ejabberd_hooks:add(is_muc_room_owner, MyHost, ?MODULE, is_room_owner, 50), ejabberd_hooks:add(muc_room_pid, MyHost, ?MODULE, muc_room_pid, 50), @@ -306,8 +312,7 @@ init([Host, Opts]) -> load_permanent_rooms(MyHost, Host, {Access, AccessCreate, AccessAdmin, AccessPersistent}, - HistorySize, - RoomShaper), + HistorySize, RoomShaper, HttpAuthPool), {ok, State}. %%-------------------------------------------------------------------- @@ -333,7 +338,8 @@ handle_call({create_instant, Room, From, Nick, Opts}, access = Access, default_room_opts = DefOpts, history_size = HistorySize, - room_shaper = RoomShaper} = State) -> + room_shaper = RoomShaper, + http_auth_pool = HttpAuthPool} = State) -> ?DEBUG("MUC: create new room '~s'~n", [Room]), NewOpts = case Opts of default -> DefOpts; @@ -342,7 +348,7 @@ handle_call({create_instant, Room, From, Nick, Opts}, {ok, Pid} = mod_muc_room:start( Host, ServerHost, Access, Room, HistorySize, - RoomShaper, From, + RoomShaper, HttpAuthPool, From, Nick, [{instant, true}|NewOpts]), register_room(Host, Room, Pid), {reply, ok, State}. @@ -486,13 +492,14 @@ route_to_nonexistent_room(Room, {From, To, Packet}, case check_user_can_create_room(ServerHost, AccessCreate, From, Room) of true -> - HistorySize = State#state.history_size, - RoomShaper = State#state.room_shaper, - DefRoomOpts = State#state.default_room_opts, + #state{history_size = HistorySize, + room_shaper = RoomShaper, + http_auth_pool = HttpAuthPool, + default_room_opts = DefRoomOpts} = State, {_, _, Nick} = jid:to_lower(To), {ok, Pid} = start_new_room(Host, ServerHost, Access, Room, - HistorySize, RoomShaper, From, - Nick, DefRoomOpts), + HistorySize, RoomShaper, HttpAuthPool, + From, Nick, DefRoomOpts), register_room(Host, Room, Pid), mod_muc_room:route(Pid, From, Nick, Packet), ok; @@ -620,8 +627,8 @@ check_user_can_create_room(ServerHost, AccessCreate, From, RoomID) -> -spec load_permanent_rooms(Host :: ejabberd:server(), Srv :: ejabberd:server(), Access :: access(), HistorySize :: 'undefined' | integer(), - RoomShaper :: shaper:shaper()) -> 'ok'. -load_permanent_rooms(Host, ServerHost, Access, HistorySize, RoomShaper) -> + RoomShaper :: shaper:shaper(), HttpAuthPool :: none | mongoose_http_client:pool()) -> 'ok'. +load_permanent_rooms(Host, ServerHost, Access, HistorySize, RoomShaper, HttpAuthPool) -> case catch mnesia:dirty_select( muc_room, [{#muc_room{name_host = {'_', Host}, _ = '_'}, [], @@ -642,6 +649,7 @@ load_permanent_rooms(Host, ServerHost, Access, HistorySize, RoomShaper) -> Room, HistorySize, RoomShaper, + HttpAuthPool, R#muc_room.opts), register_room(Host, Room, Pid); _ -> @@ -654,25 +662,26 @@ load_permanent_rooms(Host, ServerHost, Access, HistorySize, RoomShaper) -> -spec start_new_room(Host :: 'undefined' | ejabberd:server(), Srv :: ejabberd:server(), Access :: access(), room(), HistorySize :: 'undefined' | integer(), RoomShaper :: shaper:shaper(), - From :: ejabberd:jid(), nick(), DefRoomOpts :: 'undefined' | [any()]) + HttpAuthPool :: none | mongoose_http_client:pool(), From :: ejabberd:jid(), nick(), + DefRoomOpts :: 'undefined' | [any()]) -> {'error',_} | {'ok','undefined' | pid()} | {'ok','undefined' | pid(),_}. start_new_room(Host, ServerHost, Access, Room, - HistorySize, RoomShaper, From, + HistorySize, RoomShaper, HttpAuthPool, From, Nick, DefRoomOpts) -> case mnesia:dirty_read(muc_room, {Room, Host}) of [] -> ?DEBUG("MUC: open new room '~s'~n", [Room]), mod_muc_room:start(Host, ServerHost, Access, Room, HistorySize, - RoomShaper, From, + RoomShaper, HttpAuthPool, From, Nick, DefRoomOpts); [#muc_room{opts = Opts}|_] -> ?DEBUG("MUC: restore room '~s'~n", [Room]), mod_muc_room:start(Host, ServerHost, Access, Room, HistorySize, - RoomShaper, Opts) + RoomShaper, HttpAuthPool, Opts) end. diff --git a/apps/ejabberd/src/mod_muc_room.erl b/apps/ejabberd/src/mod_muc_room.erl index 5fa1f3d8707..d8802ca9c21 100644 --- a/apps/ejabberd/src/mod_muc_room.erl +++ b/apps/ejabberd/src/mod_muc_room.erl @@ -30,10 +30,10 @@ %% External exports --export([start_link/9, - start_link/7, - start/9, - start/7, +-export([start_link/10, + start_link/8, + start/10, + start/8, route/4]). %% API exports @@ -117,7 +117,8 @@ | 'limit_reached' | 'require_membership' | 'require_password' - | 'user_banned'. + | 'user_banned' + | 'http_auth'. -type users_dict() :: dict:dict(ejabberd:simple_jid(), user()). -type sessions_dict() :: dict:dict(mod_muc:nick(), ejabberd:jid()). @@ -137,51 +138,53 @@ -define(SUPERVISOR_START, gen_fsm:start(?MODULE, [Host, ServerHost, Access, Room, HistorySize, - RoomShaper, Creator, Nick, DefRoomOpts], + RoomShaper, HttpAuthPool, Creator, Nick, DefRoomOpts], ?FSMOPTS)). -else. -define(SUPERVISOR_START, Supervisor = gen_mod:get_module_proc(ServerHost, ejabberd_mod_muc_sup), supervisor:start_child(Supervisor, [Host, ServerHost, Access, Room, HistorySize, - RoomShaper, Creator, Nick, DefRoomOpts])). + RoomShaper, HttpAuthPool, Creator, Nick, DefRoomOpts])). -endif. %%%---------------------------------------------------------------------- %%% API %%%---------------------------------------------------------------------- -spec start(Host :: ejabberd:server(), ServerHost :: ejabberd:server(), - Access :: _, Room :: mod_muc:room(), HistorySize :: integer(), - RoomShaper :: shaper:shaper(), Creator :: ejabberd:jid(), - Nick :: mod_muc:nick(), DefRoomOpts :: list()) -> {'error',_} - | {'ok','undefined' | pid()} - | {'ok','undefined' | pid(),_}. -start(Host, ServerHost, Access, Room, HistorySize, RoomShaper, + Access :: _, Room :: mod_muc:room(), HistorySize :: integer(), + RoomShaper :: shaper:shaper(), HttpAuthPool :: none | mongoose_http_client:pool(), + Creator :: ejabberd:jid(), Nick :: mod_muc:nick(), + DefRoomOpts :: list()) -> {'error',_} + | {'ok','undefined' | pid()} + | {'ok','undefined' | pid(),_}. +start(Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, Creator, Nick, DefRoomOpts) -> ?SUPERVISOR_START. -spec start(Host :: ejabberd:server(), ServerHost :: ejabberd:server(), - Access :: _, Room :: mod_muc:room(), HistorySize :: integer(), - RoomShaper :: shaper:shaper(), Opts :: list()) -> {'error',_} - | {'ok','undefined' | pid()} - | {'ok','undefined' | pid(),_}. -start(Host, ServerHost, Access, Room, HistorySize, RoomShaper, Opts) -> + Access :: _, Room :: mod_muc:room(), HistorySize :: integer(), + RoomShaper :: shaper:shaper(), HttpAuthPool :: none | mongoose_http_client:pool(), + Opts :: list()) -> {'error',_} + | {'ok','undefined' | pid()} + | {'ok','undefined' | pid(),_}. +start(Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, Opts) -> Supervisor = gen_mod:get_module_proc(ServerHost, ejabberd_mod_muc_sup), supervisor:start_child(Supervisor, [Host, ServerHost, Access, Room, - HistorySize, RoomShaper, Opts]). + HistorySize, RoomShaper, HttpAuthPool, Opts]). -start_link(Host, ServerHost, Access, Room, HistorySize, RoomShaper, +start_link(Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, Creator, Nick, DefRoomOpts) -> gen_fsm:start_link(?MODULE, [Host, ServerHost, Access, Room, HistorySize, - RoomShaper, Creator, Nick, DefRoomOpts], + RoomShaper, HttpAuthPool, Creator, Nick, DefRoomOpts], ?FSMOPTS). -start_link(Host, ServerHost, Access, Room, HistorySize, RoomShaper, Opts) -> +start_link(Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, Opts) -> gen_fsm:start_link(?MODULE, [Host, ServerHost, Access, Room, HistorySize, - RoomShaper, Opts], + RoomShaper, HttpAuthPool, Opts], ?FSMOPTS). @@ -243,8 +246,8 @@ can_access_identity(RoomJID, UserJID) -> %% next state is determined accordingly (a locked room for MUC or an instant %% one for groupchat). -spec init([any(),...]) -> {'ok',statename(), state()}. -init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, Creator, _Nick, - DefRoomOpts]) -> +init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, + Creator, _Nick, DefRoomOpts]) -> process_flag(trap_exit, true), Shaper = shaper:new(RoomShaper), State = set_affiliation(Creator, owner, @@ -255,7 +258,8 @@ init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, Creator, _Nick, history = lqueue_new(HistorySize), jid = jid:make(Room, Host, <<>>), just_created = true, - room_shaper = Shaper}), + room_shaper = Shaper, + http_auth_pool = HttpAuthPool}), State1 = set_opts(DefRoomOpts, State), ?INFO_MSG("Created MUC room ~s@~s by ~s", [Room, Host, jid:to_binary(Creator)]), @@ -273,7 +277,7 @@ init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, Creator, _Nick, %% @doc A room is restored -init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, Opts]) -> +init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, HttpAuthPool, Opts]) -> process_flag(trap_exit, true), Shaper = shaper:new(RoomShaper), State = set_opts(Opts, #state{host = Host, @@ -282,7 +286,8 @@ init([Host, ServerHost, Access, Room, HistorySize, RoomShaper, Opts]) -> room = Room, history = lqueue_new(HistorySize), jid = jid:make(Room, Host, <<>>), - room_shaper = Shaper}), + room_shaper = Shaper, + http_auth_pool = HttpAuthPool}), add_to_log(room_existence, started, State), {ok, normal_state, State}. @@ -318,6 +323,7 @@ initial_state({route, From, ToNick, #xmlel{name = <<"presence">>} = Presence}, StateData) -> %% this should never happen so crash if it does <<>> = exml_query:attr(Presence, <<"type">>, <<>>), + owner = get_affiliation(From, StateData), %% prevent race condition (2 users create same room) XNamespaces = exml_query:paths(Presence, [{element, <<"x">>}, {attr, <<"xmlns">>}]), case lists:member(?NS_MUC, XNamespaces) of true -> @@ -511,10 +517,14 @@ normal_state({route, From, ToNick, JIDs -> lists:foreach(FunRouteNickIq, JIDs) end, {next_state, normal_state, StateData}; +normal_state({http_auth, AuthPid, Result, From, Nick, Packet, Role}, StateData) -> + AuthPids = StateData#state.http_auth_pids, + StateDataWithoutPid = StateData#state{http_auth_pids = lists:delete(AuthPid, AuthPids)}, + NewStateData = handle_http_auth_result(Result, From, Nick, Packet, Role, StateDataWithoutPid), + destroy_temporary_room_if_empty(NewStateData, normal_state); normal_state(_Event, StateData) -> {next_state, normal_state, StateData}. - %%---------------------------------------------------------------------- %% Func: handle_event/3 %% Returns: {next_state, NextStateName, NextStateData} | @@ -662,6 +672,10 @@ handle_info(process_room_queue, normal_state = StateName, StateData) -> {empty, _} -> {next_state, StateName, StateData} end; +handle_info({'EXIT', FromPid, _Reason}, StateName, StateData) -> + AuthPids = StateData#state.http_auth_pids, + StateWithoutPid = StateData#state{http_auth_pids = lists:delete(FromPid, AuthPids)}, + destroy_temporary_room_if_empty(StateWithoutPid, StateName); handle_info(_Info, StateName, StateData) -> {next_state, StateName, StateData}. @@ -900,7 +914,7 @@ get_participant_data(From, StateData) -> Packet :: jlib:xmlel(), state()) -> fsm_return(). process_presence(From, ToNick, Presence, StateData) -> StateData1 = process_presence1(From, ToNick, Presence, StateData), - destroy_temporary_room_if_empty(StateData1). + destroy_temporary_room_if_empty(StateData1, normal_state). -spec process_presence(From :: ejabberd:jid(), Nick :: mod_muc:nick(), @@ -917,16 +931,17 @@ rewrite_next_state(_, {stop, normal, StateData}) -> {stop, normal, StateData}. --spec destroy_temporary_room_if_empty(state()) -> fsm_return(). -destroy_temporary_room_if_empty(StateData=#state{config=C=#config{}}) -> - case (not C#config.persistent) andalso is_empty_room(StateData) of +-spec destroy_temporary_room_if_empty(state(), atom()) -> fsm_return(). +destroy_temporary_room_if_empty(StateData=#state{config=C=#config{}}, NextState) -> + case (not C#config.persistent) andalso is_empty_room(StateData) + andalso StateData#state.http_auth_pids =:= [] of true -> ?INFO_MSG("Destroyed MUC room ~s because it's temporary and empty", [jid:to_binary(StateData#state.jid)]), add_to_log(room_existence, destroyed, StateData), {stop, normal, StateData}; _ -> - {next_state, normal_state, StateData} + {next_state, NextState, StateData} end. @@ -1794,21 +1809,25 @@ choose_new_user_strategy(From, Nick, Affiliation, Role, Els, StateData) -> {_, _, _, false, _, _} -> conflict_registered; _ -> - ServiceAffiliation = get_service_affiliation(From, StateData), - case check_password( - ServiceAffiliation, Affiliation, Els, From, StateData) of - true -> allowed; - nopass -> require_password; - _ -> invalid_password - end + choose_new_user_password_strategy(From, Els, StateData) end. +-spec choose_new_user_password_strategy(ejabberd:jid(), [jlib:xmlcdata() | jlib:xmlel()], + state()) -> new_user_strategy(). +choose_new_user_password_strategy(From, Els, StateData) -> + ServiceAffiliation = get_service_affiliation(From, StateData), + Config = StateData#state.config, + case is_password_required(ServiceAffiliation, Config) of + false -> allowed; + true -> case extract_password(Els) of + false -> require_password; + Password -> check_password(StateData, Password) + end + end. -spec add_new_user(ejabberd:jid(), mod_muc:nick(), jlib:xmlel(), state() ) -> state(). -add_new_user(From, Nick, - #xmlel{attrs = Attrs, children = Els} = Packet, - #state{} = StateData) -> +add_new_user(From, Nick, #xmlel{attrs = Attrs, children = Els} = Packet, StateData) -> Lang = xml:get_attr_s(<<"xml:lang">>, Attrs), Affiliation = get_affiliation(From, StateData), Role = get_default_role(Affiliation, StateData), @@ -1844,51 +1863,116 @@ add_new_user(From, Nick, Err = jlib:make_error_reply( Packet, ?ERRT_NOT_AUTHORIZED(Lang, ErrText)), route_error(Nick, From, Err, StateData); + http_auth -> + Password = extract_password(Els), + perform_http_auth(From, Nick, Packet, Role, Password, StateData); allowed -> - NewState = - add_user_presence( - From, Packet, - add_online_user(From, Nick, Role, StateData)), - send_existing_presences(From, NewState), - send_new_presence(From, NewState), - Shift = count_stanza_shift(Nick, Els, NewState), - case send_history(From, Shift, NewState) of - true -> - ok; - _ -> - send_subject(From, Lang, StateData) - end, - case NewState#state.just_created of - true -> - NewState#state{just_created = false}; - false -> - Robots = ?DICT:erase(From, StateData#state.robots), - NewState#state{robots = Robots} - end + do_add_new_user(From, Nick, Packet, Role, StateData) end. - --spec check_password(ServiceAffiliation :: mod_muc:affiliation(), - Affiliation :: mod_muc:affiliation(), Els :: [jlib:xmlel()], _From, - state()) -> boolean() | nopass. -check_password(owner, _Affiliation, _Els, _From, _StateData) -> - %% Don't check pass if user is owner in MUC service (access_admin option) - true; -check_password(_ServiceAffiliation, _Affiliation, Els, _From, StateData) -> - case (StateData#state.config)#config.password_protected of +perform_http_auth(From, Nick, Packet, Role, Password, StateData) -> + RoomPid = self(), + RoomJid = StateData#state.jid, + Pool = StateData#state.http_auth_pool, + case is_empty_room(StateData) of + true -> + Result = make_http_auth_request(From, RoomJid, Password, Pool), + handle_http_auth_result(Result, From, Nick, Packet, Role, StateData); false -> - %% Don't check password - true; + %% Perform the request in a separate process to prevent room freeze + Pid = spawn_link( + fun() -> + Result = make_http_auth_request(From, RoomJid, Password, Pool), + gen_fsm:send_event(RoomPid, {http_auth, self(), Result, + From, Nick, Packet, Role}) + end), + AuthPids = StateData#state.http_auth_pids, + StateData#state{http_auth_pids = [Pid | AuthPids]} + end. + +make_http_auth_request(From, RoomJid, Password, Pool) -> + FromVal = uri_encode(jid:to_binary(From)), + RoomJidVal = uri_encode(jid:to_binary(RoomJid)), + PassVal = uri_encode(Password), + Path = <<"check_password?from=", FromVal/binary, + "&to=", RoomJidVal/binary, + "&pass=", PassVal/binary>>, + case mongoose_http_client:get(Pool, Path, []) of + {ok, {<<"200">>, Body}} -> decode_http_auth_response(Body); + _ -> error + end. + +handle_http_auth_result(Result, From, Nick, Packet, Role, StateData) -> + case Result of + allowed -> do_add_new_user(From, Nick, Packet, Role, StateData); + {invalid_password, ErrorMsg} -> reply_not_authorized(From, Nick, Packet, StateData, ErrorMsg); + error -> reply_service_unavailable(From, Nick, Packet, StateData, <<"Internal server error">>) + end. + +uri_encode(Bin) -> + list_to_binary(http_uri:encode(binary_to_list(Bin))). + +decode_http_auth_response(Body) -> + try decode_json_auth_response(Body) of + {0, _} -> allowed; + {AuthCode, Msg} -> {invalid_password, iolist_to_binary([AuthCode, $ , Msg])} + catch + error:_ -> error + end. + +decode_json_auth_response(Body) -> + {struct, Elements} = mochijson2:decode(Body), + Code = proplists:get_value(<<"code">>, Elements), + Msg = proplists:get_value(<<"msg">>, Elements), + {Code, Msg}. + +reply_not_authorized(From, Nick, Packet, StateData, ErrText) -> + Lang = xml:get_attr_s(<<"xml:lang">>, Packet#xmlel.attrs), + Err = jlib:make_error_reply(Packet, ?ERRT_NOT_AUTHORIZED(Lang, ErrText)), + route_error(Nick, From, Err, StateData). + +reply_service_unavailable(From, Nick, Packet, StateData, ErrText) -> + Lang = xml:get_attr_s(<<"xml:lang">>, Packet#xmlel.attrs), + Err = jlib:make_error_reply(Packet, ?ERRT_SERVICE_UNAVAILABLE(Lang, ErrText)), + route_error(Nick, From, Err, StateData). + +do_add_new_user(From, Nick, #xmlel{attrs = Attrs, children = Els} = Packet, + Role, StateData) -> + Lang = xml:get_attr_s(<<"xml:lang">>, Attrs), + NewState = + add_user_presence( + From, Packet, + add_online_user(From, Nick, Role, StateData)), + send_existing_presences(From, NewState), + send_new_presence(From, NewState), + Shift = count_stanza_shift(Nick, Els, NewState), + case send_history(From, Shift, NewState) of true -> - Pass = extract_password(Els), - case Pass of - false -> - nopass; - _ -> - (StateData#state.config)#config.password =:= Pass - end + ok; + _ -> + send_subject(From, Lang, StateData) + end, + case NewState#state.just_created of + true -> + NewState#state{just_created = false}; + false -> + Robots = ?DICT:erase(From, StateData#state.robots), + NewState#state{robots = Robots} end. +is_password_required(owner, _Config) -> + %% Don't check pass if user is owner in MUC service (access_admin option) + false; +is_password_required(_, Config) -> + Config#config.password_protected. + +check_password(#state{http_auth_pool = none, + config = #config{password = Password}}, Password) -> + allowed; +check_password(#state{http_auth_pool = none}, _Password) -> + invalid_password; +check_password(#state{http_auth_pool = _Pool}, _Password) -> + http_auth. -spec extract_password([jlib:xmlcdata() | jlib:xmlel()]) -> 'false' | binary(). extract_password([]) -> diff --git a/apps/ejabberd/src/mongoose_http_client.erl b/apps/ejabberd/src/mongoose_http_client.erl new file mode 100644 index 00000000000..20421182925 --- /dev/null +++ b/apps/ejabberd/src/mongoose_http_client.erl @@ -0,0 +1,167 @@ +%%============================================================================== +%% Copyright 2016 Erlang Solutions Ltd. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%============================================================================== +-module(mongoose_http_client). + +%% API +-export([start/0, stop/0, start_pool/2, stop_pool/1, get_pool/1, get/3, post/4]). + +%% Exported for testing +-export([start/1]). + +-record(pool, {name :: atom(), + server :: string(), + size :: pos_integer(), + max_overflow :: pos_integer(), + path_prefix :: binary(), + pool_timeout :: pos_integer(), + request_timeout :: pos_integer()}). + +-opaque pool() :: #pool{}. + +-export_type([pool/0]). + +%%------------------------------------------------------------------------------ +%% API + +-spec start() -> any(). +start() -> + case ejabberd_config:get_local_option(http_connections) of + undefined -> ok; + Opts -> start(Opts) + end. + +-spec stop() -> any(). +stop() -> + stop_supervisor(), + ets:delete(tab_name()). + +-spec start_pool(atom(), list()) -> ok. +start_pool(Name, Opts) -> + Pool = make_pool(Name, Opts), + do_start_pool(Pool), + ets:insert(tab_name(), Pool), + ok. + +-spec stop_pool(atom()) -> ok. +stop_pool(Name) -> + SupProc = sup_proc_name(), + ok = supervisor:terminate_child(SupProc, pool_proc_name(Name)), + ok = supervisor:delete_child(SupProc, pool_proc_name(Name)), + ets:delete(tab_name(), Name), + ok. + +-spec get_pool(atom()) -> pool(). +get_pool(Name) -> + [Pool] = ets:lookup(tab_name(), Name), + Pool. + +-spec get(pool(), binary(), list()) -> + {ok, {binary(), binary()}} | {error, any()}. +get(Pool, Path, Headers) -> + make_request(Pool, Path, <<"GET">>, Headers, <<>>). + +-spec post(pool(), binary(), list(), binary()) -> + {ok, {binary(), binary()}} | {error, any()}. +post(Pool, Path, Headers, Query) -> + make_request(Pool, Path, <<"POST">>, Headers, Query). + +%%------------------------------------------------------------------------------ +%% exported for testing + +start(Opts) -> + {ok, _SupProc} = start_supervisor(), + EjdSupPid = whereis(ejabberd_sup), + HeirOpt = case self() =:= EjdSupPid of + true -> []; + false -> [{heir, EjdSupPid, self()}] % for dynamic start + end, + ets:new(tab_name(), [set, public, named_table, {keypos, 2}, + {read_concurrency, true} | HeirOpt]), + [start_pool(Name, PoolOpts) || {Name, PoolOpts} <- Opts], + ok. + +%%------------------------------------------------------------------------------ +%% internal functions + +make_pool(Name, Opts) -> + #pool{name = Name, + server = gen_mod:get_opt(server, Opts, "http://localhost"), + size = gen_mod:get_opt(pool_size, Opts, 20), + max_overflow = gen_mod:get_opt(max_overflow, Opts, 5), + path_prefix = list_to_binary(gen_mod:get_opt(path_prefix, Opts, "/")), + pool_timeout = gen_mod:get_opt(pool_timeout, Opts, 200), + request_timeout = gen_mod:get_opt(request_timeout, Opts, 2000)}. + +do_start_pool(#pool{name = Name, + server = Server, + size = PoolSize, + max_overflow = MaxOverflow}) -> + ProcName = pool_proc_name(Name), + PoolOpts = [{name, {local, ProcName}}, + {size, PoolSize}, + {max_overflow, MaxOverflow}, + {worker_module, mongoose_http_client_worker}], + {ok, _} = supervisor:start_child( + sup_proc_name(), + poolboy:child_spec(ProcName, PoolOpts, [Server, []])), + ok. + +pool_proc_name(PoolName) -> + list_to_atom("mongoose_http_client_pool_" ++ atom_to_list(PoolName)). + +sup_proc_name() -> + mongoose_http_client_sup. + +tab_name() -> + mongoose_http_client_pools. + +make_request(Pool, Path, Method, Headers, Query) -> + #pool{path_prefix = PathPrefix, + name = PoolName, + pool_timeout = PoolTimeout, + request_timeout = RequestTimeout} = Pool, + FullPath = <>, + case catch poolboy:transaction( + pool_proc_name(PoolName), + fun(WorkerPid) -> + fusco:request(WorkerPid, FullPath, Method, Headers, Query, RequestTimeout) + end, + PoolTimeout) of + {'EXIT', {timeout, _}} -> + {error, pool_timeout}; + {ok, {{Code, _Reason}, _RespHeaders, RespBody, _, _}} -> + {ok, {Code, RespBody}}; + {error, timeout} -> + {error, request_timeout}; + {error, Reason} -> + {error, Reason} + end. + +start_supervisor() -> + Proc = sup_proc_name(), + ChildSpec = + {Proc, + {Proc, start_link, [Proc]}, + permanent, + infinity, + supervisor, + [Proc]}, + {ok, _} = supervisor:start_child(ejabberd_sup, ChildSpec). + +stop_supervisor() -> + Proc = sup_proc_name(), + ok = supervisor:terminate_child(ejabberd_sup, Proc), + ok = supervisor:delete_child(ejabberd_sup, Proc). diff --git a/apps/ejabberd/src/mongoose_http_client_sup.erl b/apps/ejabberd/src/mongoose_http_client_sup.erl new file mode 100644 index 00000000000..0adbc971742 --- /dev/null +++ b/apps/ejabberd/src/mongoose_http_client_sup.erl @@ -0,0 +1,45 @@ +%%============================================================================== +%% Copyright 2014 Erlang Solutions Ltd. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%============================================================================== +-module(mongoose_http_client_sup). + +-behaviour(supervisor). + +%% API +-export([start_link/1]). + +%% supervisor callbacks +-export([init/1]). + +%%------------------------------------------------------------------------------ +%% API + +-spec(start_link(term()) -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link(SupName) -> + supervisor:start_link({local, SupName}, ?MODULE, []). + +%%------------------------------------------------------------------------------ +%% supervisor callbacks + +-spec init(Args :: term()) -> {ok, {{one_for_all, pos_integer(), pos_integer()}, []}}. +init([]) -> + RestartStrategy = one_for_all, + MaxRestarts = 1000, + MaxSecondsBetweenRestarts = 3600, + + SupFlags = {RestartStrategy, MaxRestarts, MaxSecondsBetweenRestarts}, + + {ok, {SupFlags, []}}. diff --git a/apps/ejabberd/src/mongoose_http_client_worker.erl b/apps/ejabberd/src/mongoose_http_client_worker.erl new file mode 100644 index 00000000000..b59ca1c3d8a --- /dev/null +++ b/apps/ejabberd/src/mongoose_http_client_worker.erl @@ -0,0 +1,25 @@ +%%============================================================================== +%% Copyright 2014 Erlang Solutions Ltd. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%============================================================================== +-module(mongoose_http_client_worker). + +%% API +-export([start_link/1]). + +%%------------------------------------------------------------------------------ +%% API + +start_link([Host, Opts]) -> + fusco:start_link(Host, Opts). diff --git a/apps/ejabberd/test/http_client_SUITE.erl b/apps/ejabberd/test/http_client_SUITE.erl new file mode 100644 index 00000000000..5193012b2dd --- /dev/null +++ b/apps/ejabberd/test/http_client_SUITE.erl @@ -0,0 +1,111 @@ +%%============================================================================== +%% Copyright 2016 Erlang Solutions Ltd. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%============================================================================== +-module(http_client_SUITE). + +-compile(export_all). + +-include_lib("common_test/include/ct.hrl"). + +all() -> + [get_test, + post_test, + request_timeout_test, + pool_timeout_test + ]. + +init_per_suite(Config) -> + http_helper:start(8080, '_', fun process_request/1), + Pid = self(), + spawn(fun() -> + register(test_helper, self()), + mim_ct_sup:start_link(ejabberd_sup), + Pid ! ready, + receive stop -> ok end + end), + receive ready -> ok end, + meck_config(), + mongoose_http_client:start(), + meck_cleanup(), + Config. + +process_request(Req) -> + {Sleep, Req1} = cowboy_req:qs_val(<<"sleep">>, Req), + case Sleep of + <<"true">> -> timer:sleep(100); + _ -> ok + end, + {ok, Req2} = cowboy_req:reply(200, [{<<"content-type">>, <<"text/plain">>}], <<"OK">>, Req1), + Req2. + +end_per_suite(_Config) -> + http_helper:stop(), + exit(whereis(ejabberd_sup), shutdown), + whereis(test_helper) ! stop. + +init_per_testcase(request_timeout_test, Config) -> + mongoose_http_client:start_pool(tmp_pool, [{server, "http://localhost:8080"}, + {request_timeout, 10}]), + Config; +init_per_testcase(pool_timeout_test, Config) -> + mongoose_http_client:start_pool(tmp_pool, [{server, "http://localhost:8080"}, + {pool_size, 1}, + {max_overflow, 0}, + {pool_timeout, 10}]), + Config; +init_per_testcase(_TC, Config) -> + mongoose_http_client:start_pool(tmp_pool, [{server, "http://localhost:8080"}]), + Config. + +end_per_testcase(_TC, _Config) -> + mongoose_http_client:stop_pool(tmp_pool). + +get_test(_Config) -> + Pool = mongoose_http_client:get_pool(tmp_pool), + Result = mongoose_http_client:get(Pool, <<"some/path">>, []), + {ok, {<<"200">>, <<"OK">>}} = Result. + +post_test(_Config) -> + Pool = mongoose_http_client:get_pool(tmp_pool), + Result = mongoose_http_client:post(Pool, <<"some/path">>, [], <<"test request">>), + {ok, {<<"200">>, <<"OK">>}} = Result. + +request_timeout_test(_Config) -> + Pool = mongoose_http_client:get_pool(tmp_pool), + Result = mongoose_http_client:get(Pool, <<"some/path?sleep=true">>, []), + {error, request_timeout} = Result. + +pool_timeout_test(_Config) -> + Pool = mongoose_http_client:get_pool(tmp_pool), + Pid = self(), + spawn(fun() -> + mongoose_http_client:get(Pool, <<"/some/path?sleep=true">>, []), + Pid ! finished + end), + timer:sleep(10), % wait for the only pool worker to start handling the request + Result = mongoose_http_client:get(Pool, <<"some/path">>, []), + {error, pool_timeout} = Result, + receive finished -> ok after 1000 -> error(no_finished_message) end. + +domain() -> + ct:get_config({hosts, mim, domain}). + +meck_config() -> + meck:new(ejabberd_config), + meck:expect(ejabberd_config, get_local_option, fun(http_connections) -> [] end). + +meck_cleanup() -> + meck:validate(ejabberd_config), + meck:unload(ejabberd_config). diff --git a/apps/ejabberd/test/http_helper.erl b/apps/ejabberd/test/http_helper.erl new file mode 100644 index 00000000000..5dbdea51a9d --- /dev/null +++ b/apps/ejabberd/test/http_helper.erl @@ -0,0 +1,39 @@ +%%============================================================================== +%% Copyright 2016 Erlang Solutions Ltd. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%============================================================================== +-module(http_helper). + +-export([start/3, stop/0, init/3, handle/2, terminate/3]). + +start(Port, Path, HandleFun) -> + application:ensure_all_started(cowboy), + Dispatch = cowboy_router:compile([{'_', [{Path, http_helper, [HandleFun]}]}]), + {ok, _} = cowboy:start_http(http_helper_listener, 100, [{port, Port}], + [{env, [{dispatch, Dispatch}]}]). + +stop() -> + cowboy:stop_listener(http_helper_listener). + +%% Cowboy handler callbacks + +init(_Type, Req, [HandleFun]) -> + {ok, Req, HandleFun}. + +handle(Req, HandleFun) -> + Req2 = HandleFun(Req), + {ok, Req2, HandleFun}. + +terminate(_Reason, _Req, _State) -> + ok. diff --git a/doc/Advanced-configuration.md b/doc/Advanced-configuration.md index f190904ceca..29e92735471 100644 --- a/doc/Advanced-configuration.md +++ b/doc/Advanced-configuration.md @@ -216,6 +216,29 @@ The `host_config` allows configuring most options separately for specific domain * **host_config** (multi, local) * **Syntax:** `{host_config, Domain, [ {{add, modules}, [{mod_some, Opts}]}, {access, c2s, [{deny, local}]}, ... ]}.` +### Outgoing HTTP connections + +The `http_connections` option configures a list of named pools of outgoing HTTP connections that may be used by various modules. Each of the pools has a name (atom) and a list of options: + +* **Syntax:** `{http_connections, [{PoolName1, PoolOptions1}, {PoolName2, PoolOptions2}, ...]}.` + +Following pool options are recognized - all of them are optional. + +* `{server, HostName}` - string, default: `"http://localhost"` - the URL of the destination HTTP server (including port number if needed). +* `{pool_size, Number}` - positive integer, default: `20` - number of workers in the connection pool. +* `{max_overflow, Number}` - non-negative integer, default: `5` - maximum number of extra workers that can be allocated when the whole pool is busy. +* `{path_prefix, Prefix}` - string, default: `"/"` - the part of the destination URL that is appended to the host name (`host` option). +* `{pool_timeout, TimeoutValue}` - non-negative integer, default: `200` - maximum number of milliseconds to wait for an available worker from the pool. +* `{request_timeout, TimeoutValue}` - non-negative integer, default: `2000` - maximum number of milliseconds to wait for the HTTP response. + +**Example:** +``` +{http_connections, [{conn1, [{server, "http://my.server:8080"}, + {pool_size, 50}, + {path_prefix, "/my/path/"}]} + ]}. +``` + # vm.args This file contains parameters passed directly to the Erlang VM. It can be found in `[MongooseIM root]/rel/files/`. diff --git a/doc/modules/mod_muc.md b/doc/modules/mod_muc.md index 382c995fd9e..bf6e0973a36 100644 --- a/doc/modules/mod_muc.md +++ b/doc/modules/mod_muc.md @@ -19,6 +19,7 @@ This module implements [XEP-0045: Multi-User Chat)](http://xmpp.org/extensions/x * `user_message_shaper` (atom, default: `none`): Shaper for user messages processed by a room (global for the room). * `user_presence_shaper` (atom, default: `none`): Shaper for user presences processed by a room (global for the room). * `max_user_conferences` (non-negative, default: 10): Specifies the number of rooms that a user can occupy simultaneously. +* `http_auth_pool` (atom, default: `none`): If an external HTTP service is chosen to check passwords for password-protected rooms, this option specifies the HTTP pool name to use (see [External HTTP Authentication](#external-http-authentication) below). * `default_room_options` (list of key-value tuples, default: `[]`): List of room configuration options to be overridden in initial state. * `title` (binary, default: `<<>>`): Room title, short free text. * `description` (binary, default: `<<>>`): Room description, long free text. @@ -49,3 +50,43 @@ This module implements [XEP-0045: Multi-User Chat)](http://xmpp.org/extensions/x {access_create, muc_create} ]}, ``` + +### External HTTP Authentication + +MUC rooms can be protected by password that is set by the room owner. However, MongooseIM supports another custom solution, where each attept to enter or create a room requires the password be checked by an external HTTP service. To enable this option, you need to: + +* Configure an [HTTP connection pool](../Advanced-configuration.md#outgoing-http-connections). +* Set the name of the connection pool as the value of the `http_auth_pool` option of `mod_muc`. +* Enable the `password_protected` default room option (without setting the password itself). + +Whenever a user tries to enter or create a room, the server will receive a GET request to the `check_password` path. It should return code 200 with a JSON object `{"code": Code, "msg": Message}` in the response body. If the server returns a different code, an error presence will be sent back to the client. + +* `Code` is the status code: 0 indicates successful authentication, any other value means authentication failure. +* `Message` is a string containing the message to be sent back to the XMPP client indicating the reason of failed authentication. For successful authentication it is ignored and can eg. contain the string `"OK"`. + +**Example:** + +``` + +{http_connections, [ + {my_auth_pool, [{server, "http://my_server:8000"}]} + ]}. + + +{modules, [ + + (...) + + {mod_muc, [ + {host, "muc.example.com"}, + {access, muc}, + {access_create, muc_create}, + {http_auth_pool, my_auth_pool}, + {default_room_options, [{password_protected, true}]} + ]}, + + (...) + +]}. + +``` diff --git a/rel/files/ejabberd.cfg b/rel/files/ejabberd.cfg index c39c7451e11..09fec26e02a 100755 --- a/rel/files/ejabberd.cfg +++ b/rel/files/ejabberd.cfg @@ -470,6 +470,14 @@ {{riak_server}} +%%% ================================== +%%% POOLS OF OUTGOING HTTP CONNECTIONS +%%% ================================== +%% +%% {http_connections, [{conn1, [{server, "http://server.com:8080"}, +%% {pool_size, 50}]} +%% ]}. + %% %% Interval to make a dummy SQL request to keep the connections to the %% database alive. Specify in seconds: for example 28800 means 8 hours diff --git a/test/ejabberd_tests/rebar.config b/test/ejabberd_tests/rebar.config index 09209467e95..acfd7231a2e 100644 --- a/test/ejabberd_tests/rebar.config +++ b/test/ejabberd_tests/rebar.config @@ -12,6 +12,7 @@ {katt, ".*", {git, "git://github.com/for-GET/katt.git", {tag, "1.5.2"}}}, {erlsh, ".*", {git, "git://github.com/proger/erlsh.git", "2fce513"}}, {jiffy, ".*", {git, "git://github.com/davisp/jiffy.git", "0.14.8"}}, - {proper, ".*", {git, "https://github.com/manopapad/proper.git", "20e62bc32f9bd43fe2ff52944a4ef99eb71d1399"}} + {proper, ".*", {git, "https://github.com/manopapad/proper.git", "20e62bc32f9bd43fe2ff52944a4ef99eb71d1399"}}, + {cowboy, ".*", {git, "git://github.com/ninenines/cowboy.git", {tag, "1.0.4"}}} ]}. diff --git a/test/ejabberd_tests/tests/http_helper.erl b/test/ejabberd_tests/tests/http_helper.erl index 42b4c694f3c..5dbdea51a9d 100644 --- a/test/ejabberd_tests/tests/http_helper.erl +++ b/test/ejabberd_tests/tests/http_helper.erl @@ -15,95 +15,25 @@ %%============================================================================== -module(http_helper). -%% API --export([listen_once/2, listen_once/3, listen_once/4]). +-export([start/3, stop/0, init/3, handle/2, terminate/3]). -%% @doc Same as listen_once(PPid, Port, undefined, "OK") -listen_once(PPid, Port) -> - listen_once(PPid, Port, undefined). +start(Port, Path, HandleFun) -> + application:ensure_all_started(cowboy), + Dispatch = cowboy_router:compile([{'_', [{Path, http_helper, [HandleFun]}]}]), + {ok, _} = cowboy:start_http(http_helper_listener, 100, [{port, Port}], + [{env, [{dispatch, Dispatch}]}]). -%% @doc Same as listen_once(PPid, Port, ReqPattern, "OK") -listen_once(PPid, Port, ReqPattern) -> - listen_once(PPid, Port, ReqPattern, "OK"). +stop() -> + cowboy:stop_listener(http_helper_listener). -%% @doc Starts an http listener which waits for one request; when it receives -%% expected request it sends a message {ok, got_http_request, Packet} to -%% the "owner" process and terminates -%% PPid - process waiting for notification -%% Port - self-explanatory -%% ReqPattern - either a binary or a list of binaries; incoming request must match all of them -%% to be accepted -%% Response - what is to be sent back to the http client when a proper request comes, after 200 header -%% default is "OK"; by passing 'none' as Response you can create a "broken" listener which accepts -%% connections but does not respond -listen_once(PPid, Port, ReqPattern, Response) -> - spawn(fun() -> onetime_http_server(PPid, Port, ReqPattern, Response) end). +%% Cowboy handler callbacks -onetime_http_server(PPid, Port, ReqPattern, Response) -> - {ok, LSock} = gen_tcp:listen(Port, [binary, {packet, 0}, - {active, false}, {reuseaddr, true}]), - {ok, Sock} = gen_tcp:accept(LSock), - do_recv(Sock, <<>>, PPid, ReqPattern, Response), - gen_tcp:close(Sock). +init(_Type, Req, [HandleFun]) -> + {ok, Req, HandleFun}. -do_recv(Sock, _, _, _, none) -> - case gen_tcp:recv(Sock, 0) of - {ok, _} -> - do_recv(Sock, none, none, none, none); - {error, closed} -> - ok - end; -do_recv(Sock, Bs, PPid, ReqPattern, Response) -> - case gen_tcp:recv(Sock, 0) of - {ok, B} -> - Packet = <>, - case check_packet(Packet, ReqPattern) of - true -> - %% this is the one we are waiting for, notify owner and terminate - Resp = make_response(Response), - gen_tcp:send(Sock, Resp), - PPid ! {ok, got_http_request, Packet}, - ok; - false -> - %% not the one, send a response to keep server happy and keep working - Resp = make_response(Response), - gen_tcp:send(Sock, Resp), - do_recv(Sock, <<>>, PPid, ReqPattern, Response); - invalid -> - %% not a complete packet, keep collecting data - do_recv(Sock, Packet, PPid, ReqPattern, Response) - end; - {error, closed} -> - ok - end. +handle(Req, HandleFun) -> + Req2 = HandleFun(Req), + {ok, Req2, HandleFun}. -make_response(Response) when is_binary(Response) -> - make_response(binary_to_list(Response)); -make_response(Response) -> - Len = length(Response), - Dt = httpd_util:rfc1123_date(erlang:universaltime()), - ["HTTP/1.1 200 OK\r\nDate: ", - Dt, - "\r\nContent-Length: ", integer_to_list(Len), "\r\nContent-Type: text/html\r\nServer: MongooseTest\r\n\r\n", - Response]. - -check_packet(Packet, ReqPattern) -> - case erlang:decode_packet(http, Packet, []) of - {ok, {http_request, 'POST', _, _}, Body} -> - check_body(Body, ReqPattern); - _ -> - invalid - end. - -check_body(_, undefined) -> - true; -check_body(Body, Pattern) when is_list(Pattern) -> - R = lists:map(fun(P) -> check_body(Body, P) end, Pattern), - lists:foldl(fun(A, B) -> A and B end, true, R); -check_body(Body, Pattern) when is_binary(Pattern) -> - case binary:match(Body, Pattern) of - nomatch -> false; - {_, _} -> true - end; -check_body(_, _) -> - false. +terminate(_Reason, _Req, _State) -> + ok. diff --git a/test/ejabberd_tests/tests/mod_http_notification_SUITE.erl b/test/ejabberd_tests/tests/mod_http_notification_SUITE.erl index 0369fb35439..f9666648307 100644 --- a/test/ejabberd_tests/tests/mod_http_notification_SUITE.erl +++ b/test/ejabberd_tests/tests/mod_http_notification_SUITE.erl @@ -54,15 +54,30 @@ end_per_group(_GroupName, _Config) -> ok. init_per_testcase(CaseName, Config) -> + start_http_listener(CaseName), escalus:init_per_testcase(CaseName, Config). end_per_testcase(CaseName, Config) -> + http_helper:stop(), escalus:end_per_testcase(CaseName, Config). start_mod_http_notification(Opts) -> Domain = ct:get_config(ejabberd_domain), dynamic_modules:start(Domain, mod_http_notification, Opts). +start_http_listener(simple_message) -> + Pid = self(), + http_helper:start(8000, "/", fun(Req) -> process_notification(Req, Pid) end); +start_http_listener(simple_message_no_listener) -> + ok; +start_http_listener(simple_message_failing_listener) -> + http_helper:start(8000, "/", fun(Req) -> Req end). + +process_notification(Req, Pid) -> + {ok, Body, Req1} = cowboy_req:body(Req), + {ok, Req2} = cowboy_req:reply(200, [{<<"content-type">>, <<"text/plain">>}], <<"OK">>, Req1), + Pid ! {got_http_request, Body}, + Req2. %%%=================================================================== %%% offline tests @@ -70,22 +85,21 @@ start_mod_http_notification(Opts) -> simple_message(Config) -> %% we expect one notification message - http_helper:listen_once(self(), 8000, [<<"alice">>, <<"Simple">>]), do_simple_message(Config, <<"Hi, Simple!">>), %% fail if we didn't receive http notification - Notified = receive - {ok, got_http_request, _} -> - true - after 2000 -> - false - end, - assert_true("notified", Notified). + Body = receive + {got_http_request, Bin} -> Bin + after 2000 -> + error(missing_request) + end, + ct:pal("Got request ~p~n", [Body]), + {_, _} = binary:match(Body, <<"alice">>), + {_, _} = binary:match(Body, <<"Simple">>). simple_message_no_listener(Config) -> do_simple_message(Config, <<"Hi, NoListener!">>). simple_message_failing_listener(Config) -> - http_helper:listen_once(self(), 8000, none, none), do_simple_message(Config, <<"Hi, Failing!">>). do_simple_message(Config, Msg) -> @@ -119,9 +133,3 @@ login_send_presence(Config, User) -> {ok, Client} = escalus_client:start(Config, Spec, <<"dummy">>), escalus:send(Client, escalus_stanza:presence(<<"available">>)), Client. - - -assert_true(_, true) -> - ok; -assert_true(Name, _) -> - ct:fail("~p is not true, while should be", [Name]). diff --git a/test/ejabberd_tests/tests/muc_SUITE.erl b/test/ejabberd_tests/tests/muc_SUITE.erl index 36e6c9a394c..67cf0ba55ce 100644 --- a/test/ejabberd_tests/tests/muc_SUITE.erl +++ b/test/ejabberd_tests/tests/muc_SUITE.erl @@ -39,7 +39,7 @@ -define(MUC_HOST, <<"muc.localhost">>). -define(MUC_CLIENT_HOST, <<"localhost/res1">>). --define(PASSWORD, <<"password">>). +-define(PASSWORD, <<"pa5sw0rd">>). -define(SUBJECT, <<"subject">>). -define(WAIT_TIME, 1500). @@ -80,7 +80,9 @@ all() -> [ {group, occupant}, {group, owner}, {group, owner_no_parallel}, - {group, room_management} + {group, room_management}, + {group, http_auth_no_server}, + {group, http_auth} ]. groups() -> [ @@ -209,6 +211,18 @@ groups() -> [ {room_management, [], [ create_and_destroy_room, create_and_destroy_room_multiple_x_elements + ]}, + {http_auth_no_server, [parallel], [ + deny_access_to_http_password_protected_room_service_unavailable, + deny_creation_of_http_password_protected_room_service_unavailable + ]}, + {http_auth, [parallel], [ + enter_http_password_protected_room, + deny_access_to_password_protected_room, + deny_access_to_http_password_protected_room_wrong_password, + create_instant_http_password_protected_room, + deny_creation_of_http_password_protected_room, + deny_creation_of_http_password_protected_room_wrong_password ]} ]. @@ -261,9 +275,45 @@ init_per_group(disco_rsm, Config) -> [Alice | _] = ?config(escalus_users, Config1), start_rsm_rooms(Config1, Alice, <<"aliceonchat">>); +init_per_group(G, Config) when G =:= http_auth_no_server; + G =:= http_auth -> + ejabberd_node_utils:call_fun(mongoose_http_client, start, [[]]), + ok = ejabberd_node_utils:call_fun(mongoose_http_client, start_pool, [muc_http_auth_test, + [{server, "http://localhost:8080"}, + {path_prefix, "/muc/auth/"}, + {pool_size, 5}]]), + case G of + http_auth -> http_helper:start(8080, "/muc/auth/check_password", fun handle_http_auth/1); + _ -> ok + end, + ConfigWithModules = dynamic_modules:save_modules(domain(), Config), + dynamic_modules:ensure_modules(domain(), required_modules(http_auth)), + ConfigWithModules; + init_per_group(_GroupName, Config) -> escalus:create_users(Config, escalus:get_users([alice, bob, kate])). +required_modules(http_auth) -> + [{mod_muc, [ + {host, "muc.@HOST@"}, + {access, muc}, + {access_create, muc_create}, + {http_auth_pool, muc_http_auth_test}, + {default_room_options, [{password_protected, true}]} + ]} + ]. + +handle_http_auth(Req) -> + {Pass, Req1} = cowboy_req:qs_val(<<"pass">>, Req), + {Code, Msg} = case Pass of + ?PASSWORD -> {0, <<"OK">>}; + _ -> {121, <<"Password expired">>} + end, + Resp = iolist_to_binary(mochijson2:encode({struct, [{<<"code">>, Code}, + {<<"msg">>, Msg}]})), + {ok, Req2} = cowboy_req:reply(200, [{<<"content-type">>, <<"application/json">>}], Resp, Req1), + Req2. + end_per_group(admin_membersonly, Config) -> destroy_room(Config), escalus:delete_users(Config, escalus:get_users([alice, bob, kate])); @@ -276,9 +326,21 @@ end_per_group(disco_rsm, Config) -> destroy_rsm_rooms(Config), escalus:delete_users(Config, escalus:get_users([alice, bob])); +end_per_group(G, Config) when G =:= http_auth_no_server; + G =:= http_auth -> + case G of + http_auth -> http_helper:stop(); + _ -> ok + end, + ejabberd_node_utils:call_fun(mongoose_http_client, stop_pool, [muc_http_auth_test]), + dynamic_modules:restore_modules(domain(), Config); + end_per_group(_GroupName, Config) -> escalus:delete_users(Config, escalus:get_users([alice, bob, kate])). +domain() -> + ct:get_config({hosts, mim, domain}). + init_per_testcase(CaseName =send_non_anonymous_history, Config) -> [Alice | _] = ?config(escalus_users, Config), Config1 = start_room(Config, Alice, <<"alicesroom">>, <<"alice">>, [{anonymous, false}]), @@ -3618,6 +3680,103 @@ pagination_after10(Config) -> end, escalus:fresh_story(Config, [{alice, 1}], F). +%%-------------------------------------------------------------------- +%% Password protection with HTTP external authentication +%%-------------------------------------------------------------------- + +deny_access_to_http_password_protected_room_wrong_password(Config1) -> + AliceSpec = given_fresh_spec(Config1, alice), + Config = given_fresh_room(Config1, AliceSpec, [{password_protected, true}]), + escalus:fresh_story(Config, [{bob, 1}], fun(Bob) -> + escalus:send(Bob, stanza_muc_enter_password_protected_room(?config(room, Config), escalus_utils:get_username(Bob), <<"badpass">>)), + escalus_assert:is_error(escalus:wait_for_stanza(Bob), <<"auth">>, <<"not-authorized">>) + end), + destroy_room(Config). + +deny_access_to_http_password_protected_room_service_unavailable(Config1) -> + AliceSpec = given_fresh_spec(Config1, alice), + Config = given_fresh_room(Config1, AliceSpec, [{password_protected, true}]), + escalus:fresh_story(Config, [{bob, 1}], fun(Bob) -> + escalus:send(Bob, stanza_muc_enter_password_protected_room(?config(room, Config), escalus_utils:get_username(Bob), ?PASSWORD)), + escalus_assert:is_error(escalus:wait_for_stanza(Bob), <<"cancel">>, <<"service-unavailable">>) + end), + destroy_room(Config). + +enter_http_password_protected_room(Config1) -> + AliceSpec = given_fresh_spec(Config1, alice), + Config = given_fresh_room(Config1, AliceSpec, [{password_protected, true}]), + escalus:fresh_story(Config, [{bob, 1}], fun(Bob) -> + escalus:send(Bob, stanza_muc_enter_password_protected_room(?config(room, Config), escalus_utils:get_username(Bob), ?PASSWORD)), + Presence = escalus:wait_for_stanza(Bob), + is_self_presence(Bob, ?config(room, Config), Presence) + end), + destroy_room(Config). + +create_instant_http_password_protected_room(Config) -> + escalus:fresh_story(Config, [{alice, 1}, {bob, 1}], fun(Alice, Bob) -> + + %% Create the room (should be locked on creation) + RoomName = fresh_room_name(), + Presence = stanza_muc_enter_password_protected_room(RoomName, <<"alice-the-owner">>, ?PASSWORD), + escalus:send(Alice, Presence), + was_room_created(escalus:wait_for_stanza(Alice)), + + escalus:wait_for_stanza(Alice), % topic + + R = escalus_stanza:setattr(stanza_instant_room(<>), + <<"from">>, escalus_utils:get_jid(Alice)), + escalus:send(Alice, R), + IQ = escalus:wait_for_stanza(Alice), + escalus:assert(is_iq_result, IQ), + + %% Bob should be able to join the room + escalus:send(Bob, stanza_muc_enter_password_protected_room(RoomName, <<"bob">>, ?PASSWORD)), + escalus:wait_for_stanza(Alice), %Bobs presence + %% Bob should receive (in that order): Alices presence, his presence and the topic + + Preds = [fun(Stanza) -> escalus_pred:is_presence(Stanza) andalso + escalus_pred:is_stanza_from(<>, Stanza) + end, + fun(Stanza) -> escalus_pred:is_presence(Stanza) andalso + escalus_pred:is_stanza_from(<>, Stanza) + end], + escalus:assert_many(Preds, escalus:wait_for_stanzas(Bob, 2)), + escalus:wait_for_stanza(Bob), %topic + escalus_assert:has_no_stanzas(Bob), + escalus_assert:has_no_stanzas(Alice) + end). + +deny_creation_of_http_password_protected_room(Config) -> + escalus:fresh_story(Config, [{alice, 1}], fun(Alice) -> + %% Fail to create the room + RoomName = fresh_room_name(), + Presence = stanza_muc_enter_room(RoomName, <<"alice-the-owner">>), + escalus:send(Alice, Presence), + escalus_assert:is_error(escalus:wait_for_stanza(Alice), <<"auth">>, <<"not-authorized">>), + escalus_assert:has_no_stanzas(Alice) + end). + +deny_creation_of_http_password_protected_room_wrong_password(Config) -> + escalus:fresh_story(Config, [{alice, 1}], fun(Alice) -> + %% Fail to create the room + RoomName = fresh_room_name(), + Presence = stanza_muc_enter_password_protected_room(RoomName, <<"alice-the-owner">>, <<"badpass">>), + escalus:send(Alice, Presence), + escalus_assert:is_error(escalus:wait_for_stanza(Alice), <<"auth">>, <<"not-authorized">>), + escalus_assert:has_no_stanzas(Alice) + end). + + +deny_creation_of_http_password_protected_room_service_unavailable(Config) -> + escalus:fresh_story(Config, [{alice, 1}], fun(Alice) -> + %% Fail to create the room + RoomName = fresh_room_name(), + Presence = stanza_muc_enter_password_protected_room(RoomName, <<"alice-the-owner">>, ?PASSWORD), + escalus:send(Alice, Presence), + escalus_assert:is_error(escalus:wait_for_stanza(Alice), <<"cancel">>, <<"service-unavailable">>), + escalus_assert:has_no_stanzas(Alice) + end). + %% @doc Based on examples from http://xmpp.org/extensions/xep-0059.html %% @end %%