340 lines
11 KiB
Erlang
340 lines
11 KiB
Erlang
%%--------------------------------------------------------------------
|
|
%% Copyright (c) 2024 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_auth_utils).
|
|
|
|
-include_lib("emqx/include/emqx_placeholder.hrl").
|
|
-include_lib("snabbkaffe/include/trace.hrl").
|
|
|
|
%% Template parsing/rendering
|
|
-export([
|
|
parse_deep/2,
|
|
parse_str/2,
|
|
parse_sql/3,
|
|
render_deep_for_json/2,
|
|
render_deep_for_url/2,
|
|
render_deep_for_raw/2,
|
|
render_str/2,
|
|
render_urlencoded_str/2,
|
|
render_sql_params/2
|
|
]).
|
|
|
|
%% URL parsing
|
|
-export([parse_url/1]).
|
|
|
|
%% HTTP request/response helpers
|
|
-export([generate_request/2]).
|
|
|
|
-define(DEFAULT_HTTP_REQUEST_CONTENT_TYPE, <<"application/json">>).
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% API
|
|
%%--------------------------------------------------------------------
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% Template parsing/rendering
|
|
|
|
parse_deep(Template, AllowedVars) ->
|
|
Result = emqx_template:parse_deep(Template),
|
|
handle_disallowed_placeholders(Result, AllowedVars, {deep, Template}).
|
|
|
|
parse_str(Template, AllowedVars) ->
|
|
Result = emqx_template:parse(Template),
|
|
handle_disallowed_placeholders(Result, AllowedVars, {string, Template}).
|
|
|
|
parse_sql(Template, ReplaceWith, AllowedVars) ->
|
|
{Statement, Result} = emqx_template_sql:parse_prepstmt(
|
|
Template,
|
|
#{parameters => ReplaceWith, strip_double_quote => true}
|
|
),
|
|
{Statement, handle_disallowed_placeholders(Result, AllowedVars, {string, Template})}.
|
|
|
|
handle_disallowed_placeholders(Template, AllowedVars, Source) ->
|
|
case emqx_template:validate(AllowedVars, Template) of
|
|
ok ->
|
|
Template;
|
|
{error, Disallowed} ->
|
|
?tp(warning, "auth_template_invalid", #{
|
|
template => Source,
|
|
reason => Disallowed,
|
|
allowed => #{placeholders => AllowedVars},
|
|
notice =>
|
|
"Disallowed placeholders will be rendered as is."
|
|
" However, consider using `${$}` escaping for literal `$` where"
|
|
" needed to avoid unexpected results."
|
|
}),
|
|
Result = prerender_disallowed_placeholders(Template, AllowedVars),
|
|
case Source of
|
|
{string, _} ->
|
|
emqx_template:parse(Result);
|
|
{deep, _} ->
|
|
emqx_template:parse_deep(Result)
|
|
end
|
|
end.
|
|
|
|
prerender_disallowed_placeholders(Template, AllowedVars) ->
|
|
{Result, _} = emqx_template:render(Template, #{}, #{
|
|
var_trans => fun(Name, _) ->
|
|
% NOTE
|
|
% Rendering disallowed placeholders in escaped form, which will then
|
|
% parse as a literal string.
|
|
case lists:member(Name, AllowedVars) of
|
|
true -> "${" ++ Name ++ "}";
|
|
false -> "${$}{" ++ Name ++ "}"
|
|
end
|
|
end
|
|
}),
|
|
Result.
|
|
|
|
render_deep_for_json(Template, Credential) ->
|
|
% NOTE
|
|
% Ignoring errors here, undefined bindings will be replaced with empty string.
|
|
{Term, _Errors} = emqx_template:render(
|
|
Template,
|
|
rename_client_info_vars(Credential),
|
|
#{var_trans => fun to_string_for_json/2}
|
|
),
|
|
Term.
|
|
|
|
render_deep_for_raw(Template, Credential) ->
|
|
% NOTE
|
|
% Ignoring errors here, undefined bindings will be replaced with empty string.
|
|
{Term, _Errors} = emqx_template:render(
|
|
Template,
|
|
rename_client_info_vars(Credential),
|
|
#{var_trans => fun to_string_for_raw/2}
|
|
),
|
|
Term.
|
|
|
|
render_deep_for_url(Template, Credential) ->
|
|
render_deep_for_raw(Template, Credential).
|
|
|
|
render_str(Template, Credential) ->
|
|
% NOTE
|
|
% Ignoring errors here, undefined bindings will be replaced with empty string.
|
|
{String, _Errors} = emqx_template:render(
|
|
Template,
|
|
rename_client_info_vars(Credential),
|
|
#{var_trans => fun to_string/2}
|
|
),
|
|
unicode:characters_to_binary(String).
|
|
|
|
render_urlencoded_str(Template, Credential) ->
|
|
% NOTE
|
|
% Ignoring errors here, undefined bindings will be replaced with empty string.
|
|
{String, _Errors} = emqx_template:render(
|
|
Template,
|
|
rename_client_info_vars(Credential),
|
|
#{var_trans => fun to_urlencoded_string/2}
|
|
),
|
|
unicode:characters_to_binary(String).
|
|
|
|
render_sql_params(ParamList, Credential) ->
|
|
% NOTE
|
|
% Ignoring errors here, undefined bindings will be replaced with empty string.
|
|
{Row, _Errors} = emqx_template:render(
|
|
ParamList,
|
|
rename_client_info_vars(Credential),
|
|
#{var_trans => fun to_sql_value/2}
|
|
),
|
|
Row.
|
|
|
|
to_urlencoded_string(Name, Value) ->
|
|
case uri_string:compose_query([{<<"q">>, to_string(Name, Value)}]) of
|
|
<<"q=", EncodedBin/binary>> ->
|
|
EncodedBin;
|
|
"q=" ++ EncodedStr ->
|
|
list_to_binary(EncodedStr)
|
|
end.
|
|
|
|
to_string(Name, Value) ->
|
|
emqx_template:to_string(render_var(Name, Value)).
|
|
|
|
%% This converter is to generate data structure possibly with non-utf8 strings.
|
|
%% It converts to unicode only strings (character lists).
|
|
|
|
to_string_for_raw(Name, Value) ->
|
|
strings_to_unicode(Name, render_var(Name, Value)).
|
|
|
|
%% This converter is to generate data structure suitable for JSON serialization.
|
|
%% JSON strings are sequences of unicode characters, not bytes.
|
|
%% So we force all rendered data to be unicode, not only character lists.
|
|
|
|
to_string_for_json(Name, Value) ->
|
|
all_to_unicode(Name, render_var(Name, Value)).
|
|
|
|
strings_to_unicode(_Name, Value) when is_binary(Value) ->
|
|
Value;
|
|
strings_to_unicode(Name, Value) when is_list(Value) ->
|
|
to_unicode_binary(Name, Value);
|
|
strings_to_unicode(_Name, Value) ->
|
|
emqx_template:to_string(Value).
|
|
|
|
all_to_unicode(Name, Value) when is_list(Value) orelse is_binary(Value) ->
|
|
to_unicode_binary(Name, Value);
|
|
all_to_unicode(_Name, Value) ->
|
|
emqx_template:to_string(Value).
|
|
|
|
to_unicode_binary(Name, Value) when is_list(Value) orelse is_binary(Value) ->
|
|
try unicode:characters_to_binary(Value) of
|
|
Encoded when is_binary(Encoded) ->
|
|
Encoded;
|
|
_ ->
|
|
error({encode_error, {non_unicode_data, Name}})
|
|
catch
|
|
error:badarg ->
|
|
error({encode_error, {non_unicode_data, Name}})
|
|
end.
|
|
|
|
to_sql_value(Name, Value) ->
|
|
emqx_utils_sql:to_sql_value(render_var(Name, Value)).
|
|
|
|
render_var(_, undefined) ->
|
|
% NOTE
|
|
% Any allowed but undefined binding will be replaced with empty string, even when
|
|
% rendering SQL values.
|
|
<<>>;
|
|
render_var(?VAR_CERT_PEM, Value) ->
|
|
base64:encode(Value);
|
|
render_var(?VAR_PEERHOST, Value) ->
|
|
inet:ntoa(Value);
|
|
render_var(?VAR_PASSWORD, Value) ->
|
|
iolist_to_binary(Value);
|
|
render_var(_Name, Value) ->
|
|
Value.
|
|
|
|
rename_client_info_vars(ClientInfo) ->
|
|
Renames = [
|
|
{cn, cert_common_name},
|
|
{dn, cert_subject},
|
|
{protocol, proto_name}
|
|
],
|
|
lists:foldl(
|
|
fun({Old, New}, Acc) ->
|
|
emqx_utils_maps:rename(Old, New, Acc)
|
|
end,
|
|
ClientInfo,
|
|
Renames
|
|
).
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% URL parsing
|
|
|
|
-spec parse_url(binary()) ->
|
|
{_Base :: emqx_utils_uri:request_base(), _Path :: binary(), _Query :: binary()}.
|
|
parse_url(Url) ->
|
|
Parsed = emqx_utils_uri:parse(Url),
|
|
case Parsed of
|
|
#{scheme := undefined} ->
|
|
throw({invalid_url, {no_scheme, Url}});
|
|
#{authority := undefined} ->
|
|
throw({invalid_url, {no_host, Url}});
|
|
#{authority := #{userinfo := Userinfo}} when Userinfo =/= undefined ->
|
|
throw({invalid_url, {userinfo_not_supported, Url}});
|
|
#{fragment := Fragment} when Fragment =/= undefined ->
|
|
throw({invalid_url, {fragments_not_supported, Url}});
|
|
_ ->
|
|
case emqx_utils_uri:request_base(Parsed) of
|
|
{ok, Base} ->
|
|
{Base, emqx_utils_uri:path(Parsed),
|
|
emqx_maybe:define(emqx_utils_uri:query(Parsed), <<>>)};
|
|
{error, Reason} ->
|
|
throw({invalid_url, {invalid_base, Reason, Url}})
|
|
end
|
|
end.
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% HTTP request/response helpers
|
|
|
|
generate_request(
|
|
#{
|
|
method := Method,
|
|
headers := Headers,
|
|
base_path_template := BasePathTemplate,
|
|
base_query_template := BaseQueryTemplate,
|
|
body_template := BodyTemplate
|
|
},
|
|
Values
|
|
) ->
|
|
Path = render_urlencoded_str(BasePathTemplate, Values),
|
|
Query = render_deep_for_url(BaseQueryTemplate, Values),
|
|
case Method of
|
|
get ->
|
|
Body = render_deep_for_url(BodyTemplate, Values),
|
|
NPath = append_query(Path, Query, Body),
|
|
{ok, {NPath, Headers}};
|
|
_ ->
|
|
try
|
|
ContentType = post_request_content_type(Headers),
|
|
Body = serialize_body(ContentType, BodyTemplate, Values),
|
|
NPathQuery = append_query(Path, Query),
|
|
{ok, {NPathQuery, Headers, Body}}
|
|
catch
|
|
error:{encode_error, _} = Reason ->
|
|
{error, Reason}
|
|
end
|
|
end.
|
|
|
|
post_request_content_type(Headers) ->
|
|
proplists:get_value(<<"content-type">>, Headers, ?DEFAULT_HTTP_REQUEST_CONTENT_TYPE).
|
|
|
|
append_query(Path, []) ->
|
|
Path;
|
|
append_query(Path, Query) ->
|
|
[Path, $?, uri_string:compose_query(Query)].
|
|
append_query(Path, Query, Body) ->
|
|
append_query(Path, Query ++ maps:to_list(Body)).
|
|
|
|
serialize_body(<<"application/json">>, BodyTemplate, ClientInfo) ->
|
|
Body = emqx_auth_utils:render_deep_for_json(BodyTemplate, ClientInfo),
|
|
emqx_utils_json:encode(Body);
|
|
serialize_body(<<"application/x-www-form-urlencoded">>, BodyTemplate, ClientInfo) ->
|
|
Body = emqx_auth_utils:render_deep_for_url(BodyTemplate, ClientInfo),
|
|
uri_string:compose_query(maps:to_list(Body));
|
|
serialize_body(undefined, _BodyTemplate, _ClientInfo) ->
|
|
throw(missing_content_type_header);
|
|
serialize_body(ContentType, _BodyTemplate, _ClientInfo) ->
|
|
throw({unknown_content_type_header_value, ContentType}).
|
|
|
|
-ifdef(TEST).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
templates_test_() ->
|
|
[
|
|
?_assertEqual(
|
|
{
|
|
#{port => 80, scheme => http, host => "example.com"},
|
|
<<"">>,
|
|
<<"client=${clientid}">>
|
|
},
|
|
parse_url(<<"http://example.com?client=${clientid}">>)
|
|
),
|
|
?_assertEqual(
|
|
{
|
|
#{port => 80, scheme => http, host => "example.com"},
|
|
<<"/path">>,
|
|
<<"client=${clientid}">>
|
|
},
|
|
parse_url(<<"http://example.com/path?client=${clientid}">>)
|
|
),
|
|
?_assertEqual(
|
|
{#{port => 80, scheme => http, host => "example.com"}, <<"/path">>, <<>>},
|
|
parse_url(<<"http://example.com/path">>)
|
|
)
|
|
].
|
|
|
|
-endif.
|