emqx/apps/emqx_authn/test/emqx_authentication_SUITE.erl

596 lines
19 KiB
Erlang

%%--------------------------------------------------------------------
%% 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())
).