From 2cddce54795efca664cf453c5196716f2305c7fd Mon Sep 17 00:00:00 2001 From: firest Date: Tue, 19 Sep 2023 14:10:29 +0800 Subject: [PATCH] feat(dashboard): add SSO feature and integrate with LDAP --- .../emqx_dashboard/include/emqx_dashboard.hrl | 13 +- .../src/emqx_dashboard_admin.erl | 27 +- apps/emqx_dashboard_sso/BSL.txt | 94 ++++++ apps/emqx_dashboard_sso/README.md | 11 + apps/emqx_dashboard_sso/docker-ct | 1 + apps/emqx_dashboard_sso/rebar.config | 7 + .../src/emqx_dashboard_sso.app.src | 19 ++ .../src/emqx_dashboard_sso.erl | 45 +++ .../src/emqx_dashboard_sso_api.erl | 235 +++++++++++++++ .../src/emqx_dashboard_sso_app.erl | 18 ++ .../src/emqx_dashboard_sso_ldap.erl | 134 +++++++++ .../src/emqx_dashboard_sso_manager.erl | 269 ++++++++++++++++++ .../src/emqx_dashboard_sso_schema.erl | 82 ++++++ .../src/emqx_dashboard_sso_sup.erl | 22 ++ .../src/emqx_enterprise_schema.erl | 3 +- apps/emqx_machine/priv/reboot_lists.eterm | 3 +- mix.exs | 3 +- rebar.config.erl | 1 + rel/i18n/emqx_dashboard_sso_api.hocon | 50 ++++ rel/i18n/emqx_dashboard_sso_ldap.hocon | 11 + rel/i18n/emqx_dashboard_sso_schema.hocon | 11 + 21 files changed, 1046 insertions(+), 13 deletions(-) create mode 100644 apps/emqx_dashboard_sso/BSL.txt create mode 100644 apps/emqx_dashboard_sso/README.md create mode 100644 apps/emqx_dashboard_sso/docker-ct create mode 100644 apps/emqx_dashboard_sso/rebar.config create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_schema.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl create mode 100644 rel/i18n/emqx_dashboard_sso_api.hocon create mode 100644 rel/i18n/emqx_dashboard_sso_ldap.hocon create mode 100644 rel/i18n/emqx_dashboard_sso_schema.hocon diff --git a/apps/emqx_dashboard/include/emqx_dashboard.hrl b/apps/emqx_dashboard/include/emqx_dashboard.hrl index 8ebb4d3d5..8cb1853c9 100644 --- a/apps/emqx_dashboard/include/emqx_dashboard.hrl +++ b/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). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl index 06dac9a01..d5e83b293 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl +++ b/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]}). diff --git a/apps/emqx_dashboard_sso/BSL.txt b/apps/emqx_dashboard_sso/BSL.txt new file mode 100644 index 000000000..0acc0e696 --- /dev/null +++ b/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. diff --git a/apps/emqx_dashboard_sso/README.md b/apps/emqx_dashboard_sso/README.md new file mode 100644 index 000000000..da4ff7c9a --- /dev/null +++ b/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). diff --git a/apps/emqx_dashboard_sso/docker-ct b/apps/emqx_dashboard_sso/docker-ct new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/emqx_dashboard_sso/docker-ct @@ -0,0 +1 @@ + diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config new file mode 100644 index 000000000..efc4a4539 --- /dev/null +++ b/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"}} +]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src new file mode 100644 index 000000000..b35f4e23a --- /dev/null +++ b/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 "]}, + {links, [ + {"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-dashboard5"} + ]} +]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl new file mode 100644 index 000000000..957437161 --- /dev/null +++ b/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}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl new file mode 100644 index 000000000..5bd6a9da3 --- /dev/null +++ b/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}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl new file mode 100644 index 000000000..2ad280b5e --- /dev/null +++ b/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. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl new file mode 100644 index 000000000..94762081c --- /dev/null +++ b/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. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl new file mode 100644 index 000000000..63bb6536c --- /dev/null +++ b/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. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_schema.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_schema.erl new file mode 100644 index 000000000..ce9a53557 --- /dev/null +++ b/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">> + } + )} + ]. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl new file mode 100644 index 000000000..fb3f5a68d --- /dev/null +++ b/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) + ]}}. diff --git a/apps/emqx_enterprise/src/emqx_enterprise_schema.erl b/apps/emqx_enterprise/src/emqx_enterprise_schema.erl index a801825e0..c238dcea4 100644 --- a/apps/emqx_enterprise/src/emqx_enterprise_schema.erl +++ b/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() -> diff --git a/apps/emqx_machine/priv/reboot_lists.eterm b/apps/emqx_machine/priv/reboot_lists.eterm index d794dabd9..b90c52d36 100644 --- a/apps/emqx_machine/priv/reboot_lists.eterm +++ b/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 => diff --git a/mix.exs b/mix.exs index 68365f44c..451eacaad 100644 --- a/mix.exs +++ b/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 diff --git a/rebar.config.erl b/rebar.config.erl index 255a8e3cc..c81c40eb7 100644 --- a/rebar.config.erl +++ b/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() -> diff --git a/rel/i18n/emqx_dashboard_sso_api.hocon b/rel/i18n/emqx_dashboard_sso_api.hocon new file mode 100644 index 000000000..122ef0085 --- /dev/null +++ b/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""" + +} diff --git a/rel/i18n/emqx_dashboard_sso_ldap.hocon b/rel/i18n/emqx_dashboard_sso_ldap.hocon new file mode 100644 index 000000000..f15975416 --- /dev/null +++ b/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""" +} diff --git a/rel/i18n/emqx_dashboard_sso_schema.hocon b/rel/i18n/emqx_dashboard_sso_schema.hocon new file mode 100644 index 000000000..72d35a760 --- /dev/null +++ b/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""" +}