feat(sso): add OIDC support

This commit is contained in:
firest 2024-06-19 21:22:27 +08:00
parent 14e2ed7be1
commit 512b4b9cbb
7 changed files with 347 additions and 3 deletions

View File

@ -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"}}}
]}.

View File

@ -7,7 +7,8 @@
stdlib,
emqx_dashboard,
emqx_ldap,
esaml
esaml,
oidcc
]},
{mod, {emqx_dashboard_sso_app, []}},
{env, []},

View File

@ -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) ->

View File

@ -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)).

View File

@ -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).

View File

@ -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."""
}

View File

@ -0,0 +1,6 @@
emqx_dashboard_sso_oidc_api {
code_callback.desc:
"""The callback path for the OIDC authorization server.."""
}