feat(sso): add OIDC support
This commit is contained in:
parent
14e2ed7be1
commit
512b4b9cbb
|
@ -4,5 +4,6 @@
|
||||||
{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.3"}}}
|
{esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}},
|
||||||
|
{oidcc, {git, "https://github.com/erlef/oidcc.git", {tag, "v3.2.0"}}}
|
||||||
]}.
|
]}.
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
stdlib,
|
stdlib,
|
||||||
emqx_dashboard,
|
emqx_dashboard,
|
||||||
emqx_ldap,
|
emqx_ldap,
|
||||||
esaml
|
esaml,
|
||||||
|
oidcc
|
||||||
]},
|
]},
|
||||||
{mod, {emqx_dashboard_sso_app, []}},
|
{mod, {emqx_dashboard_sso_app, []}},
|
||||||
{env, []},
|
{env, []},
|
||||||
|
|
|
@ -92,7 +92,8 @@ provider(Backend) ->
|
||||||
backends() ->
|
backends() ->
|
||||||
#{
|
#{
|
||||||
ldap => emqx_dashboard_sso_ldap,
|
ldap => emqx_dashboard_sso_ldap,
|
||||||
saml => emqx_dashboard_sso_saml
|
saml => emqx_dashboard_sso_saml,
|
||||||
|
oidc => emqx_dashboard_sso_oidc
|
||||||
}.
|
}.
|
||||||
|
|
||||||
format(Args) ->
|
format(Args) ->
|
||||||
|
|
|
@ -0,0 +1,158 @@
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
|
-module(emqx_dashboard_sso_oidc).
|
||||||
|
|
||||||
|
-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
|
||||||
|
-include_lib("emqx/include/logger.hrl").
|
||||||
|
-include_lib("hocon/include/hoconsc.hrl").
|
||||||
|
|
||||||
|
-behaviour(emqx_dashboard_sso).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
namespace/0,
|
||||||
|
fields/1,
|
||||||
|
desc/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
hocon_ref/0,
|
||||||
|
login_ref/0,
|
||||||
|
login/2,
|
||||||
|
create/1,
|
||||||
|
update/2,
|
||||||
|
destroy/1,
|
||||||
|
convert_certs/2
|
||||||
|
]).
|
||||||
|
|
||||||
|
-define(PROVIDER_SVR_NAME, sso_oidc_provider).
|
||||||
|
-define(RESPHEADERS, #{
|
||||||
|
<<"cache-control">> => <<"no-cache">>,
|
||||||
|
<<"pragma">> => <<"no-cache">>,
|
||||||
|
<<"content-type">> => <<"text/plain">>
|
||||||
|
}).
|
||||||
|
-define(REDIRECT_BODY, <<"Redirecting...">>).
|
||||||
|
|
||||||
|
%%------------------------------------------------------------------------------
|
||||||
|
%% Hocon Schema
|
||||||
|
%%------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace() ->
|
||||||
|
"sso".
|
||||||
|
|
||||||
|
hocon_ref() ->
|
||||||
|
hoconsc:ref(?MODULE, oidc).
|
||||||
|
|
||||||
|
login_ref() ->
|
||||||
|
hoconsc:ref(?MODULE, login).
|
||||||
|
|
||||||
|
fields(oidc) ->
|
||||||
|
emqx_dashboard_sso_schema:common_backend_schema([oidc]) ++
|
||||||
|
[
|
||||||
|
{issuer,
|
||||||
|
?HOCON(
|
||||||
|
binary(),
|
||||||
|
#{desc => ?DESC(issuer), required => true}
|
||||||
|
)},
|
||||||
|
{clientid,
|
||||||
|
?HOCON(
|
||||||
|
binary(),
|
||||||
|
#{desc => ?DESC(clientid), required => true}
|
||||||
|
)},
|
||||||
|
{secret,
|
||||||
|
?HOCON(
|
||||||
|
binary(),
|
||||||
|
#{desc => ?DESC(secret), required => true}
|
||||||
|
)},
|
||||||
|
{scopes,
|
||||||
|
?HOCON(
|
||||||
|
?ARRAY(binary()),
|
||||||
|
#{desc => ?DESC(scopes), default => [<<"openid">>]}
|
||||||
|
)},
|
||||||
|
{name_var,
|
||||||
|
?HOCON(
|
||||||
|
binary(),
|
||||||
|
#{desc => ?DESC(name_var), default => <<"${sub}">>}
|
||||||
|
)},
|
||||||
|
{dashboard_addr,
|
||||||
|
?HOCON(binary(), #{
|
||||||
|
desc => ?DESC(dashboard_addr),
|
||||||
|
default => <<"http://127.0.0.1:18083">>
|
||||||
|
})}
|
||||||
|
];
|
||||||
|
fields(login) ->
|
||||||
|
[
|
||||||
|
emqx_dashboard_sso_schema:backend_schema([oidc])
|
||||||
|
].
|
||||||
|
|
||||||
|
desc(oidc) ->
|
||||||
|
"OIDC";
|
||||||
|
desc(_) ->
|
||||||
|
undefined.
|
||||||
|
|
||||||
|
%%------------------------------------------------------------------------------
|
||||||
|
%% APIs
|
||||||
|
%%------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create(#{issuer := Issuer, name_var := NameVar} = Config) ->
|
||||||
|
case
|
||||||
|
oidcc_provider_configuration_worker:start_link(#{
|
||||||
|
issuer => Issuer,
|
||||||
|
name => {local, ?PROVIDER_SVR_NAME}
|
||||||
|
})
|
||||||
|
of
|
||||||
|
{ok, Pid} ->
|
||||||
|
{ok, #{
|
||||||
|
pid => Pid,
|
||||||
|
config => Config,
|
||||||
|
name_tokens => emqx_placeholder:preproc_tmpl(NameVar)
|
||||||
|
}};
|
||||||
|
{error, _} = Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
update(Config, State) ->
|
||||||
|
destroy(State),
|
||||||
|
create(Config).
|
||||||
|
|
||||||
|
destroy(#{pid := Pid}) ->
|
||||||
|
_ = catch gen_server:stop(Pid),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
login(
|
||||||
|
_Req,
|
||||||
|
#{
|
||||||
|
config := #{
|
||||||
|
clientid := ClientId,
|
||||||
|
secret := Secret,
|
||||||
|
scopes := Scopes
|
||||||
|
}
|
||||||
|
} = State
|
||||||
|
) ->
|
||||||
|
case
|
||||||
|
oidcc:create_redirect_url(
|
||||||
|
?PROVIDER_SVR_NAME,
|
||||||
|
ClientId,
|
||||||
|
Secret,
|
||||||
|
#{
|
||||||
|
scopes => Scopes,
|
||||||
|
state => random_bin(),
|
||||||
|
nonce => random_bin(),
|
||||||
|
redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(State)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
of
|
||||||
|
{ok, [Base, Delimiter, Params]} ->
|
||||||
|
RedirectUri = <<Base/binary, Delimiter/binary, Params/binary>>,
|
||||||
|
Redirect = {302, ?RESPHEADERS#{<<"location">> => RedirectUri}, ?REDIRECT_BODY},
|
||||||
|
{redirect, Redirect};
|
||||||
|
{error, _Reason} = Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
convert_certs(_Dir, Conf) ->
|
||||||
|
Conf.
|
||||||
|
|
||||||
|
random_bin() ->
|
||||||
|
emqx_utils_conv:bin(emqx_utils:gen_id(16)).
|
|
@ -0,0 +1,156 @@
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
|
-module(emqx_dashboard_sso_oidc_api).
|
||||||
|
|
||||||
|
-behaviour(minirest_api).
|
||||||
|
|
||||||
|
-include_lib("hocon/include/hoconsc.hrl").
|
||||||
|
-include_lib("emqx/include/logger.hrl").
|
||||||
|
-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
|
||||||
|
|
||||||
|
-import(hoconsc, [
|
||||||
|
mk/2,
|
||||||
|
array/1,
|
||||||
|
enum/1,
|
||||||
|
ref/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
-import(emqx_dashboard_sso_api, [login_meta/3]).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
api_spec/0,
|
||||||
|
paths/0,
|
||||||
|
schema/1,
|
||||||
|
namespace/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
-export([code_callback/2, make_callback_url/1]).
|
||||||
|
|
||||||
|
-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD').
|
||||||
|
-define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND').
|
||||||
|
-define(TAGS, <<"Dashboard Single Sign-On">>).
|
||||||
|
-define(BACKEND, oidc).
|
||||||
|
-define(BASE_PATH, "/api/v5").
|
||||||
|
-define(CALLBACK_PATH, "/sso/oidc/callback").
|
||||||
|
|
||||||
|
namespace() -> "dashboard_sso".
|
||||||
|
|
||||||
|
api_spec() ->
|
||||||
|
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false, translate_body => false}).
|
||||||
|
|
||||||
|
paths() ->
|
||||||
|
[
|
||||||
|
?CALLBACK_PATH
|
||||||
|
].
|
||||||
|
|
||||||
|
%% Handles Authorization Code callback from the OP.
|
||||||
|
schema("/sso/oidc/callback") ->
|
||||||
|
#{
|
||||||
|
'operationId' => code_callback,
|
||||||
|
get => #{
|
||||||
|
tags => [?TAGS],
|
||||||
|
desc => ?DESC(code_callback),
|
||||||
|
responses => #{
|
||||||
|
200 => emqx_dashboard_api:fields([token, version, license]),
|
||||||
|
401 => response_schema(401),
|
||||||
|
404 => response_schema(404)
|
||||||
|
},
|
||||||
|
security => []
|
||||||
|
}
|
||||||
|
}.
|
||||||
|
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
%% API
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
code_callback(get, #{query_string := #{<<"code">> := Code}}) ->
|
||||||
|
case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of
|
||||||
|
#{pid := Pid, config := #{clientid := ClientId, secret := Secret}} = State ->
|
||||||
|
case
|
||||||
|
oidcc:retrieve_token(
|
||||||
|
Code,
|
||||||
|
Pid,
|
||||||
|
ClientId,
|
||||||
|
Secret,
|
||||||
|
#{redirect_uri => make_callback_url(State)}
|
||||||
|
)
|
||||||
|
of
|
||||||
|
{ok, Token} ->
|
||||||
|
retrieve_userinfo(Token, State);
|
||||||
|
{error, Reason} ->
|
||||||
|
{401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}}
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
{404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
%% internal
|
||||||
|
%%--------------------------------------------------------------------
|
||||||
|
retrieve_userinfo(Token, #{
|
||||||
|
pid := Pid,
|
||||||
|
config := #{clientid := ClientId, secret := Secret},
|
||||||
|
name_tokens := NameTks
|
||||||
|
}) ->
|
||||||
|
case
|
||||||
|
oidcc:retrieve_userinfo(
|
||||||
|
Token,
|
||||||
|
Pid,
|
||||||
|
ClientId,
|
||||||
|
Secret,
|
||||||
|
#{}
|
||||||
|
)
|
||||||
|
of
|
||||||
|
{ok, UserInfo} ->
|
||||||
|
?SLOG(debug, #{
|
||||||
|
msg => "sso_oidc_login_user_info",
|
||||||
|
user_info => UserInfo
|
||||||
|
}),
|
||||||
|
Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo),
|
||||||
|
case ensure_user_exists(Username) of
|
||||||
|
{ok, Role, DashboardToken} ->
|
||||||
|
?SLOG(info, #{
|
||||||
|
msg => "dashboard_sso_login_successful"
|
||||||
|
}),
|
||||||
|
{200, login_meta(Username, Role, DashboardToken)};
|
||||||
|
{error, Reason} ->
|
||||||
|
?SLOG(info, #{
|
||||||
|
msg => "dashboard_sso_login_failed",
|
||||||
|
reason => emqx_utils:redact(Reason)
|
||||||
|
}),
|
||||||
|
{401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}}
|
||||||
|
end;
|
||||||
|
{error, Reason} ->
|
||||||
|
{401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
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)).
|
||||||
|
|
||||||
|
reason_to_message(Bin) when is_binary(Bin) ->
|
||||||
|
Bin;
|
||||||
|
reason_to_message(Term) ->
|
||||||
|
erlang:iolist_to_binary(io_lib:format("~p", [Term])).
|
||||||
|
|
||||||
|
ensure_user_exists(<<>>) ->
|
||||||
|
{error, <<"Username can not be empty">>};
|
||||||
|
ensure_user_exists(<<"undefined">>) ->
|
||||||
|
{error, <<"Username can not be undefined">>};
|
||||||
|
ensure_user_exists(Username) ->
|
||||||
|
case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of
|
||||||
|
[User] ->
|
||||||
|
emqx_dashboard_token:sign(User, <<>>);
|
||||||
|
[] ->
|
||||||
|
case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of
|
||||||
|
{ok, _} ->
|
||||||
|
ensure_user_exists(Username);
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
make_callback_url(#{config := #{dashboard_addr := Addr}}) ->
|
||||||
|
list_to_binary(binary_to_list(Addr) ++ ?BASE_PATH ++ ?CALLBACK_PATH).
|
|
@ -0,0 +1,21 @@
|
||||||
|
emqx_dashboard_sso_oidc {
|
||||||
|
|
||||||
|
issuer.desc:
|
||||||
|
"""The URL of the OIDC issuer."""
|
||||||
|
|
||||||
|
clientid.desc:
|
||||||
|
"""The clientId for this backend."""
|
||||||
|
|
||||||
|
secret.desc:
|
||||||
|
"""The client secret."""
|
||||||
|
|
||||||
|
scopes.desc:
|
||||||
|
"""The scopes, its default value is `["openid"]`."""
|
||||||
|
|
||||||
|
name_var.desc:
|
||||||
|
"""A template to map OIDC user information to a DashBoard name, its default value is `${sub}`."""
|
||||||
|
|
||||||
|
dashboard_addr.desc:
|
||||||
|
"""The address of the EMQX Dashboard."""
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
emqx_dashboard_sso_oidc_api {
|
||||||
|
|
||||||
|
code_callback.desc:
|
||||||
|
"""The callback path for the OIDC authorization server.."""
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue