Sfoglia il codice sorgente

Merge pull request #11656 from JimMoen/feat-saml-sso

feat: saml integration for dashboard sso
JimMoen 2 anni fa
parent
commit
7105f68d2d

+ 5 - 3
.github/CODEOWNERS

@@ -5,9 +5,11 @@
 /apps/emqx/                @emqx/emqx-review-board @lafirest
 /apps/emqx/                @emqx/emqx-review-board @lafirest
 /apps/emqx_authn/          @emqx/emqx-review-board @JimMoen @savonarola
 /apps/emqx_authn/          @emqx/emqx-review-board @JimMoen @savonarola
 /apps/emqx_authz/          @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_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_ft/             @emqx/emqx-review-board @savonarola @keynslug
 /apps/emqx_gateway/        @emqx/emqx-review-board @lafirest
 /apps/emqx_gateway/        @emqx/emqx-review-board @lafirest
 /apps/emqx_management/     @emqx/emqx-review-board @lafirest @sstrigler
 /apps/emqx_management/     @emqx/emqx-review-board @lafirest @sstrigler
@@ -18,7 +20,7 @@
 /apps/emqx_rule_engine/    @emqx/emqx-review-board @kjellwinblad
 /apps/emqx_rule_engine/    @emqx/emqx-review-board @kjellwinblad
 /apps/emqx_slow_subs/      @emqx/emqx-review-board @lafirest
 /apps/emqx_slow_subs/      @emqx/emqx-review-board @lafirest
 /apps/emqx_statsd/         @emqx/emqx-review-board @JimMoen
 /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
 ## CI
 /deploy/  @emqx/emqx-review-board @Rory-Z
 /deploy/  @emqx/emqx-review-board @Rory-Z

+ 1 - 1
apps/emqx/test/emqx_bpapi_static_checks.erl

@@ -48,7 +48,7 @@
 
 
 %% Applications and modules we wish to ignore in the analysis:
 %% Applications and modules we wish to ignore in the analysis:
 -define(IGNORED_APPS,
 -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(IGNORED_MODULES, "emqx_rpc").
 -define(FORCE_DELETED_MODULES, [
 -define(FORCE_DELETED_MODULES, [

+ 1 - 1
apps/emqx_dashboard_rbac/README.md

@@ -12,4 +12,4 @@ Please see our [contributing.md](../../CONTRIBUTING.md).
 
 
 ## License
 ## License
 
 
-See [APL](../../APL.txt).
+EMQ Business Source License 1.1, refer to [LICENSE](BSL.txt).

+ 2 - 2
apps/emqx_dashboard_rbac/rebar.config

@@ -1,6 +1,6 @@
 %% -*- mode: erlang; -*-
 %% -*- mode: erlang; -*-
-
 {erl_opts, [debug_info]}.
 {erl_opts, [debug_info]}.
+
 {deps, [
 {deps, [
-        {emqx_connector, {path, "../../apps/emqx_dashboard"}}
+  {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}
 ]}.
 ]}.

+ 2 - 1
apps/emqx_dashboard_sso/rebar.config

@@ -3,5 +3,6 @@
 {erl_opts, [debug_info]}.
 {erl_opts, [debug_info]}.
 {deps, [
 {deps, [
         {emqx_ldap, {path, "../../apps/emqx_ldap"}},
         {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"}}}
 ]}.
 ]}.

+ 2 - 1
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src

@@ -6,7 +6,8 @@
         kernel,
         kernel,
         stdlib,
         stdlib,
         emqx_dashboard,
         emqx_dashboard,
-        emqx_ldap
+        emqx_ldap,
+        esaml
     ]},
     ]},
     {mod, {emqx_dashboard_sso_app, []}},
     {mod, {emqx_dashboard_sso_app, []}},
     {env, []},
     {env, []},

+ 7 - 2
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl

@@ -39,7 +39,9 @@
     {ok, NewState :: state()} | {error, Reason :: term()}.
     {ok, NewState :: state()} | {error, Reason :: term()}.
 -callback destroy(State :: state()) -> ok.
 -callback destroy(State :: state()) -> ok.
 -callback login(request(), State :: state()) ->
 -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
 %% Callback Interface
@@ -77,4 +79,7 @@ provider(Backend) ->
     maps:get(Backend, backends()).
     maps:get(Backend, backends()).
 
 
 backends() ->
 backends() ->
-    #{ldap => emqx_dashboard_sso_ldap}.
+    #{
+        ldap => emqx_dashboard_sso_ldap,
+        saml => emqx_dashboard_sso_saml
+    }.

+ 48 - 29
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl

@@ -16,6 +16,8 @@
     ref/1
     ref/1
 ]).
 ]).
 
 
