Prechádzať zdrojové kódy

feat(dashboard): add SSO feature and integrate with LDAP

firest 2 rokov pred
rodič
commit
2cddce5479

+ 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).

+ 21 - 6
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,11 @@ 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(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};
@@ -115,6 +120,8 @@ do_add_user(Username, Password, Role, Desc) ->
     return(Res).
 
 %% 0-9 or A-Z or a-z or $_
+legal_username(?SSO_USERNAME(_, _)) ->
+    ok;
 legal_username(<<>>) ->
     {error, <<"Username cannot be empty">>};
 legal_username(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.
@@ -410,6 +417,14 @@ legal_role(Role) ->
 role(Data) ->
     emqx_dashboard_rbac:role(Data).
 
+-spec add_sso_user(atom(), binary(), dashboard_user_role(), binary()) ->
+    {ok, map()} | {error, any()}.
+add_sso_user(Backend, Username, Role, Desc) ->
+    add_user(?SSO_USERNAME(Backend, Username), <<>>, Role, Desc).
+
+-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]}).

+ 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 [APL](../../APL.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"}
+    ]}
+]}.

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

@@ -0,0 +1,45 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso).
+
+-include_lib("hocon/include/hoconsc.hrl").
+
+-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().
+
+-callback hocon_ref() -> ?R_REF(Module :: atom(), Name :: atom() | binary()).
+-callback login_ref() -> ?R_REF(Module :: atom(), Name :: atom() | binary()).
+-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 sign(request(), State :: state()) ->
+    {ok, Token :: binary()} | {error, Reason :: term()}.
+
+%%------------------------------------------------------------------------------
+%% API
+%%------------------------------------------------------------------------------
+types() ->
+    maps:keys(backends()).
+
+modules() ->
+    maps:values(backends()).
+
+provider(Backend) ->
+    maps:get(Backend, backends()).
+
+backends() ->
+    #{ldap => emqx_dashboard_sso_ldap}.

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

