emqx/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl

281 lines
9.0 KiB
Erlang

%%--------------------------------------------------------------------
%% Copyright (c) 2023-2024 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([
namespace/0,
hocon_ref/0,
login_ref/0,
fields/1,
desc/1
]).
%% emqx_dashboard_sso callbacks
-export([
create/1,
update/2,
destroy/1,
convert_certs/2
]).
-export([login/2, callback/2]).
-dialyzer({nowarn_function, do_create/1}).
-define(RESPHEADERS, #{
<<"cache-control">> => <<"no-cache">>,
<<"pragma">> => <<"no-cache">>,
<<"content-type">> => <<"text/plain">>
}).
-define(REDIRECT_BODY, <<"Redirecting...">>).
-define(DIR, <<"saml_sp_certs">>).
%%------------------------------------------------------------------------------
%% Hocon Schema
%%------------------------------------------------------------------------------
namespace() -> "dashboard".
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.
%% TODO: 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(#{enable := false} = _Config) ->
{ok, undefined};
create(#{sp_sign_request := true} = Config) ->
try
do_create(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#{sp_private_key => undefined, sp_public_key => undefined}).
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, <<>>),
Redirect =
case is_msie(Headers) of
true ->
Html = esaml_binding:encode_http_post(IDP, SignedXml, <<>>),
{200, ?RESPHEADERS, Html};
false ->
{302, ?RESPHEADERS#{<<"location">> => Target}, ?REDIRECT_BODY}
end,
{redirect, Redirect}.
callback(_Req = #{body := Body}, #{sp := SP, dashboard_addr := DashboardAddr} = _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),
gen_redirect_response(DashboardAddr, Username);
{error, Reason0} ->
Reason = [
"Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason0])
],
{error, iolist_to_binary(Reason)}
end.
convert_certs(
Dir,
#{<<"sp_sign_request">> := true, <<"sp_public_key">> := Cert, <<"sp_private_key">> := Key} =
Conf
) ->
case
emqx_tls_lib:ensure_ssl_files(
Dir, #{enable => true, certfile => Cert, keyfile => Key}, #{}
)
of
{ok, #{certfile := CertPath, keyfile := KeyPath}} ->
Conf#{<<"sp_public_key">> => bin(CertPath), <<"sp_private_key">> => bin(KeyPath)};
{error, Reason} ->
?SLOG(error, #{msg => "failed_to_save_sp_sign_keys", reason => Reason}),
throw("Failed to save sp signing key(s)")
end;
convert_certs(_Dir, Conf) ->
Conf.
%%------------------------------------------------------------------------------
%% Internal functions
%%------------------------------------------------------------------------------
bin(X) -> iolist_to_binary(X).
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.
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.
gen_redirect_response(DashboardAddr, Username) ->
case ensure_user_exists(Username) of
{ok, Role, Token} ->
Target = login_redirect_target(DashboardAddr, Username, Role, Token),
{redirect, {302, ?RESPHEADERS#{<<"location">> => Target}, ?REDIRECT_BODY}};
{error, Reason} ->
{error, Reason}
end.
%%------------------------------------------------------------------------------
%% Helpers functions
%%------------------------------------------------------------------------------
%% 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.
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).
login_redirect_target(DashboardAddr, Username, Role, Token) ->
LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token, saml),
<<DashboardAddr/binary, "/?login_meta=", (base64_login_meta(LoginMeta))/binary>>.
base64_login_meta(LoginMeta) ->
base64:encode(emqx_utils_json:encode(LoginMeta)).