diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index efc4a4539..a26bc8fe7 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/apps/emqx_dashboard_sso/rebar.config @@ -3,5 +3,6 @@ {erl_opts, [debug_info]}. {deps, [ {emqx_ldap, {path, "../../apps/emqx_ldap"}}, - {emqx_dashboard, {path, "../../apps/emqx_dashboard"}} + {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}, + {esaml, {git, "https://github.com/JimMoen/esaml", {branch, "master"}}} ]}. 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 f60273590..e00a3cbfa 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src @@ -6,7 +6,8 @@ kernel, stdlib, emqx_dashboard, - emqx_ldap + emqx_ldap, + esaml ]}, {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 ecc6f40d2..8a0bb18e8 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl @@ -77,4 +77,7 @@ provider(Backend) -> maps:get(Backend, backends()). backends() -> - #{ldap => emqx_dashboard_sso_ldap}. + #{ + ldap => emqx_dashboard_sso_ldap, + saml => emqx_dashboard_sso_saml + }. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl index 7bcc65d2e..a6041c6af 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl @@ -16,6 +16,8 @@ ref/1 ]). +-import(emqx_dashboard_sso, [provider/1]). + -export([ api_spec/0, fields/1, @@ -28,11 +30,14 @@ running/2, login/2, sso/2, - backend/2 + backend/2, + sp_saml_metadata/2, + sp_saml_callback/2 ]). -export([sso_parameters/1]). +-define(REDIRECT, 'REDIRECT'). -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). -define(BAD_REQUEST, 'BAD_REQUEST'). -define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND'). @@ -48,7 +53,10 @@ paths() -> "/sso", "/sso/:backend", "/sso/running", - "/sso/login/:backend" + "/sso/login/:backend", + "/sso_saml/acs", + "/sso_saml/metadata" + %% "/sso_saml/logout" ]. schema("/sso/running") -> @@ -74,6 +82,9 @@ schema("/sso") -> } } }; +%% Visit "/sso/login/saml" to start the saml authentication process -- first check to see if +%% we are already logged in, otherwise we will make an AuthnRequest and send it to +%% our IDP schema("/sso/login/:backend") -> #{ 'operationId' => login, @@ -84,6 +95,8 @@ schema("/sso/login/:backend") -> 'requestBody' => login_union(), responses => #{ 200 => emqx_dashboard_api:fields([role, token, version, license]), + %% Redirect to IDP for saml + 302 => response_schema(302), 401 => response_schema(401), 404 => response_schema(404) }, @@ -121,8 +134,41 @@ schema("/sso/:backend") -> 404 => response_schema(404) } } + }; +%% Handles HTTP-POST bound assertions coming back from the IDP. +schema("/sso_saml/acs") -> + #{ + 'operationId' => sp_saml_callback, + post => #{ + tags => [?TAGS], + desc => ?DESC(saml_sso_acs), + %% 'requestbody' => saml_response(), + %% SAMLResponse and RelayState + %% should return 302 to redirect to dashboard + responses => #{ + 302 => response_schema(302), + 401 => response_schema(401), + 404 => response_schema(404) + } + } + }; +schema("/sso_saml/metadata") -> + #{ + 'operationId' => sp_saml_metadata, + get => #{ + tags => [?TAGS], + desc => ?DESC(sp_saml_metadata), + 'requestbody' => saml_metadata_response(), + responses => #{ + 200 => emqx_dashboard_api:fields([token, version, license]), + 404 => response_schema(404) + } + } }. +%% TODO: +%% schema("/sso_saml/logout") -> + fields(backend_status) -> emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()). @@ -141,22 +187,19 @@ running(get, _Request) -> maps:values(SSO) )}. -login(post, #{bindings := #{backend := Backend}, body := Sign}) -> +login(post, #{bindings := #{backend := Backend}, body := Sign, headers := Headers}) -> case emqx_dashboard_sso_manager:lookup_state(Backend) of undefined -> {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>}; State -> - Provider = emqx_dashboard_sso:provider(Backend), + Provider = provider(Backend), case emqx_dashboard_sso:login(Provider, Sign, State) of {ok, Role, Token} -> ?SLOG(info, #{msg => "dashboard_sso_login_successful", request => Sign}), - Version = iolist_to_binary(proplists:get_value(version, emqx_sys:info())), - {200, #{ - role => Role, - token => Token, - version => Version, - license => #{edition => emqx_release:edition()} - }}; + {200, login_reply(Role, Token)}; + {redirect, RedirectFun} -> + ?SLOG(info, #{msg => "dashboard_sso_login_redirect", request => Sign}), + RedirectFun(Headers); {error, Reason} -> ?SLOG(info, #{ msg => "dashboard_sso_login_failed", @@ -191,11 +234,41 @@ backend(delete, #{bindings := #{backend := Backend}}) -> ?SLOG(info, #{msg => "Delete SSO backend", backend => Backend}), handle_backend_update_result(emqx_dashboard_sso_manager:delete(Backend), undefined). +sp_saml_metadata(get, _Req) -> + case emqx_dashboard_sso_manager:lookup_state(saml) of + undefined -> + {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>}; + #{sp := SP} = _State -> + SignedXml = SP:generate_metadata(), + Metadata = xmerl:export([SignedXml], xmerl_xml), + {200, [{<<"Content-Type">>, <<"text/xml">>}], Metadata} + end. + +sp_saml_callback(post, Req) -> + case emqx_dashboard_sso_manager:lookup_state(saml) of + undefined -> + {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>}; + State -> + case (provider(saml)):callback(Req, State) of + {ok, Token} -> + {200, [{<<"Content-Type">>, <<"text/html">>}], login_reply(Token)}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_saml_sso_login_failed", + request => Req, + reason => Reason + }), + {403, #{code => <<"UNAUTHORIZED">>, message => Reason}} + end + end. + sso_parameters(Params) -> backend_name_as_arg(query, [local], <<"local">>) ++ Params. %% ------------------------------------------------------------------------------------------------- %% internal +response_schema(302) -> + emqx_dashboard_swagger:error_codes([?REDIRECT], ?DESC(redirect)); response_schema(401) -> emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401)); response_schema(404) -> @@ -207,6 +280,18 @@ backend_union() -> login_union() -> hoconsc:union([emqx_dashboard_sso:login_ref(Mod) || Mod <- emqx_dashboard_sso:modules()]). +saml_metadata_response() -> + #{ + 'content' => #{ + 'application/xml' => #{ + schema => #{ + type => <<"string">>, + format => <<"binary">> + } + } + } + }. + backend_name_in_path() -> backend_name_as_arg(path, [], <<"ldap">>). @@ -228,13 +313,10 @@ 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. +valid_config(Backend, #{<<"backend">> := Backend} = Config, Fun) -> + Fun(Backend, Config); +valid_config(_, _, _) -> + {error, invalid_config}. handle_backend_update_result({ok, _}, Config) -> {200, to_json(Config)}; @@ -254,3 +336,11 @@ to_json(Data) -> {K, emqx_utils_maps:binary_string(V)} end ). + +login_reply(Role, Token) -> + #{ + role => Role, + token => Token, + version => iolist_to_binary(proplists:get_value(version, emqx_sys:info())), + license => #{edition => emqx_release:edition()} + }. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl index 2ad280b5e..feef9d4e3 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_app.erl @@ -12,6 +12,7 @@ ]). start(_StartType, _StartArgs) -> + {ok, _} = application:ensure_all_started(esaml), emqx_dashboard_sso_sup:start_link(). stop(_State) -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl index afa27cb47..43ebbfa72 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl @@ -77,7 +77,7 @@ delete(Backend) -> lookup_state(Backend) -> case ets:lookup(dashboard_sso, Backend) of [Data] -> - Data#dashboard_sso.state; + Data; [] -> undefined end. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl new file mode 100644 index 000000000..4b1dad0c8 --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl @@ -0,0 +1,194 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_saml). + +-include_lib("emqx_dashboard/include/emqx_dashboard.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("esaml/include/esaml.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 +]). + +-export([callback/2]). + +%%------------------------------------------------------------------------------ +%% Hocon Schema +%%------------------------------------------------------------------------------ + +hocon_ref() -> + hoconsc:ref(?MODULE, saml). + +login_ref() -> + hoconsc:ref(?MODULE, login). + +fields(saml) -> + emqx_dashboard_sso_schema:common_backend_schema([saml]) ++ + [ + {dashboard_addr, fun dashboard_addr/1}, + {idp_metadata_url, fun idp_metadata_url/1}, + {sp_sign_request, fun sp_sign_request/1}, + {sp_public_key, fun sp_public_key/1}, + {sp_private_key, fun sp_private_key/1} + ]; +fields(login) -> + [ + emqx_dashboard_sso_schema:backend_schema([saml]) + ]. + +dashboard_addr(type) -> binary(); +%% without any path +dashboard_addr(desc) -> ?DESC(dashboard_addr); +dashboard_addr(default) -> <<"https://127.0.0.1:18083">>; +dashboard_addr(_) -> undefined. + +%% TOOD: support raw xml metadata in hocon (maybe?🤔) +idp_metadata_url(type) -> binary(); +idp_metadata_url(desc) -> ?DESC(idp_metadata_url); +idp_metadata_url(default) -> <<"https://idp.example.com">>; +idp_metadata_url(_) -> undefined. + +sp_sign_request(type) -> boolean(); +sp_sign_request(desc) -> ?DESC(sign_request); +sp_sign_request(default) -> false; +sp_sign_request(_) -> undefined. + +sp_public_key(type) -> binary(); +sp_public_key(desc) -> ?DESC(sp_public_key); +sp_public_key(default) -> <<"Pub Key">>; +sp_public_key(_) -> undefined. + +sp_private_key(type) -> binary(); +sp_private_key(desc) -> ?DESC(sp_private_key); +sp_private_key(required) -> false; +sp_private_key(format) -> <<"password">>; +sp_private_key(sensitive) -> true; +sp_private_key(_) -> undefined. + +desc(saml) -> + "saml"; +desc(_) -> + undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +create( + #{ + dashboard_addr := DashboardAddr, + idp_metadata_url := IDPMetadataURL, + sp_sign_request := SignRequest + } = Config +) -> + BaseURL = binary_to_list(DashboardAddr) ++ "/api/v5", + %% {Config, State} = parse_config(Config), + SP = esaml_sp:setup(#esaml_sp{ + %% TODO: save cert and key then return path + %% TODO: #esaml_sp.key #esaml_sp.certificate support + %% key = PrivKey, + %% certificate = Cert, + sp_sign_requests = SignRequest, + trusted_fingerprints = [], + consume_uri = BaseURL ++ "/sso_saml/acs", + metadata_uri = BaseURL ++ "/sso_saml/metadata", + org = #esaml_org{ + name = "EMQX Team", + displayname = "EMQX Dashboard", + url = DashboardAddr + }, + tech = #esaml_contact{ + name = "EMQX Team", + email = "contact@emqx.io" + } + }), + IdpMeta = esaml_util:load_metadata(binary_to_list(IDPMetadataURL)), + + {ok, Config#{idp_meta => IdpMeta, sp => SP}}. + +update(_Config0, State) -> + {ok, State}. + +destroy(#{resource_id := ResourceId}) -> + _ = emqx_resource:remove_local(ResourceId), + ok. + +login(_Req, #{sp := SP, idp_meta := #esaml_idp_metadata{login_location = IDP}} = _State) -> + SignedXml = SP:generate_authn_request(IDP), + Target = esaml_binding:encode_http_redirect(IDP, SignedXml, <<>>), + %% TODO: _Req acutally is HTTP request body, not fully request + RedirectFun = fun(Headers) -> + case is_msie(Headers) of + true -> + Html = esaml_binding:encode_http_post(IDP, SignedXml, <<>>), + {200, + [ + {<<"Cache-Control">>, <<"no-cache">>}, + {<<"Pragma">>, <<"no-cache">>} + ], + Html}; + false -> + {302, redirect_header(Target), <<"Redirecting...">>} + end + end, + {redirect, RedirectFun}. + +callback(Req, #{sp := SP} = _State) -> + case esaml_cowboy:validate_assertion(SP, fun esaml_util:check_dupe_ets/2, Req) of + {ok, Assertion, _RelayState, _Req2} -> + Subject = Assertion#esaml_assertion.subject, + Username = Subject#esaml_subject.name, + ensure_user_exists(Username); + {error, Reason0, _Req2} -> + Reason = [ + "Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason0]) + ], + {error, Reason} + end. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +%% -define(DIR, <<"SAML_SSO_sp_certs">>). +%% -define(RSA_KEYS_A, [sp_public_key, sp_private_key]). + +is_msie(Headers) -> + UA = maps:get(<<"user-agent">>, Headers, <<"">>), + not (binary:match(UA, <<"MSIE">>) =:= nomatch). + +redirect_header(TargetUrl) -> + [ + {<<"Cache-Control">>, <<"no-cache">>}, + {<<"Pragma">>, <<"no-cache">>}, + {<<"Location">>, TargetUrl} + ]. + +%% TODO: unify with emqx_dashboard_sso_manager:ensure_user_exists/1 +ensure_user_exists(Username) -> + case emqx_dashboard_admin:lookup_user(saml, Username) of + [User] -> + emqx_dashboard_token:sign(User, <<>>); + [] -> + case emqx_dashboard_admin:add_sso_user(saml, Username, ?ROLE_VIEWER, <<>>) of + {ok, _} -> + ensure_user_exists(Username); + Error -> + Error + end + end. diff --git a/rebar.config b/rebar.config index fd3f9820d..6a06cb532 100644 --- a/rebar.config +++ b/rebar.config @@ -84,6 +84,7 @@ %% in conflict by erlavro and rocketmq , {jsone, {git, "https://github.com/emqx/jsone.git", {tag, "1.7.1"}}} , {uuid, {git, "https://github.com/okeuday/uuid.git", {tag, "v2.0.6"}}} + %% , {esaml, {git, " %% trace , {opentelemetry_api, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api"}} , {opentelemetry, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry"}} diff --git a/rel/i18n/emqx_dashboard_sso_api.hocon b/rel/i18n/emqx_dashboard_sso_api.hocon index e14039156..a7ce1f97e 100644 --- a/rel/i18n/emqx_dashboard_sso_api.hocon +++ b/rel/i18n/emqx_dashboard_sso_api.hocon @@ -30,6 +30,15 @@ delete_backend.desc: delete_backend.label: """Delete Backend""" +saml_sso_acs.desc: +"""SAML SSO ACS URL""" + +sp_saml_metadata.desc: +"""SP SAML Metadata""" + +redirect.desc: +"""Redirect to IDP SSO login page""" + login_failed401.desc: """Login failed. Bad username or password""" diff --git a/rel/i18n/emqx_dashboard_sso_saml.hocon b/rel/i18n/emqx_dashboard_sso_saml.hocon new file mode 100644 index 000000000..c4bb57a27 --- /dev/null +++ b/rel/i18n/emqx_dashboard_sso_saml.hocon @@ -0,0 +1,28 @@ +emqx_dashboard_sso_saml { + +dashboard_addr.desc: +"""The address of the EMQX Dashboard.""" +dashboard_addr.label: +"""Dashboard Address""" + +idp_metadata_url.desc: +"""The URL of the IdP metadata.""" +idp_metadata_url.label: +"""IdP Metadata URL""" + +sign_request.desc: +"""Whether to sign the SAML request.""" +sign_request.label: +"""Sign SAML Request""" + +sp_public_key.desc: +"""The public key of the SP.""" +sp_public_key.label: +"""SP Public Key""" + +sp_private_key.desc: +"""The private key of the SP.""" +sp_private_key.label: +"""SP Private Key""" + +}