Przeglądaj źródła

feat: saml integration for dashboard sso

JimMoen 2 lat temu
rodzic
commit
c9e0d4fc30

+ 2 - 1
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"}}}
 ]}.

+ 2 - 1
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, []},

+ 4 - 1
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
+    }.

+ 108 - 18
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()}
+    }.

+ 1 - 0
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) ->

+ 1 - 1
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.

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

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

+ 9 - 0
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"""
 

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