Merge pull request #6415 from zhongwencool/api-key-secret

feat(api-key): support api_key/api_secret authorize
This commit is contained in:
zhongwencool 2021-12-15 18:58:11 +08:00 committed by GitHub
commit 10449a8909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 616 additions and 44 deletions

View File

@ -37,6 +37,8 @@
-type bar_separated_list() :: list().
-type ip_port() :: tuple().
-type cipher() :: map().
-type rfc3339_system_time() :: integer().
-type unicode_binary() :: binary().
-typerefl_from_string({duration/0, emqx_schema, to_duration}).
-typerefl_from_string({duration_s/0, emqx_schema, to_duration_s}).
@ -49,6 +51,8 @@
-typerefl_from_string({ip_port/0, emqx_schema, to_ip_port}).
-typerefl_from_string({cipher/0, emqx_schema, to_erl_cipher_suite}).
-typerefl_from_string({comma_separated_atoms/0, emqx_schema, to_comma_separated_atoms}).
-typerefl_from_string({rfc3339_system_time/0, emqx_schema, rfc3339_to_system_time}).
-typerefl_from_string({unicode_binary/0, emqx_schema, to_unicode_binary}).
-export([ validate_heap_size/1
, parse_user_lookup_fun/1
@ -61,7 +65,9 @@
to_percent/1, to_comma_separated_list/1,
to_bar_separated_list/1, to_ip_port/1,
to_erl_cipher_suite/1,
to_comma_separated_atoms/1]).
to_comma_separated_atoms/1,
rfc3339_to_system_time/1,
to_unicode_binary/1]).
-behaviour(hocon_schema).
@ -69,7 +75,9 @@
bytesize/0, wordsize/0, percent/0, file/0,
comma_separated_list/0, bar_separated_list/0, ip_port/0,
cipher/0,
comma_separated_atoms/0]).
comma_separated_atoms/0,
rfc3339_system_time/0,
unicode_binary/0]).
-export([namespace/0, roots/0, roots/1, fields/1]).
-export([conf_get/2, conf_get/3, keys/2, filter/1]).
@ -118,7 +126,8 @@ EMQ X can be configured with:
<br>
<ul>
<li><code>[]</code>: The default value, it allows *ALL* logins</li>
<li>one: For example <code>{enable:true,backend:\"built-in-database\",mechanism=\"password-based\"}</code></li>
<li>one: For example <code>{enable:true,backend:\"built-in-database\",mechanism=\"password-based\"}
</code></li>
<li>chain: An array of structs.</li>
</ul>
<br>
@ -1374,6 +1383,16 @@ to_comma_separated_list(Str) ->
to_comma_separated_atoms(Str) ->
{ok, lists:map(fun to_atom/1, string:tokens(Str, ", "))}.
rfc3339_to_system_time(DateTime) ->
try
{ok, calendar:rfc3339_to_system_time(DateTime, [{unit, second}])}
catch error: _ ->
{error, bad_rfc3339_timestamp}
end.
to_unicode_binary(Str) ->
{ok, unicode:characters_to_binary(Str)}.
to_bar_separated_list(Str) ->
{ok, string:tokens(Str, "| ")}.
@ -1461,7 +1480,8 @@ authentication(Desc) ->
%% the type checks are done in emqx_auth application when it boots.
%% and in emqx_authentication_config module for rutime changes.
Default = hoconsc:lazy(hoconsc:union([typerefl:map(), hoconsc:array(typerefl:map())])),
%% as the type is lazy, the runtime module injection from EMQX_AUTHENTICATION_SCHEMA_MODULE_PT_KEY
%% as the type is lazy, the runtime module injection
%% from EMQX_AUTHENTICATION_SCHEMA_MODULE_PT_KEY
%% is for now only affecting document generation.
%% maybe in the future, we can find a more straightforward way to support
%% * document generation (at compile time)

View File

@ -23,7 +23,7 @@
, stop_listeners/0]).
%% Authorization
-export([authorize_appid/1]).
-export([authorize/1]).
-include_lib("emqx/include/logger.hrl").
@ -37,7 +37,7 @@
start_listeners() ->
{ok, _} = application:ensure_all_started(minirest),
Authorization = {?MODULE, authorize_appid},
Authorization = {?MODULE, authorize},
GlobalSpec = #{
openapi => "3.0.0",
info => #{title => "EMQ X API", version => "5.0.0"},
@ -45,10 +45,9 @@ start_listeners() ->
components => #{
schemas => #{},
'securitySchemes' => #{
application => #{
type => 'apiKey',
name => "authorization",
in => header}}}},
'basicAuth' => #{type => http, scheme => basic},
'bearerAuth' => #{type => http, scheme => bearer}
}}},
Dispatch =
case os:getenv("_EMQX_ENABLE_DASHBOARD") of
V when V =:= "true" orelse V =:= "1" ->
@ -63,7 +62,7 @@ start_listeners() ->
base_path => ?BASE_PATH,
modules => minirest_api:find_api_modules(apps()),
authorization => Authorization,
security => [#{application => []}],
security => [#{'basicAuth' => []}, #{'bearerAuth' => []}],
swagger_global_spec => GlobalSpec,
dispatch => Dispatch,
middlewares => [cowboy_router, ?EMQX_MIDDLE, cowboy_handler]
@ -130,36 +129,45 @@ listener_name(Protocol, Port) ->
Name = "dashboard:" ++ atom_to_list(Protocol) ++ ":" ++ integer_to_list(Port),
list_to_atom(Name).
authorize_appid(Req) ->
authorize(Req) ->
case cowboy_req:parse_header(<<"authorization">>, Req) of
{basic, Username, Password} ->
case emqx_dashboard_admin:check(Username, Password) of
ok ->
ok;
{error, <<"username_not_found">>} ->
Path = cowboy_req:path(Req),
case emqx_mgmt_auth:authorize(Path, Username, Password) of
ok ->
ok;
{error, <<"not_allowed">>} ->
return_unauthorized(
<<"WORNG_USERNAME_OR_PWD">>,
<<"Check username/password">>);
{error, _} ->
return_unauthorized(
<<"WORNG_USERNAME_OR_PWD_OR_API_KEY_OR_API_SECRET">>,
<<"Check username/password or api_key/api_secret">>)
end;
{error, _} ->
{401, #{<<"WWW-Authenticate">> =>
<<"Basic Realm=\"minirest-server\"">>},
#{code => <<"ERROR_USERNAME_OR_PWD">>,
message => <<"Check your username and password">>}}
return_unauthorized(<<"WORNG_USERNAME_OR_PWD">>, <<"Check username/password">>)
end;
{bearer, Token} ->
case emqx_dashboard_admin:verify_token(Token) of
ok ->
ok;
{error, token_timeout} ->
{401, #{<<"WWW-Authenticate">> =>
<<"Bearer Realm=\"minirest-server\"">>},
#{code => <<"TOKEN_TIME_OUT">>,
message => <<"POST '/login', get your new token">>}};
return_unauthorized(<<"TOKEN_TIME_OUT">>, <<"POST '/login', get new token">>);
{error, not_found} ->
{401, #{<<"WWW-Authenticate">> =>
<<"Bearer Realm=\"minirest-server\"">>},
#{code => <<"BAD_TOKEN">>,
message => <<"POST '/login'">>}}
return_unauthorized(<<"BAD_TOKEN">>, <<"POST '/login'">>)
end;
_ ->
{401, #{<<"WWW-Authenticate">> =>
<<"Basic Realm=\"minirest-server\"">>},
#{code => <<"ERROR_USERNAME_OR_PWD">>,
message => <<"Check your username and password">>}}
return_unauthorized(<<"AUTHORIZATION_HEADER_ERROR">>,
<<"Support authorization: basic/bearer ">>)
end.
return_unauthorized(Code, Message) ->
{401, #{<<"WWW-Authenticate">> =>
<<"Basic Realm=\"minirest-server\"">>},
#{code => Code, message => Message}
}.

