diff --git a/apps/emqx/src/emqx_schema.erl b/apps/emqx/src/emqx_schema.erl
index 8b863d062..85b411217 100644
--- a/apps/emqx/src/emqx_schema.erl
+++ b/apps/emqx/src/emqx_schema.erl
@@ -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:
[]
: The default value, it allows *ALL* logins
-- one: For example
{enable:true,backend:\"built-in-database\",mechanism=\"password-based\"}
+- one: For example
{enable:true,backend:\"built-in-database\",mechanism=\"password-based\"}
+
- chain: An array of structs.
@@ -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)
diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl
index 0dbcb8f1e..953e3e52b 100644
--- a/apps/emqx_dashboard/src/emqx_dashboard.erl
+++ b/apps/emqx_dashboard/src/emqx_dashboard.erl
@@ -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}
+ }.
diff --git a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl
index d95f0276c..2664f2936 100644
--- a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl
+++ b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl
@@ -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(),
+ <>.
+
+verify_hash(Origin, SaltHash) ->
+ case SaltHash of
+ <> ->
+ case Hash =:= sha256(Salt, Origin) of
+ true -> ok;
+ false -> error
+ end;
+ _ -> error
+ end.
+
+sha256(SaltBin, Password) ->
+ crypto:hash('sha256', <>).
+
%% @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 = <>}] ->
- 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(),
- <>.
-
-sha256(SaltBin, Password) ->
- crypto:hash('sha256', <>).
-
-spec(add_default_user() -> {ok, map() | empty | default_user_exists } | {error, any()}).
add_default_user() ->
add_default_user(binenv(default_username), binenv(default_password)).
diff --git a/apps/emqx_dashboard/src/emqx_dashboard_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_api.erl
index c40c4bd22..45a3b7c56 100644
--- a/apps/emqx_dashboard/src/emqx_dashboard_api.erl
+++ b/apps/emqx_dashboard/src/emqx_dashboard_api.erl
@@ -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(),
diff --git a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl
index 5b9c9d588..2dcdba643 100644
--- a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl
+++ b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl
@@ -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">>};
diff --git a/apps/emqx_management/src/emqx_mgmt_api_app.erl b/apps/emqx_management/src/emqx_mgmt_api_app.erl
new file mode 100644
index 000000000..b77f1a214
--- /dev/null
+++ b/apps/emqx_management/src/emqx_mgmt_api_app.erl
@@ -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))
+ }.
diff --git a/apps/emqx_management/src/emqx_mgmt_auth.erl b/apps/emqx_management/src/emqx_mgmt_auth.erl
new file mode 100644
index 000000000..ae6b0820d
--- /dev/null
+++ b/apps/emqx_management/src/emqx_mgmt_auth.erl
@@ -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()).
diff --git a/apps/emqx_management/test/emqx_mgmt_auth_api_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_auth_api_SUITE.erl
new file mode 100644
index 000000000..185ad5343
--- /dev/null
+++ b/apps/emqx_management/test/emqx_mgmt_auth_api_SUITE.erl
@@ -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)).