+-import(emqx_dashboard_sso, [provider/1]).
+
 -export([
 -export([
     api_spec/0,
     api_spec/0,
     fields/1,
     fields/1,
@@ -31,8 +33,9 @@
     backend/2
     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_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD').
 -define(BAD_REQUEST, 'BAD_REQUEST').
 -define(BAD_REQUEST, 'BAD_REQUEST').
 -define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND').
 -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") ->
 schema("/sso/login/:backend") ->
     #{
     #{
         'operationId' => login,
         'operationId' => login,
@@ -84,6 +90,8 @@ schema("/sso/login/:backend") ->
             'requestBody' => login_union(),
             'requestBody' => login_union(),
             responses => #{
             responses => #{
                 200 => emqx_dashboard_api:fields([role, token, version, license]),
                 200 => emqx_dashboard_api:fields([role, token, version, license]),
+                %% Redirect to IDP for saml
+                302 => response_schema(302),
                 401 => response_schema(401),
                 401 => response_schema(401),
                 404 => response_schema(404)
                 404 => response_schema(404)
             },
             },
@@ -126,8 +134,10 @@ schema("/sso/:backend") ->
 fields(backend_status) ->
 fields(backend_status) ->
     emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()).
     emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()).
 
 
-%% -------------------------------------------------------------------------------------------------
+%%--------------------------------------------------------------------
 %% API
 %% API
+%%--------------------------------------------------------------------
+
 running(get, _Request) ->
 running(get, _Request) ->
     SSO = emqx:get_config([dashboard_sso], #{}),
     SSO = emqx:get_config([dashboard_sso], #{}),
     {200,
     {200,
@@ -141,29 +151,25 @@ running(get, _Request) ->
             maps:values(SSO)
             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
     case emqx_dashboard_sso_manager:lookup_state(Backend) of
         undefined ->
         undefined ->
-            {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>};
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
         State ->
         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} ->
                 {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} ->
                 {error, Reason} ->
                     ?SLOG(info, #{
                     ?SLOG(info, #{
                         msg => "dashboard_sso_login_failed",
                         msg => "dashboard_sso_login_failed",
-                        request => Sign,
+                        request => Request,
                         reason => Reason
                         reason => Reason
                     }),
                     }),
-                    {401, ?BAD_USERNAME_OR_PWD, <<"Auth failed">>}
+                    {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}}
             end
             end
     end.
     end.
 
 
@@ -180,7 +186,7 @@ sso(get, _Request) ->
 backend(get, #{bindings := #{backend := Type}}) ->
 backend(get, #{bindings := #{backend := Type}}) ->
     case emqx:get_config([dashboard_sso, Type], undefined) of
     case emqx:get_config([dashboard_sso, Type], undefined) of
         undefined ->
         undefined ->
-            {404, ?BACKEND_NOT_FOUND};
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
         Backend ->
         Backend ->
             {200, to_json(Backend)}
             {200, to_json(Backend)}
     end;
     end;
@@ -194,8 +200,12 @@ backend(delete, #{bindings := #{backend := Backend}}) ->
 sso_parameters(Params) ->
 sso_parameters(Params) ->
     backend_name_as_arg(query, [local], <<"local">>) ++ Params.
     backend_name_as_arg(query, [local], <<"local">>) ++ Params.
 
 
-%% -------------------------------------------------------------------------------------------------
+%%--------------------------------------------------------------------
 %% internal
 %% internal
+%%--------------------------------------------------------------------
+
+response_schema(302) ->
+    emqx_dashboard_swagger:error_codes([?REDIRECT], ?DESC(redirect));
 response_schema(401) ->
 response_schema(401) ->
     emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401));
     emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401));
 response_schema(404) ->
 response_schema(404) ->
