Merge pull request #8088 from savonarola/authn-import-users-request

feat(authn api): add method for user file upload
This commit is contained in:
Ilya Averyanov 2022-06-02 13:51:46 +03:00 committed by GitHub
commit 1bad5f8b7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 557 additions and 273 deletions

View File

@ -166,11 +166,11 @@ when
when when
State :: state(). State :: state().
-callback import_users(Filename, State) -> -callback import_users({Filename, FileData}, State) ->
ok ok
| {error, term()} | {error, term()}
when when
Filename :: binary(), State :: state(). Filename :: binary(), FileData :: binary(), State :: state().
-callback add_user(UserInfo, State) -> -callback add_user(UserInfo, State) ->
{ok, User} {ok, User}
@ -385,7 +385,8 @@ list_authenticators(ChainName) ->
move_authenticator(ChainName, AuthenticatorID, Position) -> move_authenticator(ChainName, AuthenticatorID, Position) ->
call({move_authenticator, ChainName, AuthenticatorID, Position}). call({move_authenticator, ChainName, AuthenticatorID, Position}).
-spec import_users(chain_name(), authenticator_id(), binary()) -> ok | {error, term()}. -spec import_users(chain_name(), authenticator_id(), {binary(), binary()}) ->
ok | {error, term()}.
import_users(ChainName, AuthenticatorID, Filename) -> import_users(ChainName, AuthenticatorID, Filename) ->
call({import_users, ChainName, AuthenticatorID, Filename}). call({import_users, ChainName, AuthenticatorID, Filename}).

View File

