Prechádzať zdrojové kódy

Merge pull request #11631 from lafirest/feat/dashboard_ldap

feat(dashboard): add SSO feature and integrate with LDAP
lafirest 2 rokov pred
rodič
commit
bc6edac45f
27 zmenil súbory, kde vykonal 1162 pridanie a 47 odobranie
  1. 9 4
      apps/emqx_dashboard/include/emqx_dashboard.hrl
  2. 43 11
      apps/emqx_dashboard/src/emqx_dashboard_admin.erl
  3. 47 17
      apps/emqx_dashboard/src/emqx_dashboard_api.erl
  4. 9 1
      apps/emqx_dashboard/src/emqx_dashboard_token.erl
  5. 8 8
      apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl
  6. 94 0
      apps/emqx_dashboard_sso/BSL.txt
  7. 11 0
      apps/emqx_dashboard_sso/README.md
  8. 1 0
      apps/emqx_dashboard_sso/docker-ct
  9. 7 0
      apps/emqx_dashboard_sso/rebar.config
  10. 19 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src
  11. 79 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl
  12. 225 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl
  13. 18 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl
  14. 144 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl
  15. 252 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl
  16. 83 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_schema.erl
  17. 22 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl
  18. 1 1
      apps/emqx_enterprise/src/emqx_enterprise.app.src
  19. 2 1
      apps/emqx_enterprise/src/emqx_enterprise_schema.erl
  20. 15 2
      apps/emqx_ldap/src/emqx_ldap.erl
  21. 2 1
      apps/emqx_machine/priv/reboot_lists.eterm
  22. 2 1
      mix.exs
  23. 1 0
      rebar.config.erl
  24. 6 0
      rel/i18n/emqx_dashboard_api.hocon
  25. 40 0
      rel/i18n/emqx_dashboard_sso_api.hocon
  26. 11 0
      rel/i18n/emqx_dashboard_sso_ldap.hocon
  27. 11 0
      rel/i18n/emqx_dashboard_sso_schema.hocon

+ 9 - 4
apps/emqx_dashboard/include/emqx_dashboard.hrl

@@ -22,18 +22,23 @@
 %% a predefined configuration would replace these macros.
 -define(ROLE_VIEWER, <<"viewer">>).
 -define(ROLE_SUPERUSER, <<"superuser">>).
-
 -define(ROLE_DEFAULT, ?ROLE_SUPERUSER).
 
+-define(SSO_USERNAME(Backend, Name), {Backend, Name}).
+
+-type dashboard_sso_backend() :: atom().
+-type dashboard_sso_username() :: {dashboard_sso_backend(), binary()}.
+-type dashboard_username() :: binary() | dashboard_sso_username().
+-type dashboard_user_role() :: binary().
+
 -record(?ADMIN, {
-    username :: binary(),
+    username :: dashboard_username(),
     pwdhash :: binary(),
     description :: binary(),
-    role = ?ROLE_DEFAULT :: binary(),
+    role = ?ROLE_DEFAULT :: dashboard_user_role(),
     extra = #{} :: map()
 }).
 
--type dashboard_user_role() :: binary().
 -type dashboard_user() :: #?ADMIN{}.
 
 -define(ADMIN_JWT, emqx_admin_jwt).

+ 43 - 11
apps/emqx_dashboard/src/emqx_dashboard_admin.erl

@@ -60,6 +60,10 @@
 
 -export([backup_tables/0]).
 
+-if(?EMQX_RELEASE_EDITION == ee).
+-export([add_sso_user/4, lookup_user/2]).
+-endif.
+
 -type emqx_admin() :: #?ADMIN{}.
 
 %%--------------------------------------------------------------------
@@ -99,10 +103,9 @@ add_default_user() ->
 %% API
 %%--------------------------------------------------------------------
 
--spec add_user(binary(), binary(), dashboard_user_role(), binary()) -> {ok, map()} | {error, any()}.
-add_user(Username, Password, Role, Desc) when
-    is_binary(Username), is_binary(Password)
-->
+-spec add_user(dashboard_username(), binary(), dashboard_user_role(), binary()) ->
+    {ok, map()} | {error, any()}.
+add_user(Username, Password, Role, Desc) when is_binary(Username), is_binary(Password) ->
     case {legal_username(Username), legal_password(Password), legal_role(Role)} of
         {ok, ok, ok} -> do_add_user(Username, Password, Role, Desc);
         {{error, Reason}, _, _} -> {error, Reason};
@@ -204,7 +207,7 @@ add_user_(Username, Password, Role, Desc) ->
                 description = Desc
             },
             mnesia:write(Admin),
-            #{username => Username, role => Role, description => Desc};
+            flatten_username(#{username => Username, role => Role, description => Desc});
         [_] ->
             mnesia:abort(<<"username_already_exist">>)
     end.
@@ -225,7 +228,8 @@ remove_user(Username) when is_binary(Username) ->
             {error, Reason}
     end.
 
--spec update_user(binary(), dashboard_user_role(), binary()) -> {ok, map()} | {error, term()}.
+-spec update_user(dashboard_username(), dashboard_user_role(), binary()) ->
+    {ok, map()} | {error, term()}.
 update_user(Username, Role, Desc) when is_binary(Username) ->
     case legal_role(Role) of
         ok ->
@@ -272,7 +276,10 @@ update_user_(Username, Role, Desc) ->
             mnesia:abort(<<"username_not_found">>);
         [Admin] ->
             mnesia:write(Admin#?ADMIN{role = Role, description = Desc}),
-            {role(Admin) =:= Role, #{username => Username, role => Role, description => Desc}}
+            {
+                role(Admin) =:= Role,
+                flatten_username(#{username => Username, role => Role, description => Desc})
+            }
     end.
 
 change_password(Username, OldPasswd, NewPasswd) when is_binary(Username) ->
@@ -312,8 +319,8 @@ update_pwd(Username, Fun) ->
         end,
     return(mria:transaction(?DASHBOARD_SHARD, Trans)).
 
--spec lookup_user(binary()) -> [emqx_admin()].
-lookup_user(Username) when is_binary(Username) ->
+-spec lookup_user(dashboard_username()) -> [emqx_admin()].
+lookup_user(Username) ->
     Fun = fun() -> mnesia:read(?ADMIN, Username) end,
     {atomic, User} = mria:ro_transaction(?DASHBOARD_SHARD, Fun),
     User.
@@ -328,11 +335,11 @@ all_users() ->
                 role = Role
             }
         ) ->
-            #{
+            flatten_username(#{
                 username => Username,
                 description => Desc,
                 role => ensure_role(Role)
-            }
+            })
         end,
         ets:tab2list(?ADMIN)
     ).