View File

@ -41,6 +41,9 @@
, verify_token/1
, destroy_token_by_username/2
]).
-export([ hash/1
, verify_hash/2
]).
-export([add_default_user/0]).
@ -106,6 +109,23 @@ remove_user(Username) when is_binary(Username) ->
update_user(Username, Desc) when is_binary(Username) ->
return(mria:transaction(?DASHBOARD_SHARD, fun update_user_/2, [Username, Desc])).
hash(Password) ->
SaltBin = emqx_dashboard_token:salt(),
<<SaltBin/binary, (sha256(SaltBin, Password))/binary>>.
verify_hash(Origin, SaltHash) ->
case SaltHash of
<<Salt:4/binary, Hash/binary>> ->
case Hash =:= sha256(Salt, Origin) of
true -> ok;
false -> error
end;
_ -> error
end.
sha256(SaltBin, Password) ->
crypto:hash('sha256', <<SaltBin/binary, Password/binary>>).
%% @private
update_user_(Username, Desc) ->
case mnesia:wread({?ADMIN, Username}) of
@ -170,13 +190,13 @@ check(_, undefined) ->
{error, <<"password_not_provided">>};
check(Username, Password) ->
case lookup_user(Username) of
[#?ADMIN{pwdhash = <<Salt:4/binary, Hash/binary>>}] ->
case Hash =:= sha256(Salt, Password) of
true -> ok;
false -> {error, <<"BAD_USERNAME_OR_PASSWORD">>}
[#?ADMIN{pwdhash = PwdHash}] ->
case verify_hash(Password, PwdHash) of
ok -> ok;
error -> {error, <<"password_error">>}
end;
[] ->
{error, <<"BAD_USERNAME_OR_PASSWORD">>}
{error, <<"username_not_found">>}
end.
%%--------------------------------------------------------------------
@ -204,13 +224,6 @@ destroy_token_by_username(Username, Token) ->
%% Internal functions
%%--------------------------------------------------------------------
hash(Password) ->
SaltBin = emqx_dashboard_token:salt(),
<<SaltBin/binary, (sha256(SaltBin, Password))/binary>>.
sha256(SaltBin, Password) ->
crypto:hash('sha256', <<SaltBin/binary, Password/binary>>).
-spec(add_default_user() -> {ok, map() | empty | default_user_exists } | {error, any()}).
add_default_user() ->
add_default_user(binenv(default_username), binenv(default_password)).

View File

@ -123,7 +123,8 @@ schema("/users/:username") ->
#{in => path, example => <<"admin">>})}],
'requestBody' => [
{ description
, mk(binary(), #{desc => <<"User description">>, example => <<"administrator">>})}
, mk(emqx_schema:unicode_binary(),
#{desc => <<"User description">>, example => <<"administrator">>})}
],
responses => #{
200 => mk( ref(?MODULE, user)
@ -175,7 +176,7 @@ schema("/users/:username/change_pwd") ->
fields(user) ->
[
{description,
mk(binary(),
mk(emqx_schema:unicode_binary(),
#{desc => <<"User description">>, example => "administrator"})},
{username,
mk(binary(),

View File

@ -19,7 +19,7 @@
-define(METHODS, [get, post, put, head, delete, patch, options, trace]).
-define(DEFAULT_FIELDS, [example, allowReserved, style,
-define(DEFAULT_FIELDS, [example, allowReserved, style, format,
explode, maxLength, allowEmptyValue, deprecated, minimum, maximum]).
-define(INIT_SCHEMA, #{fields => #{}, translations => #{},
@ -65,7 +65,7 @@ spec(Module, Options) ->
lists:foldl(fun(Path, {AllAcc, AllRefsAcc}) ->
{OperationId, Specs, Refs} = parse_spec_ref(Module, Path),
CheckSchema = support_check_schema(Options),
{[{Path, Specs, OperationId, CheckSchema} | AllAcc],
{[{filename:join("/", Path), Specs, OperationId, CheckSchema} | AllAcc],
Refs ++ AllRefsAcc}
end, {[], []}, Paths),
{ApiSpec, components(lists:usort(AllRefs))}.
@ -408,6 +408,9 @@ typename_to_spec("non_neg_integer()", _Mod) -> #{type => integer, minimum => 1,
typename_to_spec("number()", _Mod) -> #{type => number, example => 42};
typename_to_spec("string()", _Mod) -> #{type => string, example => <<"string-example">>};
typename_to_spec("atom()", _Mod) -> #{type => string, example => atom};
typename_to_spec("rfc3339_system_time()", _Mod) -> #{type => string,
example => <<"2021-12-05T02:01:34.186Z">>, format => <<"date-time">>};
typename_to_spec("unicode_binary()", _Mod) -> #{type => string, example => <<"unicode-binary">>};
typename_to_spec("duration()", _Mod) -> #{type => string, example => <<"12m">>};
typename_to_spec("duration_s()", _Mod) -> #{type => string, example => <<"1h">>};
typename_to_spec("duration_ms()", _Mod) -> #{type => string, example => <<"32s">>};

View File

@ -0,0 +1,171 @@
%%--------------------------------------------------------------------
%% 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_mgmt_api_app).
-behaviour(minirest_api).
-include_lib("typerefl/include/types.hrl").
-export([api_spec/0, fields/1, paths/0, schema/1, namespace/0]).
-export([api_key/2, api_key_by_name/2]).
-export([validate_name/1]).
namespace() -> "api_key".
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}).
paths() ->
["/api_key", "/api_key/:name"].
schema("/api_key") ->
#{
'operationId' => api_key,
get => #{
description => "Return api_key list",
responses => #{
200 => delete([api_secret], fields(app))
}
},
post => #{
description => "Create new api_key",
'requestBody' => delete([created_at, api_key, api_secret], fields(app)),
responses => #{
200 => hoconsc:ref(app)
}
}
};
schema("/api_key/:name") ->
#{
'operationId' => api_key_by_name,
get => #{
description => "Return the specific api_key",
parameters => [hoconsc:ref(name)],
responses => #{
200 => delete([api_secret], fields(app))
}
},
put => #{
description => "Update the specific api_key",
parameters => [hoconsc:ref(name)],
'requestBody' => delete([created_at, api_key, api_secret, name], fields(app)),
responses => #{
200 => delete([api_secret], fields(app))
}
},
delete => #{
description => "Delete the specific api_key",
parameters => [hoconsc:ref(name)],
responses => #{
204 => <<"Delete successfully">>
}
}
}.
fields(app) ->
[
{name, hoconsc:mk(binary(),
#{desc => "Unique and format by [a-zA-Z0-9-_]",
validator => fun ?MODULE:validate_name/1,
example => <<"EMQX-API-KEY-1">>})},
{api_key, hoconsc:mk(binary(),
#{desc => """TODO:uses HMAC-SHA256 for signing.""",
example => <<"a4697a5c75a769f6">>})},
{api_secret, hoconsc:mk(binary(),
#{desc => """An API secret is a simple encrypted string that identifies"""
"""an application without any principal."""
"""They are useful for accessing public data anonymously,"""
"""and are used to associate API requests.""",
example => <<"MzAyMjk3ODMwMDk0NjIzOTUxNjcwNzQ0NzQ3MTE2NDYyMDI">>})},
{expired_at, hoconsc:mk(emqx_schema:rfc3339_system_time(),
#{desc => "No longer valid datetime",
example => <<"2021-12-05T02:01:34.186Z">>,
nullable => true
})},
{created_at, hoconsc:mk(emqx_schema:rfc3339_system_time(),
#{desc => "ApiKey create datetime",
example => <<"2021-12-01T00:00:00.000Z">>
})},
{desc, hoconsc:mk(emqx_schema:unicode_binary(),
#{example => <<"Note">>, nullable => true})},
{enable, hoconsc:mk(boolean(), #{desc => "Enable/Disable", nullable => true})}
];
fields(name) ->
[{name, hoconsc:mk(binary(),
#{
desc => <<"[a-zA-Z0-9-_]">>,
example => <<"EMQX-API-KEY-1">>,
in => path,
validator => fun ?MODULE:validate_name/1
})}
].
-define(NAME_RE, "^[A-Za-z]+[A-Za-z0-9-_]*$").
validate_name(Name) ->
NameLen = byte_size(Name),
case NameLen > 0 andalso NameLen =< 256 of
true ->
case re:run(Name, ?NAME_RE) of
nomatch -> {error, "Name should be " ?NAME_RE};
_ -> ok
end;
false -> {error, "Name Length must =< 256"}
end.
delete(Keys, Fields) ->
lists:foldl(fun(Key, Acc) -> lists:keydelete(Key, 1, Acc) end, Fields, Keys).
api_key(get, _) ->
{200, [format(App) || App <- emqx_mgmt_auth:list()]};
api_key(post, #{body := App}) ->
#{
<<"name">> := Name,
<<"desc">> := Desc0,
<<"expired_at">> := ExpiredAt,
<<"enable">> := Enable
} = App,
Desc = unicode:characters_to_binary(Desc0, unicode),
case emqx_mgmt_auth:create(Name, Enable, ExpiredAt, Desc) of
{ok, NewApp} -> {200, format(NewApp)};
{error, Reason} -> {400, Reason}
end.
api_key_by_name(get, #{bindings := #{name := Name}}) ->
case emqx_mgmt_auth:read(Name) of
{ok, App} -> {200, format(App)};
{error, not_found} -> {404, <<"NOT_FOUND">>}
end;
api_key_by_name(delete, #{bindings := #{name := Name}}) ->
case emqx_mgmt_auth:delete(Name) of
{ok, _} -> {204};
{error, not_found} -> {404, <<"NOT_FOUND">>}
end;
api_key_by_name(put, #{bindings := #{name := Name}, body := Body}) ->
Enable = maps:get(<<"enable">>, Body, undefined),
ExpiredAt = maps:get(<<"expired_at">>, Body, undefined),
Desc = maps:get(<<"desc">>, Body, undefined),
case emqx_mgmt_auth:update(Name, Enable, ExpiredAt, Desc) of
{ok, App} -> {200, format(App)};
{error, not_found} -> {404, <<"NOT_FOUND">>}
end.
format(App = #{expired_at := ExpiredAt, created_at := CreateAt}) ->
App#{
expired_at => list_to_binary(calendar:system_time_to_rfc3339(ExpiredAt)),
created_at => list_to_binary(calendar:system_time_to_rfc3339(CreateAt))
}.

View File

@ -0,0 +1,170 @@
%%--------------------------------------------------------------------
%% 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_mgmt_auth).
-include_lib("emqx/include/emqx.hrl").
%% API
-export([mnesia/1]).
-boot_mnesia({mnesia, [boot]}).
-export([ create/4
, read/1
, update/4
, delete/1
, list/0
]).
-export([ authorize/3 ]).
-define(APP, emqx_app).
-record(?APP, {
name = <<>> :: binary() | '_',
api_key = <<>> :: binary() | '_',
api_secret_hash = <<>> :: binary() | '_',
enable = true :: boolean() | '_',
desc = <<>> :: binary() | '_',
expired_at = 0 :: integer() | '_',
created_at = 0 :: integer() | '_'
}).
mnesia(boot) ->
ok = mria:create_table(?APP, [
{type, set},
{rlog_shard, ?COMMON_SHARD},
{storage, disc_copies},
{record_name, ?APP},
{attributes, record_info(fields, ?APP)}]).
create(Name, Enable, ExpiredAt, Desc) ->
case mnesia:table_info(?APP, size) < 30 of
true -> create_app(Name, Enable, ExpiredAt, Desc);
false -> {error, "Maximum ApiKey"}
end.
read(Name) ->
Fun = fun() ->
case mnesia:read(?APP, Name) of
[] -> mnesia:abort(not_found);
[App] -> to_map(App)
end
end,
trans(Fun).
update(Name, Enable, ExpiredAt, Desc) ->
Fun = fun() ->
case mnesia:read(?APP, Name, write) of
[] -> mnesia:abort(not_found);
[App0 = #?APP{enable = Enable0, expired_at = ExpiredAt0, desc = Desc0}] ->
App =
App0#?APP{
enable = ensure_not_undefined(Enable, Enable0),
expired_at = ensure_not_undefined(ExpiredAt, ExpiredAt0),
desc = ensure_not_undefined(Desc, Desc0)
},
ok = mnesia:write(App),
to_map(App)
end
end,
trans(Fun).
delete(Name) ->
Fun = fun() ->
case mnesia:read(?APP, Name) of
[] -> mnesia:abort(not_found);
[_App] -> mnesia:delete({?APP, Name}) end
end,
trans(Fun).
list() ->
to_map(ets:match_object(?APP, #?APP{_ = '_'})).
authorize(<<"/api/v5/users", _/binary>>, _ApiKey, _ApiSecret) -> {error, <<"not_allowed">>};
authorize(<<"/api/v5/api_key", _/binary>>, _ApiKey, _ApiSecret) -> {error, <<"not_allowed">>};
authorize(_Path, ApiKey, ApiSecret) ->
Now = erlang:system_time(second),
case find_by_api_key(ApiKey) of
{ok, true, ExpiredAt, SecretHash} when ExpiredAt >= Now ->
case emqx_dashboard_admin:verify_hash(ApiSecret, SecretHash) of
ok -> ok;
error -> {error, "secret_error"}
end;
{ok, true, _ExpiredAt, _SecretHash} -> {error, "secret_expired"};
{ok, false, _ExpiredAt, _SecretHash} -> {error, "secret_disable"};
{error, Reason} -> {error, Reason}
end.
find_by_api_key(ApiKey) ->
Fun = fun() -> mnesia:match_object(#?APP{api_key = ApiKey, _ = '_'}) end,
case trans(Fun) of
{ok, [#?APP{api_secret_hash = SecretHash, enable = Enable, expired_at = ExpiredAt}]} ->
{ok, Enable, ExpiredAt, SecretHash};
_ -> {error, "not_found"}
end.
ensure_not_undefined(undefined, Old) -> Old;
ensure_not_undefined(New, _Old) -> New.
to_map(Apps)when is_list(Apps) ->
Fields = record_info(fields, ?APP),
lists:map(fun(Trace0 = #?APP{}) ->
[_ | Values] = tuple_to_list(Trace0),
maps:remove(api_secret_hash, maps:from_list(lists:zip(Fields, Values)))
end, Apps);
to_map(App0) ->
[App] = to_map([App0]),
App.
create_app(Name, Enable, ExpiredAt, Desc) ->
ApiSecret = generate_api_secret(),
App =
#?APP{
name = Name,
enable = Enable,
expired_at = ExpiredAt,
desc = Desc,
created_at = erlang:system_time(second),
api_secret_hash = emqx_dashboard_admin:hash(ApiSecret),
api_key = list_to_binary(emqx_misc:gen_id(16))
},
case create_app(App) of
{error, api_key_already_existed} -> create_app(Name, Enable, ExpiredAt, Desc);
{ok, Res} -> {ok, Res#{api_secret => ApiSecret}};
Error -> Error
end.
create_app(App = #?APP{api_key = ApiKey, name = Name}) ->
trans(fun() ->
case mnesia:read(?APP, Name) of
[_] -> mnesia:abort(name_already_existed);
[] ->
case mnesia:match_object(?APP, #?APP{api_key = ApiKey, _ = '_'}, read) of
[] ->
ok = mnesia:write(App),
to_map(App);
_ -> mnesia:abort(api_key_already_existed)
end
end
end).
trans(Fun) ->
case mria:transaction(?COMMON_SHARD, Fun) of
{atomic, Res} -> {ok, Res};
{aborted, Error} -> {error, Error}
end.
generate_api_secret() ->
emqx_guid:to_base62(emqx_guid:gen()).

View File

@ -0,0 +1,186 @@
%%--------------------------------------------------------------------
%% 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_mgmt_auth_api_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
-include_lib("eunit/include/eunit.hrl").
all() -> [{group, parallel}, {group, sequence}].
suite() -> [{timetrap, {minutes, 1}}].
groups() -> [
{parallel, [parallel], [t_create, t_update, t_delete, t_authorize]},
{sequence, [], [t_create_failed]}
].
init_per_suite(Config) ->
emqx_mgmt_api_test_util:init_suite(),
Config.
end_per_suite(_) ->
emqx_mgmt_api_test_util:end_suite().
t_create(_Config) ->
Name = <<"EMQX-API-KEY-1">>,
{ok, Create} = create_app(Name),
?assertMatch(#{<<"api_key">> := _,
<<"api_secret">> := _,
<<"created_at">> := _,
<<"desc">> := _,
<<"enable">> := true,
<<"expired_at">> := _,
<<"name">> := Name}, Create),
{ok, List} = list_app(),
[App] = lists:filter(fun(#{<<"name">> := NameA}) -> NameA =:= Name end, List),
?assertEqual(false, maps:is_key(<<"api_secret">>, App)),
{ok, App1} = read_app(Name),
?assertEqual(Name, maps:get(<<"name">>, App1)),
?assertEqual(true, maps:get(<<"enable">>, App1)),
?assertEqual(false, maps:is_key(<<"api_secret">>, App1)),
?assertEqual({error, {"HTTP/1.1", 404, "Not Found"}}, read_app(<<"EMQX-API-KEY-NO-EXIST">>)),
ok.
t_create_failed(_Config) ->
BadRequest = {error, {"HTTP/1.1", 400, "Bad Request"}},
?assertEqual(BadRequest, create_app(<<" error format name">>)),
LongName = iolist_to_binary(lists:duplicate(257, "A")),
?assertEqual(BadRequest, create_app(<<" error format name">>)),
?assertEqual(BadRequest, create_app(LongName)),
{ok, List} = list_app(),
CreateNum = 30 - erlang:length(List),
Names = lists:map(fun(Seq) ->
<<"EMQX-API-FAILED-KEY-", (integer_to_binary(Seq))/binary>>
end, lists:seq(1, CreateNum)),
lists:foreach(fun(N) -> {ok, _} = create_app(N) end, Names),
?assertEqual(BadRequest, create_app(<<"EMQX-API-KEY-MAXIMUM">>)),
lists:foreach(fun(N) -> {ok, _} = delete_app(N) end, Names),
Name = <<"EMQX-API-FAILED-KEY-1">>,
?assertMatch({ok, _}, create_app(Name)),
?assertEqual(BadRequest, create_app(Name)),
{ok, _} = delete_app(Name),
?assertMatch({ok, #{<<"name">> := Name}}, create_app(Name)),
{ok, _} = delete_app(Name),
ok.
t_update(_Config) ->
Name = <<"EMQX-API-UPDATE-KEY">>,
{ok, _} = create_app(Name),
ExpiredAt = to_rfc3339(erlang:system_time(second) + 10000),
Change = #{
expired_at => ExpiredAt,
desc => <<"NoteVersion1"/utf8>>,
enable => false
},
{ok, Update1} = update_app(Name, Change),
?assertEqual(Name, maps:get(<<"name">>, Update1)),
?assertEqual(false, maps:get(<<"enable">>, Update1)),
?assertEqual(<<"NoteVersion1"/utf8>>, maps:get(<<"desc">>, Update1)),
?assertEqual(calendar:rfc3339_to_system_time(binary_to_list(ExpiredAt)),
calendar:rfc3339_to_system_time(binary_to_list(maps:get(<<"expired_at">>, Update1)))
),
?assertEqual({error, {"HTTP/1.1", 404, "Not Found"}}, update_app(<<"Not-Exist">>, Change)),
ok.
t_delete(_Config) ->
Name = <<"EMQX-API-DELETE-KEY">>,
{ok, _Create} = create_app(Name),
{ok, Delete} = delete_app(Name),
?assertEqual([], Delete),
?assertEqual({error, {"HTTP/1.1", 404, "Not Found"}}, delete_app(Name)),
ok.
t_authorize(_Config) ->
Name = <<"EMQX-API-AUTHORIZE-KEY">>,
{ok, #{<<"api_key">> := ApiKey, <<"api_secret">> := ApiSecret}} = create_app(Name),
BasicHeader = emqx_common_test_http:auth_header(binary_to_list(ApiKey),
binary_to_list(ApiSecret)),
SecretError = emqx_common_test_http:auth_header(binary_to_list(ApiKey),
binary_to_list(ApiKey)),
KeyError = emqx_common_test_http:auth_header("not_found_key", binary_to_list(ApiSecret)),
Unauthorized = {error, {"HTTP/1.1", 401, "Unauthorized"}},
BanPath = emqx_mgmt_api_test_util:api_path(["banned"]),
ApiKeyPath = emqx_mgmt_api_test_util:api_path(["api_key"]),
UserPath = emqx_mgmt_api_test_util:api_path(["users"]),
{ok, _Status} = emqx_mgmt_api_test_util:request_api(get, BanPath, BasicHeader),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, BanPath, KeyError)),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, BanPath, SecretError)),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, ApiKeyPath, BasicHeader)),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, UserPath, BasicHeader)),
?assertMatch({ok, #{<<"api_key">> := _, <<"enable">> := false}},
update_app(Name, #{enable => false})),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, BanPath, BasicHeader)),
Expired = #{
expired_at => to_rfc3339(erlang:system_time(second) - 1),
enable => true
},
?assertMatch({ok, #{<<"api_key">> := _, <<"enable">> := true}}, update_app(Name, Expired)),
?assertEqual(Unauthorized, emqx_mgmt_api_test_util:request_api(get, BanPath, BasicHeader)),
ok.
list_app() ->
Path = emqx_mgmt_api_test_util:api_path(["api_key"]),
case emqx_mgmt_api_test_util:request_api(get, Path) of
{ok, Apps} -> {ok, emqx_json:decode(Apps, [return_maps])};
Error -> Error
end.
read_app(Name) ->
Path = emqx_mgmt_api_test_util:api_path(["api_key", Name]),
case emqx_mgmt_api_test_util:request_api(get, Path) of
{ok, Res} -> {ok, emqx_json:decode(Res, [return_maps])};
Error -> Error
end.
create_app(Name) ->
AuthHeader = emqx_mgmt_api_test_util:auth_header_(),
Path = emqx_mgmt_api_test_util:api_path(["api_key"]),
ExpiredAt = to_rfc3339(erlang:system_time(second) + 1000),
App = #{
name => Name,
expired_at => ExpiredAt,
desc => <<"Note"/utf8>>,
enable => true
},
case emqx_mgmt_api_test_util:request_api(post, Path, "", AuthHeader, App) of
{ok, Res} -> {ok, emqx_json:decode(Res, [return_maps])};
Error -> Error
end.
delete_app(Name) ->
DeletePath = emqx_mgmt_api_test_util:api_path(["api_key", Name]),
emqx_mgmt_api_test_util:request_api(delete, DeletePath).
update_app(Name, Change) ->
AuthHeader = emqx_mgmt_api_test_util:auth_header_(),
UpdatePath = emqx_mgmt_api_test_util:api_path(["api_key", Name]),
case emqx_mgmt_api_test_util:request_api(put, UpdatePath, "", AuthHeader, Change) of
{ok, Update} -> {ok, emqx_json:decode(Update, [return_maps])};
Error -> Error
end.
to_rfc3339(Sec) ->
list_to_binary(calendar:system_time_to_rfc3339(Sec)).