@ -105,20 +105,6 @@ emqx_authn_api {
} }
} }
authentication_id_import_users_post {
desc {
en: """Import users into authenticator in global authentication chain."""
zh: """为全局认证链上的指定认证器导入用户数据。"""
}
}
listeners_listener_id_authentication_id_import_users_post {
desc {
en: """Import users into authenticator in listener authentication chain."""
zh: """为监听器认证链上的指定认证器导入用户数据。"""
}
}
authentication_id_users_post { authentication_id_users_post {
desc { desc {
en: """Create users for authenticator in global authentication chain.""" en: """Create users for authenticator in global authentication chain."""

View File

@ -0,0 +1,17 @@
emqx_authn_user_import_api {
authentication_id_import_users_post {
desc {
en: """Import users into authenticator in global authentication chain."""
zh: """为全局认证链上的指定认证器导入用户数据。"""
}
}
listeners_listener_id_authentication_id_import_users_post {
desc {
en: """Import users into authenticator in listener authentication chain."""
zh: """为监听器认证链上的指定认证器导入用户数据。"""
}
}
}

View File

@ -62,8 +62,6 @@
listener_authenticator_status/2, listener_authenticator_status/2,
authenticator_move/2, authenticator_move/2,
listener_authenticator_move/2, listener_authenticator_move/2,
authenticator_import_users/2,
listener_authenticator_import_users/2,
authenticator_users/2, authenticator_users/2,
authenticator_user/2, authenticator_user/2,
listener_authenticator_users/2, listener_authenticator_users/2,
@ -75,7 +73,6 @@
-export([ -export([
authenticator_examples/0, authenticator_examples/0,
request_move_examples/0, request_move_examples/0,
request_import_users_examples/0,
request_user_create_examples/0, request_user_create_examples/0,
request_user_update_examples/0, request_user_update_examples/0,
response_user_examples/0, response_user_examples/0,
@ -90,7 +87,11 @@
find_user/3, find_user/3,
update_user/4, update_user/4,
serialize_error/1, serialize_error/1,
aggregate_metrics/1 aggregate_metrics/1,
with_chain/2,
param_auth_id/0,
param_listener_id/0
]). ]).
-elvis([{elvis_style, god_modules, disable}]). -elvis([{elvis_style, god_modules, disable}]).
@ -104,7 +105,6 @@ paths() ->
"/authentication/:id", "/authentication/:id",
"/authentication/:id/status", "/authentication/:id/status",
"/authentication/:id/move", "/authentication/:id/move",
"/authentication/:id/import_users",
"/authentication/:id/users", "/authentication/:id/users",
"/authentication/:id/users/:user_id", "/authentication/:id/users/:user_id",
@ -112,7 +112,6 @@ paths() ->
"/listeners/:listener_id/authentication/:id", "/listeners/:listener_id/authentication/:id",
"/listeners/:listener_id/authentication/:id/status", "/listeners/:listener_id/authentication/:id/status",
"/listeners/:listener_id/authentication/:id/move", "/listeners/:listener_id/authentication/:id/move",
"/listeners/:listener_id/authentication/:id/import_users",
"/listeners/:listener_id/authentication/:id/users", "/listeners/:listener_id/authentication/:id/users",
"/listeners/:listener_id/authentication/:id/users/:user_id" "/listeners/:listener_id/authentication/:id/users/:user_id"
]. ].
@ -122,7 +121,6 @@ roots() ->
request_user_create, request_user_create,
request_user_update, request_user_update,
request_move, request_move,
request_import_users,
response_user, response_user,
response_users response_users
]. ].
@ -139,9 +137,6 @@ fields(request_user_update) ->
]; ];
fields(request_move) -> fields(request_move) ->
[{position, mk(binary(), #{required => true})}]; [{position, mk(binary(), #{required => true})}];
fields(request_import_users) ->
%% TODO: add file update
[{filename, mk(binary(), #{required => true})}];
fields(response_user) -> fields(response_user) ->
[ [
{user_id, mk(binary(), #{required => true})}, {user_id, mk(binary(), #{required => true})},
@ -375,42 +370,6 @@ schema("/listeners/:listener_id/authentication/:id/move") ->
} }
} }
}; };
schema("/authentication/:id/import_users") ->
#{
'operationId' => authenticator_import_users,
post => #{
tags => ?API_TAGS_GLOBAL,
description => ?DESC(authentication_id_import_users_post),
parameters => [param_auth_id()],
'requestBody' => emqx_dashboard_swagger:schema_with_examples(
ref(request_import_users),
request_import_users_examples()
),
responses => #{
204 => <<"Users imported">>,
400 => error_codes([?BAD_REQUEST], <<"Bad Request">>),
404 => error_codes([?NOT_FOUND], <<"Not Found">>)
}
}
};
schema("/listeners/:listener_id/authentication/:id/import_users") ->
#{
'operationId' => listener_authenticator_import_users,
post => #{
tags => ?API_TAGS_SINGLE,
description => ?DESC(listeners_listener_id_authentication_id_import_users_post),
parameters => [param_listener_id(), param_auth_id()],
'requestBody' => emqx_dashboard_swagger:schema_with_examples(
ref(request_import_users),
request_import_users_examples()
),
responses => #{
204 => <<"Users imported">>,
400 => error_codes([?BAD_REQUEST], <<"Bad Request">>),
404 => error_codes([?NOT_FOUND], <<"Not Found">>)
}
}
};
schema("/authentication/:id/users") -> schema("/authentication/:id/users") ->
#{ #{
'operationId' => authenticator_users, 'operationId' => authenticator_users,
@ -747,39 +706,6 @@ listener_authenticator_move(
listener_authenticator_move(post, #{bindings := #{listener_id := _, id := _}, body := _}) -> listener_authenticator_move(post, #{bindings := #{listener_id := _, id := _}, body := _}) ->
serialize_error({missing_parameter, position}). serialize_error({missing_parameter, position}).
authenticator_import_users(
post,
#{
bindings := #{id := AuthenticatorID},
body := #{<<"filename">> := Filename}
}
) ->
case emqx_authentication:import_users(?GLOBAL, AuthenticatorID, Filename) of
ok -> {204};
{error, Reason} -> serialize_error(Reason)
end;
authenticator_import_users(post, #{bindings := #{id := _}, body := _}) ->
serialize_error({missing_parameter, filename}).
listener_authenticator_import_users(
post,
#{
bindings := #{listener_id := ListenerID, id := AuthenticatorID},
body := #{<<"filename">> := Filename}
}
) ->
with_chain(
ListenerID,
fun(ChainName) ->
case emqx_authentication:import_users(ChainName, AuthenticatorID, Filename) of
ok -> {204};
{error, Reason} -> serialize_error(Reason)
end
end
);
listener_authenticator_import_users(post, #{bindings := #{listener_id := _, id := _}, body := _}) ->
serialize_error({missing_parameter, filename}).
authenticator_users(post, #{bindings := #{id := AuthenticatorID}, body := UserInfo}) -> authenticator_users(post, #{bindings := #{id := AuthenticatorID}, body := UserInfo}) ->
add_user(?GLOBAL, AuthenticatorID, UserInfo); add_user(?GLOBAL, AuthenticatorID, UserInfo);
authenticator_users(get, #{bindings := #{id := AuthenticatorID}, query_string := QueryString}) -> authenticator_users(get, #{bindings := #{id := AuthenticatorID}, query_string := QueryString}) ->
@ -1579,22 +1505,6 @@ request_move_examples() ->
} }
}. }.
request_import_users_examples() ->
#{
import_csv => #{
summary => <<"Import users from CSV file">>,
value => #{
filename => <<"/path/to/user/data.csv">>
}
},
import_json => #{
summary => <<"Import users from JSON file">>,
value => #{
filename => <<"/path/to/user/data.json">>
}
}
}.
response_user_examples() -> response_user_examples() ->
#{ #{
regular_user => #{ regular_user => #{

