Merge pull request #11656 from JimMoen/feat-saml-sso
feat: saml integration for dashboard sso
This commit is contained in:
commit
7105f68d2d
|
@ -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
|
||||
|
|
|
@ -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, [
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
%% -*- mode: erlang; -*-
|
||||
|
||||
{erl_opts, [debug_info]}.
|
||||
|
||||
{deps, [
|
||||
{emqx_connector, {path, "../../apps/emqx_dashboard"}}
|
||||
{emqx_dashboard, {path, "../../apps/emqx_dashboard"}}
|
||||
]}.
|
||||
|
|
|
@ -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"}}}
|
||||
]}.
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
kernel,
|
||||
stdlib,
|
||||
emqx_dashboard,
|
||||
emqx_ldap
|
||||
emqx_ldap,
|
||||
esaml
|
||||
]},
|
||||
{mod, {emqx_dashboard_sso_app, []}},
|
||||
{env, []},
|
||||
|
|
|
@ -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
|
||||
}.
|
||||
|
|
|
@ -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()}
|
||||
}.
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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.
|
|
@ -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">>
|
||||
}
|
||||
}
|
||||
}
|
||||
}.
|
16
rebar.config
16
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,
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
||||
}
|
|
@ -286,3 +286,5 @@ FormatType
|
|||
RocketMQ
|
||||
Keyspace
|
||||
OpenTSDB
|
||||
saml
|
||||
idp
|
||||
|
|
Loading…
Reference in New Issue