diff --git a/apps/emqx_dashboard/etc/emqx_dashboard.conf b/apps/emqx_dashboard/etc/emqx_dashboard.conf index c25c2802d..2bc84569c 100644 --- a/apps/emqx_dashboard/etc/emqx_dashboard.conf +++ b/apps/emqx_dashboard/etc/emqx_dashboard.conf @@ -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 diff --git a/apps/emqx_dashboard/include/emqx_dashboard.hrl b/apps/emqx_dashboard/include/emqx_dashboard.hrl index 65f1d6ff5..265552bf7 100644 --- a/apps/emqx_dashboard/include/emqx_dashboard.hrl +++ b/apps/emqx_dashboard/include/emqx_dashboard.hrl @@ -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{}). diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl index 656283bf6..27fe4e77d 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard.erl @@ -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) -> diff --git a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl index fdec41b2b..6334bbba6 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl @@ -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 = <>}] -> 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), <>. +add_default_user() -> + add_default_user(binenv(default_username), binenv(default_password)). + binenv(Key) -> iolist_to_binary(emqx_config:get([emqx_dashboard, Key], "")). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_api.erl index a56df7ec3..48045b160 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_api.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_api.erl @@ -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']} + } + }). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_app.erl b/apps/emqx_dashboard/src/emqx_dashboard_app.erl index 54202d806..edcc19d8b 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_app.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_app.erl @@ -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) -> diff --git a/apps/emqx_dashboard/src/emqx_dashboard_jwt.erl b/apps/emqx_dashboard/src/emqx_dashboard_jwt.erl new file mode 100644 index 000000000..291affd66 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_jwt.erl @@ -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(<>), + #{ + <<"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), + <>. + +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). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_schema.erl b/apps/emqx_dashboard/src/emqx_dashboard_schema.erl index 2dae5e7e4..a42428e50 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_schema.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_schema.erl @@ -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") -> diff --git a/apps/emqx_dashboard/src/emqx_dashboard_sup.erl b/apps/emqx_dashboard/src/emqx_dashboard_sup.erl index 8ec161f11..f3ecd6128 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_sup.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_sup.erl @@ -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)]}}.