diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index 90172af3d..ae4125add 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/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/emqx/oidcc.git", {tag, "v3.2.0-1"}}} ]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src index 7f5e8f04a..95d49a150 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src @@ -1,13 +1,14 @@ {application, emqx_dashboard_sso, [ {description, "EMQX Dashboard Single Sign-On"}, - {vsn, "0.1.4"}, + {vsn, "0.1.5"}, {registered, [emqx_dashboard_sso_sup]}, {applications, [ kernel, stdlib, emqx_dashboard, emqx_ldap, - esaml + esaml, + oidcc ]}, {mod, {emqx_dashboard_sso_app, []}}, {env, []}, diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl index 2750fc528..599a4ad8f 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl +++ b/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) -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl index 20bc99de1..81abc8f19 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl @@ -33,7 +33,7 @@ backend/2 ]). --export([sso_parameters/1, login_meta/3]). +-export([sso_parameters/1, login_meta/4]). -define(REDIRECT, 'REDIRECT'). -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). @@ -168,7 +168,7 @@ login(post, #{bindings := #{backend := Backend}, body := Body} = Request) -> request => emqx_utils:redact(Request) }), Username = maps:get(<<"username">>, Body), - {200, login_meta(Username, Role, Token)}; + {200, login_meta(Username, Role, Token, Backend)}; {redirect, Redirect} -> ?SLOG(info, #{ msg => "dashboard_sso_login_redirect", @@ -286,11 +286,12 @@ to_redacted_json(Data) -> end ). -login_meta(Username, Role, Token) -> +login_meta(Username, Role, Token, Backend) -> #{ username => Username, role => Role, token => Token, version => iolist_to_binary(proplists:get_value(version, emqx_sys:info())), - license => #{edition => emqx_release:edition()} + license => #{edition => emqx_release:edition()}, + backend => Backend }. 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..6834da9e9 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 @@ -106,7 +107,14 @@ get_backend_status(Backend, _) -> end. update(Backend, Config) -> - update_config(Backend, {?FUNCTION_NAME, Backend, Config}). + UpdateConf = + case emqx:get_raw_config(?MOD_KEY_PATH(Backend), #{}) of + RawConf when is_map(RawConf) -> + emqx_utils:deobfuscate(Config, RawConf); + null -> + Config + end, + update_config(Backend, {?FUNCTION_NAME, Backend, UpdateConf}). delete(Backend) -> update_config(Backend, {?FUNCTION_NAME, Backend}). @@ -154,8 +162,7 @@ init([]) -> {read_concurrency, true} ] ), - start_backend_services(), - {ok, #{}}. + {ok, #{}, {continue, start_backend_services}}. handle_call(_Request, _From, State) -> Reply = ok, @@ -167,6 +174,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 new file mode 100644 index 000000000..dbc0d7f0b --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -0,0 +1,294 @@ +%%-------------------------------------------------------------------- +%% 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, ?MODULE). +-define(RESPHEADERS, #{ + <<"cache-control">> => <<"no-cache">>, + <<"pragma">> => <<"no-cache">>, + <<"content-type">> => <<"text/plain">> +}). +-define(REDIRECT_BODY, <<"Redirecting...">>). +-define(PKCE_VERIFIER_LEN, 60). + +%%------------------------------------------------------------------------------ +%% 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, + emqx_schema_secret:mk( + maps:merge(#{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">> + })}, + {session_expiry, + ?HOCON(emqx_schema:timeout_duration_s(), #{ + desc => ?DESC(session_expiry), + default => <<"30s">> + })}, + {require_pkce, + ?HOCON(boolean(), #{ + desc => ?DESC(require_pkce), + default => false + })}, + {preferred_auth_methods, + ?HOCON( + ?ARRAY( + ?ENUM([ + private_key_jwt, + client_secret_jwt, + client_secret_post, + client_secret_basic, + none + ]) + ), + #{ + desc => ?DESC(preferred_auth_methods), + default => [ + client_secret_post, + client_secret_basic, + none + ] + } + )}, + {provider, + ?HOCON(?ENUM([okta, generic]), #{ + mapping => "oidcc.provider", + desc => ?DESC(provider), + default => generic + })}, + {fallback_methods, + ?HOCON(?ARRAY(binary()), #{ + mapping => "oidcc.fallback_methods", + desc => ?DESC(fallback_methods), + default => [<<"RS256">>] + })}, + {client_jwks, + %% TODO: add url JWKS + ?HOCON(?UNION([none, ?R_REF(client_file_jwks)]), #{ + desc => ?DESC(client_jwks), + default => none + })} + ]; +fields(client_file_jwks) -> + [ + {type, + ?HOCON(?ENUM([file]), #{ + desc => ?DESC(client_file_jwks_type), + required => true + })}, + {file, + ?HOCON(binary(), #{ + desc => ?DESC(client_file_jwks_file), + required => true + })} + ]; +fields(login) -> + [ + emqx_dashboard_sso_schema:backend_schema([oidc]) + ]. + +desc(oidc) -> + "OIDC"; +desc(client_file_jwks) -> + ?DESC(client_file_jwks); +desc(_) -> + undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +create(#{name_var := NameVar} = Config) -> + case + emqx_dashboard_sso_oidc_session:start( + ?PROVIDER_SVR_NAME, + Config + ) + of + {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 + ClientJwks = init_client_jwks(Config), + {ok, #{ + name => ?PROVIDER_SVR_NAME, + config => Config, + client_jwks => ClientJwks, + name_tokens => emqx_placeholder:preproc_tmpl(NameVar) + }} + end. + +update(Config, State) -> + destroy(State), + create(Config). + +destroy(State) -> + emqx_dashboard_sso_oidc_session:stop(), + try_delete_jwks_file(State). + +-dialyzer({nowarn_function, login/2}). +login( + _Req, + #{ + client_jwks := ClientJwks, + config := #{ + clientid := ClientId, + secret := Secret, + scopes := Scopes, + require_pkce := RequirePKCE, + preferred_auth_methods := AuthMethods + } + } = Cfg +) -> + Nonce = emqx_dashboard_sso_oidc_session:random_bin(), + Opts = maybe_require_pkce(RequirePKCE, #{ + scopes => Scopes, + nonce => Nonce, + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(Cfg) + }), + + Data = maps:with([nonce, require_pkce, pkce_verifier], Opts), + State = emqx_dashboard_sso_oidc_session:new(Data), + + case + oidcc:create_redirect_url( + ?PROVIDER_SVR_NAME, + ClientId, + emqx_secret:unwrap(Secret), + Opts#{ + state => State, + client_jwks => ClientJwks, + preferred_auth_methods => AuthMethods + } + ) + of + {ok, [Base, Delimiter, Params]} -> + RedirectUri = <>, + Redirect = {302, ?RESPHEADERS#{<<"location">> => RedirectUri}, ?REDIRECT_BODY}, + {redirect, Redirect}; + {error, _Reason} = Error -> + Error + end. + +convert_certs( + Dir, + #{ + <<"client_jwks">> := #{ + <<"type">> := file, + <<"file">> := Content + } = Jwks + } = Conf +) -> + case save_jwks_file(Dir, Content) of + {ok, Path} -> + Conf#{<<"client_jwks">> := Jwks#{<<"file">> := Path}}; + {error, Reason} -> + ?SLOG(error, #{msg => "failed_to_save_client_jwks", reason => Reason}), + throw("Failed to save client jwks") + end; +convert_certs(_Dir, Conf) -> + Conf. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +save_jwks_file(Dir, Content) -> + Path = filename:join([emqx_tls_lib:pem_dir(Dir), "client_jwks"]), + case filelib:ensure_dir(Path) of + ok -> + case file:write_file(Path, Content) of + ok -> + {ok, Path}; + {error, Reason} -> + {error, #{failed_to_write_file => Reason, file_path => Path}} + end; + {error, Reason} -> + {error, #{failed_to_create_dir_for => Path, reason => Reason}} + end. + +try_delete_jwks_file(#{config := #{client_jwks := #{type := file, file := File}}}) -> + _ = file:delete(File), + ok; +try_delete_jwks_file(_) -> + ok. + +maybe_require_pkce(false, Opts) -> + Opts; +maybe_require_pkce(true, Opts) -> + Opts#{ + require_pkce => true, + pkce_verifier => emqx_dashboard_sso_oidc_session:random_bin(?PKCE_VERIFIER_LEN) + }. + +init_client_jwks(#{client_jwks := #{type := file, file := File}}) -> + case jose_jwk:from_file(File) of + {error, _} -> + none; + Jwks -> + Jwks + end; +init_client_jwks(_) -> + none. 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 new file mode 100644 index 000000000..3514b4fbb --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -0,0 +1,214 @@ +%%-------------------------------------------------------------------- +%% 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(RESPHEADERS, #{ + <<"cache-control">> => <<"no-cache">>, + <<"pragma">> => <<"no-cache">>, + <<"content-type">> => <<"text/plain">> +}). +-define(REDIRECT_BODY, <<"Redirecting...">>). + +-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 := QS}) -> + case ensure_sso_state(QS) of + {ok, Target} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_successful" + }), + + {302, ?RESPHEADERS#{<<"location">> => Target}, ?REDIRECT_BODY}; + {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 +%%-------------------------------------------------------------------- + +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_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, + client_jwks := ClientJwks, + config := #{ + clientid := ClientId, + secret := Secret, + preferred_auth_methods := AuthMethods + } + } = Cfg, + Data +) -> + case + oidcc:retrieve_token( + Code, + Name, + ClientId, + emqx_secret:unwrap(Secret), + Data#{ + redirect_uri => make_callback_url(Cfg), + client_jwks => ClientJwks, + preferred_auth_methods => AuthMethods + } + ) + of + {ok, Token} -> + retrieve_userinfo(Token, Cfg); + {error, _Reason} = Error -> + Error + end. + +retrieve_userinfo( + Token, + #{ + name := Name, + client_jwks := ClientJwks, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks + } = Cfg +) -> + case + oidcc:retrieve_userinfo( + Token, + Name, + ClientId, + emqx_secret:unwrap(Secret), + #{client_jwks => ClientJwks} + ) + of + {ok, UserInfo} -> + ?SLOG(debug, #{ + msg => "sso_oidc_login_user_info", + user_info => UserInfo + }), + Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), + ensure_user_exists(Cfg, Username); + {error, _Reason} = Error -> + Error + end. + +-dialyzer({nowarn_function, ensure_user_exists/2}). +ensure_user_exists(_Cfg, <<>>) -> + {error, <<"Username can not be empty">>}; +ensure_user_exists(_Cfg, <<"undefined">>) -> + {error, <<"Username can not be undefined">>}; +ensure_user_exists(Cfg, Username) -> + case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of + [User] -> + case emqx_dashboard_token:sign(User, <<>>) of + {ok, Role, Token} -> + {ok, login_redirect_target(Cfg, Username, Role, Token)}; + Error -> + Error + end; + [] -> + case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of + {ok, _} -> + ensure_user_exists(Cfg, Username); + Error -> + Error + end + end. + +make_callback_url(#{config := #{dashboard_addr := Addr}}) -> + list_to_binary(binary_to_list(Addr) ++ ?BASE_PATH ++ ?CALLBACK_PATH). + +login_redirect_target(#{config := #{dashboard_addr := Addr}}, Username, Role, Token) -> + LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token, oidc), + MetaBin = base64:encode(emqx_utils_json:encode(LoginMeta)), + < + gen_server:start_link({local, ?MODULE}, ?MODULE, Cfg, []). + +start(Name, #{issuer := Issuer, session_expiry := SessionExpiry0}) -> + case + emqx_dashboard_sso_sup:start_child( + oidcc_provider_configuration_worker, + [ + #{ + issuer => Issuer, + name => {local, Name} + } + ] + ) + of + {error, _} = Error -> + Error; + _ -> + SessionExpiry = timer:seconds(SessionExpiry0), + 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_saml.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl index eaa550d64..9efd2effd 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl @@ -273,7 +273,7 @@ is_msie(Headers) -> not (binary:match(UA, <<"MSIE">>) =:= nomatch). login_redirect_target(DashboardAddr, Username, Role, Token) -> - LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token), + LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token, saml), <