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:

@@ -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)).