289 lines
9.2 KiB
Erlang
289 lines
9.2 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_http).
|
|
|
|
-include_lib("emqx_auth/include/emqx_authn.hrl").
|
|
-include_lib("emqx/include/logger.hrl").
|
|
|
|
-behaviour(emqx_authn_provider).
|
|
|
|
-export([
|
|
create/2,
|
|
update/2,
|
|
authenticate/2,
|
|
destroy/1
|
|
]).
|
|
|
|
%%------------------------------------------------------------------------------
|
|
%% APIs
|
|
%%------------------------------------------------------------------------------
|
|
|
|
create(_AuthenticatorID, Config) ->
|
|
create(Config).
|
|
|
|
create(Config0) ->
|
|
with_validated_config(Config0, fun(Config, State) ->
|
|
ResourceId = emqx_authn_utils:make_resource_id(?MODULE),
|
|
% {Config, State} = parse_config(Config0),
|
|
{ok, _Data} = emqx_authn_utils:create_resource(
|
|
ResourceId,
|
|
emqx_bridge_http_connector,
|
|
Config
|
|
),
|
|
{ok, State#{resource_id => ResourceId}}
|
|
end).
|
|
|
|
update(Config0, #{resource_id := ResourceId} = _State) ->
|
|
with_validated_config(Config0, fun(Config, NState) ->
|
|
% {Config, NState} = parse_config(Config0),
|
|
case emqx_authn_utils:update_resource(emqx_bridge_http_connector, Config, ResourceId) of
|
|
{error, Reason} ->
|
|
error({load_config_error, Reason});
|
|
{ok, _} ->
|
|
{ok, NState#{resource_id => ResourceId}}
|
|
end
|
|
end).
|
|
|
|
authenticate(#{auth_method := _}, _) ->
|
|
ignore;
|
|
authenticate(
|
|
Credential,
|
|
#{
|
|
resource_id := ResourceId,
|
|
method := Method,
|
|
request_timeout := RequestTimeout
|
|
} = State
|
|
) ->
|
|
Request = generate_request(Credential, State),
|
|
Response = emqx_resource:simple_sync_query(ResourceId, {Method, Request, RequestTimeout}),
|
|
?TRACE_AUTHN_PROVIDER("http_response", #{
|
|
request => request_for_log(Credential, State),
|
|
response => response_for_log(Response),
|
|
resource => ResourceId
|
|
}),
|
|
case Response of
|
|
{ok, 204, _Headers} ->
|
|
{ok, #{is_superuser => false}};
|
|
{ok, 200, Headers, Body} ->
|
|
handle_response(Headers, Body);
|
|
{ok, _StatusCode, _Headers} = Response ->
|
|
ignore;
|
|
{ok, _StatusCode, _Headers, _Body} = Response ->
|
|
ignore;
|
|
{error, _Reason} ->
|
|
ignore
|
|
end.
|
|
|
|
destroy(#{resource_id := ResourceId}) ->
|
|
_ = emqx_resource:remove_local(ResourceId),
|
|
ok.
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% Internal functions
|
|
%%--------------------------------------------------------------------
|
|
|
|
with_validated_config(Config, Fun) ->
|
|
Pipeline = [
|
|
fun check_ssl_opts/1,
|
|
fun check_headers/1,
|
|
fun parse_config/1
|
|
],
|
|
case emqx_utils:pipeline(Pipeline, Config, undefined) of
|
|
{ok, NConfig, ProviderState} ->
|
|
Fun(NConfig, ProviderState);
|
|
{error, Reason, _} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
check_ssl_opts(#{url := <<"https://", _/binary>>, ssl := #{enable := false}}) ->
|
|
{error,
|
|
{invalid_ssl_opts,
|
|
<<"it's required to enable the TLS option to establish a https connection">>}};
|
|
check_ssl_opts(_) ->
|
|
ok.
|
|
|
|
check_headers(#{headers := Headers, method := get}) ->
|
|
case maps:is_key(<<"content-type">>, Headers) of
|
|
false ->
|
|
ok;
|
|
true ->
|
|
{error, {invalid_headers, <<"HTTP GET requests cannot include content-type header.">>}}
|
|
end;
|
|
check_headers(_) ->
|
|
ok.
|
|
|
|
parse_config(
|
|
#{
|
|
method := Method,
|
|
url := RawUrl,
|
|
headers := Headers,
|
|
request_timeout := RequestTimeout
|
|
} = Config
|
|
) ->
|
|
{BaseUrl0, Path, Query} = emqx_authn_utils:parse_url(RawUrl),
|
|
{ok, BaseUrl} = emqx_http_lib:uri_parse(BaseUrl0),
|
|
State = #{
|
|
method => Method,
|
|
path => Path,
|
|
headers => ensure_header_name_type(Headers),
|
|
base_path_template => emqx_authn_utils:parse_str(Path),
|
|
base_query_template => emqx_authn_utils:parse_deep(
|
|
cow_qs:parse_qs(to_bin(Query))
|
|
),
|
|
body_template => emqx_authn_utils:parse_deep(maps:get(body, Config, #{})),
|
|
request_timeout => RequestTimeout,
|
|
url => RawUrl
|
|
},
|
|
{ok, Config#{base_url => BaseUrl, pool_type => random}, State}.
|
|
|
|
generate_request(Credential, #{
|
|
method := Method,
|
|
headers := Headers0,
|
|
base_path_template := BasePathTemplate,
|
|
base_query_template := BaseQueryTemplate,
|
|
body_template := BodyTemplate
|
|
}) ->
|
|
Headers = maps:to_list(Headers0),
|
|
Path = emqx_authn_utils:render_urlencoded_str(BasePathTemplate, Credential),
|
|
Query = emqx_authn_utils:render_deep(BaseQueryTemplate, Credential),
|
|
Body = emqx_authn_utils:render_deep(BodyTemplate, Credential),
|
|
case Method of
|
|
get ->
|
|
NPathQuery = append_query(to_list(Path), to_list(Query) ++ maps:to_list(Body)),
|
|
{NPathQuery, Headers};
|
|
post ->
|
|
NPathQuery = append_query(to_list(Path), to_list(Query)),
|
|
ContentType = proplists:get_value(<<"content-type">>, Headers),
|
|
NBody = serialize_body(ContentType, Body),
|
|
{NPathQuery, Headers, NBody}
|
|
end.
|
|
|
|
append_query(Path, []) ->
|
|
Path;
|
|
append_query(Path, Query) ->
|
|
Path ++ "?" ++ binary_to_list(qs(Query)).
|
|
|
|
qs(KVs) ->
|
|
qs(KVs, []).
|
|
|
|
qs([], Acc) ->
|
|
<<$&, Qs/binary>> = iolist_to_binary(lists:reverse(Acc)),
|
|
Qs;
|
|
qs([{K, V} | More], Acc) ->
|
|
qs(More, [["&", uri_encode(K), "=", uri_encode(V)] | Acc]).
|
|
|
|
serialize_body(<<"application/json">>, Body) ->
|
|
emqx_utils_json:encode(Body);
|
|
serialize_body(<<"application/x-www-form-urlencoded">>, Body) ->
|
|
qs(maps:to_list(Body)).
|
|
|
|
handle_response(Headers, Body) ->
|
|
ContentType = proplists:get_value(<<"content-type">>, Headers),
|
|
case safely_parse_body(ContentType, Body) of
|
|
{ok, NBody} ->
|
|
case maps:get(<<"result">>, NBody, <<"ignore">>) of
|
|
<<"allow">> ->
|
|
Res = emqx_authn_utils:is_superuser(NBody),
|
|
%% TODO: Return by user property
|
|
{ok, Res#{user_property => maps:get(<<"user_property">>, NBody, #{})}};
|
|
<<"deny">> ->
|
|
{error, not_authorized};
|
|
<<"ignore">> ->
|
|
ignore;
|
|
_ ->
|
|
ignore
|
|
end;
|
|
{error, Reason} ->
|
|
?TRACE_AUTHN_PROVIDER(
|
|
error,
|
|
"parse_http_response_failed",
|
|
#{content_type => ContentType, body => Body, reason => Reason}
|
|
),
|
|
ignore
|
|
end.
|
|
|
|
safely_parse_body(ContentType, Body) ->
|
|
try
|
|
parse_body(ContentType, Body)
|
|
catch
|
|
_Class:_Reason ->
|
|
{error, invalid_body}
|
|
end.
|
|
|
|
parse_body(<<"application/json", _/binary>>, Body) ->
|
|
{ok, emqx_utils_json:decode(Body, [return_maps])};
|
|
parse_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) ->
|
|
Flags = [<<"result">>, <<"is_superuser">>],
|
|
RawMap = maps:from_list(cow_qs:parse_qs(Body)),
|
|
NBody = maps:with(Flags, RawMap),
|
|
{ok, NBody};
|
|
parse_body(ContentType, _) ->
|
|
{error, {unsupported_content_type, ContentType}}.
|
|
|
|
uri_encode(T) ->
|
|
emqx_http_lib:uri_encode(to_list(T)).
|
|
|
|
request_for_log(Credential, #{url := Url, method := Method} = State) ->
|
|
SafeCredential = emqx_authn_utils:without_password(Credential),
|
|
case generate_request(SafeCredential, State) of
|
|
{PathQuery, Headers} ->
|
|
#{
|
|
method => Method,
|
|
base_url => Url,
|
|
path_query => PathQuery,
|
|
headers => Headers
|
|
};
|
|
{PathQuery, Headers, Body} ->
|
|
#{
|
|
method => Method,
|
|
base_url => Url,
|
|
path_query => PathQuery,
|
|
headers => Headers,
|
|
body => Body
|
|
}
|
|
end.
|
|
|
|
response_for_log({ok, StatusCode, Headers}) ->
|
|
#{status => StatusCode, headers => Headers};
|
|
response_for_log({ok, StatusCode, Headers, Body}) ->
|
|
#{status => StatusCode, headers => Headers, body => Body};
|
|
response_for_log({error, Error}) ->
|
|
#{error => Error}.
|
|
|
|
to_list(A) when is_atom(A) ->
|
|
atom_to_list(A);
|
|
to_list(B) when is_binary(B) ->
|
|
binary_to_list(B);
|
|
to_list(L) when is_list(L) ->
|
|
L.
|
|
|
|
to_bin(B) when is_binary(B) ->
|
|
B;
|
|
to_bin(L) when is_list(L) ->
|
|
list_to_binary(L).
|
|
|
|
ensure_header_name_type(Headers) ->
|
|
Fun = fun
|
|
(Key, _Val, Acc) when is_binary(Key) ->
|
|
Acc;
|
|
(Key, Val, Acc) when is_atom(Key) ->
|
|
Acc2 = maps:remove(Key, Acc),
|
|
BinKey = erlang:atom_to_binary(Key),
|
|
Acc2#{BinKey => Val}
|
|
end,
|
|
maps:fold(Fun, Headers, Headers).
|