%%-------------------------------------------------------------------- %% Copyright (c) 2020-2023 EMQ Technologies Co., Ltd. All Rights Reserved. %% %% 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(emqx_authentication_SUITE). -behaviour(hocon_schema). -behaviour(emqx_authentication). -compile(export_all). -compile(nowarn_export_all). -include_lib("emqx/include/emqx_hooks.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("eunit/include/eunit.hrl"). -include("emqx_authentication.hrl"). -define(AUTHN, emqx_authentication). -define(config(KEY), (fun() -> {KEY, _V_} = lists:keyfind(KEY, 1, Config), _V_ end)() ). -define(CONF_ROOT, ?EMQX_AUTHENTICATION_CONFIG_ROOT_NAME_ATOM). -define(NOT_SUPERUSER, #{is_superuser => false}). -define(assertAuthSuccessForUser(User), ?assertMatch( {ok, _}, emqx_access_control:authenticate(ClientInfo#{username => atom_to_binary(User)}) ) ). -define(assertAuthFailureForUser(User), ?assertMatch( {error, _}, emqx_access_control:authenticate(ClientInfo#{username => atom_to_binary(User)}) ) ). %%------------------------------------------------------------------------------ %% Callbacks %%------------------------------------------------------------------------------ create(_AuthenticatorID, _Config) -> {ok, #{mark => 1}}. update(_Config, _State) -> {ok, #{mark => 2}}. authenticate(#{username := <<"good">>}, _State) -> {ok, #{is_superuser => true}}; authenticate(#{username := <<"ignore">>}, _State) -> ignore; authenticate(#{username := <<"emqx_authn_ignore_for_hook_good">>}, _State) -> ignore; authenticate(#{username := <<"emqx_authn_ignore_for_hook_bad">>}, _State) -> ignore; authenticate(#{username := _}, _State) -> {error, bad_username_or_password}. hook_authenticate(#{username := <<"hook_user_good">>}, _AuthResult) -> {ok, {ok, ?NOT_SUPERUSER}}; hook_authenticate(#{username := <<"hook_user_bad">>}, _AuthResult) -> {ok, {error, invalid_username}}; hook_authenticate(#{username := <<"hook_user_finally_good">>}, _AuthResult) -> {stop, {ok, ?NOT_SUPERUSER}}; hook_authenticate(#{username := <<"hook_user_finally_bad">>}, _AuthResult) -> {stop, {error, invalid_username}}; hook_authenticate(#{username := <<"emqx_authn_ignore_for_hook_good">>}, _AuthResult) -> {ok, {ok, ?NOT_SUPERUSER}}; hook_authenticate(#{username := <<"emqx_authn_ignore_for_hook_bad">>}, _AuthResult) -> {stop, {error, invalid_username}}; hook_authenticate(_ClientId, AuthResult) -> {ok, AuthResult}. destroy(_State) -> ok. all() -> emqx_common_test_helpers:all(?MODULE). init_per_suite(Config) -> Apps = emqx_cth_suite:start( [ emqx, emqx_conf, emqx_authn ], #{work_dir => ?config(priv_dir)} ), ok = deregister_providers(), [{apps, Apps} | Config]. end_per_suite(Config) -> emqx_cth_suite:stop(?config(apps)), ok. init_per_testcase(Case, Config) -> ?MODULE:Case({'init', Config}). end_per_testcase(Case, Config) -> _ = ?MODULE:Case({'end', Config}), ok. %%================================================================================= %% Testcases %%================================================================================= t_chain({'init', Config}) -> Config; t_chain(Config) when is_list(Config) -> % CRUD of authentication chain ChainName = 'test', ?assertMatch({ok, []}, ?AUTHN:list_chains()), ?assertMatch({ok, []}, ?AUTHN:list_chain_names()), %% to create a chain we need create an authenticator AuthenticatorConfig = #{ mechanism => password_based, backend => built_in_database, enable => true }, register_provider({password_based, built_in_database}, ?MODULE), ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig), ?assertMatch({ok, #{name := ChainName, authenticators := [_]}}, ?AUTHN:lookup_chain(ChainName)), ?assertMatch({ok, [#{name := ChainName}]}, ?AUTHN:list_chains()), ?assertEqual({ok, [ChainName]}, ?AUTHN:list_chain_names()), ?assertEqual(ok, ?AUTHN:delete_chain(ChainName)), ?assertMatch({error, {not_found, {chain, ChainName}}}, ?AUTHN:lookup_chain(ChainName)), ok; t_chain({'end', _Config}) -> ?AUTHN:delete_chain(test), ?AUTHN:deregister_providers([{password_based, built_in_database}]), ok. t_authenticator({'init', Config}) -> [ {"auth1", {password_based, built_in_database}}, {"auth2", {password_based, mysql}} | Config ]; t_authenticator(Config) when is_list(Config) -> ChainName = 'test', AuthenticatorConfig1 = #{ mechanism => password_based, backend => built_in_database, enable => true }, % Create an authenticator when the provider does not exist ?assertEqual( {error, {no_available_provider_for, {password_based, built_in_database}}}, ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig1) ), AuthNType1 = ?config("auth1"), register_provider(AuthNType1, ?MODULE), ID1 = <<"password_based:built_in_database">>, % CRUD of authenticator ?assertMatch( {ok, #{id := ID1, state := #{mark := 1}}}, ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig1) ), ?assertMatch({ok, #{id := ID1}}, ?AUTHN:lookup_authenticator(ChainName, ID1)), ?assertMatch({ok, [#{id := ID1}]}, ?AUTHN:list_authenticators(ChainName)), ?assertEqual( {error, {already_exists, {authenticator, ID1}}}, ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig1) ), ?assertMatch( {ok, #{id := ID1, state := #{mark := 2}}}, ?AUTHN:update_authenticator(ChainName, ID1, AuthenticatorConfig1) ), ?assertEqual(ok, ?AUTHN:delete_authenticator(ChainName, ID1)), ?assertEqual( {error, {not_found, {chain, test}}}, ?AUTHN:update_authenticator(ChainName, ID1, AuthenticatorConfig1) ), ?assertMatch( {error, {not_found, {chain, ChainName}}}, ?AUTHN:list_authenticators(ChainName) ), % Multiple authenticators exist at the same time AuthNType2 = ?config("auth2"), register_provider(AuthNType2, ?MODULE), ID2 = <<"password_based:mysql">>, AuthenticatorConfig2 = #{ mechanism => 'password_based', backend => mysql, enable => true }, ?assertMatch( {ok, #{id := ID1}}, ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig1) ), ?assertMatch( {ok, #{id := ID2}}, ?AUTHN:create_authenticator(ChainName, AuthenticatorConfig2) ), % Move authenticator ?assertMatch({ok, [#{id := ID1}, #{id := ID2}]}, ?AUTHN:list_authenticators(ChainName)), ?assertEqual(ok, ?AUTHN:move_authenticator(ChainName, ID2, ?CMD_MOVE_FRONT)), ?assertMatch({ok, [#{id := ID2}, #{id := ID1}]}, ?AUTHN:list_authenticators(ChainName)), ?assertEqual(ok, ?AUTHN:move_authenticator(ChainName, ID2, ?CMD_MOVE_REAR)), ?assertMatch({ok, [#{id := ID1}, #{id := ID2}]}, ?AUTHN:list_authenticators(ChainName)), ?assertEqual(ok, ?AUTHN:move_authenticator(ChainName, ID2, ?CMD_MOVE_BEFORE(ID1))), ?assertMatch({ok, [#{id := ID2}, #{id := ID1}]}, ?AUTHN:list_authenticators(ChainName)), ?assertEqual(ok, ?AUTHN:move_authenticator(ChainName, ID2, ?CMD_MOVE_AFTER(ID1))), ?assertMatch({ok, [#{id := ID1}, #{id := ID2}]}, ?AUTHN:list_authenticators(ChainName)); t_authenticator({'end', Config}) -> ?AUTHN:delete_chain(test), ?AUTHN:deregister_providers([?config("auth1"), ?config("auth2")]), ok. t_authenticate({init, Config}) -> [ {listener_id, 'tcp:default'}, {authn_type, {password_based, built_in_database}} | Config ]; t_authenticate(Config) when is_list(Config) -> ListenerID = ?config(listener_id), AuthNType = ?config(authn_type), ClientInfo = #{ zone => default, listener => ListenerID, protocol => mqtt, username => <<"good">>, password => <<"any">> }, ?assertEqual({ok, #{is_superuser => false}}, emqx_access_control:authenticate(ClientInfo)), register_provider(AuthNType, ?MODULE), AuthenticatorConfig = #{ mechanism => password_based, backend => built_in_database, enable => true }, ?assertMatch({ok, _}, ?AUTHN:create_authenticator(ListenerID, AuthenticatorConfig)), ?assertEqual( {ok, #{is_superuser => true}}, emqx_access_control:authenticate(ClientInfo) ), ?assertEqual( {error, bad_username_or_password}, emqx_access_control:authenticate(ClientInfo#{username => <<"bad">>}) ); t_authenticate({'end', Config}) -> ?AUTHN:delete_chain(?config(listener_id)), ?AUTHN:deregister_provider(?config(authn_type)), ok. t_update_config({init, Config}) -> Global = 'mqtt:global', AuthNType1 = {password_based, built_in_database}, AuthNType2 = {password_based, mysql}, [ {global, Global}, {"auth1", AuthNType1}, {"auth2", AuthNType2} | Config ]; t_update_config(Config) when is_list(Config) -> emqx_config_handler:add_handler([?CONF_ROOT], emqx_authentication_config), ok = emqx_config_handler:add_handler( [listeners, '?', '?', ?CONF_ROOT], emqx_authentication_config ), ok = register_provider(?config("auth1"), ?MODULE), ok = register_provider(?config("auth2"), ?MODULE), Global = ?config(global), %% We mocked provider implementation, but did't mock the schema %% so we should provide full config AuthenticatorConfig1 = #{ <<"mechanism">> => <<"password_based">>, <<"backend">> => <<"built_in_database">>, <<"enable">> => true }, AuthenticatorConfig2 = #{ <<"mechanism">> => <<"password_based">>, <<"backend">> => <<"mysql">>, <<"query">> => <<"SELECT password_hash, salt FROM users WHERE username = ?">>, <<"server">> => <<"127.0.0.1:5432">>, <<"database">> => <<"emqx">>, <<"enable">> => true }, ID1 = <<"password_based:built_in_database">>, ID2 = <<"password_based:mysql">>, ?assertMatch({ok, []}, ?AUTHN:list_chains()), ?assertMatch( {ok, _}, update_config([?CONF_ROOT], {create_authenticator, Global, AuthenticatorConfig1}) ), ?assertMatch( {ok, #{id := ID1, state := #{mark := 1}}}, ?AUTHN:lookup_authenticator(Global, ID1) ), ?assertMatch( {ok, _}, update_config([?CONF_ROOT], {create_authenticator, Global, AuthenticatorConfig2}) ), ?assertMatch( {ok, #{id := ID2, state := #{mark := 1}}}, ?AUTHN:lookup_authenticator(Global, ID2) ), ?assertMatch( {ok, _}, update_config( [?CONF_ROOT], {update_authenticator, Global, ID1, AuthenticatorConfig1#{<<"enable">> => false}} ) ), ?assertMatch( {ok, #{id := ID1, state := #{mark := 2}}}, ?AUTHN:lookup_authenticator(Global, ID1) ), ?assertMatch( {ok, _}, update_config([?CONF_ROOT], {move_authenticator, Global, ID2, ?CMD_MOVE_FRONT}) ), ?assertMatch({ok, [#{id := ID2}, #{id := ID1}]}, ?AUTHN:list_authenticators(Global)), [Raw2, Raw1] = emqx:get_raw_config([?CONF_ROOT]), ?assertMatch({ok, _}, update_config([?CONF_ROOT], [Raw1, Raw2])), ?assertMatch({ok, [#{id := ID1}, #{id := ID2}]}, ?AUTHN:list_authenticators(Global)), ?assertMatch({ok, _}, update_config([?CONF_ROOT], {delete_authenticator, Global, ID1})), ?assertEqual( {error, {not_found, {authenticator, ID1}}}, ?AUTHN:lookup_authenticator(Global, ID1) ), ?assertMatch( {ok, _}, update_config([?CONF_ROOT], {delete_authenticator, Global, ID2}) ), ?assertEqual( {error, {not_found, {chain, Global}}}, ?AUTHN:lookup_authenticator(Global, ID2) ), ListenerID = 'tcp:default', ConfKeyPath = [listeners, tcp, default, ?CONF_ROOT], ?assertMatch( {ok, _}, update_config( ConfKeyPath, {create_authenticator, ListenerID, AuthenticatorConfig1} ) ), ?assertMatch( {ok, #{id := ID1, state := #{mark := 1}}}, ?AUTHN:lookup_authenticator(ListenerID, ID1) ), ?assertMatch( {ok, _}, update_config( ConfKeyPath, {create_authenticator, ListenerID, AuthenticatorConfig2} ) ), ?assertMatch( {ok, #{id := ID2, state := #{mark := 1}}}, ?AUTHN:lookup_authenticator(ListenerID, ID2) ), ?assertMatch( {ok, _}, update_config( ConfKeyPath, {update_authenticator, ListenerID, ID1, AuthenticatorConfig1#{<<"enable">> => false}} ) ), ?assertMatch( {ok, #{id := ID1, state := #{mark := 2}}}, ?AUTHN:lookup_authenticator(ListenerID, ID1) ), ?assertMatch( {ok, _}, update_config(ConfKeyPath, {move_authenticator, ListenerID, ID2, ?CMD_MOVE_FRONT}) ), ?assertMatch( {ok, [#{id := ID2}, #{id := ID1}]}, ?AUTHN:list_authenticators(ListenerID) ), [LRaw2, LRaw1] = emqx:get_raw_config(ConfKeyPath), ?assertMatch({ok, _}, update_config(ConfKeyPath, [LRaw1, LRaw2])), ?assertMatch( {ok, [#{id := ID1}, #{id := ID2}]}, ?AUTHN:list_authenticators(ListenerID) ), ?assertMatch( {ok, _}, update_config(ConfKeyPath, {delete_authenticator, ListenerID, ID1}) ), ?assertEqual( {error, {not_found, {authenticator, ID1}}}, ?AUTHN:lookup_authenticator(ListenerID, ID1) ); t_update_config({'end', Config}) -> ?AUTHN:delete_chain(?config(global)), ?AUTHN:deregister_providers([?config("auth1"), ?config("auth2")]), ok. t_restart({'init', Config}) -> Config; t_restart(Config) when is_list(Config) -> ?assertEqual({ok, []}, ?AUTHN:list_chain_names()), %% to create a chain we need create an authenticator AuthenticatorConfig = #{ mechanism => password_based, backend => built_in_database, enable => true }, register_provider({password_based, built_in_database}, ?MODULE), ?AUTHN:create_authenticator(test_chain, AuthenticatorConfig), ?assertEqual({ok, [test_chain]}, ?AUTHN:list_chain_names()), ok = supervisor:terminate_child(emqx_authentication_sup, ?AUTHN), {ok, _} = supervisor:restart_child(emqx_authentication_sup, ?AUTHN), ?assertEqual({ok, [test_chain]}, ?AUTHN:list_chain_names()); t_restart({'end', _Config}) -> ?AUTHN:delete_chain(test_chain), ?AUTHN:deregister_providers([{password_based, built_in_database}]), ok. t_combine_authn_and_callback({init, Config}) -> [ {listener_id, 'tcp:default'}, {authn_type, {password_based, built_in_database}} | Config ]; t_combine_authn_and_callback(Config) when is_list(Config) -> ListenerID = ?config(listener_id), ClientInfo = #{ zone => default, listener => ListenerID, protocol => mqtt, password => <<"any">> }, %% no emqx_authentication authenticators, anonymous is allowed ?assertAuthSuccessForUser(bad), AuthNType = ?config(authn_type), register_provider(AuthNType, ?MODULE), AuthenticatorConfig = #{ mechanism => password_based, backend => built_in_database, enable => true }, {ok, _} = ?AUTHN:create_authenticator(ListenerID, AuthenticatorConfig), %% emqx_authentication alone ?assertAuthSuccessForUser(good), ?assertAuthFailureForUser(ignore), ?assertAuthFailureForUser(bad), %% add hook with higher priority ok = hook(?HP_AUTHN + 1), %% for hook unrelataed users everything is the same ?assertAuthSuccessForUser(good), ?assertAuthFailureForUser(ignore), ?assertAuthFailureForUser(bad), %% higher-priority hook can permit access with {ok,...}, %% then emqx_authentication overrides the result ?assertAuthFailureForUser(hook_user_good), ?assertAuthFailureForUser(hook_user_bad), %% higher-priority hook can permit and return {stop,...}, %% then emqx_authentication cannot override the result ?assertAuthSuccessForUser(hook_user_finally_good), ?assertAuthFailureForUser(hook_user_finally_bad), ok = unhook(), %% add hook with lower priority ok = hook(?HP_AUTHN - 1), %% for hook unrelataed users ?assertAuthSuccessForUser(good), ?assertAuthFailureForUser(bad), ?assertAuthFailureForUser(ignore), %% lower-priority hook can overrride emqx_authentication result %% for ignored users ?assertAuthSuccessForUser(emqx_authn_ignore_for_hook_good), ?assertAuthFailureForUser(emqx_authn_ignore_for_hook_bad), %% lower-priority hook cannot overrride %% successful/unsuccessful emqx_authentication result ?assertAuthFailureForUser(hook_user_finally_good), ?assertAuthFailureForUser(hook_user_finally_bad), ?assertAuthFailureForUser(hook_user_good), ?assertAuthFailureForUser(hook_user_bad), ok = unhook(); t_combine_authn_and_callback({'end', Config}) -> ?AUTHN:delete_chain(?config(listener_id)), ?AUTHN:deregister_provider(?config(authn_type)), ok. %%================================================================================= %% Helpers fns %%================================================================================= hook(Priority) -> ok = emqx_hooks:put( 'client.authenticate', {?MODULE, hook_authenticate, []}, Priority ). unhook() -> ok = emqx_hooks:del('client.authenticate', {?MODULE, hook_authenticate}). update_config(Path, ConfigRequest) -> emqx:update_config(Path, ConfigRequest, #{rawconf_with_defaults => true}). certs(Certs) -> CertsPath = emqx_common_test_helpers:deps_path(emqx, "etc/certs"), lists:foldl( fun({Key, Filename}, Acc) -> {ok, Bin} = file:read_file(filename:join([CertsPath, Filename])), Acc#{Key => Bin} end, #{}, Certs ). register_provider(Type, Module) -> ok = ?AUTHN:register_providers([{Type, Module}]). deregister_providers() -> lists:foreach( fun({Type, _Module}) -> ok = ?AUTHN:deregister_provider(Type) end, maps:to_list(?AUTHN:get_providers()) ).