Skip to content

Commit

Permalink
Merge pull request #4059 from esl/limit-number-of-users-per-domain
Browse files Browse the repository at this point in the history
Limit the number of users per domain
  • Loading branch information
chrzaszcz authored Jul 21, 2023
2 parents 41cdb68 + 3516724 commit bb1f56c
Show file tree
Hide file tree
Showing 8 changed files with 92 additions and 10 deletions.
41 changes: 41 additions & 0 deletions big_tests/tests/graphql_account_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ admin_account_tests() ->
admin_check_non_existing_user,
admin_register_user,
admin_register_random_user,
admin_register_user_non_existing_domain,
admin_register_user_limit_error,
admin_remove_non_existing_user,
admin_remove_existing_user,
admin_ban_user,
Expand Down Expand Up @@ -78,6 +80,7 @@ domain_admin_tests() ->
domain_admin_register_user_no_permission,
admin_register_random_user,
domain_admin_register_random_user_no_permission,
admin_register_user_limit_error,
admin_remove_existing_user,
domain_admin_remove_user_no_permission,
admin_ban_user,
Expand Down Expand Up @@ -144,6 +147,13 @@ init_per_testcase(admin_check_plain_password_hash = C, Config) ->
Config2 = escalus:create_users(Config1, escalus:get_users([carol])),
escalus:init_per_testcase(C, Config2)
end;
init_per_testcase(admin_register_user_limit_error = C, Config) ->
Domain = domain_helper:domain(),
{ok, HostType} = rpc(mim(), mongoose_domain_api, get_domain_host_type, [Domain]),
OptKey = [{auth, HostType}, max_users_per_domain],
Config1 = mongoose_helper:backup_and_set_config_option(Config, OptKey, 3),
Config2 = [{bob, <<"bob">>}, {kate, <<"kate">>}, {john, <<"john">>} | Config1],
escalus:init_per_testcase(C, Config2);
init_per_testcase(domain_admin_check_plain_password_hash_no_permission = C, Config) ->
{_, AuthMods} = lists:keyfind(ctl_auth_mods, 1, Config),
case lists:member(ejabberd_auth_ldap, AuthMods) of
Expand All @@ -169,6 +179,13 @@ end_per_testcase(admin_register_user = C, Config) ->
end_per_testcase(admin_check_plain_password_hash, Config) ->
mongoose_helper:restore_config(Config),
escalus:delete_users(Config, escalus:get_users([carol]));
end_per_testcase(admin_register_user_limit_error = C, Config) ->
Domain = domain_helper:domain(),
rpc(mim(), mongoose_account_api, unregister_user, [proplists:get_value(bob, Config), Domain]),
rpc(mim(), mongoose_account_api, unregister_user, [proplists:get_value(kate, Config), Domain]),
rpc(mim(), mongoose_account_api, unregister_user, [proplists:get_value(john, Config), Domain]),
mongoose_helper:restore_config(Config),
escalus:end_per_testcase(C, Config);
end_per_testcase(domain_admin_check_plain_password_hash_no_permission, Config) ->
mongoose_helper:restore_config(Config),
escalus:delete_users(Config, escalus:get_users([carol, alice_bis]));
Expand Down Expand Up @@ -347,6 +364,30 @@ admin_register_random_user(Config) ->
?assertNotEqual(nomatch, binary:match(Msg, <<"successfully registered">>)),
{ok, _} = rpc(mim(), mongoose_account_api, unregister_user, [Username, Server]).

admin_register_user_non_existing_domain(Config) ->
% Try to register a user with a non-existing domain
Resp = register_user(<<"unknown">>, <<"alice">>, <<"test_password">>, Config),
?assertMatch({_, _}, binary:match(get_err_msg(Resp), <<"not_allowed">>)).

