emqx/apps/emqx_auth/src/emqx_auth_utils.erl

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.