View File

@ -0,0 +1,144 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 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_user_import_api).
-behaviour(minirest_api).
-include("emqx_authn.hrl").
-include_lib("emqx/include/logger.hrl").
-include_lib("emqx/include/emqx_authentication.hrl").
-include_lib("hocon/include/hoconsc.hrl").
-import(emqx_dashboard_swagger, [error_codes/2]).
-define(BAD_REQUEST, 'BAD_REQUEST').
-define(NOT_FOUND, 'NOT_FOUND').
% Swagger
-define(API_TAGS_GLOBAL, [
?EMQX_AUTHENTICATION_CONFIG_ROOT_NAME_BINARY,
<<"authentication config(global)">>
]).
-define(API_TAGS_SINGLE, [
?EMQX_AUTHENTICATION_CONFIG_ROOT_NAME_BINARY,
<<"authentication config(single listener)">>
]).
-export([
api_spec/0,
paths/0,
schema/1
]).
-export([
authenticator_import_users/2,
listener_authenticator_import_users/2
]).
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}).
paths() ->
[
"/authentication/:id/import_users",
"/listeners/:listener_id/authentication/:id/import_users"
].
schema("/authentication/:id/import_users") ->
#{
'operationId' => authenticator_import_users,
post => #{
tags => ?API_TAGS_GLOBAL,
description => ?DESC(authentication_id_import_users_post),
parameters => [emqx_authn_api:param_auth_id()],
'requestBody' => #{
content => #{
'multipart/form-data' => #{
schema => #{
filename => file
}
}
}
},
responses => #{
204 => <<"Users imported">>,
400 => error_codes([?BAD_REQUEST], <<"Bad Request">>),
404 => error_codes([?NOT_FOUND], <<"Not Found">>)
}
}
};
schema("/listeners/:listener_id/authentication/:id/import_users") ->
#{
'operationId' => listener_authenticator_import_users,
post => #{
tags => ?API_TAGS_SINGLE,
description => ?DESC(listeners_listener_id_authentication_id_import_users_post),
parameters => [emqx_authn_api:param_listener_id(), emqx_authn_api:param_auth_id()],
'requestBody' => #{
content => #{
'multipart/form-data' => #{
schema => #{
filename => file
}
}
}
},
responses => #{
204 => <<"Users imported">>,
400 => error_codes([?BAD_REQUEST], <<"Bad Request">>),
404 => error_codes([?NOT_FOUND], <<"Not Found">>)
}
}
}.
authenticator_import_users(
post,
#{
bindings := #{id := AuthenticatorID},
body := #{<<"filename">> := #{type := _} = File}
}
) ->
[{FileName, FileData}] = maps:to_list(maps:without([type], File)),
case emqx_authentication:import_users(?GLOBAL, AuthenticatorID, {FileName, FileData}) of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end;
authenticator_import_users(post, #{bindings := #{id := _}, body := _}) ->
emqx_authn_api:serialize_error({missing_parameter, filename}).
listener_authenticator_import_users(
post,
#{
bindings := #{listener_id := ListenerID, id := AuthenticatorID},
body := #{<<"filename">> := #{type := _} = File}
}
) ->
[{FileName, FileData}] = maps:to_list(maps:without([type], File)),
emqx_authn_api:with_chain(
ListenerID,
fun(ChainName) ->
case
emqx_authentication:import_users(ChainName, AuthenticatorID, {FileName, FileData})
of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end
end
);
listener_authenticator_import_users(post, #{bindings := #{listener_id := _, id := _}, body := _}) ->
emqx_authn_api:serialize_error({missing_parameter, filename}).

View File

@ -182,13 +182,14 @@ destroy(#{user_group := UserGroup}) ->
end end
). ).
import_users(Filename0, State) -> import_users({Filename0, FileData}, State) ->
Filename = to_binary(Filename0), Filename = to_binary(Filename0),
case filename:extension(Filename) of case filename:extension(Filename) of
<<".json">> -> <<".json">> ->
import_users_from_json(Filename, State); import_users_from_json(FileData, State);
<<".csv">> -> <<".csv">> ->
import_users_from_csv(Filename, State); CSV = csv_data(FileData),
import_users_from_csv(CSV, State);
<<>> -> <<>> ->
{error, unknown_file_format}; {error, unknown_file_format};
Extension -> Extension ->
@ -327,31 +328,19 @@ run_fuzzy_filter(
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
%% Example: data/user-credentials.json %% Example: data/user-credentials.json
import_users_from_json(Filename, #{user_group := UserGroup}) -> import_users_from_json(Bin, #{user_group := UserGroup}) ->
case file:read_file(Filename) of case emqx_json:safe_decode(Bin, [return_maps]) of
{ok, Bin} -> {ok, List} ->
case emqx_json:safe_decode(Bin, [return_maps]) of trans(fun import/2, [UserGroup, List]);
{ok, List} ->
trans(fun import/2, [UserGroup, List]);
{error, Reason} ->
{error, Reason}
end;
{error, Reason} -> {error, Reason} ->
{error, Reason} {error, Reason}
end. end.
%% Example: data/user-credentials.csv %% Example: data/user-credentials.csv
import_users_from_csv(Filename, #{user_group := UserGroup}) -> import_users_from_csv(CSV, #{user_group := UserGroup}) ->
case file:open(Filename, [read, binary]) of case get_csv_header(CSV) of
{ok, File} -> {ok, Seq, NewCSV} ->
case get_csv_header(File) of trans(fun import_csv/3, [UserGroup, NewCSV, Seq]);
{ok, Seq} ->
Result = trans(fun import/3, [UserGroup, File, Seq]),
_ = file:close(File),
Result;
{error, Reason} ->
{error, Reason}
end;
{error, Reason} -> {error, Reason} ->
{error, Reason} {error, Reason}
end. end.
@ -375,9 +364,9 @@ import(_UserGroup, [_ | _More]) ->
{error, bad_format}. {error, bad_format}.
%% Importing 5w users needs 1.7 seconds %% Importing 5w users needs 1.7 seconds
import(UserGroup, File, Seq) -> import_csv(UserGroup, CSV, Seq) ->
case file:read_line(File) of case csv_read_line(CSV) of
{ok, Line} -> {ok, Line, NewCSV} ->
Fields = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]), Fields = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]),
case get_user_info_by_seq(Fields, Seq) of case get_user_info_by_seq(Fields, Seq) of
{ok, {ok,
@ -388,25 +377,21 @@ import(UserGroup, File, Seq) ->
Salt = maps:get(salt, UserInfo, <<>>), Salt = maps:get(salt, UserInfo, <<>>),
IsSuperuser = maps:get(is_superuser, UserInfo, false), IsSuperuser = maps:get(is_superuser, UserInfo, false),
insert_user(UserGroup, UserID, PasswordHash, Salt, IsSuperuser), insert_user(UserGroup, UserID, PasswordHash, Salt, IsSuperuser),
import(UserGroup, File, Seq); import_csv(UserGroup, NewCSV, Seq);
{error, Reason} -> {error, Reason} ->
{error, Reason} {error, Reason}
end; end;
eof -> eof ->
ok; ok
{error, Reason} ->
{error, Reason}
end. end.
get_csv_header(File) -> get_csv_header(CSV) ->
case file:read_line(File) of case csv_read_line(CSV) of
{ok, Line} -> {ok, Line, NewCSV} ->
Seq = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]), Seq = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]),
{ok, Seq}; {ok, Seq, NewCSV};
eof -> eof ->
{error, empty_file}; {error, empty_file}
{error, Reason} ->
{error, Reason}
end. end.
get_user_info_by_seq(Fields, Seq) -> get_user_info_by_seq(Fields, Seq) ->
@ -487,3 +472,12 @@ group_match_spec(UserGroup, QString) ->
User User
end) end)
end. end.
csv_data(Data) ->
Lines = binary:split(Data, [<<"\r">>, <<"\n">>], [global, trim_all]),
{csv_data, Lines}.
csv_read_line({csv_data, [Line | Lines]}) ->
{ok, Line, {csv_data, Lines}};
csv_read_line({csv_data, []}) ->
eof.

