482 lines
15 KiB
Erlang
482 lines
15 KiB
Erlang
%%--------------------------------------------------------------------
|
|
%% Copyright (c) 2020-2023 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_mgmt.hrl").
|
|
-include_lib("emqx/include/emqx.hrl").
|
|
-include_lib("emqx/include/logger.hrl").
|
|
-include_lib("emqx_dashboard/include/emqx_dashboard_rbac.hrl").
|
|
|
|
-behaviour(emqx_db_backup).
|
|
|
|
%% API
|
|
-export([mnesia/1]).
|
|
-boot_mnesia({mnesia, [boot]}).
|
|
-behaviour(emqx_config_handler).
|
|
|
|
-export([
|
|
create/5,
|
|
read/1,
|
|
update/5,
|
|
delete/1,
|
|
list/0,
|
|
init_bootstrap_file/0,
|
|
format/1
|
|
]).
|
|
|
|
-export([authorize/4]).
|
|
-export([post_config_update/5]).
|
|
|
|
-export([backup_tables/0, validate_mnesia_backup/1]).
|
|
|
|
%% Internal exports (RPC)
|
|
-export([
|
|
do_update/5,
|
|
do_delete/1,
|
|
do_create_app/1,
|
|
do_force_create_app/1
|
|
]).
|
|
|
|
-ifdef(TEST).
|
|
-export([create/7]).
|
|
-export([trans/2, force_create_app/1]).
|
|
-endif.
|
|
|
|
-define(APP, emqx_app).
|
|
|
|
-record(?APP, {
|
|
name = <<>> :: binary() | '_',
|
|
api_key = <<>> :: binary() | '_',
|
|
api_secret_hash = <<>> :: binary() | '_',
|
|
enable = true :: boolean() | '_',
|
|
%% Since v5.4.0 the `desc` has changed to `extra`
|
|
%% desc = <<>> :: binary() | '_',
|
|
extra = #{} :: binary() | map() | '_',
|
|
expired_at = 0 :: integer() | undefined | infinity | '_',
|
|
created_at = 0 :: integer() | '_'
|
|
}).
|
|
|
|
-define(DEFAULT_HASH_LEN, 16).
|
|
|
|
mnesia(boot) ->
|
|
Fields = record_info(fields, ?APP),
|
|
ok = mria:create_table(?APP, [
|
|
{type, set},
|
|
{rlog_shard, ?COMMON_SHARD},
|
|
{storage, disc_copies},
|
|
{record_name, ?APP},
|
|
{attributes, Fields}
|
|
]).
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% Data backup
|
|
%%--------------------------------------------------------------------
|
|
|
|
backup_tables() -> [?APP].
|
|
|
|
validate_mnesia_backup({schema, _Tab, CreateList} = Schema) ->
|
|
case emqx_mgmt_data_backup:default_validate_mnesia_backup(Schema) of
|
|
ok ->
|
|
ok;
|
|
_ ->
|
|
case proplists:get_value(attributes, CreateList) of
|
|
%% Since v5.4.0 the `desc` has changed to `extra`
|
|
[name, api_key, api_secret_hash, enable, desc, expired_at, created_at] ->
|
|
ok;
|
|
Fields ->
|
|
{error, {unknow_fields, Fields}}
|
|
end
|
|
end;
|
|
validate_mnesia_backup(_Other) ->
|
|
ok.
|
|
|
|
post_config_update([api_key], _Req, NewConf, _OldConf, _AppEnvs) ->
|
|
#{bootstrap_file := File} = NewConf,
|
|
case init_bootstrap_file(File) of
|
|
ok ->
|
|
?SLOG(debug, #{msg => "init_bootstrap_api_keys_from_file_ok", file => File});
|
|
{error, Reason} ->
|
|
Msg = "init_bootstrap_api_keys_from_file_failed",
|
|
?SLOG(error, #{msg => Msg, reason => Reason, file => File})
|
|
end,
|
|
ok.
|
|
|
|
-spec init_bootstrap_file() -> ok | {error, _}.
|
|
init_bootstrap_file() ->
|
|
File = bootstrap_file(),
|
|
?SLOG(debug, #{msg => "init_bootstrap_api_keys_from_file", file => File}),
|
|
init_bootstrap_file(File).
|
|
|
|
create(Name, Enable, ExpiredAt, Desc, Role) ->
|
|
ApiKey = generate_unique_api_key(Name),
|
|
ApiSecret = generate_api_secret(),
|
|
create(Name, ApiKey, ApiSecret, Enable, ExpiredAt, Desc, Role).
|
|
|
|
create(Name, ApiKey, ApiSecret, Enable, ExpiredAt, Desc, Role) ->
|
|
case mnesia:table_info(?APP, size) < 100 of
|
|
true -> create_app(Name, ApiKey, ApiSecret, Enable, ExpiredAt, Desc, Role);
|
|
false -> {error, "Maximum number of ApiKeys reached."}
|
|
end.
|
|
|
|
read(Name) ->
|
|
case mnesia:dirty_read(?APP, Name) of
|
|
[App] -> {ok, to_map(App)};
|
|
[] -> {error, not_found}
|
|
end.
|
|
|
|
update(Name, Enable, ExpiredAt, Desc, Role) ->
|
|
case valid_role(Role) of
|
|
ok ->
|
|
trans(fun ?MODULE:do_update/5, [Name, Enable, ExpiredAt, Desc, Role]);
|
|
Error ->
|
|
Error
|
|
end.
|
|
|
|
do_update(Name, Enable, ExpiredAt, Desc, Role) ->
|
|
case mnesia:read(?APP, Name, write) of
|
|
[] ->
|
|
mnesia:abort(not_found);
|
|
[App0 = #?APP{enable = Enable0, extra = Extra0}] ->
|
|
#{desc := Desc0} = Extra = normalize_extra(Extra0),
|
|
App =
|
|
App0#?APP{
|
|
expired_at = ExpiredAt,
|
|
enable = ensure_not_undefined(Enable, Enable0),
|
|
extra = Extra#{desc := ensure_not_undefined(Desc, Desc0), role := Role}
|
|
},
|
|
ok = mnesia:write(App),
|
|
to_map(App)
|
|
end.
|
|
|
|
delete(Name) ->
|
|
trans(fun ?MODULE:do_delete/1, [Name]).
|
|
|
|
do_delete(Name) ->
|
|
case mnesia:read(?APP, Name) of
|
|
[] -> mnesia:abort(not_found);
|
|
[_App] -> mnesia:delete({?APP, Name})
|
|
end.
|
|
|
|
format(App = #{expired_at := ExpiredAt, created_at := CreateAt}) ->
|
|
format_app_extend(App#{
|
|
expired_at => format_epoch(ExpiredAt),
|
|
created_at => format_epoch(CreateAt)
|
|
}).
|
|
|
|
format_epoch(infinity) ->
|
|
<<"infinity">>;
|
|
format_epoch(Epoch) ->
|
|
emqx_utils_calendar:epoch_to_rfc3339(Epoch, second).
|
|
|
|
list() ->
|
|
to_map(ets:match_object(?APP, #?APP{_ = '_'})).
|
|
|
|
authorize(<<"/api/v5/users", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
|
|
{error, <<"not_allowed">>};
|
|
authorize(<<"/api/v5/api_key", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
|
|
{error, <<"not_allowed">>};
|
|
authorize(<<"/api/v5/logout", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
|
|
{error, <<"not_allowed">>};
|
|
authorize(_Path, Req, ApiKey, ApiSecret) ->
|
|
Now = erlang:system_time(second),
|
|
case find_by_api_key(ApiKey) of
|
|
{ok, true, ExpiredAt, SecretHash, Role} when ExpiredAt >= Now ->
|
|
case emqx_dashboard_admin:verify_hash(ApiSecret, SecretHash) of
|
|
ok -> check_rbac(Req, ApiKey, Role);
|
|
error -> {error, "secret_error"}
|
|
end;
|
|
{ok, true, _ExpiredAt, _SecretHash, _Role} ->
|
|
{error, "secret_expired"};
|
|
{ok, false, _ExpiredAt, _SecretHash, _Role} ->
|
|
{error, "secret_disable"};
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
find_by_api_key(ApiKey) ->
|
|
Fun = fun() -> mnesia:match_object(#?APP{api_key = ApiKey, _ = '_'}) end,
|
|
case mria:ro_transaction(?COMMON_SHARD, Fun) of
|
|
{atomic, [
|
|
#?APP{
|
|
api_secret_hash = SecretHash, enable = Enable, expired_at = ExpiredAt, extra = Extra
|
|
}
|
|
]} ->
|
|
{ok, Enable, ExpiredAt, SecretHash, get_role(Extra)};
|
|
_ ->
|
|
{error, "not_found"}
|
|
end.
|
|
|
|
ensure_not_undefined(undefined, Old) -> Old;
|
|
ensure_not_undefined(New, _Old) -> New.
|
|
|
|
to_map(Apps) when is_list(Apps) ->
|
|
[to_map(App) || App <- Apps];
|
|
to_map(#?APP{
|
|
name = N, api_key = K, enable = E, expired_at = ET, created_at = CT, extra = Extra0
|
|
}) ->
|
|
#{role := Role, desc := Desc} = normalize_extra(Extra0),
|
|
#{
|
|
name => N,
|
|
api_key => K,
|
|
enable => E,
|
|
expired_at => ET,
|
|
created_at => CT,
|
|
desc => Desc,
|
|
expired => is_expired(ET),
|
|
role => Role
|
|
}.
|
|
|
|
is_expired(undefined) -> false;
|
|
is_expired(ExpiredTime) -> ExpiredTime < erlang:system_time(second).
|
|
|
|
create_app(Name, ApiKey, ApiSecret, Enable, ExpiredAt, Desc, Role) ->
|
|
App =
|
|
#?APP{
|
|
name = Name,
|
|
enable = Enable,
|
|
expired_at = ExpiredAt,
|
|
extra = #{desc => Desc, role => Role},
|
|
created_at = erlang:system_time(second),
|
|
api_secret_hash = emqx_dashboard_admin:hash(ApiSecret),
|
|
api_key = ApiKey
|
|
},
|
|
case create_app(App) of
|
|
{ok, Res} ->
|
|
{ok, Res#{api_secret => ApiSecret}};
|
|
Error ->
|
|
Error
|
|
end.
|
|
|
|
create_app(App = #?APP{extra = #{role := Role}}) ->
|
|
case valid_role(Role) of
|
|
ok ->
|
|
trans(fun ?MODULE:do_create_app/1, [App]);
|
|
Error ->
|
|
Error
|
|
end.
|
|
|
|
force_create_app(App) ->
|
|
trans(fun ?MODULE:do_force_create_app/1, [App]).
|
|
|
|
do_create_app(App = #?APP{api_key = ApiKey, name = Name}) ->
|
|
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.
|
|
|
|
do_force_create_app(App) ->
|
|
_ = maybe_cleanup_api_key(App),
|
|
ok = mnesia:write(App).
|
|
|
|
maybe_cleanup_api_key(#?APP{name = Name, api_key = ApiKey}) ->
|
|
case mnesia:match_object(?APP, #?APP{api_key = ApiKey, _ = '_'}, read) of
|
|
[] ->
|
|
ok;
|
|
[#?APP{name = Name}] ->
|
|
?SLOG(debug, #{
|
|
msg => "same_apikey_detected",
|
|
info => <<"The last `KEY:SECRET` in bootstrap file will be used.">>
|
|
}),
|
|
ok;
|
|
[_App1] ->
|
|
?SLOG(info, #{
|
|
msg => "update_apikey_name_from_old_version",
|
|
info =>
|
|
<<"Update ApiKey name with new name rule, see also: ",
|
|
"https://github.com/emqx/emqx/pull/11798">>
|
|
}),
|
|
ok;
|
|
Existed ->
|
|
%% Duplicated or upgraded from old version:
|
|
%% Which `Name` and `ApiKey` are not related in old version.
|
|
%% So delete it/(them) and write a new record with a name strongly related to the apikey.
|
|
%% The apikeys generated from the file do not have names.
|
|
%% Generate a name for the apikey from the apikey itself by rule:
|
|
%% Use `from_bootstrap_file_` as the prefix, and the first 16 digits of the
|
|
%% sha512 hexadecimal value of the `ApiKey` as the suffix to form the name of the apikey.
|
|
%% e.g. The name of the apikey: `example-api-key:secret_xxxx` is `from_bootstrap_file_53280fb165b6cd37`
|
|
?SLOG(info, #{
|
|
msg => "duplicated_apikey_detected",
|
|
info => <<"Delete duplicated apikeys and write a new one from bootstrap file">>
|
|
}),
|
|
_ = lists:map(
|
|
fun(#?APP{name = N}) -> ok = mnesia:delete({?APP, N}) end, Existed
|
|
),
|
|
ok
|
|
end.
|
|
|
|
hash_string_from_seed(Seed, PrefixLen) ->
|
|
<<Integer:512>> = crypto:hash(sha512, Seed),
|
|
list_to_binary(string:slice(io_lib:format("~128.16.0b", [Integer]), 0, PrefixLen)).
|
|
|
|
%% Form Dashboard API Key pannel, only `Name` provided for users
|
|
generate_unique_api_key(Name) ->
|
|
hash_string_from_seed(Name, ?DEFAULT_HASH_LEN).
|
|
|
|
%% Form BootStrap File, only `ApiKey` provided from file, no `Name`
|
|
generate_unique_name(NamePrefix, ApiKey) ->
|
|
<<NamePrefix/binary, (hash_string_from_seed(ApiKey, ?DEFAULT_HASH_LEN))/binary>>.
|
|
|
|
trans(Fun, Args) ->
|
|
case mria:sync_transaction(?COMMON_SHARD, Fun, Args) of
|
|
{atomic, Res} -> {ok, Res};
|
|
{aborted, Error} -> {error, Error}
|
|
end.
|
|
|
|
generate_api_secret() ->
|
|
Random = crypto:strong_rand_bytes(32),
|
|
emqx_base62:encode(Random).
|
|
|
|
bootstrap_file() ->
|
|
emqx:get_config([api_key, bootstrap_file], <<>>).
|
|
|
|
init_bootstrap_file(<<>>) ->
|
|
ok;
|
|
init_bootstrap_file(File) ->
|
|
case file:open(File, [read, binary]) of
|
|
{ok, Dev} ->
|
|
{ok, MP} = re:compile(<<"(\.+):(\.+)(?::(\.+))?$">>, [ungreedy]),
|
|
init_bootstrap_file(File, Dev, MP);
|
|
{error, Reason0} ->
|
|
Reason = emqx_utils:explain_posix(Reason0),
|
|
?SLOG(
|
|
error,
|
|
#{
|
|
msg => "failed_to_open_the_bootstrap_file",
|
|
file => File,
|
|
reason => Reason
|
|
}
|
|
),
|
|
{error, Reason}
|
|
end.
|
|
|
|
init_bootstrap_file(File, Dev, MP) ->
|
|
try
|
|
add_bootstrap_file(File, Dev, MP, 1)
|
|
catch
|
|
throw:Error -> {error, Error};
|
|
Type:Reason:Stacktrace -> {error, {Type, Reason, Stacktrace}}
|
|
after
|
|
file:close(Dev)
|
|
end.
|
|
|
|
-define(BOOTSTRAP_TAG, <<"Bootstrapped From File">>).
|
|
-define(FROM_BOOTSTRAP_FILE_PREFIX, <<"from_bootstrap_file_">>).
|
|
|
|
add_bootstrap_file(File, Dev, MP, Line) ->
|
|
case file:read_line(Dev) of
|
|
{ok, Bin} ->
|
|
case parse_bootstrap_line(Bin, MP) of
|
|
{ok, [ApiKey, ApiSecret, Role]} ->
|
|
App =
|
|
#?APP{
|
|
name = generate_unique_name(?FROM_BOOTSTRAP_FILE_PREFIX, ApiKey),
|
|
api_key = ApiKey,
|
|
api_secret_hash = emqx_dashboard_admin:hash(ApiSecret),
|
|
enable = true,
|
|
extra = #{desc => ?BOOTSTRAP_TAG, role => Role},
|
|
created_at = erlang:system_time(second),
|
|
expired_at = infinity
|
|
},
|
|
case force_create_app(App) of
|
|
{ok, ok} ->
|
|
add_bootstrap_file(File, Dev, MP, Line + 1);
|
|
{error, Reason} ->
|
|
throw(#{file => File, line => Line, content => Bin, reason => Reason})
|
|
end;
|
|
{error, Reason} ->
|
|
?SLOG(
|
|
error,
|
|
#{
|
|
msg => "failed_to_load_bootstrap_file",
|
|
file => File,
|
|
line => Line,
|
|
content => Bin,
|
|
reason => Reason
|
|
}
|
|
),
|
|
throw(#{file => File, line => Line, content => Bin, reason => Reason})
|
|
end;
|
|
eof ->
|
|
ok;
|
|
{error, Reason} ->
|
|
throw(#{file => File, line => Line, reason => Reason})
|
|
end.
|
|
|
|
parse_bootstrap_line(Bin, MP) ->
|
|
case re:run(Bin, MP, [global, {capture, all_but_first, binary}]) of
|
|
{match, [[_ApiKey, _ApiSecret] = Args]} ->
|
|
{ok, Args ++ [?ROLE_API_DEFAULT]};
|
|
{match, [[_ApiKey, _ApiSecret, Role] = Args]} ->
|
|
case valid_role(Role) of
|
|
ok ->
|
|
{ok, Args};
|
|
_Error ->
|
|
{error, {"invalid_role", Role}}
|
|
end;
|
|
_ ->
|
|
{error, "invalid_format"}
|
|
end.
|
|
|
|
get_role(#{role := Role}) ->
|
|
Role;
|
|
%% Before v5.4.0,
|
|
%% the field in the position of the `extra` is `desc` which is a binary for description
|
|
get_role(_Desc) ->
|
|
?ROLE_API_DEFAULT.
|
|
|
|
normalize_extra(Map) when is_map(Map) ->
|
|
Map;
|
|
normalize_extra(Desc) ->
|
|
#{desc => Desc, role => ?ROLE_API_DEFAULT}.
|
|
|
|
-if(?EMQX_RELEASE_EDITION == ee).
|
|
check_rbac(Req, ApiKey, Role) ->
|
|
case emqx_dashboard_rbac:check_rbac(Req, ApiKey, Role) of
|
|
true ->
|
|
ok;
|
|
_ ->
|
|
{error, unauthorized_role}
|
|
end.
|
|
|
|
format_app_extend(App) ->
|
|
App.
|
|
|
|
valid_role(Role) ->
|
|
emqx_dashboard_rbac:valid_api_role(Role).
|
|
|
|
-else.
|
|
|
|
check_rbac(_Req, _ApiKey, _Role) ->
|
|
ok.
|
|
|
|
format_app_extend(App) ->
|
|
maps:remove(role, App).
|
|
|
|
valid_role(?ROLE_API_DEFAULT) ->
|
|
ok;
|
|
valid_role(_) ->
|
|
{error, <<"Role does not exist">>}.
|
|
|
|
-endif.
|