admin_register_user_limit_error(Config) ->
Password = <<"password">>,
Domain = domain_helper:domain(),
Path = [data, account, registerUser, message],
Resp1 = register_user(Domain, proplists:get_value(bob, Config), Password, Config),
?assertNotEqual(nomatch, binary:match(get_ok_value(Path, Resp1), <<"successfully registered">>)),
Resp2 = register_user(Domain, proplists:get_value(kate, Config), Password, Config),
?assertNotEqual(nomatch, binary:match(get_ok_value(Path, Resp2), <<"successfully registered">>)),
%% One user was registered in the init_per_group, and two more were registered in this test case
%% There are three registered users at this moment
%% The next (fourth) registration should exceed the limit of three
JohnNick = proplists:get_value(john, Config),
Resp3 = register_user(Domain, JohnNick, Password, Config),
?assertMatch({_, _}, binary:match(get_err_msg(Resp3), <<"limit has been exceeded">>)),
%% Make sure the fourth account wasn't created
CheckUserPath = [data, account, checkUser],
Resp4 = check_user(<<JohnNick/binary, "@", Domain/binary>>, Config),
?assertMatch(#{<<"exist">> := false, <<"message">> := _}, get_ok_value(CheckUserPath, Resp4)).

admin_remove_non_existing_user(Config) ->
% Non-existing user, non-existing domain
Resp = remove_user(?NOT_EXISTING_JID, Config),
Expand Down
10 changes: 10 additions & 0 deletions doc/configuration/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ There are three possible ways of using the `SASL EXTERNAL` mechanism:

This option allows you to list the enabled ones in the order of preference (they are tried until one succeeds or the list is exhausted).

### `auth.max_users_per_domain`
* **Syntax:** positive integer or string `"infinity"`, representing maximum amount of users that can be registered in a domain
* **Default:** `"infinity"`
* **Example:** `max_users_per_domain = 10000`

Limits the number of users that can be registered for each domain. If the option is configured to the value `"infinity"`, no limit is present.

!!! Warning
The limit only works for the following authentication methods: `internal`, `rdbms` and `ldap`.

## Password-related options

These options are common to the `http`, `rdbms` and `internal` methods.
Expand Down
21 changes: 18 additions & 3 deletions src/auth/ejabberd_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ set_password(#jid{luser = LUser, lserver = LServer}, Password) ->
end.

-spec try_register(jid:jid() | error, binary()) ->
ok | {error, exists | not_allowed | invalid_jid | null_password}.
ok | {error, exists | not_allowed | invalid_jid | null_password | limit_per_domain_exceeded}.
try_register(_, <<>>) ->
{error, null_password};
try_register(#jid{luser = <<>>}, _) ->
Expand All @@ -221,7 +221,7 @@ try_register(JID, Password) ->
do_try_register_if_does_not_exist(Exists, JID, Password).

-spec do_try_register_if_does_not_exist(boolean(), jid:jid(), binary()) ->
ok | {error, exists | not_allowed | invalid_jid | null_password}.
ok | {error, exists | not_allowed | invalid_jid | null_password | limit_per_domain_exceeded}.
do_try_register_if_does_not_exist(true, _, _) ->
{error, exists};
do_try_register_if_does_not_exist(_, JID, Password) ->
Expand All @@ -236,7 +236,12 @@ do_try_register_if_does_not_exist(_, JID, Password) ->
end
end,
Opts = #{default => {error, not_allowed}, metric => try_register},
call_auth_modules_for_domain(LServer, F, Opts).
case is_user_number_below_limit(LServer) of
true ->
call_auth_modules_for_domain(LServer, F, Opts);
false ->
{error, limit_per_domain_exceeded}
end.

%% @doc Registered users list do not include anonymous users logged
-spec get_vh_registered_users(Server :: jid:server()) -> [jid:simple_bare_jid()].
Expand Down Expand Up @@ -578,3 +583,13 @@ fold_auth_modules([AuthModule | AuthModules], F, CurAcc) ->
{stop, Value} ->
Value
end.

is_user_number_below_limit(Domain) ->
case mongoose_domain_api:get_domain_host_type(Domain) of
{ok, HostType} ->
Limit = mongoose_config:get_opt([{auth, HostType}, max_users_per_domain]),
Current = get_vh_registered_users_number(Domain),
Current < Limit;
{error, not_found} ->
true
end.
7 changes: 5 additions & 2 deletions src/config/mongoose_config_spec.erl
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,13 @@ auth() ->
<<"sasl_mechanisms">> =>
#list{items = #option{type = atom,
validate = {module, cyrsasl},
process = fun ?MODULE:process_sasl_mechanism/1}}
process = fun ?MODULE:process_sasl_mechanism/1}},
<<"max_users_per_domain">> => #option{type = int_or_infinity,
validate = positive}
},
defaults = #{<<"sasl_external">> => [standard],
<<"sasl_mechanisms">> => cyrsasl:default_modules()},
<<"sasl_mechanisms">> => cyrsasl:default_modules(),
<<"max_users_per_domain">> => infinity},
process = fun ?MODULE:process_auth/1,
wrap = host_config
}.
Expand Down
6 changes: 5 additions & 1 deletion src/mongoose_account_api.erl
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
check_password_hash/4,
import_users/1]).