View File

@ -18,7 +18,7 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-compile(export_all). -compile(export_all).
-import(emqx_dashboard_api_test_helpers, [request/3, uri/1]). -import(emqx_dashboard_api_test_helpers, [request/3, uri/1, multipart_formdata_request/3]).
-include("emqx_authn.hrl"). -include("emqx_authn.hrl").
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
@ -643,19 +643,24 @@ test_authenticator_import_users(PathPrefix) ->
emqx_authn_test_lib:built_in_database_example() emqx_authn_test_lib:built_in_database_example()
), ),
{ok, 400, _} = request(post, ImportUri, #{}), {ok, 400, _} = multipart_formdata_request(ImportUri, [], []),
{ok, 400, _} = multipart_formdata_request(ImportUri, [], [
{ok, 400, _} = request(post, ImportUri, #{filename => <<"/etc/passwd">>}), {filenam, "user-credentials.json", <<>>}
]),
{ok, 400, _} = request(post, ImportUri, #{filename => <<"/not_exists.csv">>}),
Dir = code:lib_dir(emqx_authn, test), Dir = code:lib_dir(emqx_authn, test),
JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]), JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]),
CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]), CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]),
{ok, 204, _} = request(post, ImportUri, #{filename => JSONFileName}), {ok, JSONData} = file:read_file(JSONFileName),
{ok, 204, _} = multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.json", JSONData}
]),
{ok, 204, _} = request(post, ImportUri, #{filename => CSVFileName}). {ok, CSVData} = file:read_file(CSVFileName),
{ok, 204, _} = multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.csv", CSVData}
]).
t_switch_to_global_chain(_) -> t_switch_to_global_chain(_) ->
{ok, 200, _} = request( {ok, 200, _} = request(

View File

@ -228,54 +228,75 @@ t_import_users(_) ->
Config = Config0#{password_hash_algorithm => #{name => sha256}}, Config = Config0#{password_hash_algorithm => #{name => sha256}},
{ok, State} = emqx_authn_mnesia:create(?AUTHN_ID, Config), {ok, State} = emqx_authn_mnesia:create(?AUTHN_ID, Config),
ok = emqx_authn_mnesia:import_users( ?assertEqual(
data_filename(<<"user-credentials.json">>), ok,
State emqx_authn_mnesia:import_users(
sample_filename_and_data(<<"user-credentials.json">>),
State
)
), ),
ok = emqx_authn_mnesia:import_users( ?assertEqual(
data_filename(<<"user-credentials.csv">>), ok,
State emqx_authn_mnesia:import_users(
sample_filename_and_data(<<"user-credentials.csv">>),
State
)
), ),
{error, {unsupported_file_format, _}} = emqx_authn_mnesia:import_users( ?assertMatch(
<<"/file/with/unknown.extension">>, {error, {unsupported_file_format, _}},
State emqx_authn_mnesia:import_users(
{<<"/file/with/unknown.extension">>, <<>>},
State
)
), ),
{error, unknown_file_format} = emqx_authn_mnesia:import_users( ?assertEqual(
<<"/file/with/no/extension">>, {error, unknown_file_format},
State emqx_authn_mnesia:import_users(
{<<"/file/with/no/extension">>, <<>>},
State
)
), ),
{error, enoent} = emqx_authn_mnesia:import_users( ?assertEqual(
<<"/file/that/not/exist.json">>, {error, bad_format},
State emqx_authn_mnesia:import_users(
sample_filename_and_data(<<"user-credentials-malformed-0.json">>),
State
)
), ),
{error, bad_format} = emqx_authn_mnesia:import_users( ?assertMatch(
data_filename(<<"user-credentials-malformed-0.json">>), {error, {_, invalid_json}},
State emqx_authn_mnesia:import_users(
sample_filename_and_data(<<"user-credentials-malformed-1.json">>),
State
)
), ),
{error, {_, invalid_json}} = emqx_authn_mnesia:import_users( ?assertEqual(
data_filename(<<"user-credentials-malformed-1.json">>), {error, bad_format},
State emqx_authn_mnesia:import_users(
), sample_filename_and_data(<<"user-credentials-malformed.csv">>),
State
{error, bad_format} = emqx_authn_mnesia:import_users( )
data_filename(<<"user-credentials-malformed.csv">>),
State
). ).
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
%% Helpers %% Helpers
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
data_filename(Name) -> sample_filename(Name) ->
Dir = code:lib_dir(emqx_authn, test), Dir = code:lib_dir(emqx_authn, test),
filename:join([Dir, <<"data">>, Name]). filename:join([Dir, <<"data">>, Name]).
sample_filename_and_data(Name) ->
Filename = sample_filename(Name),
{ok, Data} = file:read_file(Filename),
{Filename, Data}.
config() -> config() ->
#{ #{
user_id_type => username, user_id_type => username,

View File

@ -22,6 +22,8 @@
request/2, request/2,
request/3, request/3,
request/4, request/4,
multipart_formdata_request/3,
multipart_formdata_request/4,
uri/0, uri/0,
uri/1 uri/1
]). ]).
@ -97,3 +99,67 @@ auth_header(Username) ->
Password = <<"public">>, Password = <<"public">>,
{ok, Token} = emqx_dashboard_admin:sign_token(Username, Password), {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
{"Authorization", "Bearer " ++ binary_to_list(Token)}. {"Authorization", "Bearer " ++ binary_to_list(Token)}.
multipart_formdata_request(Url, Fields, Files) ->
multipart_formdata_request(Url, <<"admin">>, Fields, Files).
multipart_formdata_request(Url, Username, Fields, Files) ->
Boundary =
"------------" ++ integer_to_list(rand:uniform(99999999999999999)) ++
integer_to_list(erlang:system_time(millisecond)),
Body = format_multipart_formdata(Boundary, Fields, Files),
ContentType = lists:concat(["multipart/form-data; boundary=", Boundary]),
Headers =
[
auth_header(Username),
{"Content-Length", integer_to_list(length(Body))}
],
case httpc:request(post, {Url, Headers, ContentType, Body}, [], []) of
{error, socket_closed_remotely} ->
{error, socket_closed_remotely};
{ok, {{"HTTP/1.1", Code, _}, _Headers, Return}} ->
{ok, Code, Return};
{ok, {Reason, _, _}} ->
{error, Reason}
end.
format_multipart_formdata(Boundary, Fields, Files) ->
FieldParts = lists:map(
fun({FieldName, FieldContent}) ->
[
lists:concat(["--", Boundary]),
lists:concat([
"Content-Disposition: form-data; name=\"", atom_to_list(FieldName), "\""
]),
"",
to_list(FieldContent)
]
end,
Fields
),
FieldParts2 = lists:append(FieldParts),
FileParts = lists:map(
fun({FieldName, FileName, FileContent}) ->
[
lists:concat(["--", Boundary]),
lists:concat([
"Content-Disposition: form-data; name=\"",
atom_to_list(FieldName),
"\"; filename=\"",
FileName,
"\""
]),
lists:concat(["Content-Type: ", "application/octet-stream"]),
"",
to_list(FileContent)
]
end,
Files
),
FileParts2 = lists:append(FileParts),
EndingParts = [lists:concat(["--", Boundary, "--"]), ""],
Parts = lists:append([FieldParts2, FileParts2, EndingParts]),
string:join(Parts, "\r\n").
to_list(Bin) when is_binary(Bin) -> binary_to_list(Bin);
to_list(Str) when is_list(Str) -> Str.

View File

@ -46,8 +46,7 @@
-export([ -export([
authn/2, authn/2,
users/2, users/2,
users_insta/2, users_insta/2
import_users/2
]). ]).
%% internal export for emqx_gateway_api_listeners module %% internal export for emqx_gateway_api_listeners module
@ -64,8 +63,7 @@ paths() ->
[ [
"/gateway/:name/authentication", "/gateway/:name/authentication",
"/gateway/:name/authentication/users", "/gateway/:name/authentication/users",
"/gateway/:name/authentication/users/:uid", "/gateway/:name/authentication/users/:uid"
"/gateway/:name/authentication/import_users"
]. ].
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
@ -160,32 +158,6 @@ users_insta(delete, #{bindings := #{name := Name0, uid := UserId}}) ->
emqx_authn_api:delete_user(ChainName, AuthId, UserId) emqx_authn_api:delete_user(ChainName, AuthId, UserId)
end). end).
import_users(post, #{
bindings := #{name := Name0},
body := Body
}) ->
with_authn(Name0, fun(
_GwName,
#{
id := AuthId,
chain_name := ChainName
}
) ->
case maps:get(<<"filename">>, Body, undefined) of
undefined ->
emqx_authn_api:serialize_error({missing_parameter, filename});
Filename ->
case
emqx_authentication:import_users(
ChainName, AuthId, Filename
)
of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end
end
end).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% Utils %% Utils
@ -326,21 +298,6 @@ schema("/gateway/:name/authentication/users/:uid") ->
responses => responses =>
?STANDARD_RESP(#{204 => <<"User Deleted">>}) ?STANDARD_RESP(#{204 => <<"User Deleted">>})
} }
};
schema("/gateway/:name/authentication/import_users") ->
#{
'operationId' => import_users,
post =>
#{
desc => ?DESC(import_users),
parameters => params_gateway_name_in_path(),
'requestBody' => emqx_dashboard_swagger:schema_with_examples(
ref(emqx_authn_api, request_import_users),
emqx_authn_api:request_import_users_examples()
),
responses =>
?STANDARD_RESP(#{204 => <<"Imported">>})
}
}. }.
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------

