فهرست منبع

feat: dashboard api support jwt

DDDHuang 4 سال پیش
والد
کامیت
93dbdaa84a

+ 2 - 0
apps/emqx_dashboard/etc/emqx_dashboard.conf

@@ -7,6 +7,8 @@ emqx_dashboard:{
     default_password: "public"
     default_password: "public"
     ## notice: sample_interval should be divisible by 60.
     ## notice: sample_interval should be divisible by 60.
     sample_interval: 10s
     sample_interval: 10s
+    ## api jwt timeout. default is 30 minute
+    jwt_exptime: 30m
     listeners: [
     listeners: [
         {
         {
             num_acceptors: 4
             num_acceptors: 4

+ 12 - 1
apps/emqx_dashboard/include/emqx_dashboard.hrl

@@ -14,7 +14,18 @@
 %% limitations under the License.
 %% 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{}).
 -type(mqtt_admin() :: #mqtt_admin{}).
 
 

+ 15 - 8
apps/emqx_dashboard/src/emqx_dashboard.erl

@@ -104,20 +104,27 @@ listener_name(Proto) ->
 
 
 authorize_appid(Req) ->
 authorize_appid(Req) ->
     case cowboy_req:parse_header(<<"authorization">>, Req) of
     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 ->
                     ok;
                     ok;
-                {error, _} ->
+                {error, token_timeout} ->
                     {401, #{<<"WWW-Authenticate">> =>
                     {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;
             end;
         _ ->
         _ ->
             {401, #{<<"WWW-Authenticate">> =>
             {401, #{<<"WWW-Authenticate">> =>
-                      <<"Basic Realm=\"minirest-server\"">>},
-                    <<"UNAUTHORIZED">>}
+                    <<"Bearer Realm=\"minirest-server\"">>},
+                  #{code => <<"UNAUTHORIZED">>,
+                    message => <<"POST '/login'">>}}
     end.
     end.
 
 
 format(Port) when is_integer(Port) ->
 format(Port) when is_integer(Port) ->

+ 22 - 38
apps/emqx_dashboard/src/emqx_dashboard_admin.erl

@@ -18,8 +18,6 @@
 
 
 -module(emqx_dashboard_admin).
 -module(emqx_dashboard_admin).
 
 
--behaviour(gen_server).
-
 -include("emqx_dashboard.hrl").
 -include("emqx_dashboard.hrl").
 
 
 -rlog_shard({?DASHBOARD_SHARD, mqtt_admin}).
 -rlog_shard({?DASHBOARD_SHARD, mqtt_admin}).
@@ -30,9 +28,6 @@
 %% Mnesia bootstrap
 %% Mnesia bootstrap
 -export([mnesia/1]).
 -export([mnesia/1]).
 
 
-%% API Function Exports
--export([start_link/0]).
-
 %% mqtt_admin api
 %% mqtt_admin api
 -export([ add_user/3
 -export([ add_user/3
         , force_add_user/3
         , force_add_user/3
@@ -45,15 +40,13 @@
         , check/2
         , 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
 %% Mnesia bootstrap
 %%--------------------------------------------------------------------
 %%--------------------------------------------------------------------
@@ -73,10 +66,6 @@ mnesia(copy) ->
 %% API
 %% 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()}).
 -spec(add_user(binary(), binary(), binary()) -> ok | {error, any()}).
 add_user(Username, Password, Tags) when is_binary(Username), is_binary(Password) ->
 add_user(Username, Password, Tags) when is_binary(Username), is_binary(Password) ->
     Admin = #mqtt_admin{username = Username, password = hash(Password), tags = Tags},
     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>>}] ->
         [#mqtt_admin{password = <<Salt:4/binary, Hash/binary>>}] ->
             case Hash =:= md5_hash(Salt, Password) of
             case Hash =:= md5_hash(Salt, Password) of
                 true  -> ok;
                 true  -> ok;
-                false -> {error, <<"Password Error">>}
+                false -> {error, <<"PASSWORD_ERROR">>}
             end;
             end;
         [] ->
         [] ->
-            {error, <<"Username Not Found">>}
+            {error, <<"USERNAME_ERROR">>}
     end.
     end.
 
 
 %%--------------------------------------------------------------------
 %%--------------------------------------------------------------------
-%% gen_server callbacks
-%%--------------------------------------------------------------------
-
-init([]) ->
-    %% Add default admin user
-    _ = add_default_user(binenv(default_username), binenv(default_password)),
-    {ok, state}.
-
-handle_call(_Req, _From, State) ->
-    {reply, error, State}.
-
-handle_cast(_Msg, State) ->
-    {noreply, State}.
-
-handle_info(_Msg, State) ->
-    {noreply, State}.
+%% jwt
+jwt_sign(Username, Password) ->
+    case check(Username, Password) of
+        ok ->
+            emqx_dashboard_jwt:sign(Username, Password);
+        Error ->
+            Error
+    end.
 
 
-terminate(_Reason, _State) ->
-    ok.
+jwt_verify(Token) ->
+    emqx_dashboard_jwt:verify(Token).
 
 
-code_change(_OldVsn, State, _Extra) ->
-    {ok, State}.
+jwt_destroy_by_username(Username) ->
+    emqx_dashboard_jwt:destroy_by_username(Username).
 
 
 %%--------------------------------------------------------------------
 %%--------------------------------------------------------------------
 %% Internal functions
 %% Internal functions
@@ -216,6 +197,9 @@ salt() ->
     Salt = rand:uniform(16#ffffffff),
     Salt = rand:uniform(16#ffffffff),
     <<Salt:32>>.
     <<Salt:32>>.
 
 
+add_default_user() ->
+    add_default_user(binenv(default_username), binenv(default_password)).
+
 binenv(Key) ->
 binenv(Key) ->
     iolist_to_binary(emqx_config:get([emqx_dashboard, Key], "")).
     iolist_to_binary(emqx_config:get([emqx_dashboard, Key], "")).
 
 

+ 100 - 41
apps/emqx_dashboard/src/emqx_dashboard_api.erl

@@ -16,6 +16,16 @@
 
 
 -module(emqx_dashboard_api).
 -module(emqx_dashboard_api).
 
 
+-ifndef(EMQX_ENTERPRISE).
+
+-define(RELEASE, community).
+
+-else.
+
+-define(VERSION, enterprise).
+
+-endif.
+
 -behaviour(minirest_api).
 -behaviour(minirest_api).
 
 
 -include("emqx_dashboard.hrl").
 -include("emqx_dashboard.hrl").
@@ -28,17 +38,27 @@
 
 
 -export([api_spec/0]).
 -export([api_spec/0]).
 
 
--export([ auth/2
+-export([ login/2
+        , logout/2
         , users/2
         , users/2
         , user/2
         , user/2
         , change_pwd/2
         , change_pwd/2
         ]).
         ]).
 
 
+-define(EMPTY(V), (V == undefined orelse V == <<>>)).
+
 api_spec() ->
 api_spec() ->
-    {[auth_api(), users_api(), user_api(), change_pwd_api()], schemas()}.
+    {
+        [ login_api()
+        , logout_api()
+        , users_api()
+        , user_api()
+        , change_pwd_api()
+        ],
+        []}.
 
 
-schemas() ->
-    [#{auth => #{
+login_api() ->
+    AuthSchema = #{
         type => object,
         type => object,
         properties => #{
         properties => #{
             username => #{
             username => #{
@@ -46,10 +66,55 @@ schemas() ->
                 description => <<"Username">>},
                 description => <<"Username">>},
             password => #{
             password => #{
                 type => string,
                 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 => []
+        }
+    },
+    {"/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">>)}
         }
         }
-    }},
-    #{show_user => #{
+    },
+    {"/logout", Metadata, logout}.
+
+users_api() ->
+    ShowSchema = #{
         type => object,
         type => object,
         properties => #{
         properties => #{
             username => #{
             username => #{
