emqx/apps/emqx_authn/test/emqx_authn_api_SUITE.erl

794 lines
22 KiB
Erlang

%%--------------------------------------------------------------------
%% Copyright (c) 2021-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_authn_api_SUITE).
-compile(nowarn_export_all).
-compile(export_all).
-import(emqx_dashboard_api_test_helpers, [multipart_formdata_request/3]).
-import(emqx_mgmt_api_test_util, [request/3, uri/1]).
-include("emqx_authn.hrl").
-include_lib("eunit/include/eunit.hrl").
-define(TCP_DEFAULT, 'tcp:default').
-define(assertAuthenticatorsMatch(Guard, Path),
(fun() ->
{ok, 200, Response} = request(get, uri(Path)),
?assertMatch(Guard, emqx_utils_json:decode(Response, [return_maps]))
end)()
).
all() ->
emqx_common_test_helpers:all(?MODULE).
groups() ->
[].
init_per_testcase(t_authenticator_fail, Config) ->
meck:expect(emqx_authn_proto_v1, lookup_from_all_nodes, 3, [{error, {exception, badarg}}]),
init_per_testcase(default, Config);
init_per_testcase(_Case, Config) ->
{ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000),
emqx_authn_test_lib:delete_authenticators(
[?CONF_NS_ATOM],
?GLOBAL
),
emqx_authn_test_lib:delete_authenticators(
[listeners, tcp, default, ?CONF_NS_ATOM],
?TCP_DEFAULT
),
{atomic, ok} = mria:clear_table(emqx_authn_mnesia),
Config.
end_per_testcase(t_authenticator_fail, Config) ->
meck:unload(emqx_authn_proto_v1),
Config;
end_per_testcase(_, Config) ->
Config.
init_per_suite(Config) ->
emqx_config:erase(?EMQX_AUTHENTICATION_CONFIG_ROOT_NAME_BINARY),
_ = application:load(emqx_conf),
ok = emqx_mgmt_api_test_util:init_suite(
[emqx_conf, emqx_authn]
),
?AUTHN:delete_chain(?GLOBAL),
{ok, Chains} = ?AUTHN:list_chains(),
?assertEqual(length(Chains), 0),
Config.
end_per_suite(_Config) ->
emqx_mgmt_api_test_util:end_suite([emqx_authn]),
ok.
%%------------------------------------------------------------------------------
%% Tests
%%------------------------------------------------------------------------------
t_invalid_listener(_) ->
{ok, 404, _} = request(get, uri(["listeners", "invalid", ?CONF_NS])),
{ok, 404, _} = request(get, uri(["listeners", "in:valid", ?CONF_NS])).
t_authenticators(_) ->
test_authenticators([]).
t_authenticator(_) ->
test_authenticator([]).
t_authenticator_fail(_) ->
ValidConfig0 = emqx_authn_test_lib:http_example(),
{ok, 200, _} = request(
post,
uri([?CONF_NS]),
ValidConfig0
),
?assertMatch(
{ok, 500, _},
request(
get,
uri([?CONF_NS, "password_based:http", "status"])
)
).
t_authenticator_users(_) ->
test_authenticator_users([]).
t_authenticator_user(_) ->
test_authenticator_user([]).
t_authenticator_position(_) ->
test_authenticator_position([]).
t_authenticator_import_users(_) ->
test_authenticator_import_users([]).
%t_listener_authenticators(_) ->
% test_authenticators(["listeners", ?TCP_DEFAULT]).
%t_listener_authenticator(_) ->
% test_authenticator(["listeners", ?TCP_DEFAULT]).
%t_listener_authenticator_users(_) ->
% test_authenticator_users(["listeners", ?TCP_DEFAULT]).
%t_listener_authenticator_user(_) ->
% test_authenticator_user(["listeners", ?TCP_DEFAULT]).
%t_listener_authenticator_position(_) ->
% test_authenticator_position(["listeners", ?TCP_DEFAULT]).
%t_listener_authenticator_import_users(_) ->
% test_authenticator_import_users(["listeners", ?TCP_DEFAULT]).
t_aggregate_metrics(_) ->
Metrics = #{
'emqx@node1.emqx.io' => #{
metrics =>
#{
failed => 0,
total => 1,
rate => 0.0,
rate_last5m => 0.0,
rate_max => 0.1,
success => 1,
nomatch => 1
}
},
'emqx@node2.emqx.io' => #{
metrics =>
#{
failed => 0,
total => 1,
rate => 0.0,
rate_last5m => 0.0,
rate_max => 0.1,
success => 1,
nomatch => 2
}
}
},
Res = emqx_authn_api:aggregate_metrics(maps:values(Metrics)),
?assertEqual(
#{
metrics =>
#{
failed => 0,
total => 2,
rate => 0.0,
rate_last5m => 0.0,
rate_max => 0.2,
success => 2,
nomatch => 3
}
},
Res
).
test_authenticators(PathPrefix) ->
ValidConfig = emqx_authn_test_lib:http_example(),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
ValidConfig
),
{ok, 409, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
ValidConfig
),
InvalidConfig0 = ValidConfig#{method => <<"delete">>},
{ok, 400, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
InvalidConfig0
),
ValidConfig1 = ValidConfig#{
method => <<"get">>,
headers => #{<<"content-type">> => <<"application/json">>}
},
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
ValidConfig1
),
?assertAuthenticatorsMatch(
[#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>}],
PathPrefix ++ [?CONF_NS]
).
test_authenticator(PathPrefix) ->
ValidConfig0 = emqx_authn_test_lib:http_example(),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
ValidConfig0
),
{ok, 200, _} = request(
get,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"])
),
{ok, 200, Res} = request(
get,
uri(PathPrefix ++ [?CONF_NS, "password_based:http", "status"])
),
{ok, RList} = emqx_utils_json:safe_decode(Res),
Snd = fun({_, Val}) -> Val end,
LookupVal = fun LookupV(List, RestJson) ->
case List of
[Name] -> Snd(lists:keyfind(Name, 1, RestJson));
[Name | NS] -> LookupV(NS, Snd(lists:keyfind(Name, 1, RestJson)))
end
end,
LookFun = fun(List) -> LookupVal(List, RList) end,
MetricsList = [
{<<"failed">>, 0},
{<<"total">>, 0},
{<<"rate">>, 0.0},
{<<"rate_last5m">>, 0.0},
{<<"rate_max">>, 0.0},
{<<"success">>, 0}
],
EqualFun = fun({M, V}) ->
?assertEqual(
V,
LookFun([
<<"metrics">>,
M
])
)
end,
lists:map(EqualFun, MetricsList),
?assertEqual(
<<"connected">>,
LookFun([<<"status">>])
),
?assertMatch(
{ok, 404, _},
request(
get,
uri(PathPrefix ++ [?CONF_NS, "unknown_auth_chain", "status"])
)
),
{ok, 404, _} = request(
get,
uri(PathPrefix ++ [?CONF_NS, "password_based:redis"])
),
{ok, 404, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "password_based:built_in_database"]),
emqx_authn_test_lib:built_in_database_example()
),
InvalidConfig0 = ValidConfig0#{method => <<"delete">>},
{ok, 400, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
InvalidConfig0
),
ValidConfig1 = ValidConfig0#{
method => <<"get">>,
headers => #{<<"content-type">> => <<"application/json">>}
},
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
ValidConfig1
),
ValidConfig2 = ValidConfig0#{pool_size => 9},
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
ValidConfig2
),
{ok, 404, _} = request(
delete,
uri(PathPrefix ++ [?CONF_NS, "password_based:redis"])
),
{ok, 204, _} = request(
delete,
uri(PathPrefix ++ [?CONF_NS, "password_based:http"])
),
?assertAuthenticatorsMatch([], PathPrefix ++ [?CONF_NS]).
test_authenticator_users(PathPrefix) ->
UsersUri = uri(PathPrefix ++ [?CONF_NS, "password_based:built_in_database", "users"]),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
emqx_authn_test_lib:built_in_database_example()
),
{ok, Client} = emqtt:start_link(
[
{username, <<"u_event">>},
{clientid, <<"c_event">>},
{proto_ver, v5},
{properties, #{'Session-Expiry-Interval' => 60}}
]
),
process_flag(trap_exit, true),
?assertMatch({error, _}, emqtt:connect(Client)),
timer:sleep(300),
UsersUri0 = uri(PathPrefix ++ [?CONF_NS, "password_based:built_in_database", "status"]),
{ok, 200, PageData0} = request(get, UsersUri0),
case PathPrefix of
[] ->
#{
<<"metrics">> := #{
<<"total">> := 1,
<<"success">> := 0,
<<"nomatch">> := 1
}
} = emqx_utils_json:decode(PageData0, [return_maps]);
["listeners", 'tcp:default'] ->
#{
<<"metrics">> := #{
<<"total">> := 1,
<<"success">> := 0,
<<"nomatch">> := 1
}
} = emqx_utils_json:decode(PageData0, [return_maps])
end,
InvalidUsers = [
#{clientid => <<"u1">>, password => <<"p1">>},
#{user_id => <<"u2">>},
#{user_id => <<"u3">>, password => <<"p3">>, foobar => <<"foobar">>}
],
lists:foreach(
fun(User) -> {ok, 400, _} = request(post, UsersUri, User) end,
InvalidUsers
),
ValidUsers = [
#{user_id => <<"u1">>, password => <<"p1">>},
#{user_id => <<"u2">>, password => <<"p2">>, is_superuser => true},
#{user_id => <<"u3">>, password => <<"p3">>}
],
lists:foreach(
fun(User) ->
{ok, 201, UserData} = request(post, UsersUri, User),
CreatedUser = emqx_utils_json:decode(UserData, [return_maps]),
?assertMatch(#{<<"user_id">> := _}, CreatedUser)
end,
ValidUsers
),
{ok, Client1} = emqtt:start_link(
[
{username, <<"u1">>},
{password, <<"p1">>},
{clientid, <<"c_event">>},
{proto_ver, v5},
{properties, #{'Session-Expiry-Interval' => 60}}
]
),
{ok, _} = emqtt:connect(Client1),
timer:sleep(300),
UsersUri01 = uri(PathPrefix ++ [?CONF_NS, "password_based:built_in_database", "status"]),
{ok, 200, PageData01} = request(get, UsersUri01),
case PathPrefix of
[] ->
#{
<<"metrics">> := #{
<<"total">> := 2,
<<"success">> := 1,
<<"nomatch">> := 1
}
} = emqx_utils_json:decode(PageData01, [return_maps]);
["listeners", 'tcp:default'] ->
#{
<<"metrics">> := #{
<<"total">> := 2,
<<"success">> := 1,
<<"nomatch">> := 1
}
} = emqx_utils_json:decode(PageData01, [return_maps])
end,
{ok, 200, Page1Data} = request(get, UsersUri ++ "?page=1&limit=2"),
#{
<<"data">> := Page1Users,
<<"meta">> :=
#{
<<"page">> := 1,
<<"limit">> := 2,
<<"count">> := 3
}
} =
emqx_utils_json:decode(Page1Data, [return_maps]),
{ok, 200, Page2Data} = request(get, UsersUri ++ "?page=2&limit=2"),
#{
<<"data">> := Page2Users,
<<"meta">> :=
#{
<<"page">> := 2,
<<"limit">> := 2,
<<"count">> := 3
}
} = emqx_utils_json:decode(Page2Data, [return_maps]),
?assertEqual(2, length(Page1Users)),
?assertEqual(1, length(Page2Users)),
?assertEqual(
[<<"u1">>, <<"u2">>, <<"u3">>],
lists:usort([UserId || #{<<"user_id">> := UserId} <- Page1Users ++ Page2Users])
),
{ok, 200, Super1Data} = request(get, UsersUri ++ "?page=1&limit=3&is_superuser=true"),
#{
<<"data">> := Super1Users,
<<"meta">> :=
#{
<<"page">> := 1,
<<"limit">> := 3,
<<"count">> := 1
}
} = emqx_utils_json:decode(Super1Data, [return_maps]),
?assertEqual(
[<<"u2">>],
lists:usort([UserId || #{<<"user_id">> := UserId} <- Super1Users])
),
{ok, 200, Super2Data} = request(get, UsersUri ++ "?page=1&limit=3&is_superuser=false"),
#{
<<"data">> := Super2Users,
<<"meta">> :=
#{
<<"page">> := 1,
<<"limit">> := 3,
<<"count">> := 2
}
} = emqx_utils_json:decode(Super2Data, [return_maps]),
?assertEqual(
[<<"u1">>, <<"u3">>],
lists:usort([UserId || #{<<"user_id">> := UserId} <- Super2Users])
),
ok.
test_authenticator_user(PathPrefix) ->
UsersUri = uri(PathPrefix ++ [?CONF_NS, "password_based:built_in_database", "users"]),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
emqx_authn_test_lib:built_in_database_example()
),
User = #{user_id => <<"u1">>, password => <<"p1">>},
{ok, 201, _} = request(post, UsersUri, User),
{ok, 404, _} = request(get, UsersUri ++ "/u123"),
{ok, 409, _} = request(post, UsersUri, User),
{ok, 200, UserData} = request(get, UsersUri ++ "/u1"),
FetchedUser = emqx_utils_json:decode(UserData, [return_maps]),
?assertMatch(#{<<"user_id">> := <<"u1">>}, FetchedUser),
?assertNotMatch(#{<<"password">> := _}, FetchedUser),
ValidUserUpdates = [
#{password => <<"p1">>},
#{password => <<"p1">>, is_superuser => true}
],
lists:foreach(
fun(UserUpdate) -> {ok, 200, _} = request(put, UsersUri ++ "/u1", UserUpdate) end,
ValidUserUpdates
),
InvalidUserUpdates = [#{user_id => <<"u1">>, password => <<"p1">>}],
lists:foreach(
fun(UserUpdate) -> {ok, 400, _} = request(put, UsersUri ++ "/u1", UserUpdate) end,
InvalidUserUpdates
),
{ok, 404, _} = request(delete, UsersUri ++ "/u123"),
{ok, 204, _} = request(delete, UsersUri ++ "/u1").
test_authenticator_position(PathPrefix) ->
AuthenticatorConfs = [
emqx_authn_test_lib:http_example(),
emqx_authn_test_lib:jwt_example(),
emqx_authn_test_lib:built_in_database_example()
],
lists:foreach(
fun(Conf) ->
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
Conf
)
end,
AuthenticatorConfs
),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"built_in_database">>}
],
PathPrefix ++ [?CONF_NS]
),
%% Invalid moves
{ok, 400, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "up"])
),
{ok, 404, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position"])
),
{ok, 404, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "before:invalid"])
),
{ok, 404, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "before:password_based:redis"])
),
%% Valid moves
%% test front
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "front"])
),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"built_in_database">>}
],
PathPrefix ++ [?CONF_NS]
),
%% test rear
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "rear"])
),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"built_in_database">>},
#{<<"mechanism">> := <<"jwt">>}
],
PathPrefix ++ [?CONF_NS]
),
%% test before
{ok, 204, _} = request(
put,
uri(PathPrefix ++ [?CONF_NS, "jwt", "position", "before:password_based:built_in_database"])
),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"jwt">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"built_in_database">>}
],
PathPrefix ++ [?CONF_NS]
),
%% test after
{ok, 204, _} = request(
put,
uri(
PathPrefix ++
[
?CONF_NS,
"password_based%3Abuilt_in_database",
"position",
"after:password_based:http"
]
)
),
?assertAuthenticatorsMatch(
[
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"http">>},
#{<<"mechanism">> := <<"password_based">>, <<"backend">> := <<"built_in_database">>},
#{<<"mechanism">> := <<"jwt">>}
],
PathPrefix ++ [?CONF_NS]
).
test_authenticator_import_users(PathPrefix) ->
ImportUri = uri(
PathPrefix ++
[?CONF_NS, "password_based:built_in_database", "import_users"]
),
{ok, 200, _} = request(
post,
uri(PathPrefix ++ [?CONF_NS]),
emqx_authn_test_lib:built_in_database_example()
),
{ok, 400, _} = multipart_formdata_request(ImportUri, [], []),
{ok, 400, _} = multipart_formdata_request(ImportUri, [], [
{filenam, "user-credentials.json", <<>>}
]),
Dir = code:lib_dir(emqx_authn, test),
JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]),
CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]),
{ok, JSONData} = file:read_file(JSONFileName),
{ok, 204, _} = multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.json", JSONData}
]),
{ok, CSVData} = file:read_file(CSVFileName),
{ok, 204, _} = multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.csv", CSVData}
]).
%% listener authn api is not supported since 5.1.0
%% Don't support listener switch to global chain.
ignore_switch_to_global_chain(_) ->
{ok, 200, _} = request(
post,
uri([?CONF_NS]),
emqx_authn_test_lib:built_in_database_example()
),
{ok, 200, _} = request(
post,
uri([listeners, "tcp:default", ?CONF_NS]),
emqx_authn_test_lib:built_in_database_example()
),
{ok, 200, _} = request(
post,
uri([listeners, "tcp:default", ?CONF_NS]),
maps:put(enable, false, emqx_authn_test_lib:http_example())
),
GlobalUser = #{user_id => <<"global_user">>, password => <<"p1">>},
{ok, 201, _} = request(
post,
uri([?CONF_NS, "password_based:built_in_database", "users"]),
GlobalUser
),
ListenerUser = #{user_id => <<"listener_user">>, password => <<"p1">>},
{ok, 201, _} = request(
post,
uri([listeners, "tcp:default", ?CONF_NS, "password_based:built_in_database", "users"]),
ListenerUser
),
process_flag(trap_exit, true),
%% Listener user should be OK
{ok, Client0} = emqtt:start_link([
{username, <<"listener_user">>},
{password, <<"p1">>}
]),
?assertMatch(
{ok, _},
emqtt:connect(Client0)
),
ok = emqtt:disconnect(Client0),
%% Global user should not be OK
{ok, Client1} = emqtt:start_link([
{username, <<"global_user">>},
{password, <<"p1">>}
]),
?assertMatch(
{error, {unauthorized_client, _}},
emqtt:connect(Client1)
),
{ok, 204, _} = request(
delete,
uri([listeners, "tcp:default", ?CONF_NS, "password_based:built_in_database"])
),
%% Now listener has only disabled authenticators, should allow anonymous access
{ok, Client2} = emqtt:start_link([
{username, <<"any_user">>},
{password, <<"any_password">>}
]),
?assertMatch(
{ok, _},
emqtt:connect(Client2)
),
ok = emqtt:disconnect(Client2),
{ok, 204, _} = request(
delete,
uri([listeners, "tcp:default", ?CONF_NS, "password_based:http"])
),
%% Local chain is empty now and should be removed
%% Listener user should not be OK
{ok, Client3} = emqtt:start_link([
{username, <<"listener_user">>},
{password, <<"p1">>}
]),
?assertMatch(
{error, {unauthorized_client, _}},
emqtt:connect(Client3)
),
%% Global user should be now OK, switched back to the global chain
{ok, Client4} = emqtt:start_link([
{username, <<"global_user">>},
{password, <<"p1">>}
]),
?assertMatch(
{ok, _},
emqtt:connect(Client4)
),
ok = emqtt:disconnect(Client4).
%%------------------------------------------------------------------------------
%% Helpers
%%------------------------------------------------------------------------------
request(Method, Url) ->
request(Method, Url, []).