View File

@ -0,0 +1,190 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2021-2022 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_gateway_api_authn_user_import).
-behaviour(minirest_api).
-include("emqx_gateway_http.hrl").
-include_lib("hocon/include/hoconsc.hrl").
-include_lib("typerefl/include/types.hrl").
-import(emqx_dashboard_swagger, [error_codes/2]).
-import(hoconsc, [mk/2, ref/2]).
-import(
emqx_gateway_http,
[
with_authn/2,
with_listener_authn/3
]
).
%% minirest/dashboard_swagger behaviour callbacks
-export([
api_spec/0,
paths/0,
schema/1
]).
%% http handlers
-export([
import_users/2,
import_listener_users/2
]).
%%--------------------------------------------------------------------
%% minirest behaviour callbacks
%%--------------------------------------------------------------------
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}).
paths() ->
[
"/gateway/:name/authentication/import_users",
"/gateway/:name/listeners/:id/authentication/import_users"
].
%%--------------------------------------------------------------------
%% http handlers
import_users(post, #{
bindings := #{name := Name0},
body := Body
}) ->
with_authn(Name0, fun(
_GwName,
#{
id := AuthId,
chain_name := ChainName
}
) ->
case maps:get(<<"filename">>, Body, undefined) of
undefined ->
emqx_authn_api:serialize_error({missing_parameter, filename});
File ->
[{FileName, FileData}] = maps:to_list(maps:without([type], File)),
case
emqx_authentication:import_users(
ChainName, AuthId, {FileName, FileData}
)
of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end
end
end).
import_listener_users(post, #{
bindings := #{name := Name0, id := Id},
body := Body
}) ->
with_listener_authn(
Name0,
Id,
fun(_GwName, #{id := AuthId, chain_name := ChainName}) ->
case maps:get(<<"filename">>, Body, undefined) of
undefined ->
emqx_authn_api:serialize_error({missing_parameter, filename});
File ->
[{FileName, FileData}] = maps:to_list(maps:without([type], File)),
case
emqx_authentication:import_users(
ChainName, AuthId, {FileName, FileData}
)
of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end
end
end
).
%%--------------------------------------------------------------------
%% Swagger defines
%%--------------------------------------------------------------------
schema("/gateway/:name/authentication/import_users") ->
#{
'operationId' => import_users,
post =>
#{
desc => ?DESC(emqx_gateway_api_authn, import_users),
parameters => params_gateway_name_in_path(),
'requestBody' => #{
content => #{
'multipart/form-data' => #{
schema => #{
filename => file
}
}
}
},
responses =>
?STANDARD_RESP(#{204 => <<"Imported">>})
}
};
schema("/gateway/:name/listeners/:id/authentication/import_users") ->
#{
'operationId' => import_listener_users,
post =>
#{
desc => ?DESC(emqx_gateway_api_listeners, import_users),
parameters => params_gateway_name_in_path() ++
params_listener_id_in_path(),
'requestBody' => #{
content => #{
'multipart/form-data' => #{
schema => #{
filename => file
}
}
}
},
responses =>
?STANDARD_RESP(#{204 => <<"Imported">>})
}
}.
%%--------------------------------------------------------------------
%% params defines
%%--------------------------------------------------------------------
params_gateway_name_in_path() ->
[
{name,
mk(
binary(),
#{
in => path,
desc => ?DESC(emqx_gateway_api, gateway_name),
example => <<"stomp">>
}
)}
].
params_listener_id_in_path() ->
[
{id,
mk(
binary(),
#{
in => path,
desc => ?DESC(emqx_gateway_api_listeners, listener_id),
example => <<"stomp:tcp:def">>
}
)}
].