@@ -57,10 +122,8 @@ schemas() ->
                 description => <<"Username">>},
                 description => <<"Username">>},
             tag => #{
             tag => #{
                 type => string,
                 type => string,
-                description => <<"Tag">>}
-        }
-    }},
-    #{create_user => #{
+                description => <<"Tag">>}}},
+    CreateSchema = #{
         type => object,
         type => object,
         properties => #{
         properties => #{
             username => #{
             username => #{
@@ -71,36 +134,17 @@ schemas() ->
                 description => <<"Password">>},
                 description => <<"Password">>},
             tag => #{
             tag => #{
                 type => string,
                 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 = #{
     Metadata = #{
         get => #{
         get => #{
             description => <<"Get dashboard users">>,
             description => <<"Get dashboard users">>,
             responses => #{
             responses => #{
-                <<"200">> => response_array_schema(<<"">>, show_user)
+                <<"200">> => response_array_schema(<<"">>, ShowSchema)
             }
             }
         },
         },
         post => #{
         post => #{
             description => <<"Create dashboard users">>,
             description => <<"Create dashboard users">>,
-            'requestBody' => request_body_schema(create_user),
+            'requestBody' => request_body_schema(CreateSchema),
             responses => #{
             responses => #{
                 <<"200">> => response_schema(<<"Create Users successfully">>),
                 <<"200">> => response_schema(<<"Create Users successfully">>),
                 <<"400">> => bad_request()
                 <<"400">> => bad_request()
