feat(sso): add OIDC support
This commit is contained in:
parent
14e2ed7be1
commit
512b4b9cbb
|
@ -4,5 +4,6 @@
|
|||
{deps, [
|
||||
{emqx_ldap, {path, "../../apps/emqx_ldap"}},
|
||||
{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,
|
||||
emqx_dashboard,
|
||||
emqx_ldap,
|
||||
esaml
|
||||
esaml,
|
||||
oidcc
|
||||
]},
|
||||
{mod, {emqx_dashboard_sso_app, []}},
|
||||
{env, []},
|
||||
|
|
|
@ -92,7 +92,8 @@ provider(Backend) ->
|
|||
backends() ->
|
||||
#{
|
||||
ldap => emqx_dashboard_sso_ldap,
|
||||
saml => emqx_dashboard_sso_saml
|
||||
saml => emqx_dashboard_sso_saml,
|
||||
oidc => emqx_dashboard_sso_oidc
|
||||
}.
|
||||
|
||||
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