From 512b4b9cbb9a244d2dfc9f192b2ef679fc41db88 Mon Sep 17 00:00:00 2001 From: firest Date: Wed, 19 Jun 2024 21:22:27 +0800 Subject: [PATCH] feat(sso): add OIDC support --- apps/emqx_dashboard_sso/rebar.config | 3 +- .../src/emqx_dashboard_sso.app.src | 3 +- .../src/emqx_dashboard_sso.erl | 3 +- .../src/emqx_dashboard_sso_oidc.erl | 158 ++++++++++++++++++ .../src/emqx_dashboard_sso_oidc_api.erl | 156 +++++++++++++++++ rel/i18n/emqx_dashboard_sso_oidc.hocon | 21 +++ rel/i18n/emqx_dashboard_sso_oidc_api.hocon | 6 + 7 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl create mode 100644 rel/i18n/emqx_dashboard_sso_oidc.hocon create mode 100644 rel/i18n/emqx_dashboard_sso_oidc_api.hocon diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index 90172af3d..070e1edb1 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/apps/emqx_dashboard_sso/rebar.config @@ -4,5 +4,6 @@ {deps, [ {emqx_ldap, {path, "../../apps/emqx_ldap"}}, {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}, - {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}} + {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}}, + {oidcc, {git, "https://github.com/erlef/oidcc.git", {tag, "v3.2.0"}}} ]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src index 7f5e8f04a..ab40e3848 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src @@ -7,7 +7,8 @@ stdlib, emqx_dashboard, emqx_ldap, - esaml + esaml, + oidcc ]}, {mod, {emqx_dashboard_sso_app, []}}, {env, []}, diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl index 2750fc528..599a4ad8f 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl @@ -92,7 +92,8 @@ provider(Backend) -> backends() -> #{ ldap => emqx_dashboard_sso_ldap, - saml => emqx_dashboard_sso_saml + saml => emqx_dashboard_sso_saml, + oidc => emqx_dashboard_sso_oidc }. format(Args) -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl new file mode 100644 index 000000000..26e5a4d44 --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -0,0 +1,158 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc). + +-include_lib("emqx_dashboard/include/emqx_dashboard.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). + +-behaviour(emqx_dashboard_sso). + +-export([ + namespace/0, + fields/1, + desc/1 +]). + +-export([ + hocon_ref/0, + login_ref/0, + login/2, + create/1, + update/2, + destroy/1, + convert_certs/2 +]). + +-define(PROVIDER_SVR_NAME, sso_oidc_provider). +-define(RESPHEADERS, #{ + <<"cache-control">> => <<"no-cache">>, + <<"pragma">> => <<"no-cache">>, + <<"content-type">> => <<"text/plain">> +}). +-define(REDIRECT_BODY, <<"Redirecting...">>). + +%%------------------------------------------------------------------------------ +%% Hocon Schema +%%------------------------------------------------------------------------------ + +namespace() -> + "sso". + +hocon_ref() -> + hoconsc:ref(?MODULE, oidc). + +login_ref() -> + hoconsc:ref(?MODULE, login). + +fields(oidc) -> + emqx_dashboard_sso_schema:common_backend_schema([oidc]) ++ + [ + {issuer, + ?HOCON( + binary(), + #{desc => ?DESC(issuer), required => true} + )}, + {clientid, + ?HOCON( + binary(), + #{desc => ?DESC(clientid), required => true} + )}, + {secret, + ?HOCON( + binary(), + #{desc => ?DESC(secret), required => true} + )}, + {scopes, + ?HOCON( + ?ARRAY(binary()), + #{desc => ?DESC(scopes), default => [<<"openid">>]} + )}, + {name_var, + ?HOCON( + binary(), + #{desc => ?DESC(name_var), default => <<"${sub}">>} + )}, + {dashboard_addr, + ?HOCON(binary(), #{ + desc => ?DESC(dashboard_addr), + default => <<"http://127.0.0.1:18083">> + })} + ]; +fields(login) -> + [ + emqx_dashboard_sso_schema:backend_schema([oidc]) + ]. + +desc(oidc) -> + "OIDC"; +desc(_) -> + undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +create(#{issuer := Issuer, name_var := NameVar} = Config) -> + case + oidcc_provider_configuration_worker:start_link(#{ + issuer => Issuer, + name => {local, ?PROVIDER_SVR_NAME} + }) + of + {ok, Pid} -> + {ok, #{ + pid => Pid, + config => Config, + name_tokens => emqx_placeholder:preproc_tmpl(NameVar) + }}; + {error, _} = Error -> + Error + end. + +update(Config, State) -> + destroy(State), + create(Config). + +destroy(#{pid := Pid}) -> + _ = catch gen_server:stop(Pid), + ok. + +login( + _Req, + #{ + config := #{ + clientid := ClientId, + secret := Secret, + scopes := Scopes + } + } = State +) -> + case + oidcc:create_redirect_url( + ?PROVIDER_SVR_NAME, + ClientId, + Secret, + #{ + scopes => Scopes, + state => random_bin(), + nonce => random_bin(), + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(State) + } + ) + of + {ok, [Base, Delimiter, Params]} -> + RedirectUri = <>, + Redirect = {302, ?RESPHEADERS#{<<"location">> => RedirectUri}, ?REDIRECT_BODY}, + {redirect, Redirect}; + {error, _Reason} = Error -> + Error + end. + +convert_certs(_Dir, Conf) -> + Conf. + +random_bin() -> + emqx_utils_conv:bin(emqx_utils:gen_id(16)). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl new file mode 100644 index 000000000..b57ec8fbe --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -0,0 +1,156 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc_api). + +-behaviour(minirest_api). + +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_dashboard/include/emqx_dashboard.hrl"). + +-import(hoconsc, [ + mk/2, + array/1, + enum/1, + ref/1 +]). + +-import(emqx_dashboard_sso_api, [login_meta/3]). + +-export([ + api_spec/0, + paths/0, + schema/1, + namespace/0 +]). + +-export([code_callback/2, make_callback_url/1]). + +-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). +-define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND'). +-define(TAGS, <<"Dashboard Single Sign-On">>). +-define(BACKEND, oidc). +-define(BASE_PATH, "/api/v5"). +-define(CALLBACK_PATH, "/sso/oidc/callback"). + +namespace() -> "dashboard_sso". + +api_spec() -> + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false, translate_body => false}). + +paths() -> + [ + ?CALLBACK_PATH + ]. + +%% Handles Authorization Code callback from the OP. +schema("/sso/oidc/callback") -> + #{ + 'operationId' => code_callback, + get => #{ + tags => [?TAGS], + desc => ?DESC(code_callback), + responses => #{ + 200 => emqx_dashboard_api:fields([token, version, license]), + 401 => response_schema(401), + 404 => response_schema(404) + }, + security => [] + } + }. + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- +code_callback(get, #{query_string := #{<<"code">> := Code}}) -> + case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of + #{pid := Pid, config := #{clientid := ClientId, secret := Secret}} = State -> + case + oidcc:retrieve_token( + Code, + Pid, + ClientId, + Secret, + #{redirect_uri => make_callback_url(State)} + ) + of + {ok, Token} -> + retrieve_userinfo(Token, State); + {error, Reason} -> + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} + end; + _ -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}} + end. + +%%-------------------------------------------------------------------- +%% internal +%%-------------------------------------------------------------------- +retrieve_userinfo(Token, #{ + pid := Pid, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks +}) -> + case + oidcc:retrieve_userinfo( + Token, + Pid, + ClientId, + Secret, + #{} + ) + of + {ok, UserInfo} -> + ?SLOG(debug, #{ + msg => "sso_oidc_login_user_info", + user_info => UserInfo + }), + Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), + case ensure_user_exists(Username) of + {ok, Role, DashboardToken} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_successful" + }), + {200, login_meta(Username, Role, DashboardToken)}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_failed", + reason => emqx_utils:redact(Reason) + }), + {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}} + end; + {error, Reason} -> + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} + end. + +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)). + +reason_to_message(Bin) when is_binary(Bin) -> + Bin; +reason_to_message(Term) -> + erlang:iolist_to_binary(io_lib:format("~p", [Term])). + +ensure_user_exists(<<>>) -> + {error, <<"Username can not be empty">>}; +ensure_user_exists(<<"undefined">>) -> + {error, <<"Username can not be undefined">>}; +ensure_user_exists(Username) -> + case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of + [User] -> + emqx_dashboard_token:sign(User, <<>>); + [] -> + case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of + {ok, _} -> + ensure_user_exists(Username); + Error -> + Error + end + end. + +make_callback_url(#{config := #{dashboard_addr := Addr}}) -> + list_to_binary(binary_to_list(Addr) ++ ?BASE_PATH ++ ?CALLBACK_PATH). diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon new file mode 100644 index 000000000..da9a7ec43 --- /dev/null +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -0,0 +1,21 @@ +emqx_dashboard_sso_oidc { + +issuer.desc: +"""The URL of the OIDC issuer.""" + +clientid.desc: +"""The clientId for this backend.""" + +secret.desc: +"""The client secret.""" + +scopes.desc: +"""The scopes, its default value is `["openid"]`.""" + +name_var.desc: +"""A template to map OIDC user information to a DashBoard name, its default value is `${sub}`.""" + +dashboard_addr.desc: +"""The address of the EMQX Dashboard.""" + +} diff --git a/rel/i18n/emqx_dashboard_sso_oidc_api.hocon b/rel/i18n/emqx_dashboard_sso_oidc_api.hocon new file mode 100644 index 000000000..b164d3db4 --- /dev/null +++ b/rel/i18n/emqx_dashboard_sso_oidc_api.hocon @@ -0,0 +1,6 @@ +emqx_dashboard_sso_oidc_api { + +code_callback.desc: +"""The callback path for the OIDC authorization server..""" + +}