diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl index 20289c2b3..c1d450ef2 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl @@ -17,6 +17,7 @@ handle_call/3, handle_cast/2, handle_info/2, + handle_continue/2, terminate/2, code_change/3, format_status/2 @@ -154,8 +155,7 @@ init([]) -> {read_concurrency, true} ] ), - start_backend_services(), - {ok, #{}}. + {ok, #{}, {continue, start_backend_services}}. handle_call(_Request, _From, State) -> Reply = ok, @@ -167,6 +167,12 @@ handle_cast(_Request, State) -> handle_info(_Info, State) -> {noreply, State}. +handle_continue(start_backend_services, State) -> + start_backend_services(), + {noreply, State}; +handle_continue(_Info, State) -> + {noreply, State}. + terminate(_Reason, _State) -> remove_handler(), ok. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index 26e5a4d44..46062c893 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -26,7 +26,7 @@ convert_certs/2 ]). --define(PROVIDER_SVR_NAME, sso_oidc_provider). +-define(PROVIDER_SVR_NAME, ?MODULE). -define(RESPHEADERS, #{ <<"cache-control">> => <<"no-cache">>, <<"pragma">> => <<"no-cache">>, @@ -79,6 +79,11 @@ fields(oidc) -> ?HOCON(binary(), #{ desc => ?DESC(dashboard_addr), default => <<"http://127.0.0.1:18083">> + })}, + {session_expiry, + ?HOCON(emqx_schema:timeout_duration_ms(), #{ + desc => ?DESC(session_expiry), + default => <<"30s">> })} ]; fields(login) -> @@ -95,30 +100,32 @@ desc(_) -> %% APIs %%------------------------------------------------------------------------------ -create(#{issuer := Issuer, name_var := NameVar} = Config) -> +create(#{name_var := NameVar} = Config) -> case - oidcc_provider_configuration_worker:start_link(#{ - issuer => Issuer, - name => {local, ?PROVIDER_SVR_NAME} - }) + emqx_dashboard_sso_oidc_session:start( + ?PROVIDER_SVR_NAME, + Config + ) of - {ok, Pid} -> + {error, _} = Error -> + Error; + _ -> + %% Note: the oidcc maintains an ETS with the same name of the provider gen_server, + %% we should use this name in each API calls not the PID, + %% or it would backoff to sync calls to the gen_server {ok, #{ - pid => Pid, + name => ?PROVIDER_SVR_NAME, 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. +destroy(_) -> + emqx_dashboard_sso_oidc_session:stop(). login( _Req, @@ -128,8 +135,12 @@ login( secret := Secret, scopes := Scopes } - } = State + } = Cfg ) -> + Nonce = emqx_dashboard_sso_oidc_session:random_bin(), + Data = #{nonce => Nonce}, + + State = emqx_dashboard_sso_oidc_session:new(Data), case oidcc:create_redirect_url( ?PROVIDER_SVR_NAME, @@ -137,9 +148,9 @@ login( Secret, #{ scopes => Scopes, - state => random_bin(), - nonce => random_bin(), - redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(State) + state => State, + nonce => Nonce, + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(Cfg) } ) of @@ -153,6 +164,3 @@ login( convert_certs(_Dir, Conf) -> Conf. - -random_bin() -> - emqx_utils_conv:bin(emqx_utils:gen_id(16)). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index b57ec8fbe..a2b795aea 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -64,66 +64,26 @@ schema("/sso/oidc/callback") -> %%-------------------------------------------------------------------- %% 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">>}} +code_callback(get, #{query_string := QS}) -> + case ensure_sso_state(QS) of + {ok, Username, Role, DashboardToken} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_successful" + }), + {200, login_meta(Username, Role, DashboardToken)}; + {error, invalid_backend} -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_failed", + reason => emqx_utils:redact(Reason) + }), + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} 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)); @@ -135,6 +95,68 @@ reason_to_message(Bin) when is_binary(Bin) -> reason_to_message(Term) -> erlang:iolist_to_binary(io_lib:format("~p", [Term])). +ensure_sso_state(QS) -> + case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of + undefined -> + {error, invalid_backend}; + Cfg -> + ensure_oidc_state(QS, Cfg) + end. + +ensure_oidc_state(#{<<"state">> := State} = QS, Cfg) -> + case emqx_dashboard_sso_oidc_session:lookup(State) of + {ok, Data} -> + emqx_dashboard_sso_oidc_session:delete(State), + retrieve_token(QS, Cfg, Data); + _ -> + {error, session_not_exists} + end. + +retrieve_token( + #{<<"code">> := Code}, + #{name := Name, config := #{clientid := ClientId, secret := Secret}} = Cfg, + #{nonce := Nonce} = _Data +) -> + case + oidcc:retrieve_token( + Code, + Name, + ClientId, + Secret, + #{redirect_uri => make_callback_url(Cfg), nonce => Nonce} + ) + of + {ok, Token} -> + retrieve_userinfo(Token, Cfg); + {error, _Reason} = Error -> + Error + end. + +retrieve_userinfo(Token, #{ + name := Name, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks +}) -> + case + oidcc:retrieve_userinfo( + Token, + Name, + ClientId, + Secret, + #{} + ) + of + {ok, UserInfo} -> + ?SLOG(debug, #{ + msg => "sso_oidc_login_user_info", + user_info => UserInfo + }), + Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), + ensure_user_exists(Username); + {error, _Reason} = Error -> + Error + end. + ensure_user_exists(<<>>) -> {error, <<"Username can not be empty">>}; ensure_user_exists(<<"undefined">>) -> @@ -142,7 +164,12 @@ ensure_user_exists(<<"undefined">>) -> ensure_user_exists(Username) -> case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of [User] -> - emqx_dashboard_token:sign(User, <<>>); + case emqx_dashboard_token:sign(User, <<>>) of + {ok, Role, Token} -> + {ok, Username, Role, Token}; + Error -> + Error + end; [] -> case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of {ok, _} -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl new file mode 100644 index 000000000..86ee1050a --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl @@ -0,0 +1,156 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc_session). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). + +%% API +-export([start_link/1, start/2, stop/0]). + +%% gen_server callbacks +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3, + format_status/2 +]). + +-export([new/1, delete/1, lookup/1, random_bin/0, random_bin/1]). + +-define(TAB, ?MODULE). + +-record(?TAB, { + state :: binary(), + created_at :: non_neg_integer(), + data :: map() +}). + +-define(DEFAULT_RANDOM_LEN, 32). +-define(NOW, erlang:system_time(millisecond)). + +%%------------------------------------------------------------------------------ +%% API +%%------------------------------------------------------------------------------ +start_link(Cfg) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, Cfg, []). + +start(Name, #{issuer := Issuer, session_expiry := SessionExpiry}) -> + case + emqx_dashboard_sso_sup:start_child( + oidcc_provider_configuration_worker, + [ + #{ + issuer => Issuer, + name => {local, Name} + } + ] + ) + of + {error, _} = Error -> + Error; + _ -> + emqx_dashboard_sso_sup:start_child(?MODULE, [SessionExpiry]) + end. + +stop() -> + _ = emqx_dashboard_sso_sup:stop_child(oidcc_provider_configuration_worker), + _ = emqx_dashboard_sso_sup:stop_child(?MODULE), + ok. + +new(Data) -> + State = new_state(), + ets:insert( + ?TAB, + #?TAB{ + state = State, + created_at = ?NOW, + data = Data + } + ), + State. + +delete(State) -> + ets:delete(?TAB, State). + +lookup(State) -> + case ets:lookup(?TAB, State) of + [#?TAB{data = Data}] -> + {ok, Data}; + _ -> + undefined + end. + +random_bin() -> + random_bin(?DEFAULT_RANDOM_LEN). + +random_bin(Len) -> + emqx_utils_conv:bin(emqx_utils:gen_id(Len)). + +%%------------------------------------------------------------------------------ +%% gen_server callbacks +%%------------------------------------------------------------------------------ +init(SessionExpiry) -> + process_flag(trap_exit, true), + emqx_utils_ets:new( + ?TAB, + [ + ordered_set, + public, + named_table, + {keypos, #?TAB.state}, + {read_concurrency, true} + ] + ), + State = #{session_expiry => SessionExpiry}, + tick_session_expiry(State), + {ok, State}. + +handle_call(_Request, _From, State) -> + Reply = ok, + {reply, Reply, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info(tick_session_expiry, #{session_expiry := SessionExpiry} = State) -> + Now = ?NOW, + Spec = ets:fun2ms(fun(#?TAB{created_at = CreatedAt}) -> + Now - CreatedAt >= SessionExpiry + end), + _ = ets:select_delete(?TAB, Spec), + tick_session_expiry(State), + {noreply, State}; +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +format_status(_Opt, Status) -> + Status. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ +new_state() -> + State = random_bin(), + case ets:lookup(?TAB, State) of + [] -> + State; + _ -> + new_state() + end. + +tick_session_expiry(#{session_expiry := SessionExpiry}) -> + erlang:send_after(SessionExpiry, self(), tick_session_expiry). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl index 9d2bbc365..6336965fd 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl @@ -6,17 +6,24 @@ -behaviour(supervisor). --export([start_link/0]). +-export([start_link/0, start_child/2, stop_child/1]). -export([init/1]). --define(CHILD(I, ShutDown), {I, {I, start_link, []}, permanent, ShutDown, worker, [I]}). +-define(CHILD(I, Args), {I, {I, start_link, Args}, permanent, 5000, worker, [I]}). +-define(CHILD(I), ?CHILD(I, [])). start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). +start_child(Mod, Args) -> + supervisor:start_child(?MODULE, ?CHILD(Mod, Args)). + +stop_child(Mod) -> + supervisor:terminate_child(?MODULE, Mod). + init([]) -> {ok, {{one_for_one, 5, 100}, [ - ?CHILD(emqx_dashboard_sso_manager, 5000) + ?CHILD(emqx_dashboard_sso_manager) ]}}. diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index da9a7ec43..797829317 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -18,4 +18,7 @@ name_var.desc: dashboard_addr.desc: """The address of the EMQX Dashboard.""" +session_expiry.desc: +"""The valid time span for an OIDC `state`, the default is `30s`, if the code response returned by the authorization server exceeds this time span, it will be treated as invalid.""" + }