Просмотр исходного кода

Merge pull request #6302 from savonarola/test-authn-resources-http

chore(authn): add HTTP backend tests
Ilya Averyanov 4 лет назад
Родитель
Сommit
14b1a2fbf9

+ 20 - 11
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).

+ 392 - 0
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).

+ 89 - 0
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).