%%-------------------------------------------------------------------- %% 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) -> <> = 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) -> <>. 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.