@@ -171,20 +215,26 @@ path_param_username() ->
         example => <<"admin">>
         example => <<"admin">>
     }.
     }.
 
 
--define(EMPTY(V), (V == undefined orelse V == <<>>)).
-
-auth(post, Request) ->
+login(post, Request) ->
     {ok, Body, _} = cowboy_req:read_body(Request),
     {ok, Body, _} = cowboy_req:read_body(Request),
     Params = emqx_json:decode(Body, [return_maps]),
     Params = emqx_json:decode(Body, [return_maps]),
     Username = maps:get(<<"username">>, Params),
     Username = maps:get(<<"username">>, Params),
     Password = maps:get(<<"password">>, 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.
     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) ->
 users(get, _Request) ->
     {200, [row(User) || User <- emqx_dashboard_admin:all_users()]};
     {200, [row(User) || User <- emqx_dashboard_admin:all_users()]};
 
 
@@ -251,3 +301,12 @@ bad_request() ->
                             code => #{type => string}
                             code => #{type => string}
                         }
                         }
                     }).
                     }).
+unauthorized_request() ->
+    response_schema(<<"Unauthorized">>,
+                    #{
+                        type => object,
+                        properties => #{
+                            message => #{type => string},
+                            code => #{type => string, enum => ['PASSWORD_ERROR', 'USERNAME_ERROR']}
+                        }
+                    }).

+ 1 - 0
apps/emqx_dashboard/src/emqx_dashboard_app.erl

@@ -29,6 +29,7 @@ start(_StartType, _StartArgs) ->
     ok = ekka_rlog:wait_for_shards([?DASHBOARD_SHARD], infinity),
     ok = ekka_rlog:wait_for_shards([?DASHBOARD_SHARD], infinity),
     emqx_dashboard:start_listeners(),
     emqx_dashboard:start_listeners(),
     emqx_dashboard_cli:load(),
     emqx_dashboard_cli:load(),
+    ok = emqx_dashboard_admin:add_default_user(),
     {ok, Sup}.
     {ok, Sup}.
 
 
 stop(_State) ->
 stop(_State) ->

+ 202 - 0
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(<<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).

+ 1 - 0
apps/emqx_dashboard/src/emqx_dashboard_schema.erl

@@ -28,6 +28,7 @@ fields("emqx_dashboard") ->
     , {default_username, fun default_username/1}
     , {default_username, fun default_username/1}
     , {default_password, fun default_password/1}
     , {default_password, fun default_password/1}
     , {sample_interval, emqx_schema:t(emqx_schema:duration_s(), undefined, "10s")}
     , {sample_interval, emqx_schema:t(emqx_schema:duration_s(), undefined, "10s")}
+    , {jwt_exptime, emqx_schema:t(emqx_schema:duration(), undefined, "30m")}
     ];
     ];
 
 
 fields("http") ->
 fields("http") ->

+ 1 - 1
apps/emqx_dashboard/src/emqx_dashboard_sup.erl

@@ -29,4 +29,4 @@ start_link() ->
 
 
 init([]) ->
 init([]) ->
     {ok, {{one_for_all, 10, 100},
     {ok, {{one_for_all, 10, 100},
-        [?CHILD(emqx_dashboard_admin), ?CHILD(emqx_dashboard_collection)]}}.
+        [?CHILD(emqx_dashboard_jwt), ?CHILD(emqx_dashboard_collection)]}}.