@@ -228,24 +238,25 @@ on_backend_update(Backend, Config, Fun) ->
     Result = valid_config(Backend, Config, Fun),
     Result = valid_config(Backend, Config, Fun),
     handle_backend_update_result(Result, Config).
     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)};
     {200, to_json(Config)};
 handle_backend_update_result(ok, _) ->
 handle_backend_update_result(ok, _) ->
     204;
     204;
 handle_backend_update_result({error, not_exists}, _) ->
 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}, _) ->
 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}, _) ->
 handle_backend_update_result({error, Reason}, _) ->
-    {400, ?BAD_REQUEST, Reason}.
+    {400, #{code => ?BAD_REQUEST, message => Reason}}.
 
 
 to_json(Data) ->
 to_json(Data) ->
     emqx_utils_maps:jsonable_map(
     emqx_utils_maps:jsonable_map(
@@ -254,3 +265,11 @@ to_json(Data) ->
             {K, emqx_utils_maps:binary_string(V)}
             {K, emqx_utils_maps:binary_string(V)}
         end
         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()}
+    }.

+ 3 - 3
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl

@@ -121,7 +121,7 @@ adjust_ldap_field(Any) ->
     Any.
     Any.
 
 
 login(
 login(
-    #{<<"username">> := Username} = Req,
+    #{body := #{<<"username">> := Username} = Sign} = _Req,
     #{
     #{
         query_timeout := Timeout,
         query_timeout := Timeout,
         resource_id := ResourceId
         resource_id := ResourceId
@@ -130,7 +130,7 @@ login(
     case
     case
         emqx_resource:simple_sync_query(
         emqx_resource:simple_sync_query(
             ResourceId,
             ResourceId,
-            {query, Req, [], Timeout}
+            {query, Sign, [], Timeout}
         )
         )
     of
     of
         {ok, []} ->
         {ok, []} ->
@@ -139,7 +139,7 @@ login(
             case
             case
                 emqx_resource:simple_sync_query(
                 emqx_resource:simple_sync_query(
                     ResourceId,
                     ResourceId,
-                    {bind, Req}
+                    {bind, Sign}
                 )
                 )
             of
             of
                 ok ->
                 ok ->

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

+ 132 - 0
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">>
+                }
+            }
+        }
+    }.

+ 8 - 8
rebar.config

@@ -84,14 +84,14 @@
     %% in conflict by erlavro and rocketmq
     %% in conflict by erlavro and rocketmq
     , {jsone, {git, "https://github.com/emqx/jsone.git", {tag, "1.7.1"}}}
     , {jsone, {git, "https://github.com/emqx/jsone.git", {tag, "1.7.1"}}}
     , {uuid, {git, "https://github.com/okeuday/uuid.git", {tag, "v2.0.6"}}}
     , {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,
 {xref_ignores,

+ 9 - 0
rel/i18n/emqx_dashboard_sso_api.hocon

@@ -30,6 +30,15 @@ delete_backend.desc:
 delete_backend.label:
 delete_backend.label:
 """Delete Backend"""
 """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_failed401.desc:
 """Login failed. Bad username or password"""
 """Login failed. Bad username or password"""
 
 

+ 28 - 0
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"""
+
+}

+ 2 - 0
scripts/spellcheck/dicts/emqx.txt

@@ -286,3 +286,5 @@ FormatType
 RocketMQ
 RocketMQ
 Keyspace
 Keyspace
 OpenTSDB
 OpenTSDB
+saml
+idp