View File

@ -54,8 +54,7 @@
listeners_insta/2, listeners_insta/2,
listeners_insta_authn/2, listeners_insta_authn/2,
users/2, users/2,
users_insta/2, users_insta/2
import_users/2
]). ]).
%% RPC %% RPC
@ -74,8 +73,7 @@ paths() ->
"/gateway/:name/listeners/:id", "/gateway/:name/listeners/:id",
"/gateway/:name/listeners/:id/authentication", "/gateway/:name/listeners/:id/authentication",
"/gateway/:name/listeners/:id/authentication/users", "/gateway/:name/listeners/:id/authentication/users",
"/gateway/:name/listeners/:id/authentication/users/:uid", "/gateway/:name/listeners/:id/authentication/users/:uid"
"/gateway/:name/listeners/:id/authentication/import_users"
]. ].
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
@ -239,30 +237,6 @@ users_insta(delete, #{bindings := #{name := Name0, id := Id, uid := UserId}}) ->
end end
). ).
import_users(post, #{
bindings := #{name := Name0, id := Id},
body := Body
}) ->
with_listener_authn(
Name0,
Id,
fun(_GwName, #{id := AuthId, chain_name := ChainName}) ->
case maps:get(<<"filename">>, Body, undefined) of
undefined ->
emqx_authn_api:serialize_error({missing_parameter, filename});
Filename ->
case
emqx_authentication:import_users(
ChainName, AuthId, Filename
)
of
ok -> {204};
{error, Reason} -> emqx_authn_api:serialize_error(Reason)
end
end
end
).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% Utils %% Utils
@ -549,22 +523,6 @@ schema("/gateway/:name/listeners/:id/authentication/users/:uid") ->
responses => responses =>
?STANDARD_RESP(#{204 => <<"Deleted">>}) ?STANDARD_RESP(#{204 => <<"Deleted">>})
} }
};
schema("/gateway/:name/listeners/:id/authentication/import_users") ->
#{
'operationId' => import_users,
post =>
#{
desc => ?DESC(import_users),
parameters => params_gateway_name_in_path() ++
params_listener_id_in_path(),
'requestBody' => emqx_dashboard_swagger:schema_with_examples(
ref(emqx_authn_api, request_import_users),
emqx_authn_api:request_import_users_examples()
),
responses =>
?STANDARD_RESP(#{204 => <<"Imported">>})
}
}. }.
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------

