Merge pull request #6415 from zhongwencool/api-key-secret
feat(api-key): support api_key/api_secret authorize
This commit is contained in:
commit
10449a8909
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
}.
|
||||
|
|
|
@ -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)).
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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">>};
|
||||
|
|
|
@ -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))
|
||||
}.
|
|
@ -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()).
|
|
@ -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)).
|
Loading…
Reference in New Issue