-type register_result() :: {ok | exists | invalid_jid | cannot_register, iolist()}.
-type register_result() :: {ok | exists | invalid_jid | cannot_register |
limit_per_domain_exceeded, iolist()}.

-type unregister_result() :: {ok | not_allowed | invalid_jid | user_does_not_exist, string()}.

Expand Down Expand Up @@ -87,6 +88,9 @@ register_user(User, Host, Password) ->
{error, invalid_jid} ->
String = io_lib:format("Invalid JID ~s@~s", [User, Host]),
{invalid_jid, String};
{error, limit_per_domain_exceeded} ->
String = io_lib:format("User limit has been exceeded for domain ~s", [Host]),
{limit_per_domain_exceeded, String};
{error, Reason} ->
String =
io_lib:format("Can't register user ~s at node ~p: ~p",
Expand Down
3 changes: 2 additions & 1 deletion src/mongoose_import_users.erl
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
-define(REGISTER_WORKERS_NUM, 10).

-type summary() :: #{reason() => [jid:jid() | binary()]}.
-type reason() :: ok | exists | not_allowed | invalid_jid | null_password | bad_csv.
-type reason() :: ok | exists | not_allowed | invalid_jid | null_password |
limit_per_domain_exceeded | bad_csv.

-export_type([summary/0, reason/0]).

Expand Down
3 changes: 2 additions & 1 deletion test/common/config_parser_helper.erl
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,8 @@ default_auth() ->
password => #{format => scram,
scram_iterations => 10000},
sasl_external => [standard],
sasl_mechanisms => cyrsasl:default_modules()}.
sasl_mechanisms => cyrsasl:default_modules(),
max_users_per_domain => infinity}.

pgsql_s2s() ->
Outgoing = (default_s2s_outgoing())#{port => 5299},
Expand Down
11 changes: 9 additions & 2 deletions test/config_parser_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ groups() ->
routing_modules,
replaced_wait_timeout,
hide_service_name,
domain_certfile]},
domain_certfile,
max_users_per_domain]},
{listen, [parallel], [listen_duplicate,
listen_c2s,
listen_c2s_fast_tls,
Expand Down Expand Up @@ -727,6 +728,12 @@ auth_sasl_mechanisms(_Config) ->
#{<<"auth">> => #{<<"sasl_mechanisms">> => [<<"external">>, <<"scram">>]}}),
?errh(#{<<"auth">> => #{<<"sasl_mechanisms">> => [<<"none">>]}}).

max_users_per_domain(_Config) ->
?cfg([{auth, ?HOST}, max_users_per_domain], infinity, #{}), % global default
?cfgh([auth, max_users_per_domain], 1000, #{<<"auth">> =>
#{<<"max_users_per_domain">> => 1000}}),
?errh(#{<<"auth">> => #{<<"max_users_per_domain">> => 0}}).

auth_allow_multiple_connections(_Config) ->
?cfgh([auth, anonymous, allow_multiple_connections], true,
auth_raw(<<"anonymous">>, #{<<"allow_multiple_connections">> => true})),
Expand Down Expand Up @@ -3071,7 +3078,7 @@ create_files(Config) ->

ensure_copied(From, To) ->
case file:copy(From, To) of
{ok,_} ->
{ok, _} ->
ok;
Other ->
error(#{what => ensure_copied_failed, from => From, to => To,
Expand Down

0 comments on commit bb1f56c

Please sign in to comment.