@@ -410,6 +417,28 @@ legal_role(Role) ->
 role(Data) ->
     emqx_dashboard_rbac:role(Data).
 
+flatten_username(#{username := ?SSO_USERNAME(Backend, Name)} = Data) ->
+    Data#{
+        username := Name,
+        backend => Backend
+    };
+flatten_username(#{username := Username} = Data) when is_binary(Username) ->
+    Data#{backend => local}.
+
+-spec add_sso_user(dashboard_sso_backend(), binary(), dashboard_user_role(), binary()) ->
+    {ok, map()} | {error, any()}.
+add_sso_user(Backend, Username0, Role, Desc) when is_binary(Username0) ->
+    case legal_role(Role) of
+        ok ->
+            Username = ?SSO_USERNAME(Backend, Username0),
+            do_add_user(Username, <<>>, Role, Desc);
+        {error, _} = Error ->
+            Error
+    end.
+
+-spec lookup_user(dashboard_sso_backend(), binary()) -> [emqx_admin()].
+lookup_user(Backend, Username) when is_atom(Backend) ->
+    lookup_user(?SSO_USERNAME(Backend, Username)).
 -else.
 
 -dialyzer({no_match, [add_user/4, update_user/3]}).
@@ -419,6 +448,9 @@ legal_role(_) ->
 
 role(_) ->
     ?ROLE_DEFAULT.
+
+flatten_username(Data) ->
+    Data.
 -endif.
 
 -ifdef(TEST).

+ 47 - 17
apps/emqx_dashboard/src/emqx_dashboard_api.erl

@@ -89,6 +89,7 @@ schema("/logout") ->
         post => #{
             tags => [<<"dashboard">>],
             desc => ?DESC(logout_api),
+            parameters => sso_parameters(),
             'requestBody' => fields([username]),
             responses => #{
                 204 => <<"Dashboard logout successfully">>,
@@ -114,7 +115,7 @@ schema("/users") ->
             desc => ?DESC(create_user_api),
             'requestBody' => fields([username, password, role, description]),
             responses => #{
-                200 => fields([username, role, description])
+                200 => fields([username, role, description, backend])
             }
         }
     };
@@ -124,17 +125,17 @@ schema("/users/:username") ->
         put => #{
             tags => [<<"dashboard">>],
             desc => ?DESC(update_user_api),
-            parameters => fields([username_in_path]),
+            parameters => sso_parameters(fields([username_in_path])),
             'requestBody' => fields([role, description]),
             responses => #{
-                200 => fields([username, role, description]),
+                200 => fields([username, role, description, backend]),
                 404 => response_schema(404)
             }
         },
         delete => #{
             tags => [<<"dashboard">>],
             desc => ?DESC(delete_user_api),
