Преглед изворни кода

feat(sso): add OIDC support

firest пре 1 година
родитељ
комит
512b4b9cbb

+ 2 - 1
apps/emqx_dashboard_sso/rebar.config

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

+ 2 - 1
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src

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

+ 2 - 1
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl

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

+ 158 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl

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

+ 156 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl

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

+ 21 - 0
rel/i18n/emqx_dashboard_sso_oidc.hocon

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

+ 6 - 0
rel/i18n/emqx_dashboard_sso_oidc_api.hocon

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