From e6c26007181a04c0c9b62821803ded63e96f0c1a Mon Sep 17 00:00:00 2001 From: Ilya Averyanov Date: Wed, 24 Nov 2021 12:49:52 +0300 Subject: [PATCH] chore(authn): add HTTP backend tests --- .../src/simple_authn/emqx_authn_http.erl | 31 +- .../emqx_authn/test/emqx_authn_http_SUITE.erl | 392 ++++++++++++++++++ .../test/emqx_authn_http_test_server.erl | 89 ++++ 3 files changed, 501 insertions(+), 11 deletions(-) create mode 100644 apps/emqx_authn/test/emqx_authn_http_SUITE.erl create mode 100644 apps/emqx_authn/test/emqx_authn_http_test_server.erl diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_http.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_http.erl index c50b9cef1..ec2da3237 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_http.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_http.erl @@ -160,13 +160,15 @@ authenticate(Credential, #{resource_id := ResourceId, Request = generate_request(Credential, State), case emqx_resource:query(ResourceId, {Method, Request, RequestTimeout}) of {ok, 204, _Headers} -> {ok, #{is_superuser => false}}; + {ok, 200, _Headers} -> {ok, #{is_superuser => false}}; {ok, 200, Headers, Body} -> ContentType = proplists:get_value(<<"content-type">>, Headers, <<"application/json">>), case safely_parse_body(ContentType, Body) of {ok, NBody} -> %% TODO: Return by user property - {ok, #{is_superuser => maps:get(<<"is_superuser">>, NBody, false), - user_property => maps:remove(<<"is_superuser">>, NBody)}}; + UserProperty = maps:remove(<<"is_superuser">>, NBody), + IsSuperuser = emqx_authn_utils:is_superuser(NBody), + {ok, IsSuperuser#{user_property => UserProperty}}; {error, _Reason} -> {ok, #{is_superuser => false}} end; @@ -208,9 +210,9 @@ check_url(URL) -> end. check_body(Body) -> - maps:fold(fun(_K, _V, false) -> false; - (_K, V, true) -> is_binary(V) - end, true, Body). + lists:all( + fun erlang:is_binary/1, + maps:values(Body)). default_headers() -> maps:put(<<"content-type">>, @@ -242,12 +244,9 @@ check_ssl_opts(Conf) -> end. check_headers(Conf) -> - Method = hocon_schema:get_value("config.method", Conf), + Method = to_bin(hocon_schema:get_value("config.method", Conf)), Headers = hocon_schema:get_value("config.headers", Conf), - case Method =:= get andalso maps:get(<<"content-type">>, Headers, undefined) =/= undefined of - true -> false; - false -> true - end. + Method =:= <<"post">> orelse (not maps:is_key(<<"content-type">>, Headers)). parse_url(URL) -> {ok, URIMap} = emqx_http_lib:uri_parse(URL), @@ -300,7 +299,7 @@ qs([], Acc) -> <<$&, Qs/binary>> = iolist_to_binary(lists:reverse(Acc)), Qs; qs([{K, V} | More], Acc) -> - qs(More, [["&", emqx_http_lib:uri_encode(K), "=", emqx_http_lib:uri_encode(V)] | Acc]). + qs(More, [["&", uri_encode(K), "=", uri_encode(V)] | Acc]). serialize_body(<<"application/json">>, Body) -> emqx_json:encode(Body); @@ -327,7 +326,17 @@ may_append_body(Output, {ok, _, _, Body}) -> may_append_body(Output, {ok, _, _}) -> Output. +uri_encode(T) -> + emqx_http_lib:uri_encode(to_bin(T)). + to_list(A) when is_atom(A) -> atom_to_list(A); to_list(B) when is_binary(B) -> binary_to_list(B). + +to_bin(A) when is_atom(A) -> + atom_to_binary(A); +to_bin(B) when is_binary(B) -> + B; +to_bin(L) when is_list(L) -> + list_to_binary(L). diff --git a/apps/emqx_authn/test/emqx_authn_http_SUITE.erl b/apps/emqx_authn/test/emqx_authn_http_SUITE.erl new file mode 100644 index 000000000..4a966fe0e --- /dev/null +++ b/apps/emqx_authn/test/emqx_authn_http_SUITE.erl @@ -0,0 +1,392 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 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_http_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include("emqx_authn.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("emqx/include/emqx_placeholder.hrl"). + +-define(PATH, [authentication]). + +-define(HTTP_PORT, 33333). +-define(HTTP_PATH, "/auth"). +-define(CREDENTIALS, #{username => <<"plain">>, + password => <<"plain">>, + listener => 'tcp:default', + protocol => mqtt + }). + + +all() -> + emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> + emqx_common_test_helpers:start_apps([emqx_authn]), + application:ensure_all_started(cowboy), + Config. + +end_per_suite(_) -> + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL), + emqx_common_test_helpers:stop_apps([emqx_authn]), + application:stop(cowboy), + ok. + +init_per_testcase(_Case, Config) -> + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL), + emqx_authn_http_test_server:start(?HTTP_PORT, ?HTTP_PATH), + Config. + +end_per_testcase(_Case, _Config) -> + ok = emqx_authn_http_test_server:stop() . + +%%------------------------------------------------------------------------------ +%% Tests +%%------------------------------------------------------------------------------ + +t_create(_Config) -> + AuthConfig = raw_http_auth_config(), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig}), + + {ok, [#{provider := emqx_authn_http}]} = emqx_authentication:list_authenticators(?GLOBAL). + +t_create_invalid(_Config) -> + AuthConfig = raw_http_auth_config(), + + InvalidConfigs = + [ + AuthConfig#{headers => []}, + AuthConfig#{method => delete} + ], + + lists:foreach( + fun(Config) -> + ct:pal("creating authenticator with invalid config: ~p", [Config]), + {error, _} = + try + emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, Config}) + catch + throw:Error -> + {error, Error} + end, + {ok, []} = emqx_authentication:list_authenticators(?GLOBAL) + end, + InvalidConfigs). + +t_authenticate(_Config) -> + ok = lists:foreach( + fun(Sample) -> + ct:pal("test_user_auth sample: ~p", [Sample]), + test_user_auth(Sample) + end, + samples()). + +test_user_auth(#{handler := Handler, + config_params := SpecificConfgParams, + result := Result}) -> + AuthConfig = maps:merge(raw_http_auth_config(), SpecificConfgParams), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig}), + + emqx_authn_http_test_server:set_handler(Handler), + + ?assertEqual(Result, emqx_access_control:authenticate(?CREDENTIALS)), + + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL). + +t_destroy(_Config) -> + AuthConfig = raw_http_auth_config(), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig}), + + ok = emqx_authn_http_test_server:set_handler( + fun(Req0, State) -> + Req = cowboy_req:reply(200, Req0), + {ok, Req, State} + end), + + {ok, [#{provider := emqx_authn_http, state := State}]} + = emqx_authentication:list_authenticators(?GLOBAL), + + Credentials = maps:with([username, password], ?CREDENTIALS), + + {ok, _} = emqx_authn_http:authenticate( + Credentials, + State), + + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL), + + % Authenticator should not be usable anymore + ?assertException( + error, + _, + emqx_authn_http:authenticate( + Credentials, + State)). + +t_update(_Config) -> + CorrectConfig = raw_http_auth_config(), + IncorrectConfig = + CorrectConfig#{url => <<"http://127.0.0.1:33333/invalid">>}, + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, IncorrectConfig}), + + ok = emqx_authn_http_test_server:set_handler( + fun(Req0, State) -> + Req = cowboy_req:reply(200, Req0), + {ok, Req, State} + end), + + {error, not_authorized} = emqx_access_control:authenticate(?CREDENTIALS), + + % We update with config with correct query, provider should update and work properly + {ok, _} = emqx:update_config( + ?PATH, + {update_authenticator, ?GLOBAL, <<"password-based:http">>, CorrectConfig}), + + {ok,_} = emqx_access_control:authenticate(?CREDENTIALS). + +t_is_superuser(_Config) -> + Config = raw_http_auth_config(), + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, Config}), + + Checks = [ + {json, <<"0">>, false}, + {json, <<"">>, false}, + {json, null, false}, + {json, 0, false}, + + {json, <<"1">>, true}, + {json, <<"val">>, true}, + {json, 1, true}, + {json, 123, true}, + + {form, <<"0">>, false}, + {form, <<"">>, false}, + + {form, <<"1">>, true}, + {form, <<"val">>, true} + ], + + lists:foreach(fun test_is_superuser/1, Checks). + +test_is_superuser({Kind, Value, ExpectedValue}) -> + + {ContentType, Res} = case Kind of + json -> + {<<"application/json">>, + jiffy:encode(#{is_superuser => Value})}; + form -> + {<<"application/x-www-form-urlencoded">>, + iolist_to_binary([<<"is_superuser=">>, Value])} + end, + + emqx_authn_http_test_server:set_handler( + fun(Req0, State) -> + Req = cowboy_req:reply( + 200, + #{<<"content-type">> => ContentType}, + Res, + Req0), + {ok, Req, State} + end), + + ?assertMatch( + {ok, #{is_superuser := ExpectedValue}}, + emqx_access_control:authenticate(?CREDENTIALS)). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +raw_http_auth_config() -> + #{ + mechanism => <<"password-based">>, + enable => <<"true">>, + + backend => <<"http">>, + method => <<"get">>, + url => <<"http://127.0.0.1:33333/auth">>, + body => #{<<"username">> => ?PH_USERNAME, <<"password">> => ?PH_PASSWORD}, + headers => #{<<"X-Test-Header">> => <<"Test Value">>} + }. + +samples() -> + [ + %% simple get request + #{handler => fun(Req0, State) -> + #{username := <<"plain">>, + password := <<"plain">> + } = cowboy_req:match_qs([username, password], Req0), + + Req = cowboy_req:reply(200, Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => false}} + }, + + %% get request with json body response + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + jiffy:encode(#{is_superuser => true}), + Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => true, user_property => #{}}} + }, + + %% get request with url-form-encoded body response + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply( + 200, + #{<<"content-type">> => + <<"application/x-www-form-urlencoded">>}, + <<"is_superuser=true">>, + Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => true, user_property => #{}}} + }, + + %% get request with response of unknown encoding + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply( + 200, + #{<<"content-type">> => + <<"test/plain">>}, + <<"is_superuser=true">>, + Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => false}} + }, + + %% simple post request, application/json + #{handler => fun(Req0, State) -> + {ok, RawBody, Req1} = cowboy_req:read_body(Req0), + #{<<"username">> := <<"plain">>, + <<"password">> := <<"plain">> + } = jiffy:decode(RawBody, [return_maps]), + Req = cowboy_req:reply(200, Req1), + {ok, Req, State} + end, + config_params => #{ + method => post, + headers => #{<<"content-type">> => <<"application/json">>} + }, + result => {ok,#{is_superuser => false}} + }, + + %% simple post request, application/x-www-form-urlencoded + #{handler => fun(Req0, State) -> + {ok, PostVars, Req1} = cowboy_req:read_urlencoded_body(Req0), + #{<<"username">> := <<"plain">>, + <<"password">> := <<"plain">> + } = maps:from_list(PostVars), + Req = cowboy_req:reply(200, Req1), + {ok, Req, State} + end, + config_params => #{ + method => post, + headers => #{<<"content-type">> => + <<"application/x-www-form-urlencoded">>} + }, + result => {ok,#{is_superuser => false}} + } + + %% 204 code + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply(204, Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => false}} + }, + + %% custom headers + #{handler => fun(Req0, State) -> + <<"Test Value">> = cowboy_req:header(<<"x-test-header">>, Req0), + Req = cowboy_req:reply(200, Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {ok,#{is_superuser => false}} + }, + + %% 400 code + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply(400, Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {error,not_authorized} + }, + + %% 500 code + #{handler => fun(Req0, State) -> + Req = cowboy_req:reply(500, Req0), + {ok, Req, State} + end, + config_params => #{}, + result => {error,not_authorized} + }, + + %% Handling error + #{handler => fun(Req0, State) -> + error(woops), + {ok, Req0, State} + end, + config_params => #{}, + result => {error,not_authorized} + } + ]. + +start_apps(Apps) -> + lists:foreach(fun application:ensure_all_started/1, Apps). + +stop_apps(Apps) -> + lists:foreach(fun application:stop/1, Apps). diff --git a/apps/emqx_authn/test/emqx_authn_http_test_server.erl b/apps/emqx_authn/test/emqx_authn_http_test_server.erl new file mode 100644 index 000000000..4896a8b13 --- /dev/null +++ b/apps/emqx_authn/test/emqx_authn_http_test_server.erl @@ -0,0 +1,89 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 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_http_test_server). + +-behaviour(gen_server). +-behaviour(cowboy_handler). + +% cowboy_server callbacks +-export([init/2]). + +% gen_server callbacks +-export([init/1, + handle_call/3, + handle_cast/2 + ]). + +% API +-export([start/2, + stop/0, + set_handler/1 + ]). + +%%------------------------------------------------------------------------------ +%% API +%%------------------------------------------------------------------------------ + +start(Port, Path) -> + Dispatch = cowboy_router:compile([ + {'_', [{Path, ?MODULE, []}]} + ]), + {ok, _} = cowboy:start_clear(?MODULE, + [{port, Port}], + #{env => #{dispatch => Dispatch}} + ), + {ok, _} = gen_server:start_link({local, ?MODULE}, ?MODULE, [], []), + ok. + +stop() -> + gen_server:stop(?MODULE), + cowboy:stop_listener(?MODULE). + +set_handler(F) when is_function(F, 2) -> + gen_server:call(?MODULE, {set_handler, F}). + +%%------------------------------------------------------------------------------ +%% gen_server API +%%------------------------------------------------------------------------------ + +init([]) -> + F = fun(Req0, State) -> + Req = cowboy_req:reply( + 400, + #{<<"content-type">> => <<"text/plain">>}, + <<"">>, + Req0), + {ok, Req, State} + end, + {ok, F}. + +handle_cast(_, F) -> + {noreply, F}. + +handle_call({set_handler, F}, _From, _F) -> + {reply, ok, F}; + +handle_call(get_handler, _From, F) -> + {reply, F, F}. + +%%------------------------------------------------------------------------------ +%% cowboy_server API +%%------------------------------------------------------------------------------ + +init(Req, State) -> + Handler = gen_server:call(?MODULE, get_handler), + Handler(Req, State).