diff --git a/big_tests/tests/graphql_account_SUITE.erl b/big_tests/tests/graphql_account_SUITE.erl index 1dd095965ca..f1e6bdc18bd 100644 --- a/big_tests/tests/graphql_account_SUITE.erl +++ b/big_tests/tests/graphql_account_SUITE.erl @@ -49,6 +49,7 @@ admin_account_tests() -> admin_check_non_existing_user, admin_register_user, admin_register_random_user, + admin_register_user_limit_error, admin_remove_non_existing_user, admin_remove_existing_user, admin_ban_user, @@ -144,6 +145,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 = {max_users_per_domain, HostType}, + Config1 = mongoose_helper:backup_and_set_config_option(Config, OptKey, 3), + Config2 = [{user1, <<"bob">>}, {user2, <<"kate">>}, {user3, <<"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 @@ -169,6 +177,12 @@ 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(user1, Config), Domain]), + rpc(mim(), mongoose_account_api, unregister_user, [proplists:get_value(user2, 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])); @@ -347,6 +361,20 @@ 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_limit_error(Config) -> + Password = <<"password">>, + Domain = domain_helper:domain(), + Path = [data, account, registerUser, message], + list_users(Domain, Config), + Resp1 = register_user(Domain, proplists:get_value(user1, Config), Password, Config), + ?assertNotEqual(nomatch, binary:match(get_ok_value(Path, Resp1), <<"successfully registered">>)), + Resp2 = register_user(Domain, proplists:get_value(user2, 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 + %% The next registration should exceed the limit of three + Resp3 = register_user(Domain, proplists:get_value(user3, Config), Password, Config), + ?assertMatch({_, _}, binary:match(get_err_msg(Resp3), <<"limit has been exceeded">>)). + admin_remove_non_existing_user(Config) -> % Non-existing user, non-existing domain Resp = remove_user(?NOT_EXISTING_JID, Config), diff --git a/doc/configuration/general.md b/doc/configuration/general.md index ebddbdfc65c..afc7d94b5f4 100644 --- a/doc/configuration/general.md +++ b/doc/configuration/general.md @@ -157,6 +157,13 @@ See the section about [redis connection setup](./outgoing-connections.md#redis-s When a user's session is replaced (due to a full JID conflict) by a new one, this parameter specifies the time MongooseIM waits for the old sessions to close. The default value is sufficient in most cases. If you observe `replaced_wait_timeout` warning in logs, then most probably the old sessions are frozen for some reason and it should be investigated. +### `general.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. + ## Message routing The following options influence the way MongooseIM routes incoming messages to their recipients. diff --git a/doc/configuration/host_config.md b/doc/configuration/host_config.md index e645155446c..64b2343ecf1 100644 --- a/doc/configuration/host_config.md +++ b/doc/configuration/host_config.md @@ -38,6 +38,7 @@ The following options are allowed: * [`route_subdomains`](general.md#generalroute_subdomains) * [`replaced_wait_timeout`](general.md#generalreplaced_wait_timeout) +* [`max_users_per_domain`](general.md#generalmax_users_per_domain) #### Example diff --git a/src/auth/ejabberd_auth.erl b/src/auth/ejabberd_auth.erl index de0bb0ab02d..5ae9eb6de4f 100644 --- a/src/auth/ejabberd_auth.erl +++ b/src/auth/ejabberd_auth.erl @@ -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 = <<>>}, _) -> @@ -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) -> @@ -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_limit_per_domain_exceeded(LServer) of + true -> + {error, limit_per_domain_exceeded}; + false -> + call_auth_modules_for_domain(LServer, F, Opts) + end. %% @doc Registered users list do not include anonymous users logged -spec get_vh_registered_users(Server :: jid:server()) -> [jid:simple_bare_jid()]. @@ -578,3 +583,13 @@ fold_auth_modules([AuthModule | AuthModules], F, CurAcc) -> {stop, Value} -> Value end. + +is_user_limit_per_domain_exceeded(Domain) -> + case mongoose_domain_api:get_domain_host_type(Domain) of + {ok, HostType} -> + Limit = mongoose_config:get_opt({max_users_per_domain, HostType}), + Current = get_vh_registered_users_number(Domain), + Current >= Limit; + {error, not_found} -> + false + end. diff --git a/src/config/mongoose_config_spec.erl b/src/config/mongoose_config_spec.erl index 95760eec463..f2ec5fddff7 100644 --- a/src/config/mongoose_config_spec.erl +++ b/src/config/mongoose_config_spec.erl @@ -194,7 +194,10 @@ general() -> wrap = global_config}, <<"domain_certfile">> => #list{items = domain_cert(), format_items = map, - wrap = global_config} + wrap = global_config}, + <<"max_users_per_domain">> => #option{type = int_or_infinity, + validate = positive, + wrap = host_config} }, wrap = none, format_items = list @@ -212,7 +215,8 @@ general_defaults() -> <<"mongooseimctl_access_commands">> => #{}, <<"routing_modules">> => mongoose_router:default_routing_modules(), <<"replaced_wait_timeout">> => 2000, - <<"hide_service_name">> => false}. + <<"hide_service_name">> => false, + <<"max_users_per_domain">> => infinity}. ctl_access_rule() -> #section{ diff --git a/src/mongoose_account_api.erl b/src/mongoose_account_api.erl index 0632507150d..00270d2e9fc 100644 --- a/src/mongoose_account_api.erl +++ b/src/mongoose_account_api.erl @@ -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()}. @@ -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", diff --git a/src/mongoose_import_users.erl b/src/mongoose_import_users.erl index 246ebd1e07a..41e12972aa4 100644 --- a/src/mongoose_import_users.erl +++ b/src/mongoose_import_users.erl @@ -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]). diff --git a/test/common/config_parser_helper.erl b/test/common/config_parser_helper.erl index 31ce554c5cb..9a62a36b280 100644 --- a/test/common/config_parser_helper.erl +++ b/test/common/config_parser_helper.erl @@ -52,7 +52,12 @@ options("host_types") -> {{replaced_wait_timeout, <<"localhost">>}, 2000}, {{replaced_wait_timeout, <<"some host type">>}, 2000}, {{replaced_wait_timeout, <<"this is host type">>}, 2000}, - {{replaced_wait_timeout, <<"yet another host type">>}, 2000}]; + {{replaced_wait_timeout, <<"yet another host type">>}, 2000}, + {{max_users_per_domain, <<"another host type">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}, + {{max_users_per_domain, <<"some host type">>}, infinity}, + {{max_users_per_domain, <<"this is host type">>}, infinity}, + {{max_users_per_domain, <<"yet another host type">>}, infinity}]; options("miscellaneous") -> [{all_metrics_are_global, false}, {http_server_name, "Apache"}, @@ -99,7 +104,9 @@ options("miscellaneous") -> {{replaced_wait_timeout, <<"anonymous.localhost">>}, 2000}, {{replaced_wait_timeout, <<"localhost">>}, 2000}, {{route_subdomains, <<"anonymous.localhost">>}, s2s}, - {{route_subdomains, <<"localhost">>}, s2s}]; + {{route_subdomains, <<"localhost">>}, s2s}, + {{max_users_per_domain, <<"anonymous.localhost">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}]; options("modules") -> [{all_metrics_are_global, false}, {default_server_domain, <<"localhost">>}, @@ -123,7 +130,9 @@ options("modules") -> {{modules, <<"dummy_host">>}, all_modules()}, {{modules, <<"localhost">>}, all_modules()}, {{replaced_wait_timeout, <<"dummy_host">>}, 2000}, - {{replaced_wait_timeout, <<"localhost">>}, 2000}]; + {{replaced_wait_timeout, <<"localhost">>}, 2000}, + {{max_users_per_domain, <<"dummy_host">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}]; options("mongooseim-pgsql") -> [{all_metrics_are_global, false}, {default_server_domain, <<"localhost">>}, @@ -280,6 +289,9 @@ options("mongooseim-pgsql") -> {{access, <<"anonymous.localhost">>}, pgsql_access()}, {{access, <<"localhost">>}, pgsql_access()}, {{access, <<"localhost.bis">>}, pgsql_access()}, + {{max_users_per_domain, <<"anonymous.localhost">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}, + {{max_users_per_domain, <<"localhost.bis">>}, infinity}, {{acl, global}, #{local => [#{match => current_domain, user_regexp => <<>>}]}}, {{acl, <<"anonymous.localhost">>}, #{local => [#{match => current_domain, @@ -353,7 +365,10 @@ options("outgoing_pools") -> {{modules, <<"localhost.bis">>}, #{}}, {{replaced_wait_timeout, <<"anonymous.localhost">>}, 2000}, {{replaced_wait_timeout, <<"localhost">>}, 2000}, - {{replaced_wait_timeout, <<"localhost.bis">>}, 2000}]; + {{replaced_wait_timeout, <<"localhost.bis">>}, 2000}, + {{max_users_per_domain, <<"anonymous.localhost">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}, + {{max_users_per_domain, <<"localhost.bis">>}, infinity}]; options("s2s_only") -> [{all_metrics_are_global, false}, {default_server_domain, <<"localhost">>}, @@ -377,7 +392,9 @@ options("s2s_only") -> {{replaced_wait_timeout, <<"dummy_host">>}, 2000}, {{replaced_wait_timeout, <<"localhost">>}, 2000}, {{s2s, <<"dummy_host">>}, custom_s2s()}, - {{s2s, <<"localhost">>}, custom_s2s()}]. + {{s2s, <<"localhost">>}, custom_s2s()}, + {{max_users_per_domain, <<"dummy_host">>}, infinity}, + {{max_users_per_domain, <<"localhost">>}, infinity}]. all_modules() -> #{mod_mam_rdbms_user => #{muc => true, pm => true}, diff --git a/test/config_parser_SUITE.erl b/test/config_parser_SUITE.erl index d07b0fdbee1..f1c75b63368 100644 --- a/test/config_parser_SUITE.erl +++ b/test/config_parser_SUITE.erl @@ -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, @@ -443,6 +444,12 @@ domain_certfile(_Config) -> || K <- maps:keys(DomCert)], ?err(#{<<"general">> => #{<<"domain_certfile">> => [DomCert, DomCert]}}). +max_users_per_domain(_Config) -> + ?cfg({max_users_per_domain, ?HOST}, infinity, #{}), % global default + ?cfgh(max_users_per_domain, 1000, #{<<"general">> => + #{<<"max_users_per_domain">> => 1000}}), + ?errh(#{<<"general">> => #{<<"max_users_per_domain">> => 0}}). + %% tests: listen listen_duplicate(_Config) -> @@ -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, diff --git a/test/mongoose_config_SUITE.erl b/test/mongoose_config_SUITE.erl index 87d48955685..ad0e55ac906 100644 --- a/test/mongoose_config_SUITE.erl +++ b/test/mongoose_config_SUITE.erl @@ -190,7 +190,8 @@ minimal_config_opts() -> {{auth, <<"localhost">>}, config_parser_helper:default_auth()}, {{modules, <<"localhost">>}, #{}}, {{replaced_wait_timeout, <<"localhost">>}, 2000}, - {{s2s, <<"localhost">>}, config_parser_helper:default_s2s()}]. + {{s2s, <<"localhost">>}, config_parser_helper:default_s2s()}, + {{max_users_per_domain, <<"localhost">>}, infinity}]. start_slave_node(Config) -> SlaveNode = do_start_slave_node(),