-            parameters => fields([username_in_path]),
+            parameters => sso_parameters(fields([username_in_path])),
             responses => #{
                 204 => <<"Delete User successfully">>,
                 400 => emqx_dashboard_swagger:error_codes(
@@ -169,7 +170,7 @@ response_schema(404) ->
     emqx_dashboard_swagger:error_codes([?USER_NOT_FOUND], ?DESC(users_api404)).
 
 fields(user) ->
-    fields([username, description]);
+    fields([username, role, description, backend]);
 fields(List) ->
     [field(Key) || Key <- List, field_filter(Key)].
 
@@ -206,7 +207,10 @@ field(old_pwd) ->
 field(new_pwd) ->
     {new_pwd, mk(binary(), #{desc => ?DESC(new_pwd)})};
 field(role) ->
-    {role, mk(binary(), #{desc => ?DESC(role), example => ?ROLE_DEFAULT})}.
+    {role,
+        mk(binary(), #{desc => ?DESC(role), default => ?ROLE_DEFAULT, example => ?ROLE_DEFAULT})};
+field(backend) ->
+    {backend, mk(binary(), #{desc => ?DESC(backend), example => <<"local">>})}.
 
 %% -------------------------------------------------------------------------------------------------
 %% API
@@ -229,15 +233,16 @@ login(post, #{body := Params}) ->
     end.
 
 logout(_, #{
-    body := #{<<"username">> := Username},
+    body := #{<<"username">> := Username0} = Req,
     headers := #{<<"authorization">> := <<"Bearer ", Token/binary>>}
 }) ->
+    Username = username(Req, Username0),
     case emqx_dashboard_admin:destroy_token_by_username(Username, Token) of
         ok ->
-            ?SLOG(info, #{msg => "Dashboard logout successfully", username => Username}),
+            ?SLOG(info, #{msg => "Dashboard logout successfully", username => Username0}),
             204;
         _R ->
-            ?SLOG(info, #{msg => "Dashboard logout failed.", username => Username}),
+            ?SLOG(info, #{msg => "Dashboard logout failed.", username => Username0}),
             {401, ?WRONG_TOKEN_OR_USERNAME, <<"Ensure your token & username">>}
     end.
 
@@ -266,9 +271,10 @@ users(post, #{body := Params}) ->
             end
     end.
 
-user(put, #{bindings := #{username := Username}, body := Params}) ->
+user(put, #{bindings := #{username := Username0}, body := Params} = Req) ->
     Role = maps:get(<<"role">>, Params, ?ROLE_DEFAULT),
     Desc = maps:get(<<"description">>, Params),
+    Username = username(Req, Username0),
     case emqx_dashboard_admin:update_user(Username, Role, Desc) of
         {ok, Result} ->
             {200, filter_result(Result)};
@@ -277,14 +283,15 @@ user(put, #{bindings := #{username := Username}, body := Params}) ->
         {error, Reason} ->
             {400, ?BAD_REQUEST, Reason}
     end;
-user(delete, #{bindings := #{username := Username}, headers := Headers}) ->
-    case Username == emqx_dashboard_admin:default_username() of
+user(delete, #{bindings := #{username := Username0}, headers := Headers} = Req) ->
+    case Username0 == emqx_dashboard_admin:default_username() of
         true ->
-            ?SLOG(info, #{msg => "Dashboard delete admin user failed", username => Username}),
-            Message = list_to_binary(io_lib:format("Cannot delete user ~p", [Username])),
+            ?SLOG(info, #{msg => "Dashboard delete admin user failed", username => Username0}),
+            Message = list_to_binary(io_lib:format("Cannot delete user ~p", [Username0])),
             {400, ?NOT_ALLOWED, Message};
         false ->
-            case is_self_auth(Username, Headers) of
+            Username = username(Req, Username0),
+            case is_self_auth(Username0, Headers) of
                 true ->
                     {400, ?NOT_ALLOWED, <<"Cannot delete self">>};
                 false ->
@@ -293,13 +300,15 @@ user(delete, #{bindings := #{username := Username}, headers := Headers}) ->
                             {404, ?USER_NOT_FOUND, Reason};
                         {ok, _} ->
                             ?SLOG(info, #{
-                                msg => "Dashboard delete admin user", username => Username
+                                msg => "Dashboard delete admin user", username => Username0
                             }),
                             {204}
                     end
             end
     end.
 
+is_self_auth(?SSO_USERNAME(_, _), _) ->
+    fasle;
 is_self_auth(Username, #{<<"authorization">> := Token}) ->
     is_self_auth(Username, Token);
 is_self_auth(Username, #{<<"Authorization">> := Token}) ->
@@ -362,6 +371,19 @@ field_filter(_) ->
 filter_result(Result) ->
     Result.
 
+sso_parameters() ->
+    sso_parameters([]).
+
+sso_parameters(Params) ->
+    emqx_dashboard_sso_api:sso_parameters(Params).
+
+username(#{bindings := #{backend := local}}, Username) ->
+    Username;
+username(#{bindings := #{backend := Backend}}, Username) ->
+    ?SSO_USERNAME(Backend, Username);
+username(_Req, Username) ->
+    Username.
+
 -else.
 
 field_filter(role) ->
@@ -372,6 +394,14 @@ field_filter(_) ->
 filter_result(Result) when is_list(Result) ->
     lists:map(fun filter_result/1, Result);
 filter_result(Result) ->
-    maps:without([role], Result).
+    maps:without([role, backend], Result).
+
+sso_parameters() ->
+    sso_parameters([]).
+
+sso_parameters(Any) ->
+    Any.
 
+username(_Req, Username) ->
+    Username.
 -endif.

+ 9 - 1
apps/emqx_dashboard/src/emqx_dashboard_token.erl

@@ -179,6 +179,9 @@ owner(Token) ->
         {atomic, []} -> {error, not_found}
     end.
 
+jwk(?SSO_USERNAME(Backend, Name), Password, Salt) ->
+    BackendBin = erlang:atom_to_binary(Backend),
+    jwk(<<BackendBin/binary, "-", Name/binary>>, Password, Salt);
 jwk(Username, Password, Salt) ->
     Key = crypto:hash(md5, <<Salt/binary, Username/binary, Password/binary>>),
     #{
@@ -192,12 +195,17 @@ jwt_expiration_time() ->
 token_ttl() ->
     emqx_conf:get([dashboard, token_expired_time], ?EXPTIME).
 
+format(Token, ?SSO_USERNAME(Backend, Name), Role, ExpTime) ->
+    format(Token, Backend, Name, Role, ExpTime);
 format(Token, Username, Role, ExpTime) ->
+    format(Token, local, Username, Role, ExpTime).
+
+format(Token, Backend, Username, Role, ExpTime) ->
     #?ADMIN_JWT{
         token = Token,
         username = Username,
         exptime = ExpTime,
-        extra = #{role => Role}
+        extra = #{role => Role, backend => Backend}
     }.
 
 %%--------------------------------------------------------------------

+ 8 - 8
apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl

@@ -61,11 +61,11 @@ t_permission(_) ->
         }
     ),
 
-    ?assertEqual(
+    ?assertMatch(
         #{
-            <<"username">> => ViewerUser,
-            <<"role">> => ?ROLE_VIEWER,
-            <<"description">> => ?ADD_DESCRIPTION
+            <<"username">> := ViewerUser,
+            <<"role">> := ?ROLE_VIEWER,
+            <<"description">> := ?ADD_DESCRIPTION
         },
         emqx_utils_json:decode(Payload, [return_maps])
     ),
@@ -104,11 +104,11 @@ t_update_role(_) ->
         }
     ),
 
-    ?assertEqual(
+    ?assertMatch(
         #{
-            <<"username">> => ?DEFAULT_SUPERUSER,
-            <<"role">> => ?ROLE_VIEWER,
-            <<"description">> => ?ADD_DESCRIPTION
+            <<"username">> := ?DEFAULT_SUPERUSER,
+            <<"role">> := ?ROLE_VIEWER,
+            <<"description">> := ?ADD_DESCRIPTION
         },
         emqx_utils_json:decode(Payload, [return_maps])
     ),

+ 94 - 0
apps/emqx_dashboard_sso/BSL.txt

@@ -0,0 +1,94 @@
+Business Source License 1.1
+
+Licensor:             Hangzhou EMQ Technologies Co., Ltd.
+Licensed Work:        EMQX Enterprise Edition
+                      The Licensed Work is (c) 2023
+                      Hangzhou EMQ Technologies Co., Ltd.
+Additional Use Grant: Students and educators are granted right to copy,
+                      modify, and create derivative work for research
+                      or education.
+Change Date:          2027-02-01
+Change License:       Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Software,
+please contact Licensor: https://www.emqx.com/en/contact
+
+Notice
+
+The Business Source License (this document, or the “License”) is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+“Business Source License” is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License’s text to license
+your works, and to refer to it using the trademark “Business Source License”,
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License’s text and the “Business
+Source License” name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+   or a license that is compatible with GPL Version 2.0 or a later version,
+   where “compatible” means that software provided under the Change License can
+   be included in a program with software provided under GPL Version 2.0 or a
+   later version. Licensor may specify additional Change Licenses without
+   limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+   impose any additional restriction on the right granted in this License, as
+   the Additional Use Grant; or (b) insert the text “None”.
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.

+ 11 - 0
apps/emqx_dashboard_sso/README.md

@@ -0,0 +1,11 @@
+# Dashboard Single sign-On
+
+Single Sign-On is a mechanism that allows a user to automatically sign in to multiple applications after signing in to one. This improves convenience and security.
+
+## Contributing
+
+Please see our [contributing.md](../../CONTRIBUTING.md).
+
+## License
+
+See EMQ Business Source License 1.1, refer to [LICENSE](BSL.txt).

+ 1 - 0
apps/emqx_dashboard_sso/docker-ct

@@ -0,0 +1 @@
+

+ 7 - 0
apps/emqx_dashboard_sso/rebar.config

@@ -0,0 +1,7 @@
+%% -*- mode: erlang; -*-
+
+{erl_opts, [debug_info]}.
+{deps, [
+        {emqx_ldap, {path, "../../apps/emqx_ldap"}},
+        {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}
+]}.

+ 19 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src

@@ -0,0 +1,19 @@
+{application, emqx_dashboard_sso, [
+    {description, "EMQX Dashboard Single Sign-On"},
+    {vsn, "0.1.0"},
+    {registered, [emqx_dashboard_sso_sup]},
+    {applications, [
+        kernel,
+        stdlib,
+        emqx_dashboard,
+        emqx_ldap
+    ]},
+    {mod, {emqx_dashboard_sso_app, []}},
+    {env, []},
+    {modules, []},
+    {maintainers, ["EMQX Team <contact@emqx.io>"]},
+    {links, [
+        {"Homepage", "https://emqx.io/"},
+        {"Github", "https://github.com/emqx/emqx-dashboard5"}
+    ]}
+]}.

+ 79 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl

@@ -0,0 +1,79 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso).
+
+-include_lib("hocon/include/hoconsc.hrl").
+
+-export([
+    hocon_ref/1,
+    login_ref/1,
+    create/2,
+    update/3,
+    destroy/2,
+    login/3
+]).
+
+-export([types/0, modules/0, provider/1, backends/0]).
+
+%%------------------------------------------------------------------------------
+%% Callbacks
+%%------------------------------------------------------------------------------
+-type request() :: map().
+-type parsed_config() :: #{
+    backend => atom(),
+    atom() => term()
+}.
+-type state() :: #{atom() => term()}.
+-type raw_config() :: #{binary() => term()}.
+-type config() :: parsed_config() | raw_config().
+-type hocon_ref() :: ?R_REF(Module :: atom(), Name :: atom() | binary()).
+
+-callback hocon_ref() -> hocon_ref().
+-callback login_ref() -> hocon_ref().
+-callback create(Config :: config()) ->
+    {ok, State :: state()} | {error, Reason :: term()}.
+-callback update(Config :: config(), State :: state()) ->
+    {ok, NewState :: state()} | {error, Reason :: term()}.
+-callback destroy(State :: state()) -> ok.
+-callback login(request(), State :: state()) ->
+    {ok, Token :: binary()} | {error, Reason :: term()}.
+
+%%------------------------------------------------------------------------------
+%% Callback Interface
+%%------------------------------------------------------------------------------
+-spec hocon_ref(Mod :: module()) -> hocon_ref().
+hocon_ref(Mod) ->
+    Mod:hocon_ref().
+
+-spec login_ref(Mod :: module()) -> hocon_ref().
+login_ref(Mod) ->
+    Mod:login_ref().
+
+create(Mod, Config) ->
+    Mod:create(Config).
+
+update(Mod, Config, State) ->
+    Mod:update(Config, State).
+
+destroy(Mod, State) ->
+    Mod:destroy(State).
+
+login(Mod, Req, State) ->
+    Mod:login(Req, State).
+
+%%------------------------------------------------------------------------------
+%% API
+%%------------------------------------------------------------------------------
+types() ->
+    maps:keys(backends()).
+
+modules() ->
+    maps:values(backends()).
+
+provider(Backend) ->
+    maps:get(Backend, backends()).
+
+backends() ->
+    #{ldap => emqx_dashboard_sso_ldap}.

+ 225 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl

@@ -0,0 +1,225 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_api).
+
+-behaviour(minirest_api).
+
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("emqx/include/logger.hrl").
+
+-import(hoconsc, [
+    mk/2,
+    array/1,
+    enum/1,
+    ref/1
+]).
+
+-export([
+    api_spec/0,
+    fields/1,
+    paths/0,
+    schema/1,
+    namespace/0
+]).
+
+-export([
+    login/2,
+    sso/2,
+    backend/2
+]).
+
+-export([sso_parameters/1]).
+
+-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD').
+-define(BAD_REQUEST, 'BAD_REQUEST').
+-define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND').
+-define(TAGS, <<"Dashboard Single Sign-On">>).
+
+namespace() -> "dashboard_sso".
+
+api_spec() ->
+    emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}).
+
+paths() ->
+    [
+        "/sso",
+        "/sso/login/:backend",
+        "/sso/:backend"
+    ].
+
+schema("/sso") ->
+    #{
+        'operationId' => sso,
+        get => #{
+            tags => [?TAGS],
+            desc => ?DESC(get_sso),
+            responses => #{
+                200 => array(ref(backend_status))
+            }
+        }
+    };
+schema("/sso/login/:backend") ->
+    #{
+        'operationId' => login,
+        post => #{
+            tags => [?TAGS],
+            desc => ?DESC(login),
+            parameters => backend_name_in_path(),
+            'requestBody' => login_union(),
+            responses => #{
+                200 => emqx_dashboard_api:fields([token, version, license]),
+                401 => response_schema(401),
+                404 => response_schema(404)
+            }
+        }
+    };
+schema("/sso/:backend") ->
+    #{
+        'operationId' => backend,
+        get => #{
+            tags => [?TAGS],
+            desc => ?DESC(get_backend),
+            parameters => backend_name_in_path(),
+            responses => #{
+                200 => backend_union(),
+                404 => response_schema(404)
+            }
+        },
+        put => #{
+            tags => [?TAGS],
+            desc => ?DESC(update_backend),
+            parameters => backend_name_in_path(),
+            'requestBody' => backend_union(),
+            responses => #{
+                200 => backend_union(),
+                404 => response_schema(404)
+            }
+        },
+        delete => #{
+            tags => [?TAGS],
+            desc => ?DESC(delete_backend),
+            parameters => backend_name_in_path(),
+            responses => #{
+                204 => <<"Delete successfully">>,
+                404 => response_schema(404)
+            }
+        }
+    }.
+
+fields(backend_status) ->
+    emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()).
+
+%% -------------------------------------------------------------------------------------------------
+%% API
+login(post, #{bindings := #{backend := Backend}, body := Sign}) ->
+    case emqx_dashboard_sso_manager:lookup_state(Backend) of
+        undefined ->
+            {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>};
+        State ->
+            Provider = emqx_dashboard_sso:provider(Backend),
+            case emqx_dashboard_sso:login(Provider, Sign, State) of
+                {ok, Token} ->
+                    ?SLOG(info, #{msg => "Dashboard SSO login successfully", request => Sign}),
+                    Version = iolist_to_binary(proplists:get_value(version, emqx_sys:info())),
+                    {200, #{
+                        token => Token,
+                        version => Version,
+                        license => #{edition => emqx_release:edition()}
+                    }};
+                {error, Reason} ->
+                    ?SLOG(info, #{
+                        msg => "Dashboard SSO login failed",
+                        request => Sign,
+                        reason => Reason
+                    }),
+                    {401, ?BAD_USERNAME_OR_PWD, <<"Auth failed">>}
+            end
+    end.
+
+sso(get, _Request) ->
+    SSO = emqx:get_config([dashboard_sso], #{}),
+    {200,
+        lists:map(
+            fun(Backend) ->
+                maps:with([backend, enable], Backend)
+            end,
+            maps:values(SSO)
+        )}.
+
+backend(get, #{bindings := #{backend := Type}}) ->
+    case emqx:get_config([dashboard_sso, Type], undefined) of
+        undefined ->
+            {404, ?BACKEND_NOT_FOUND};
+        Backend ->
+            {200, to_json(Backend)}
+    end;
+backend(put, #{bindings := #{backend := Backend}, body := Config}) ->
+    on_backend_update(Backend, Config, fun emqx_dashboard_sso_manager:update/2);
+backend(delete, #{bindings := #{backend := Backend}}) ->
+    handle_backend_update_result(emqx_dashboard_sso_manager:delete(Backend), undefined).
+
+sso_parameters(Params) ->
+    backend_name_as_arg(query, [local], <<"local">>) ++ Params.
+
+%% -------------------------------------------------------------------------------------------------
+%% internal
+response_schema(401) ->
+    emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401));
+response_schema(404) ->
+    emqx_dashboard_swagger:error_codes([?BACKEND_NOT_FOUND], ?DESC(backend_not_found)).
+
+backend_union() ->
+    hoconsc:union([emqx_dashboard_sso:hocon_ref(Mod) || Mod <- emqx_dashboard_sso:modules()]).
+
+login_union() ->
+    hoconsc:union([emqx_dashboard_sso:login_ref(Mod) || Mod <- emqx_dashboard_sso:modules()]).
+
+backend_name_in_path() ->
+    backend_name_as_arg(path, [], <<"ldap">>).
+
+backend_name_as_arg(In, Extra, Default) ->
+    [
+        {backend,
+            mk(
+                enum(Extra ++ emqx_dashboard_sso:types()),
+                #{
+                    in => In,
+                    desc => ?DESC(backend_name_in_qs),
+                    required => false,
+                    example => Default
+                }
+            )}
+    ].
+
+on_backend_update(Backend, Config, Fun) ->
+    Result = valid_config(Backend, Config, Fun),
+    handle_backend_update_result(Result, Config).
+
+valid_config(Backend, Config, Fun) ->
+    case maps:get(<<"backend">>, Config, undefined) of
+        Backend ->
+            Fun(Backend, Config);
+        _ ->
+            {error, invalid_config}
+    end.
+
+handle_backend_update_result({ok, _}, Config) ->
+    {200, to_json(Config)};
+handle_backend_update_result(ok, _) ->
+    204;
+handle_backend_update_result({error, not_exists}, _) ->
+    {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>};
+handle_backend_update_result({error, already_exists}, _) ->
+    {400, ?BAD_REQUEST, <<"Backend already exists">>};
+handle_backend_update_result({error, Reason}, _) ->
+    {400, ?BAD_REQUEST, Reason}.
+
+to_json(Data) ->
+    emqx_utils_maps:jsonable_map(
+        Data,
+        fun(K, V) ->
+            {K, emqx_utils_maps:binary_string(V)}
+        end
+    ).

+ 18 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl

@@ -0,0 +1,18 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_app).
+
+-behaviour(application).
+
+-export([
+    start/2,
+    stop/1
+]).
+
+start(_StartType, _StartArgs) ->
+    emqx_dashboard_sso_sup:start_link().
+
+stop(_State) ->
+    ok.

+ 144 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl

@@ -0,0 +1,144 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_ldap).
+
+-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
+-include_lib("emqx/include/logger.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("eldap/include/eldap.hrl").
+
+-behaviour(emqx_dashboard_sso).
+
+-export([
+    fields/1,
+    desc/1
+]).
+
+-export([
+    hocon_ref/0,
+    login_ref/0,
+    login/2,
+    create/1,
+    update/2,
+    destroy/1
+]).
+
+%%------------------------------------------------------------------------------
+%% Hocon Schema
+%%------------------------------------------------------------------------------
+
+hocon_ref() ->
+    hoconsc:ref(?MODULE, ldap).
+
+login_ref() ->
+    hoconsc:ref(?MODULE, login).
+
+fields(ldap) ->
+    emqx_dashboard_sso_schema:common_backend_schema([ldap]) ++
+        [
+            {query_timeout, fun query_timeout/1}
+        ] ++
+        emqx_ldap:fields(config) ++ emqx_ldap:fields(bind_opts);
+fields(login) ->
+    [
+        emqx_dashboard_sso_schema:backend_schema([ldap])
+        | emqx_dashboard_sso_schema:username_password_schema()
+    ].
+
+query_timeout(type) -> emqx_schema:timeout_duration_ms();
+query_timeout(desc) -> ?DESC(?FUNCTION_NAME);
+query_timeout(default) -> <<"5s">>;
+query_timeout(_) -> undefined.
+
+desc(ldap) ->
+    "LDAP";
+desc(_) ->
+    undefined.
+
+%%------------------------------------------------------------------------------
+%% APIs
+%%------------------------------------------------------------------------------
+
+create(Config0) ->
+    ResourceId = emqx_dashboard_sso_manager:make_resource_id(ldap),
+    {Config, State} = parse_config(Config0),
+    case emqx_dashboard_sso_manager:create_resource(ResourceId, emqx_ldap, Config) of
+        {ok, _} ->
+            {ok, State#{resource_id => ResourceId}};
+        {error, _} = Error ->
+            Error
+    end.
+
+update(Config0, #{resource_id := ResourceId} = _State) ->
+    {Config, NState} = parse_config(Config0),
+    case emqx_dashboard_sso_manager:update_resource(ResourceId, emqx_ldap, Config) of
+        {ok, _} ->
+            {ok, NState#{resource_id => ResourceId}};
+        {error, _} = Error ->
+            Error
+    end.
+
+destroy(#{resource_id := ResourceId}) ->
+    _ = emqx_resource:remove_local(ResourceId),
+    ok.
+
+login(
+    #{<<"username">> := Username} = Req,
+    #{
+        query_timeout := Timeout,
+        resource_id := ResourceId
+    } = _State
+) ->
+    case
+        emqx_resource:simple_sync_query(
+            ResourceId,
+            {query, Req, [], Timeout}
+        )
+    of
+        {ok, []} ->
+            {error, user_not_found};
+        {ok, [_Entry | _]} ->
+            case
+                emqx_resource:simple_sync_query(
+                    ResourceId,
+                    {bind, Req}
+                )
+            of
+                ok ->
+                    ensure_user_exists(Username);
+                {error, _} = Error ->
+                    Error
+            end;
+        {error, _} = Error ->
+            Error
+    end.
+
+parse_config(Config) ->
+    State = lists:foldl(
+        fun(Key, Acc) ->
+            case maps:find(Key, Config) of
+                {ok, Value} when is_binary(Value) ->
+                    Acc#{Key := erlang:binary_to_list(Value)};
+                _ ->
+                    Acc
+            end
+        end,
+        Config,
+        [query_timeout]
+    ),
+    {Config, State}.
+
+ensure_user_exists(Username) ->
+    case emqx_dashboard_admin:lookup_user(ldap, Username) of
+        [User] ->
+            {ok, emqx_dashboard_token:sign(User, <<>>)};
+        [] ->
+            case emqx_dashboard_admin:add_sso_user(ldap, Username, ?ROLE_VIEWER, <<>>) of
+                {ok, _} ->
+                    ensure_user_exists(Username);
+                Error ->
+                    Error
+            end
+    end.

+ 252 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl

@@ -0,0 +1,252 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_manager).
+
+-behaviour(gen_server).
+
+%% API
+-export([start_link/0]).
+
+%% gen_server callbacks
+-export([
+    init/1,
+    handle_call/3,
+    handle_cast/2,
+    handle_info/2,
+    terminate/2,
+    code_change/3,
+    format_status/2
+]).
+
+-export([
+    running/0,
+    lookup_state/1,
+    make_resource_id/1,
+    create_resource/3,
+    update_resource/3,
+    call/1
+]).
+
+-export([
+    update/2,
+    delete/1,
+    pre_config_update/3,
+    post_config_update/5
+]).
+
+-import(emqx_dashboard_sso, [provider/1]).
+
+-define(MOD_KEY_PATH, [dashboard_sso]).
+-define(RESOURCE_GROUP, <<"emqx_dashboard_sso">>).
+-define(DEFAULT_RESOURCE_OPTS, #{
+    start_after_created => false
+}).
+
+-record(dashboard_sso, {
+    backend :: atom(),
+    state :: map()
+}).
+
+%%------------------------------------------------------------------------------
+%% API
+%%------------------------------------------------------------------------------
+start_link() ->
+    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+running() ->
+    maps:fold(
+        fun
+            (Type, #{enable := true}, Acc) ->
+                [Type | Acc];
+            (_Type, _Cfg, Acc) ->
+                Acc
+        end,
+        [],
+        emqx:get_config([emqx_dashboard_sso])
+    ).
+
+update(Backend, Config) ->
+    update_config(Backend, {?FUNCTION_NAME, Backend, Config}).
+delete(Backend) ->
+    update_config(Backend, {?FUNCTION_NAME, Backend}).
+
+lookup_state(Backend) ->
+    case ets:lookup(dashboard_sso, Backend) of
+        [Data] ->
+            Data#dashboard_sso.state;
+        [] ->
+            undefined
+    end.
+
+make_resource_id(Backend) ->
+    BackendBin = bin(Backend),
+    emqx_resource:generate_id(<<"sso:", BackendBin/binary>>).
+
+create_resource(ResourceId, Module, Config) ->
+    Result = emqx_resource:create_local(
+        ResourceId,
+        ?RESOURCE_GROUP,
+        Module,
+        Config,
+        ?DEFAULT_RESOURCE_OPTS
+    ),
+    start_resource_if_enabled(ResourceId, Result, Config).
+
+update_resource(ResourceId, Module, Config) ->
+    Result = emqx_resource:recreate_local(
+        ResourceId, Module, Config, ?DEFAULT_RESOURCE_OPTS
+    ),
+    start_resource_if_enabled(ResourceId, Result, Config).
+
+call(Req) ->
+    gen_server:call(?MODULE, Req).
+
+%%------------------------------------------------------------------------------
+%% gen_server callbacks
+%%------------------------------------------------------------------------------
+init([]) ->
+    process_flag(trap_exit, true),
+    emqx_conf:add_handler(?MOD_KEY_PATH, ?MODULE),
+    emqx_utils_ets:new(
+        dashboard_sso,
+        [
+            set,
+            public,
+            named_table,
+            {keypos, #dashboard_sso.backend},
+            {read_concurrency, true}
+        ]
+    ),
+    start_backend_services(),
+    {ok, #{}}.
+
+handle_call({update_config, Req, NewConf, OldConf}, _From, State) ->
+    Result = on_config_update(Req, NewConf, OldConf),
+    io:format(">>> on_config_update:~p~n,Req:~p~n NewConf:~p~n OldConf:~p~n", [
+        Result, Req, NewConf, OldConf
+    ]),
+    {reply, Result, State};
+handle_call(_Request, _From, State) ->
+    Reply = ok,
+    {reply, Reply, State}.
+
+handle_cast(_Request, State) ->
+    {noreply, State}.
+
+handle_info(_Info, State) ->
+    {noreply, State}.
+
+terminate(_Reason, _State) ->
+    emqx_conf:remove_handler(?MOD_KEY_PATH),
+    ok.
+
+code_change(_OldVsn, State, _Extra) ->
+    {ok, State}.
+
+format_status(_Opt, Status) ->
+    Status.
+
+%%------------------------------------------------------------------------------
+%% Internal functions
+%%------------------------------------------------------------------------------
+start_backend_services() ->
+    Backends = emqx_conf:get([dashboard_sso], #{}),
+    lists:foreach(
+        fun({Backend, Config}) ->
+            Provider = provider(Backend),
+            on_backend_updated(
+                emqx_dashboard_sso:create(Provider, Config),
+                fun(State) ->
+                    ets:insert(dashboard_sso, #dashboard_sso{backend = Backend, state = State})
+                end
+            )
+        end,
+        maps:to_list(Backends)
+    ).
+
+update_config(_Backend, UpdateReq) ->
+    case emqx_conf:update([dashboard_sso], UpdateReq, #{override_to => cluster}) of
+        {ok, UpdateResult} ->
+            #{post_config_update := #{?MODULE := Result}} = UpdateResult,
+            Result;
+        Error ->
+            Error
+    end.
+
+pre_config_update(_Path, {update, Backend, Config}, OldConf) ->
+    BackendBin = bin(Backend),
+    {ok, OldConf#{BackendBin => Config}};
+pre_config_update(_Path, {delete, Backend}, OldConf) ->
+    BackendBin = bin(Backend),
+    case maps:find(BackendBin, OldConf) of
+        error ->
+            throw(not_exists);
+        {ok, _} ->
+            {ok, maps:remove(BackendBin, OldConf)}
+    end.
+
+post_config_update(_Path, UpdateReq, NewConf, OldConf, _AppEnvs) ->
+    Result = call({update_config, UpdateReq, NewConf, OldConf}),
+    {ok, Result}.
+
+on_config_update({update, Backend, _Config}, NewConf, _OldConf) ->
+    Provider = provider(Backend),
+    Config = maps:get(Backend, NewConf),
+    case lookup(Backend) of
+        undefined ->
+            on_backend_updated(
+                emqx_dashboard_sso:create(Provider, Config),
+                fun(State) ->
+                    ets:insert(dashboard_sso, #dashboard_sso{backend = Backend, state = State})
+                end
+            );
+        Data ->
+            on_backend_updated(
+                emqx_dashboard_sso:update(Provider, Config, Data#dashboard_sso.state),
+                fun(State) ->
+                    ets:insert(dashboard_sso, Data#dashboard_sso{state = State})
+                end
+            )
+    end;
+on_config_update({delete, Backend}, _NewConf, _OldConf) ->
+    case lookup(Backend) of
+        undefined ->
+            {error, not_exists};
+        Data ->
+            Provider = provider(Backend),
+            on_backend_updated(
+                emqx_dashboard_sso:destroy(Provider, Data#dashboard_sso.state),
+                fun() ->
+                    ets:delete(dashboard_sso, Backend)
+                end
+            )
+    end.
+
+lookup(Backend) ->
+    case ets:lookup(dashboard_sso, Backend) of
+        [Data] ->
+            Data;
+        [] ->
+            undefined
+    end.
+
+start_resource_if_enabled(ResourceId, {ok, _} = Result, #{enable := true}) ->
+    _ = emqx_resource:start(ResourceId),
+    Result;
+start_resource_if_enabled(_ResourceId, Result, _Config) ->
+    Result.
+
+on_backend_updated({ok, State} = Ok, Fun) ->
+    Fun(State),
+    Ok;
+on_backend_updated(ok, Fun) ->
+    Fun(),
+    ok;
+on_backend_updated(Error, _) ->
+    Error.
+
+bin(A) when is_atom(A) -> atom_to_binary(A, utf8);
+bin(L) when is_list(L) -> list_to_binary(L);
+bin(X) -> X.

+ 83 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_schema.erl

@@ -0,0 +1,83 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_schema).
+
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("typerefl/include/types.hrl").
+
+%% Hocon
+-export([namespace/0, roots/0, fields/1, tags/0, desc/1]).
+-export([
+    common_backend_schema/1,
+    backend_schema/1,
+    username_password_schema/0
+]).
+-import(hoconsc, [ref/2, mk/2, enum/1]).
+
+%%------------------------------------------------------------------------------
+%% Hocon Schema
+%%------------------------------------------------------------------------------
+namespace() -> dashboard_sso.
+
+tags() ->
+    [<<"Dashboard Single Sign-On">>].
+
+roots() -> [dashboard_sso].
+
+fields(dashboard_sso) ->
+    lists:map(
+        fun({Type, Module}) ->
+            {Type, mk(emqx_dashboard_sso:hocon_ref(Module), #{required => {false, recursively}})}
+        end,
+        maps:to_list(emqx_dashboard_sso:backends())
+    ).
+
+desc(dashboard_sso) ->
+    "Dashboard Single Sign-On";
+desc(_) ->
+    undefined.
+
+-spec common_backend_schema(list(atom())) -> proplists:proplist().
+common_backend_schema(Backend) ->
+    [
+        {enable,
+            mk(
+                boolean(), #{
+                    desc => ?DESC(backend_enable),
+                    required => false,
+                    default => false
+                }
+            )},
+        backend_schema(Backend)
+    ].
+
+backend_schema(Backend) ->
+    {backend,
+        mk(enum(Backend), #{
+            required => true,
+            desc => ?DESC(backend)
+        })}.
+
+username_password_schema() ->
+    [
+        {username,
+            mk(
+                binary(),
+                #{
+                    desc => ?DESC(username),
+                    'maxLength' => 100,
+                    example => <<"admin">>
+                }
+            )},
+        {password,
+            mk(
+                binary(),
+                #{
+                    desc => ?DESC(password),
+                    'maxLength' => 100,
+                    example => <<"public">>
+                }
+            )}
+    ].

+ 22 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl

@@ -0,0 +1,22 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_sup).
+
+-behaviour(supervisor).
+
+-export([start_link/0]).
+
+-export([init/1]).
+
+-define(CHILD(I, ShutDown), {I, {I, start_link, []}, permanent, ShutDown, worker, [I]}).
+
+start_link() ->
+    supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+init([]) ->
+    {ok,
+        {{one_for_one, 5, 100}, [
+            ?CHILD(emqx_dashboard_sso_manager, 5000)
+        ]}}.

+ 1 - 1
apps/emqx_enterprise/src/emqx_enterprise.app.src

@@ -1,6 +1,6 @@
 {application, emqx_enterprise, [
     {description, "EMQX Enterprise Edition"},
-    {vsn, "0.1.2"},
+    {vsn, "0.1.3"},
     {registered, []},
     {applications, [
         kernel,

+ 2 - 1
apps/emqx_enterprise/src/emqx_enterprise_schema.erl

@@ -11,7 +11,8 @@
 -define(EE_SCHEMA_MODULES, [
     emqx_license_schema,
     emqx_schema_registry_schema,
-    emqx_ft_schema
+    emqx_ft_schema,
+    emqx_dashboard_sso_schema
 ]).
 
 namespace() ->

+ 15 - 2
apps/emqx_ldap/src/emqx_ldap.erl

@@ -25,7 +25,7 @@
 %% ecpool connect & reconnect
 -export([connect/1]).
 
--export([roots/0, fields/1]).
+-export([roots/0, fields/1, desc/1]).
 
 -export([do_get_status/1]).
 
@@ -75,8 +75,16 @@ fields(config) ->
             ?HOCON(emqx_schema:timeout_duration_ms(), #{
                 desc => ?DESC(request_timeout),
                 default => <<"5s">>
+            })},
+        {ssl,
+            ?HOCON(?R_REF(?MODULE, ssl), #{
+                default => #{<<"enable">> => false},
+                desc => ?DESC(emqx_connector_schema_lib, "ssl")
             })}
-    ] ++ emqx_connector_schema_lib:ssl_fields();
+    ];
+fields(ssl) ->
+    Schema = emqx_schema:client_ssl_opts_schema(#{}),
+    lists:keydelete("user_lookup_fun", 1, Schema);
 fields(bind_opts) ->
     [
         {bind_password,
@@ -92,6 +100,11 @@ fields(bind_opts) ->
             )}
     ].
 
+desc(ssl) ->
+    ?DESC(emqx_connector_schema_lib, "ssl");
+desc(_) ->
+    undefined.
+
 server() ->
     Meta = #{desc => ?DESC("server")},
     emqx_schema:servers_sc(Meta, ?LDAP_HOST_OPTIONS).

+ 2 - 1
apps/emqx_machine/priv/reboot_lists.eterm

@@ -115,7 +115,8 @@
             emqx_ft,
             emqx_ldap,
             emqx_gcp_device,
-            emqx_dashboard_rbac
+            emqx_dashboard_rbac,
+            emqx_dashboard_sso
         ],
     %% must always be of type `load'
     ce_business_apps =>

+ 2 - 1
mix.exs

@@ -227,7 +227,8 @@ defmodule EMQXUmbrella.MixProject do
       :emqx_bridge_azure_event_hub,
       :emqx_ldap,
       :emqx_gcp_device,
-      :emqx_dashboard_rbac
+      :emqx_dashboard_rbac,
+      :emqx_dashboard_sso
     ])
   end
 

+ 1 - 0
rebar.config.erl

@@ -110,6 +110,7 @@ is_community_umbrella_app("apps/emqx_bridge_azure_event_hub") -> false;
 is_community_umbrella_app("apps/emqx_ldap") -> false;
 is_community_umbrella_app("apps/emqx_gcp_device") -> false;
 is_community_umbrella_app("apps/emqx_dashboard_rbac") -> false;
+is_community_umbrella_app("apps/emqx_dashboard_sso") -> false;
 is_community_umbrella_app(_) -> true.
 
 is_jq_supported() ->

+ 6 - 0
rel/i18n/emqx_dashboard_api.hocon

@@ -79,4 +79,10 @@ users_api404.desc:
 version.desc:
 """EMQX Version"""
 
+role.desc:
+"""User role"""
+
+backend.desc:
+"""User account source"""
+
 }

+ 40 - 0
rel/i18n/emqx_dashboard_sso_api.hocon

@@ -0,0 +1,40 @@
+emqx_dashboard_api {
+
+get_sso.desc:
+"""List all SSO backends"""
+get_sso.label:
+"""SSO Backends"""
+
+login.desc:
+"""Get Dashboard Auth Token."""
+login.label:
+"""Get Dashboard Auth Token."""
+
+get_backend.desc:
+"""Get details of a backend"""
+get_backend.label:
+"""Backend Details"""
+
+update_backend.desc:
+"""Update a backend"""
+update_backend.label:
+"""Update Backend"""
+
+delete_backend.desc:
+"""Delete a backend"""
+delete_backend.label:
+"""Delete Backend"""
+
+login_failed401.desc:
+"""Login failed. Bad username or password"""
+
+backend_not_found.desc:
+"""Operate failed. Backend not exists"""
+
+backend_name.desc:
+"""Backend name"""
+
+backend_name.label:
+"""Backend Name"""
+
+}

+ 11 - 0
rel/i18n/emqx_dashboard_sso_ldap.hocon

@@ -0,0 +1,11 @@
+emqx_dashboard_sso_ldap {
+
+ldap_bind.desc:
+"""Configuration of authenticator using the LDAP bind operation as the authentication method."""
+
+query_timeout.desc:
+"""Timeout for the LDAP query."""
+
+query_timeout.label:
+"""Query Timeout"""
+}

+ 11 - 0
rel/i18n/emqx_dashboard_sso_schema.hocon

@@ -0,0 +1,11 @@
+emqx_dashboard_sso_schema {
+
+backend_enable.desc:
+"""Whether to enable this backend."""
+
+backend.desc:
+"""Backend type."""
+
+backend.label:
+"""Backend Type"""
+}