@@ -0,0 +1,235 @@
+%%--------------------------------------------------------------------
+%% 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").
+-include_lib("typerefl/include/types.hrl").
+
+-import(hoconsc, [
+    mk/2,
+    array/1,
+    enum/1,
+    ref/1,
+    union/1
+]).
+
+-export([
+    api_spec/0,
+    fields/1,
+    paths/0,
+    schema/1,
+    namespace/0
+]).
+
+-export([
+    running/2,
+    login/2,
+    sso/2,
+    backend/2
+]).
+
+-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",
+        "/sso/running",
+        "/sso/:backend"
+    ].
+
+schema("/sso") ->
+    #{
+        'operationId' => sso,
+        get => #{
+            tags => [?TAGS],
+            desc => ?DESC(get_sso),
+            responses => #{
+                200 => array(ref(backend_status))
+            }
+        }
+    };
+schema("/sso/login") ->
+    #{
+        'operationId' => login,
+        post => #{
+            tags => [?TAGS],
+            desc => ?DESC(login),
+            'requestBody' => login_union(),
+            responses => #{
+                200 => emqx_dashboard_api:fields([token, version, license]),
+                401 => response_schema(401),
+                404 => response_schema(404)
+            }
+        }
+    };
+schema("/sso/running") ->
+    #{
+        'operationId' => running,
+        get => #{
+            tags => [?TAGS],
+            desc => ?DESC(get_running),
+            responses => #{
+                200 => array(enum(emqx_dashboard_sso:types()))
+            }
+        }
+    };
+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)
+            }
+        },
+        post => #{
+            tags => [?TAGS],
+            desc => ?DESC(create_backend),
+            parameters => backend_name_in_path(),
+            'requestBody' => backend_union(),
+            responses => #{
+                200 => backend_union()
+            }
+        },
+        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(enum(emqx_dashboard_sso:types())).
+
+%% -------------------------------------------------------------------------------------------------
+%% API
+running(get, _Request) ->
+    {200, emqx_dashboard_sso_manager:running()}.
+
+login(post, #{backend := Backend} = Request) ->
+    case emqx_dashboard_sso_manager:lookup_state(Backend) of
+        undefined ->
+            {404, ?BACKEND_NOT_FOUND};
+        State ->
+            Provider = emqx_dashboard_sso:provider(Backend),
+            case Provider:login(Request, State) of
+                {ok, Token} ->
+                    ?SLOG(info, #{msg => "Dashboard SSO login successfully", request => Request}),
+                    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 => Request, 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, enabled], 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, Backend}
+    end;
+backend(create, #{bindings := #{backend := Backend}, body := Config}) ->
+    on_backend_update(Backend, Config, fun emqx_dashboard_sso_manager:create/2);
+backend(put, #{bindings := #{backend := Backend}, body := Config}) ->
+    on_backend_update(Backend, Config, fun emqx_dashboard_sso_manager:update/2);
+backend(delete, #{bindings := #{backend := Backend}, body := Config}) ->
+    on_backend_update(Backend, Config, fun emqx_dashboard_sso_manager:delete/2).
+
+%% -------------------------------------------------------------------------------------------------
+%% 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([Mod:hocon_ref() || Mod <- emqx_dashboard_sso:modules()]).
+
+login_union() ->
+    hoconsc:union([Mod:login_ref() || Mod <- emqx_dashboard_sso:modules()]).
+
+backend_name_in_path() ->
+    [
+        {name,
+            mk(
+                binary(),
+                #{
+                    in => path,
+                    desc => ?DESC(backend_name_in_qs),
+                    example => <<"ldap">>
+                }
+            )}
+    ].
+
+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, Config};
+handle_backend_update_result(ok, _) ->
+    204;
+handle_backend_update_result({error, not_exists}, _) ->
+    {404, ?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}.

+ 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.

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

@@ -0,0 +1,134 @@
+%%--------------------------------------------------------------------
+%% 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
+]).
+
+-export([
+    hocon_ref/0,
+    login_ref/0,
+    sign/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.
+
+%%------------------------------------------------------------------------------
+%% 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
+    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
+    end.
+
+destroy(#{resource_id := ResourceId}) ->
+    _ = emqx_resource:remove_local(ResourceId),
+    ok.
+
+sign(
+    #{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 ->
+                    User = ensure_user_exists(Username),
+                    {ok, emqx_dashboard_token:sign(User, <<>>)};
+                {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] ->
+            User;
+        [] ->
+            emqx_dashboard_admin:add_sso_user(ldap, Username, ?ROLE_VIEWER, <<>>)
+    end.

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

@@ -0,0 +1,269 @@
+%%--------------------------------------------------------------------
+%% 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([
+    create/2,
+    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])
+    ).
+
+create(Backend, Config) ->
+    update_config(Backend, {?FUNCTION_NAME, Backend, Config}).
+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(BackendBin).
+
+create_resource(ResourceId, Module, Config) ->
+    Result = emqx_resource:create_local(
+        ResourceId,
+        ?RESOURCE_GROUP,
+        Module,
+        Config,
+        ?DEFAULT_RESOURCE_OPTS
+    ),
+    start_resource_if_enabled(Result, ResourceId, 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),
+    {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(
+                Provider:create(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, Backend], UpdateReq, #{override_to => cluster}) of
+        {ok, UpdateResult} ->
+            #{post_config_update := #{?MODULE := Result}} = UpdateResult,
+            {ok, Result};
+        Error ->
+            Error
+    end.
+
+pre_config_update(_Path, {create, Backend, Config}, OldConf) ->
+    case maps:find(Backend, OldConf) of
+        {ok, _} ->
+            throw(already_exists);
+        error ->
+            {ok, OldConf#{Backend => Config}}
+    end;
+pre_config_update(_Path, {update, Backend, Config}, OldConf) ->
+    case maps:find(Backend, OldConf) of
+        error ->
+            throw(not_exists);
+        {ok, _} ->
+            {ok, OldConf#{Backend => Config}}
+    end;
+pre_config_update(_Path, {delete, Backend}, OldConf) ->
+    case maps:find(Backend, OldConf) of
+        error ->
+            throw(not_exists);
+        {ok, _} ->
+            {ok, maps:remove(Backend, OldConf)}
+    end.
+
+post_config_update(_Path, UpdateReq, NewConf, OldConf, _AppEnvs) ->
+    Result = call({update_config, UpdateReq, NewConf, OldConf}),
+    {ok, Result}.
+
+on_config_update({create, Backend, Config}, _NewConf, _OldConf) ->
+    case lookup(Backend) of
+        undefined ->
+            Provider = provider(Backend),
+            on_backend_updated(
+                Provider:create(Config),
+                fun(State) ->
+                    ets:insert(dashboard_sso, #dashboard_sso{backend = Backend, state = State})
+                end
+            );
+        _Data ->
+            {error, already_exists}
+    end;
+on_config_update({update, Backend, Config}, _NewConf, _OldConf) ->
+    case lookup(Backend) of
+        undefined ->
+            {error, not_exists};
+        Data ->
+            Provider = provider(Backend),
+            on_backend_updated(
+                Provider:update(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(
+                Provider:destroy(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.

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

@@ -0,0 +1,82 @@
+%%--------------------------------------------------------------------
+%% 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]).
+
+%%------------------------------------------------------------------------------
+%% Hocon Schema
+%%------------------------------------------------------------------------------
+namespace() -> dashboard_sso.
+
+tags() ->
+    [<<"Dashboard Single Sign-On">>].
+
+roots() -> [dashboard_sso].
+
+fields(dashboard_sso) ->
+    lists:map(
+        fun({Type, Module}) ->
+            {Type, mk(Module:hocon_ref(), #{required => {false, recursively}})}
+        end,
+        maps:to_list(emqx_dashboard_sso:backends())
+    ).
+
+desc(dashboard_sso) ->
+    "Dashboard Single Sign-On";
+desc(_) ->
+    undefined.
+
+common_backend_schema(Backend) ->
+    [
+        {enable,
+            mk(
+                boolean(), #{
+                    desc => ?DESC(backend_enable),
+                    required => false,
+                    default => false
+                }
+            )},
+        backend_schema(Backend)
+    ].
+
+backend_schema(Backend) ->
+    {backend,
+        mk(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)
+        ]}}.

+ 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() ->

+ 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() ->

+ 50 - 0
rel/i18n/emqx_dashboard_sso_api.hocon

@@ -0,0 +1,50 @@
+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_running.desc:
+"""Get Running SSO backends"""
+get_running.label:
+"""Running SSO"""
+
+get_backend.desc:
+"""Get details of a backend"""
+get_backend.label:
+"""Backend Details"""
+
+create_backend.desc:
+"""Create a backend"""
+create_backend.label:
+"""Create Backend"""
+
+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"""
+}