View File

@ -312,6 +312,23 @@ t_authn_data_mgmt(_) ->
"/gateway/stomp/authentication/users" "/gateway/stomp/authentication/users"
), ),
ImportUri = emqx_dashboard_api_test_helpers:uri(
["gateway", "stomp", "authentication", "import_users"]
),
Dir = code:lib_dir(emqx_authn, test),
JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]),
{ok, JSONData} = file:read_file(JSONFileName),
{ok, 204, _} = emqx_dashboard_api_test_helpers:multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.json", JSONData}
]),
CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]),
{ok, CSVData} = file:read_file(CSVFileName),
{ok, 204, _} = emqx_dashboard_api_test_helpers:multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.csv", CSVData}
]),
{204, _} = request(delete, "/gateway/stomp/authentication"), {204, _} = request(delete, "/gateway/stomp/authentication"),
{204, _} = request(get, "/gateway/stomp/authentication"), {204, _} = request(get, "/gateway/stomp/authentication"),
{204, _} = request(delete, "/gateway/stomp"). {204, _} = request(delete, "/gateway/stomp").
@ -451,6 +468,24 @@ t_listeners_authn_data_mgmt(_) ->
get, get,
Path ++ "/users" Path ++ "/users"
), ),
ImportUri = emqx_dashboard_api_test_helpers:uri(
["gateway", "stomp", "listeners", "stomp:tcp:def", "authentication", "import_users"]
),
Dir = code:lib_dir(emqx_authn, test),
JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]),
{ok, JSONData} = file:read_file(JSONFileName),
{ok, 204, _} = emqx_dashboard_api_test_helpers:multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.json", JSONData}
]),
CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]),
{ok, CSVData} = file:read_file(CSVFileName),
{ok, 204, _} = emqx_dashboard_api_test_helpers:multipart_formdata_request(ImportUri, [], [
{filename, "user-credentials.csv", CSVData}
]),
{204, _} = request(delete, "/gateway/stomp"). {204, _} = request(delete, "/gateway/stomp").
t_authn_fuzzy_search(_) -> t_authn_fuzzy_search(_) ->