diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e42000489..8bb31ab71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,9 +5,11 @@ /apps/emqx/ @emqx/emqx-review-board @lafirest /apps/emqx_authn/ @emqx/emqx-review-board @JimMoen @savonarola /apps/emqx_authz/ @emqx/emqx-review-board @JimMoen @savonarola -/apps/emqx_connector/ @emqx/emqx-review-board @JimMoen +/apps/emqx_connector/ @emqx/emqx-review-board /apps/emqx_dashboard/ @emqx/emqx-review-board @JimMoen @lafirest -/apps/emqx_exhook/ @emqx/emqx-review-board @JimMoen @lafirest +/apps/emqx_dashboard_rbac/ @emqx/emqx-review-board @lafirest +/apps/emqx_dashboard_sso/ @emqx/emqx-review-board @JimMoen @lafirest +/apps/emqx_exhook/ @emqx/emqx-review-board @JimMoen @HJianBo /apps/emqx_ft/ @emqx/emqx-review-board @savonarola @keynslug /apps/emqx_gateway/ @emqx/emqx-review-board @lafirest /apps/emqx_management/ @emqx/emqx-review-board @lafirest @sstrigler @@ -18,7 +20,7 @@ /apps/emqx_rule_engine/ @emqx/emqx-review-board @kjellwinblad /apps/emqx_slow_subs/ @emqx/emqx-review-board @lafirest /apps/emqx_statsd/ @emqx/emqx-review-board @JimMoen -/apps/emqx_durable_storage/ @emqx/emqx-review-board @ieQu1 @keynslug +/apps/emqx_durable_storage/ @emqx/emqx-review-board @ieQu1 @keynslug ## CI /deploy/ @emqx/emqx-review-board @Rory-Z diff --git a/apps/emqx/test/emqx_bpapi_static_checks.erl b/apps/emqx/test/emqx_bpapi_static_checks.erl index b44e564c7..6766912c0 100644 --- a/apps/emqx/test/emqx_bpapi_static_checks.erl +++ b/apps/emqx/test/emqx_bpapi_static_checks.erl @@ -48,7 +48,7 @@ %% Applications and modules we wish to ignore in the analysis: -define(IGNORED_APPS, - "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria, amqp_client, rabbit_common" + "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria, amqp_client, rabbit_common, esaml" ). -define(IGNORED_MODULES, "emqx_rpc"). -define(FORCE_DELETED_MODULES, [ diff --git a/apps/emqx_dashboard_rbac/README.md b/apps/emqx_dashboard_rbac/README.md index 9d854d29d..70c3a8c85 100644 --- a/apps/emqx_dashboard_rbac/README.md +++ b/apps/emqx_dashboard_rbac/README.md @@ -12,4 +12,4 @@ Please see our [contributing.md](../../CONTRIBUTING.md). ## License -See [APL](../../APL.txt). +EMQ Business Source License 1.1, refer to [LICENSE](BSL.txt). diff --git a/apps/emqx_dashboard_rbac/rebar.config b/apps/emqx_dashboard_rbac/rebar.config index 03d877a31..fbd100693 100644 --- a/apps/emqx_dashboard_rbac/rebar.config +++ b/apps/emqx_dashboard_rbac/rebar.config @@ -1,6 +1,6 @@ %% -*- mode: erlang; -*- - {erl_opts, [debug_info]}. + {deps, [ - {emqx_connector, {path, "../../apps/emqx_dashboard"}} + {emqx_dashboard, {path, "../../apps/emqx_dashboard"}} ]}. diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index efc4a4539..46f26fd99 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/emqx/esaml", {tag, "v1.1.1"}}} ]}. 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..5abfa3d33 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl @@ -39,7 +39,9 @@ {ok, NewState :: state()} | {error, Reason :: term()}. -callback destroy(State :: state()) -> ok. -callback login(request(), State :: state()) -> - {ok, dashboard_user_role(), Token :: binary()} | {error, Reason :: term()}. + {ok, dashboard_user_role(), Token :: binary()} + | {redirect, tuple()} + | {error, Reason :: term()}. %%------------------------------------------------------------------------------ %% Callback Interface @@ -77,4 +79,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..c19d2b66e 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, @@ -31,8 +33,9 @@ backend/2 ]). --export([sso_parameters/1]). +-export([sso_parameters/1, login_reply/2]). +-define(REDIRECT, 'REDIRECT'). -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). -define(BAD_REQUEST, 'BAD_REQUEST'). -define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND'). @@ -74,6 +77,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 +90,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) }, @@ -126,8 +134,10 @@ schema("/sso/:backend") -> fields(backend_status) -> emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()). -%% ------------------------------------------------------------------------------------------------- +%%-------------------------------------------------------------------- %% API +%%-------------------------------------------------------------------- + running(get, _Request) -> SSO = emqx:get_config([dashboard_sso], #{}), {200, @@ -141,29 +151,25 @@ running(get, _Request) -> maps:values(SSO) )}. -login(post, #{bindings := #{backend := Backend}, body := Sign}) -> +login(post, #{bindings := #{backend := Backend}} = Request) -> case emqx_dashboard_sso_manager:lookup_state(Backend) of undefined -> - {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>}; + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; State -> - Provider = emqx_dashboard_sso:provider(Backend), - case emqx_dashboard_sso:login(Provider, Sign, State) of + case emqx_dashboard_sso:login(provider(Backend), Request, 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()} - }}; + ?SLOG(info, #{msg => "dashboard_sso_login_successful", request => Request}), + {200, login_reply(Role, Token)}; + {redirect, Redirect} -> + ?SLOG(info, #{msg => "dashboard_sso_login_redirect", request => Request}), + Redirect; {error, Reason} -> ?SLOG(info, #{ msg => "dashboard_sso_login_failed", - request => Sign, + request => Request, reason => Reason }), - {401, ?BAD_USERNAME_OR_PWD, <<"Auth failed">>} + {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}} end end. @@ -180,7 +186,7 @@ sso(get, _Request) -> backend(get, #{bindings := #{backend := Type}}) -> case emqx:get_config([dashboard_sso, Type], undefined) of undefined -> - {404, ?BACKEND_NOT_FOUND}; + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; Backend -> {200, to_json(Backend)} end; @@ -194,8 +200,12 @@ backend(delete, #{bindings := #{backend := Backend}}) -> 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) -> @@ -228,24 +238,25 @@ 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) -> +handle_backend_update_result({ok, #{backend := saml} = State}, _Config) -> + {200, to_json(maps:without([idp_meta, sp], State))}; +handle_backend_update_result({ok, _State}, Config) -> {200, to_json(Config)}; handle_backend_update_result(ok, _) -> 204; handle_backend_update_result({error, not_exists}, _) -> - {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>}; + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; handle_backend_update_result({error, already_exists}, _) -> - {400, ?BAD_REQUEST, <<"Backend already exists">>}; + {400, #{code => ?BAD_REQUEST, message => <<"Backend already exists">>}}; +handle_backend_update_result({error, failed_to_load_metadata}, _) -> + {400, #{code => ?BAD_REQUEST, message => <<"Failed to load metadata">>}}; handle_backend_update_result({error, Reason}, _) -> - {400, ?BAD_REQUEST, Reason}. + {400, #{code => ?BAD_REQUEST, message => Reason}}. to_json(Data) -> emqx_utils_maps:jsonable_map( @@ -254,3 +265,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_ldap.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl index d6acbb164..bea8ef7c6 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl @@ -121,7 +121,7 @@ adjust_ldap_field(Any) -> Any. login( - #{<<"username">> := Username} = Req, + #{body := #{<<"username">> := Username} = Sign} = _Req, #{ query_timeout := Timeout, resource_id := ResourceId @@ -130,7 +130,7 @@ login( case emqx_resource:simple_sync_query( ResourceId, - {query, Req, [], Timeout} + {query, Sign, [], Timeout} ) of {ok, []} -> @@ -139,7 +139,7 @@ login( case emqx_resource:simple_sync_query( ResourceId, - {bind, Req} + {bind, Sign} ) of ok -> 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..edfb51712 --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl @@ -0,0 +1,240 @@ +%%-------------------------------------------------------------------- +%% 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([ + hocon_ref/0, + login_ref/0, + fields/1, + desc/1 +]). + +%% emqx_dashboard_sso callbacks +-export([ + create/1, + update/2, + destroy/1 +]). + +-export([login/2, callback/2]). + +-dialyzer({nowarn_function, do_create/1}). + +-define(DIR, <<"saml_sp_certs">>). + +%%------------------------------------------------------------------------------ +%% 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(#{sp_sign_request := true} = Config) -> + try + do_create(ensure_cert_and_key(Config)) + catch + Kind:Error -> + Msg = failed_to_ensure_cert_and_key, + ?SLOG(error, #{msg => Msg, kind => Kind, error => Error}), + {error, Msg} + end; +create(#{sp_sign_request := false} = Config) -> + do_create(Config#{key => undefined, certificate => undefined}). + +do_create( + #{ + dashboard_addr := DashboardAddr, + idp_metadata_url := IDPMetadataURL, + sp_sign_request := SpSignRequest, + sp_private_key := KeyPath, + sp_public_key := CertPath + } = Config +) -> + {ok, _} = application:ensure_all_started(esaml), + BaseURL = binary_to_list(DashboardAddr) ++ "/api/v5", + SP = esaml_sp:setup(#esaml_sp{ + key = maybe_load_cert_or_key(KeyPath, fun esaml_util:load_private_key/1), + certificate = maybe_load_cert_or_key(CertPath, fun esaml_util:load_certificate/1), + sp_sign_requests = SpSignRequest, + trusted_fingerprints = [], + consume_uri = BaseURL ++ "/sso/saml/acs", + metadata_uri = BaseURL ++ "/sso/saml/metadata", + %% TODO: support conf org and contact + org = #esaml_org{ + name = "EMQX", + displayname = "EMQX Dashboard", + url = DashboardAddr + }, + tech = #esaml_contact{ + name = "EMQX", + email = "contact@emqx.io" + } + }), + try + IdpMeta = esaml_util:load_metadata(binary_to_list(IDPMetadataURL)), + State = Config, + {ok, State#{idp_meta => IdpMeta, sp => SP}} + catch + Kind:Error -> + Reason = failed_to_load_metadata, + ?SLOG(error, #{msg => Reason, kind => Kind, error => Error}), + {error, Reason} + end. + +update(Config0, State) -> + destroy(State), + create(Config0). + +destroy(_State) -> + _ = file:del_dir_r(emqx_tls_lib:pem_dir(?DIR)), + _ = application:stop(esaml), + ok. + +login( + #{headers := Headers} = _Req, + #{sp := SP, idp_meta := #esaml_idp_metadata{login_location = IDP}} = _State +) -> + SignedXml = esaml_sp:generate_authn_request(IDP, SP), + Target = esaml_binding:encode_http_redirect(IDP, SignedXml, <<>>), + RespHeaders = #{<<"Cache-Control">> => <<"no-cache">>, <<"Pragma">> => <<"no-cache">>}, + Redirect = + case is_msie(Headers) of + true -> + Html = esaml_binding:encode_http_post(IDP, SignedXml, <<>>), + {200, RespHeaders, Html}; + false -> + RespHeaders1 = RespHeaders#{<<"Location">> => Target}, + {302, RespHeaders1, <<"Redirecting...">>} + end, + {redirect, Redirect}. + +callback(_Req = #{body := Body}, #{sp := SP} = _State) -> + case do_validate_assertion(SP, fun esaml_util:check_dupe_ets/2, Body) of + {ok, Assertion, _RelayState} -> + Subject = Assertion#esaml_assertion.subject, + Username = iolist_to_binary(Subject#esaml_subject.name), + ensure_user_exists(Username); + {error, Reason0} -> + Reason = [ + "Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason0]) + ], + {error, iolist_to_binary(Reason)} + end. + +do_validate_assertion(SP, DuplicateFun, Body) -> + PostVals = cow_qs:parse_qs(Body), + SAMLEncoding = proplists:get_value(<<"SAMLEncoding">>, PostVals), + SAMLResponse = proplists:get_value(<<"SAMLResponse">>, PostVals), + RelayState = proplists:get_value(<<"RelayState">>, PostVals), + case (catch esaml_binding:decode_response(SAMLEncoding, SAMLResponse)) of + {'EXIT', Reason} -> + {error, {bad_decode, Reason}}; + Xml -> + case esaml_sp:validate_assertion(Xml, DuplicateFun, SP) of + {ok, A} -> {ok, A, RelayState}; + {error, E} -> {error, E} + end + end. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +ensure_cert_and_key(#{sp_public_key := Cert, sp_private_key := Key} = Config) -> + case + emqx_tls_lib:ensure_ssl_files( + ?DIR, #{enable => ture, certfile => Cert, keyfile => Key}, #{} + ) + of + {ok, #{certfile := CertPath, keyfile := KeyPath} = _NSSL} -> + Config#{sp_public_key => CertPath, sp_private_key => KeyPath}; + {error, #{which_options := KeyPath}} -> + error({missing_key, lists:flatten(KeyPath)}) + end. + +maybe_load_cert_or_key(undefined, _) -> + undefined; +maybe_load_cert_or_key(Path, Func) -> + Func(Path). + +is_msie(Headers) -> + UA = maps:get(<<"user-agent">>, Headers, <<"">>), + not (binary:match(UA, <<"MSIE">>) =:= nomatch). + +%% 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/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml_api.erl new file mode 100644 index 000000000..105b69141 --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml_api.erl @@ -0,0 +1,132 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_saml_api). + +-behaviour(minirest_api). + +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-import(hoconsc, [ + mk/2, + array/1, + enum/1, + ref/1 +]). + +-import(emqx_dashboard_sso, [provider/1]). + +-export([ + api_spec/0, + paths/0, + schema/1, + namespace/0 +]). + +-export([ + sp_saml_metadata/2, + sp_saml_callback/2 +]). + +-define(REDIRECT, 'REDIRECT'). +-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). +-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 => false, translate_body => false}). + +paths() -> + [ + "/sso/saml/acs", + "/sso/saml/metadata" + ]. + +%% 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' => urlencoded_request_body(), + responses => #{ + 302 => response_schema(302), + 401 => response_schema(401), + 404 => response_schema(404) + }, + security => [] + } + }; +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) + } + } + }. + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +sp_saml_metadata(get, _Req) -> + case emqx_dashboard_sso_manager:lookup_state(saml) of + undefined -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; + #{sp := SP} = _State -> + SignedXml = esaml_sp:generate_metadata(SP), + Metadata = xmerl:export([SignedXml], xmerl_xml), + {200, #{<<"Content-Type">> => <<"text/xml">>}, erlang:iolist_to_binary(Metadata)} + end. + +sp_saml_callback(post, Req) -> + case emqx_dashboard_sso_manager:lookup_state(saml) of + undefined -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; + State -> + case (provider(saml)):callback(Req, State) of + {ok, Role, Token} -> + {200, emqx_dashboard_sso_api:login_reply(Role, Token)}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_saml_sso_login_failed", + request => Req, + reason => Reason + }), + {403, #{code => <<"UNAUTHORIZED">>, message => Reason}} + end + end. + +%%-------------------------------------------------------------------- +%% 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) -> + emqx_dashboard_swagger:error_codes([?BACKEND_NOT_FOUND], ?DESC(backend_not_found)). + +saml_metadata_response() -> + #{ + 'content' => #{ + 'application/xml' => #{ + schema => #{ + type => <<"string">>, + format => <<"binary">> + } + } + } + }. diff --git a/rebar.config b/rebar.config index fd3f9820d..900353c35 100644 --- a/rebar.config +++ b/rebar.config @@ -84,14 +84,14 @@ %% 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"}}} -%% 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"}} - %% log metrics - , {opentelemetry_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_experimental"}} - , {opentelemetry_api_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api_experimental"}} - %% export - , {opentelemetry_exporter, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_exporter"}} + %% 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"}} + %% log metrics + , {opentelemetry_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_experimental"}} + , {opentelemetry_api_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api_experimental"}} + %% export + , {opentelemetry_exporter, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_exporter"}} ]}. {xref_ignores, 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""" + +} diff --git a/scripts/spellcheck/dicts/emqx.txt b/scripts/spellcheck/dicts/emqx.txt index b515a0010..83edb22d1 100644 --- a/scripts/spellcheck/dicts/emqx.txt +++ b/scripts/spellcheck/dicts/emqx.txt @@ -286,3 +286,5 @@ FormatType RocketMQ Keyspace OpenTSDB +saml +idp