Просмотр исходного кода

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

feat(api-key): support api_key/api_secret authorize
zhongwencool 4 лет назад
Родитель
Сommit
10449a8909

+ 24 - 4
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:
 <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)

+ 32 - 24
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}
+    }.

+ 25 - 12
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(),
+    <<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)).

+ 3 - 2
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(),

+ 5 - 2
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">>};

+ 171 - 0
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))
+    }.

+ 170 - 0
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()).

+ 186 - 0
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)).