feat: dashboard api support jwt
This commit is contained in:
parent
8597591e80
commit
93dbdaa84a
|
@ -7,6 +7,8 @@ emqx_dashboard:{
|
|||
default_password: "public"
|
||||
## notice: sample_interval should be divisible by 60.
|
||||
sample_interval: 10s
|
||||
## api jwt timeout. default is 30 minute
|
||||
jwt_exptime: 30m
|
||||
listeners: [
|
||||
{
|
||||
num_acceptors: 4
|
||||
|
|
|
@ -14,7 +14,18 @@
|
|||
%% limitations under the License.
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-record(mqtt_admin, {username, password, tags, role = undefined}).
|
||||
-record(mqtt_admin, {
|
||||
username :: binary(),
|
||||
password :: binary(),
|
||||
tags :: list() | binary(),
|
||||
role = undefined :: atom()
|
||||
}).
|
||||
|
||||
-record(mqtt_admin_jwt, {
|
||||
token :: binary(),
|
||||
username :: binary(),
|
||||
exptime :: integer()
|
||||
}).
|
||||
|
||||
-type(mqtt_admin() :: #mqtt_admin{}).
|
||||
|
||||
|
|
|
@ -104,20 +104,27 @@ listener_name(Proto) ->
|
|||
|
||||
authorize_appid(Req) ->
|
||||
case cowboy_req:parse_header(<<"authorization">>, Req) of
|
||||
{basic, Username, Password} ->
|
||||
case emqx_dashboard_admin:check(iolist_to_binary(Username),
|
||||
iolist_to_binary(Password)) of
|
||||
{bearer, Token} ->
|
||||
case emqx_dashboard_admin:jwt_verify(Token) of
|
||||
ok ->
|
||||
ok;
|
||||
{error, _} ->
|
||||
{error, token_timeout} ->
|
||||
{401, #{<<"WWW-Authenticate">> =>
|
||||
<<"Basic Realm=\"minirest-server\"">>},
|
||||
<<"UNAUTHORIZED">>}
|
||||
<<"Bearer Realm=\"minirest-server\"">>},
|
||||
#{code => <<"TOKEN_TIME_OUT">>,
|
||||
message => <<"POST '/login', get your new token">>}
|
||||
};
|
||||
{error, not_found} ->
|
||||
{401, #{<<"WWW-Authenticate">> =>
|
||||
<<"Bearer Realm=\"minirest-server\"">>},
|
||||
#{code => <<"BAD_TOKEN">>,
|
||||
message => <<"POST '/login'">>}}
|
||||
end;
|
||||
_ ->
|
||||
{401, #{<<"WWW-Authenticate">> =>
|
||||
<<"Basic Realm=\"minirest-server\"">>},
|
||||
<<"UNAUTHORIZED">>}
|
||||
<<"Bearer Realm=\"minirest-server\"">>},
|
||||
#{code => <<"UNAUTHORIZED">>,
|
||||
message => <<"POST '/login'">>}}
|
||||
end.
|
||||
|
||||
format(Port) when is_integer(Port) ->
|
||||
|
|
|
@ -18,8 +18,6 @@
|
|||
|
||||
-module(emqx_dashboard_admin).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("emqx_dashboard.hrl").
|
||||
|
||||
-rlog_shard({?DASHBOARD_SHARD, mqtt_admin}).
|
||||
|
@ -30,9 +28,6 @@
|
|||
%% Mnesia bootstrap
|
||||
-export([mnesia/1]).
|
||||
|
||||
%% API Function Exports
|
||||
-export([start_link/0]).
|
||||
|
||||
%% mqtt_admin api
|
||||
-export([ add_user/3
|
||||
, force_add_user/3
|
||||
|
@ -45,15 +40,13 @@
|
|||
, check/2
|
||||
]).
|
||||
|
||||
%% gen_server Function Exports
|
||||
-export([ init/1
|
||||
, handle_call/3
|
||||
, handle_cast/2
|
||||
, handle_info/2
|
||||
, terminate/2
|
||||
, code_change/3
|
||||
-export([ jwt_sign/2
|
||||
, jwt_verify/1
|
||||
, jwt_destroy_by_username/1
|
||||
]).
|
||||
|
||||
-export([add_default_user/0]).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Mnesia bootstrap
|
||||
%%--------------------------------------------------------------------
|
||||
|
@ -73,10 +66,6 @@ mnesia(copy) ->
|
|||
%% API
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec(start_link() -> {ok, pid()} | ignore | {error, any()}).
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
-spec(add_user(binary(), binary(), binary()) -> ok | {error, any()}).
|
||||
add_user(Username, Password, Tags) when is_binary(Username), is_binary(Password) ->
|
||||
Admin = #mqtt_admin{username = Username, password = hash(Password), tags = Tags},
|
||||
|
@ -170,35 +159,27 @@ check(Username, Password) ->
|
|||
[#mqtt_admin{password = <<Salt:4/binary, Hash/binary>>}] ->
|
||||
case Hash =:= md5_hash(Salt, Password) of
|
||||
true -> ok;
|
||||
false -> {error, <<"Password Error">>}
|
||||
false -> {error, <<"PASSWORD_ERROR">>}
|
||||
end;
|
||||
[] ->
|
||||
{error, <<"Username Not Found">>}
|
||||
{error, <<"USERNAME_ERROR">>}
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% gen_server callbacks
|
||||
%%--------------------------------------------------------------------
|
||||
%% jwt
|
||||
jwt_sign(Username, Password) ->
|
||||
case check(Username, Password) of
|
||||
ok ->
|
||||
emqx_dashboard_jwt:sign(Username, Password);
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
init([]) ->
|
||||
%% Add default admin user
|
||||
_ = add_default_user(binenv(default_username), binenv(default_password)),
|
||||
{ok, state}.
|
||||
jwt_verify(Token) ->
|
||||
emqx_dashboard_jwt:verify(Token).
|
||||
|
||||
handle_call(_Req, _From, State) ->
|
||||
{reply, error, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
jwt_destroy_by_username(Username) ->
|
||||
emqx_dashboard_jwt:destroy_by_username(Username).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Internal functions
|
||||
|
@ -216,6 +197,9 @@ salt() ->
|
|||
Salt = rand:uniform(16#ffffffff),
|
||||
<<Salt:32>>.
|
||||
|
||||
add_default_user() ->
|
||||
add_default_user(binenv(default_username), binenv(default_password)).
|
||||
|
||||
binenv(Key) ->
|
||||
iolist_to_binary(emqx_config:get([emqx_dashboard, Key], "")).
|
||||
|
||||
|
|
|
@ -16,6 +16,16 @@
|
|||
|
||||
-module(emqx_dashboard_api).
|
||||
|
||||
-ifndef(EMQX_ENTERPRISE).
|
||||
|
||||
-define(RELEASE, community).
|
||||
|
||||
-else.
|
||||
|
||||
-define(VERSION, enterprise).
|
||||
|
||||
-endif.
|
||||
|
||||
-behaviour(minirest_api).
|
||||
|
||||
-include("emqx_dashboard.hrl").
|
||||
|
@ -28,17 +38,27 @@
|
|||
|
||||
-export([api_spec/0]).
|
||||
|
||||
-export([ auth/2
|
||||
-export([ login/2
|
||||
, logout/2
|
||||
, users/2
|
||||
, user/2
|
||||
, change_pwd/2
|
||||
]).
|
||||
|
||||
api_spec() ->
|
||||
{[auth_api(), users_api(), user_api(), change_pwd_api()], schemas()}.
|
||||
-define(EMPTY(V), (V == undefined orelse V == <<>>)).
|
||||
|
||||
schemas() ->
|
||||
[#{auth => #{
|
||||
api_spec() ->
|
||||
{
|
||||
[ login_api()
|
||||
, logout_api()
|
||||
, users_api()
|
||||
, user_api()
|
||||
, change_pwd_api()
|
||||
],
|
||||
[]}.
|
||||
|
||||
login_api() ->
|
||||
AuthSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
username => #{
|
||||
|
@ -46,10 +66,55 @@ schemas() ->
|
|||
description => <<"Username">>},
|
||||
password => #{
|
||||
type => string,
|
||||
description => <<"password">>}
|
||||
description => <<"Password">>}}},
|
||||
TokenSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
token => #{
|
||||
type => string,
|
||||
description => <<"JWT Token">>},
|
||||
license => #{
|
||||
type => object,
|
||||
properties => #{
|
||||
edition => #{
|
||||
type => string,
|
||||
enum => [community, enterprise]}}},
|
||||
version => #{
|
||||
type => string}}},
|
||||
|
||||
Metadata = #{
|
||||
post => #{
|
||||
description => <<"Dashboard Auth">>,
|
||||
'requestBody' => request_body_schema(AuthSchema),
|
||||
responses => #{
|
||||
<<"200">> =>
|
||||
response_schema(<<"Dashboard Auth successfully">>, TokenSchema),
|
||||
<<"401">> => unauthorized_request()
|
||||
},
|
||||
security => []
|
||||
}
|
||||
}},
|
||||
#{show_user => #{
|
||||
},
|
||||
{"/login", Metadata, login}.
|
||||
logout_api() ->
|
||||
AuthSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
username => #{
|
||||
type => string,
|
||||
description => <<"Username">>}}},
|
||||
Metadata = #{
|
||||
post => #{
|
||||
description => <<"Dashboard Auth">>,
|
||||
'requestBody' => request_body_schema(AuthSchema),
|
||||
responses => #{
|
||||
<<"200">> =>
|
||||
response_schema(<<"Dashboard Auth successfully">>)}
|
||||
}
|
||||
},
|
||||
{"/logout", Metadata, logout}.
|
||||
|
||||
users_api() ->
|
||||
ShowSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
username => #{
|
||||
|
@ -57,10 +122,8 @@ schemas() ->
|
|||
description => <<"Username">>},
|
||||
tag => #{
|
||||
type => string,
|
||||
description => <<"Tag">>}
|
||||
}
|
||||
}},
|
||||
#{create_user => #{
|
||||
description => <<"Tag">>}}},
|
||||
CreateSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
username => #{
|
||||
|
@ -71,36 +134,17 @@ schemas() ->
|
|||
description => <<"Password">>},
|
||||
tag => #{
|
||||
type => string,
|
||||
description => <<"Tag">>}
|
||||
}
|
||||
}}].
|
||||
|
||||
auth_api() ->
|
||||
Metadata = #{
|
||||
post => #{
|
||||
description => <<"Dashboard Auth">>,
|
||||
'requestBody' => request_body_schema(auth),
|
||||
responses => #{
|
||||
<<"200">> =>
|
||||
response_schema(<<"Dashboard Auth successfully">>),
|
||||
<<"400">> => bad_request()
|
||||
},
|
||||
security => []
|
||||
}
|
||||
},
|
||||
{"/auth", Metadata, auth}.
|
||||
|
||||
users_api() ->
|
||||
description => <<"Tag">>}}},
|
||||
Metadata = #{
|
||||
get => #{
|
||||
description => <<"Get dashboard users">>,
|
||||
responses => #{
|
||||
<<"200">> => response_array_schema(<<"">>, show_user)
|
||||
<<"200">> => response_array_schema(<<"">>, ShowSchema)
|
||||
}
|
||||
},
|
||||
post => #{
|
||||
description => <<"Create dashboard users">>,
|
||||
'requestBody' => request_body_schema(create_user),
|
||||
'requestBody' => request_body_schema(CreateSchema),
|
||||
responses => #{
|
||||
<<"200">> => response_schema(<<"Create Users successfully">>),
|
||||
<<"400">> => bad_request()
|
||||
|
@ -171,20 +215,26 @@ path_param_username() ->
|
|||
example => <<"admin">>
|
||||
}.
|
||||
|
||||
-define(EMPTY(V), (V == undefined orelse V == <<>>)).
|
||||
|
||||
auth(post, Request) ->
|
||||
login(post, Request) ->
|
||||
{ok, Body, _} = cowboy_req:read_body(Request),
|
||||
Params = emqx_json:decode(Body, [return_maps]),
|
||||
Username = maps:get(<<"username">>, Params),
|
||||
Password = maps:get(<<"password">>, Params),
|
||||
case emqx_dashboard_admin:check(Username, Password) of
|
||||
ok ->
|
||||
{200};
|
||||
{error, Reason} ->
|
||||
{400, #{code => <<"AUTH_FAIL">>, message => Reason}}
|
||||
case emqx_dashboard_admin:jwt_sign(Username, Password) of
|
||||
{ok, Token} ->
|
||||
Version = iolist_to_binary(proplists:get_value(version, emqx_sys:info())),
|
||||
{200, #{token => Token, version => Version, license => #{edition => ?RELEASE}}};
|
||||
{error, Code} ->
|
||||
{401, #{code => Code, message => <<"Auth filed">>}}
|
||||
end.
|
||||
|
||||
logout(_, Request) ->
|
||||
{ok, Body, _} = cowboy_req:read_body(Request),
|
||||
Params = emqx_json:decode(Body, [return_maps]),
|
||||
Username = maps:get(<<"username">>, Params),
|
||||
emqx_dashboard_admin:jwt_destroy_by_username(Username),
|
||||
{200}.
|
||||
|
||||
users(get, _Request) ->
|
||||
{200, [row(User) || User <- emqx_dashboard_admin:all_users()]};
|
||||
|
||||
|
@ -251,3 +301,12 @@ bad_request() ->
|
|||
code => #{type => string}
|
||||
}
|
||||
}).
|
||||
unauthorized_request() ->
|
||||
response_schema(<<"Unauthorized">>,
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
message => #{type => string},
|
||||
code => #{type => string, enum => ['PASSWORD_ERROR', 'USERNAME_ERROR']}
|
||||
}
|
||||
}).
|
||||
|
|
|
@ -29,6 +29,7 @@ start(_StartType, _StartArgs) ->
|
|||
ok = ekka_rlog:wait_for_shards([?DASHBOARD_SHARD], infinity),
|
||||
emqx_dashboard:start_listeners(),
|
||||
emqx_dashboard_cli:load(),
|
||||
ok = emqx_dashboard_admin:add_default_user(),
|
||||
{ok, Sup}.
|
||||
|
||||
stop(_State) ->
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
%%--------------------------------------------------------------------
|
||||
%% 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_dashboard_jwt).
|
||||
|
||||
-include("emqx_dashboard.hrl").
|
||||
|
||||
-define(TAB, mqtt_admin_jwt).
|
||||
|
||||
-export([ sign/2
|
||||
, verify/1
|
||||
, destroy/1
|
||||
, destroy_by_username/1
|
||||
]).
|
||||
|
||||
-rlog_shard({?DASHBOARD_SHARD, mqtt_admin_jwt}).
|
||||
|
||||
-boot_mnesia({mnesia, [boot]}).
|
||||
-copy_mnesia({mnesia, [copy]}).
|
||||
|
||||
-export([mnesia/1]).
|
||||
|
||||
-define(EXPTIME, 60 * 60 * 1000).
|
||||
|
||||
-define(CLEAN_JWT_INTERVAL, 60 * 60 * 1000).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% gen server part
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0]).
|
||||
|
||||
-export([ init/1
|
||||
, handle_call/3
|
||||
, handle_cast/2
|
||||
, handle_info/2
|
||||
, terminate/2
|
||||
, code_change/3
|
||||
]).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% jwt function
|
||||
-spec(sign(Username :: binary(), Password :: binary()) ->
|
||||
{ok, Token :: binary()} | {error, Reason :: term()}).
|
||||
sign(Username, Password) ->
|
||||
do_sign(Username, Password).
|
||||
|
||||
-spec(verify(Token :: binary()) -> Result :: ok | {error, token_timeout | not_found}).
|
||||
verify(Token) ->
|
||||
do_verify(Token).
|
||||
|
||||
-spec(destroy(KeyOrKeys :: list() | binary() | #mqtt_admin_jwt{}) -> ok).
|
||||
destroy([]) ->
|
||||
ok;
|
||||
destroy(JWTorTokenList) when is_list(JWTorTokenList)->
|
||||
[destroy(JWTorToken) || JWTorToken <- JWTorTokenList],
|
||||
ok;
|
||||
destroy(#mqtt_admin_jwt{token = Token}) ->
|
||||
destroy(Token);
|
||||
destroy(Token) when is_binary(Token)->
|
||||
do_destroy(Token).
|
||||
|
||||
-spec(destroy_by_username(Username :: binary()) -> ok).
|
||||
destroy_by_username(Username) ->
|
||||
do_destroy_by_username(Username).
|
||||
|
||||
mnesia(boot) ->
|
||||
ok = ekka_mnesia:create_table(?TAB, [
|
||||
{type, set},
|
||||
{disc_copies, [node()]},
|
||||
{record_name, mqtt_admin_jwt},
|
||||
{attributes, record_info(fields, mqtt_admin_jwt)},
|
||||
{storage_properties, [{ets, [{read_concurrency, true},
|
||||
{write_concurrency, true}]}]}]);
|
||||
mnesia(copy) ->
|
||||
ok = ekka_mnesia:copy_table(?TAB, disc_copies).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% jwt apply
|
||||
do_sign(Username, Password) ->
|
||||
ExpTime = jwt_expiration_time(),
|
||||
Salt = salt(),
|
||||
JWK = jwk(Username, Password, Salt),
|
||||
JWS = #{
|
||||
<<"alg">> => <<"HS256">>
|
||||
},
|
||||
JWT = #{
|
||||
<<"iss">> => <<"EMQ X">>,
|
||||
<<"exp">> => ExpTime
|
||||
},
|
||||
Signed = jose_jwt:sign(JWK, JWS, JWT),
|
||||
{_, Token} = jose_jws:compact(Signed),
|
||||
ok = ekka_mnesia:dirty_write(format(Token, Username, ExpTime)),
|
||||
{ok, Token}.
|
||||
|
||||
do_verify(Token)->
|
||||
case lookup(Token) of
|
||||
{ok, JWT = #mqtt_admin_jwt{exptime = ExpTime}} ->
|
||||
case ExpTime > erlang:system_time(millisecond) of
|
||||
true ->
|
||||
ekka_mnesia:dirty_write(JWT#mqtt_admin_jwt{exptime = jwt_expiration_time()}),
|
||||
ok;
|
||||
_ ->
|
||||
{error, token_timeout}
|
||||
end;
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
do_destroy(Token) ->
|
||||
Fun = fun mnesia:delete/1,
|
||||
ekka_mnesia:transaction(?DASHBOARD_SHARD, Fun, [{?TAB, Token}]).
|
||||
|
||||
do_destroy_by_username(Username) ->
|
||||
gen_server:cast(?MODULE, {destroy, Username}).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% jwt internal util function
|
||||
|
||||
lookup(Token) ->
|
||||
case mnesia:dirty_read(?TAB, Token) of
|
||||
[JWT] -> {ok, JWT};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
lookup_by_username(Username) ->
|
||||
Spec = [{{mqtt_admin_jwt, '_', Username, '_'}, [], ['$_']}],
|
||||
mnesia:dirty_select(?TAB, Spec).
|
||||
|
||||
jwk(Username, Password, Salt) ->
|
||||
Key = erlang:md5(<<Salt/binary, Username/binary, Password/binary>>),
|
||||
#{
|
||||
<<"kty">> => <<"oct">>,
|
||||
<<"k">> => jose_base64url:encode(Key)
|
||||
}.
|
||||
|
||||
jwt_expiration_time() ->
|
||||
ExpTime = emqx_config:get([emqx_dashboard, jwt_exptime], ?EXPTIME),
|
||||
erlang:system_time(millisecond) + ExpTime.
|
||||
|
||||
salt() ->
|
||||
_ = emqx_misc:rand_seed(),
|
||||
Salt = rand:uniform(16#ffffffff),
|
||||
<<Salt:32>>.
|
||||
|
||||
format(Token, Username, ExpTime) ->
|
||||
#mqtt_admin_jwt{
|
||||
token = Token,
|
||||
username = Username,
|
||||
exptime = ExpTime
|
||||
}.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% gen server
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
init([]) ->
|
||||
timer_clean(self()),
|
||||
{ok, state}.
|
||||
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
handle_cast({destroy, Username}, State) ->
|
||||
Tokens = lookup_by_username(Username),
|
||||
destroy(Tokens),
|
||||
{noreply, State};
|
||||
handle_cast(_Request, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(clean_jwt, State) ->
|
||||
timer_clean(self()),
|
||||
Now = erlang:system_time(millisecond),
|
||||
Spec = [{{mqtt_admin_jwt, '_', '_', '$1'}, [{'<', '$1', Now}], ['$_']}],
|
||||
JWTList = mnesia:dirty_select(?TAB, Spec),
|
||||
destroy(JWTList),
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
timer_clean(Pid) ->
|
||||
erlang:send_after(?CLEAN_JWT_INTERVAL, Pid, clean_jwt).
|
|
@ -28,6 +28,7 @@ fields("emqx_dashboard") ->
|
|||
, {default_username, fun default_username/1}
|
||||
, {default_password, fun default_password/1}
|
||||
, {sample_interval, emqx_schema:t(emqx_schema:duration_s(), undefined, "10s")}
|
||||
, {jwt_exptime, emqx_schema:t(emqx_schema:duration(), undefined, "30m")}
|
||||
];
|
||||
|
||||
fields("http") ->
|
||||
|
|
|
@ -29,4 +29,4 @@ start_link() ->
|
|||
|
||||
init([]) ->
|
||||
{ok, {{one_for_all, 10, 100},
|
||||
[?CHILD(emqx_dashboard_admin), ?CHILD(emqx_dashboard_collection)]}}.
|
||||
[?CHILD(emqx_dashboard_jwt), ?CHILD(emqx_dashboard_collection)]}}.
|
||||
|
|
Loading…
Reference in New Issue