From afbf13b8a2cbeb03d91ed4702a911be0b9b098d5 Mon Sep 17 00:00:00 2001 From: firest Date: Thu, 14 Sep 2023 13:53:02 +0800 Subject: [PATCH] feat(ldap): integrate authentication with LDAP bind operation --- apps/emqx_authn/src/emqx_authn_enterprise.erl | 3 +- apps/emqx_ldap/src/emqx_ldap.erl | 31 ++- apps/emqx_ldap/src/emqx_ldap_authn.erl | 29 +- apps/emqx_ldap/src/emqx_ldap_authn_bind.erl | 121 +++++++++ apps/emqx_ldap/src/emqx_ldap_bind_worker.erl | 108 ++++++++ .../test/emqx_ldap_authn_bind_SUITE.erl | 255 ++++++++++++++++++ rel/i18n/emqx_ldap.hocon | 5 + rel/i18n/emqx_ldap_authn_bind.hocon | 11 + 8 files changed, 543 insertions(+), 20 deletions(-) create mode 100644 apps/emqx_ldap/src/emqx_ldap_authn_bind.erl create mode 100644 apps/emqx_ldap/src/emqx_ldap_bind_worker.erl create mode 100644 apps/emqx_ldap/test/emqx_ldap_authn_bind_SUITE.erl create mode 100644 rel/i18n/emqx_ldap_authn_bind.hocon diff --git a/apps/emqx_authn/src/emqx_authn_enterprise.erl b/apps/emqx_authn/src/emqx_authn_enterprise.erl index 69752555c..784074578 100644 --- a/apps/emqx_authn/src/emqx_authn_enterprise.erl +++ b/apps/emqx_authn/src/emqx_authn_enterprise.erl @@ -11,11 +11,12 @@ providers() -> [ {{password_based, ldap}, emqx_ldap_authn}, + {{password_based, ldap_bind}, emqx_ldap_authn_bind}, {gcp_device, emqx_gcp_device_authn} ]. resource_provider() -> - [emqx_ldap_authn]. + [emqx_ldap_authn, emqx_ldap_authn_bind]. -else. diff --git a/apps/emqx_ldap/src/emqx_ldap.erl b/apps/emqx_ldap/src/emqx_ldap.erl index d14ddf97d..f07f76730 100644 --- a/apps/emqx_ldap/src/emqx_ldap.erl +++ b/apps/emqx_ldap/src/emqx_ldap.erl @@ -76,7 +76,20 @@ fields(config) -> desc => ?DESC(request_timeout), default => <<"5s">> })} - ] ++ emqx_connector_schema_lib:ssl_fields(). + ] ++ emqx_connector_schema_lib:ssl_fields(); +fields(bind_opts) -> + [ + {bind_password, + ?HOCON( + binary(), + #{ + desc => ?DESC(bind_password), + default => <<"${password}">>, + example => <<"${password}">>, + validator => fun emqx_schema:non_empty_string/1 + } + )} + ]. server() -> Meta = #{desc => ?DESC("server")}, @@ -122,7 +135,12 @@ on_start( case emqx_resource_pool:start(InstId, ?MODULE, Options) of ok -> - {ok, prepare_template(Config, #{pool_name => InstId})}; + emqx_ldap_bind_worker:on_start( + InstId, + Config, + Options, + prepare_template(Config, #{pool_name => InstId}) + ); {error, Reason} -> ?tp( ldap_connector_start_failed, @@ -131,11 +149,12 @@ on_start( {error, Reason} end. -on_stop(InstId, _State) -> +on_stop(InstId, State) -> ?SLOG(info, #{ msg => "stopping_ldap_connector", connector => InstId }), + ok = emqx_ldap_bind_worker:on_stop(InstId, State), emqx_resource_pool:stop(InstId). on_query(InstId, {query, Data}, State) -> @@ -143,7 +162,9 @@ on_query(InstId, {query, Data}, State) -> on_query(InstId, {query, Data, Attrs}, State) -> on_query(InstId, {query, Data}, [{attributes, Attrs}], State); on_query(InstId, {query, Data, Attrs, Timeout}, State) -> - on_query(InstId, {query, Data}, [{attributes, Attrs}, {timeout, Timeout}], State). + on_query(InstId, {query, Data}, [{attributes, Attrs}, {timeout, Timeout}], State); +on_query(InstId, {bind, _Data} = Req, State) -> + emqx_ldap_bind_worker:on_query(InstId, Req, State). on_get_status(_InstId, #{pool_name := PoolName} = _State) -> case emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1) of @@ -233,7 +254,7 @@ do_ldap_query( {error, Reason} -> ?SLOG( error, - LogMeta#{msg => "ldap_connector_do_sql_query_failed", reason => Reason} + LogMeta#{msg => "ldap_connector_do_query_failed", reason => Reason} ), {error, {unrecoverable_error, Reason}} end. diff --git a/apps/emqx_ldap/src/emqx_ldap_authn.erl b/apps/emqx_ldap/src/emqx_ldap_authn.erl index c18ce3fc7..d5f2658bb 100644 --- a/apps/emqx_ldap/src/emqx_ldap_authn.erl +++ b/apps/emqx_ldap/src/emqx_ldap_authn.erl @@ -32,7 +32,8 @@ create/2, update/2, authenticate/2, - destroy/1 + destroy/1, + do_create/2 ]). -import(proplists, [get_value/2, get_value/3]). @@ -56,7 +57,9 @@ fields(ldap) -> {password_attribute, fun password_attribute/1}, {is_superuser_attribute, fun is_superuser_attribute/1}, {query_timeout, fun query_timeout/1} - ] ++ emqx_authn_schema:common_fields() ++ emqx_ldap:fields(config). + ] ++ + emqx_authn_schema:common_fields() ++ + emqx_ldap:fields(config). desc(ldap) -> ?DESC(ldap); @@ -86,10 +89,10 @@ refs() -> [hoconsc:ref(?MODULE, ldap)]. create(_AuthenticatorID, Config) -> - create(Config). + do_create(?MODULE, Config). -create(Config0) -> - ResourceId = emqx_authn_utils:make_resource_id(?MODULE), +do_create(Module, Config0) -> + ResourceId = emqx_authn_utils:make_resource_id(Module), {Config, State} = parse_config(Config0), {ok, _Data} = emqx_authn_utils:create_resource(ResourceId, emqx_ldap, Config), {ok, State#{resource_id => ResourceId}}. @@ -142,16 +145,14 @@ authenticate( parse_config(Config) -> State = lists:foldl( fun(Key, Acc) -> - Value = - case maps:get(Key, Config) of - Bin when is_binary(Bin) -> - erlang:binary_to_list(Bin); - Any -> - Any - end, - Acc#{Key => Value} + case maps:find(Key, Config) of + {ok, Value} when is_binary(Value) -> + Acc#{Key := erlang:binary_to_list(Value)}; + _ -> + Acc + end end, - #{}, + Config, [password_attribute, is_superuser_attribute, query_timeout] ), {Config, State}. diff --git a/apps/emqx_ldap/src/emqx_ldap_authn_bind.erl b/apps/emqx_ldap/src/emqx_ldap_authn_bind.erl new file mode 100644 index 000000000..d8b80f17d --- /dev/null +++ b/apps/emqx_ldap/src/emqx_ldap_authn_bind.erl @@ -0,0 +1,121 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_ldap_authn_bind). + +-include_lib("emqx_authn/include/emqx_authn.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("eldap/include/eldap.hrl"). + +-behaviour(hocon_schema). +-behaviour(emqx_authentication). + +-export([ + namespace/0, + tags/0, + roots/0, + fields/1, + desc/1 +]). + +-export([ + refs/0, + create/2, + update/2, + authenticate/2, + destroy/1 +]). + +%%------------------------------------------------------------------------------ +%% Hocon Schema +%%------------------------------------------------------------------------------ + +namespace() -> "authn". + +tags() -> + [<<"Authentication">>]. + +%% used for config check when the schema module is resolved +roots() -> + [{?CONF_NS, hoconsc:mk(hoconsc:ref(?MODULE, ldap_bind))}]. + +fields(ldap_bind) -> + [ + {mechanism, emqx_authn_schema:mechanism(password_based)}, + {backend, emqx_authn_schema:backend(ldap_bind)}, + {query_timeout, fun query_timeout/1} + ] ++ + emqx_authn_schema:common_fields() ++ + emqx_ldap:fields(config) ++ emqx_ldap:fields(bind_opts). + +desc(ldap_bind) -> + ?DESC(ldap_bind); +desc(_) -> + undefined. + +query_timeout(type) -> emqx_schema:timeout_duration_ms(); +query_timeout(desc) -> ?DESC(?FUNCTION_NAME); +query_timeout(default) -> <<"5s">>; +query_timeout(_) -> undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +refs() -> + [hoconsc:ref(?MODULE, ldap_bind)]. + +create(_AuthenticatorID, Config) -> + emqx_ldap_authn:do_create(?MODULE, Config). + +update(Config, State) -> + emqx_ldap_authn:update(Config, State). + +destroy(State) -> + emqx_ldap_authn:destroy(State). + +authenticate(#{auth_method := _}, _) -> + ignore; +authenticate(#{password := undefined}, _) -> + {error, bad_username_or_password}; +authenticate( + #{password := _Password} = Credential, + #{ + query_timeout := Timeout, + resource_id := ResourceId + } = _State +) -> + case + emqx_resource:simple_sync_query( + ResourceId, + {query, Credential, [], Timeout} + ) + of + {ok, []} -> + ignore; + {ok, [_Entry | _]} -> + case + emqx_resource:simple_sync_query( + ResourceId, + {bind, Credential} + ) + of + ok -> + {ok, #{is_superuser => false}}; + {error, Reason} -> + ?TRACE_AUTHN_PROVIDER(error, "ldap_bind_failed", #{ + resource => ResourceId, + reason => Reason + }), + {error, bad_username_or_password} + end; + {error, Reason} -> + ?TRACE_AUTHN_PROVIDER(error, "ldap_query_failed", #{ + resource => ResourceId, + timeout => Timeout, + reason => Reason + }), + ignore + end. diff --git a/apps/emqx_ldap/src/emqx_ldap_bind_worker.erl b/apps/emqx_ldap/src/emqx_ldap_bind_worker.erl new file mode 100644 index 000000000..496d93ff2 --- /dev/null +++ b/apps/emqx_ldap/src/emqx_ldap_bind_worker.erl @@ -0,0 +1,108 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_ldap_bind_worker). + +-include_lib("typerefl/include/types.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). +-include_lib("eldap/include/eldap.hrl"). + +-export([ + on_start/4, + on_stop/2, + on_query/3 +]). + +%% ecpool connect & reconnect +-export([connect/1]). + +-define(POOL_NAME_SUFFIX, "bind_worker"). + +%% =================================================================== +-spec on_start(binary(), hoconsc:config(), proplists:proplist(), map()) -> + {ok, binary(), map()} | {error, _}. +on_start(InstId, #{bind_password := _} = Config, Options, State) -> + PoolName = pool_name(InstId), + ?SLOG(info, #{ + msg => "starting_ldap_bind_worker", + pool => PoolName + }), + + ok = emqx_resource:allocate_resource(InstId, ?MODULE, PoolName), + case emqx_resource_pool:start(PoolName, ?MODULE, Options) of + ok -> + {ok, prepare_template(Config, State#{bind_pool_name => PoolName})}; + {error, Reason} -> + ?tp( + ldap_bind_worker_start_failed, + #{error => Reason} + ), + {error, Reason} + end; +on_start(_InstId, _Config, _Options, State) -> + {ok, State}. + +on_stop(InstId, _State) -> + case emqx_resource:get_allocated_resources(InstId) of + #{?MODULE := PoolName} -> + ?SLOG(info, #{ + msg => "starting_ldap_bind_worker", + pool => PoolName + }), + emqx_resource_pool:stop(PoolName); + _ -> + ok + end. + +on_query( + InstId, + {bind, Data}, + #{ + base_tokens := DNTks, + bind_password_tokens := PWTks, + bind_pool_name := PoolName + } = State +) -> + DN = emqx_placeholder:proc_tmpl(DNTks, Data), + Password = emqx_placeholder:proc_tmpl(PWTks, Data), + + LogMeta = #{connector => InstId, state => State}, + ?TRACE("QUERY", "ldap_connector_received", LogMeta), + case + ecpool:pick_and_do( + PoolName, + {eldap, simple_bind, [DN, Password]}, + handover + ) + of + ok -> + ?tp( + ldap_connector_query_return, + #{result => ok} + ), + ok; + {error, Reason} -> + ?SLOG( + error, + LogMeta#{msg => "ldap_bind_failed", reason => Reason} + ), + {error, {unrecoverable_error, Reason}} + end. + +%% =================================================================== + +connect(Conf) -> + emqx_ldap:connect(Conf). + +prepare_template(Config, State) -> + do_prepare_template(maps:to_list(maps:with([bind_password], Config)), State). + +do_prepare_template([{bind_password, V} | T], State) -> + do_prepare_template(T, State#{bind_password_tokens => emqx_placeholder:preproc_tmpl(V)}); +do_prepare_template([], State) -> + State. + +pool_name(InstId) -> + <>. diff --git a/apps/emqx_ldap/test/emqx_ldap_authn_bind_SUITE.erl b/apps/emqx_ldap/test/emqx_ldap_authn_bind_SUITE.erl new file mode 100644 index 000000000..5159799b6 --- /dev/null +++ b/apps/emqx_ldap/test/emqx_ldap_authn_bind_SUITE.erl @@ -0,0 +1,255 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- +-module(emqx_ldap_authn_bind_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("emqx_authn/include/emqx_authn.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(LDAP_HOST, "ldap"). +-define(LDAP_DEFAULT_PORT, 389). +-define(LDAP_RESOURCE, <<"emqx_authn_ldap_bind_SUITE">>). + +-define(PATH, [authentication]). +-define(ResourceID, <<"password_based:ldap_bind">>). + +all() -> + emqx_common_test_helpers:all(?MODULE). + +init_per_testcase(_, Config) -> + emqx_authentication:initialize_authentication(?GLOBAL, []), + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL + ), + Config. + +init_per_suite(Config) -> + _ = application:load(emqx_conf), + case emqx_common_test_helpers:is_tcp_server_available(?LDAP_HOST, ?LDAP_DEFAULT_PORT) of + true -> + Apps = emqx_cth_suite:start([emqx, emqx_conf, emqx_authn], #{ + work_dir => ?config(priv_dir, Config) + }), + {ok, _} = emqx_resource:create_local( + ?LDAP_RESOURCE, + ?RESOURCE_GROUP, + emqx_ldap, + ldap_config(), + #{} + ), + [{apps, Apps} | Config]; + false -> + {skip, no_ldap} + end. + +end_per_suite(Config) -> + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL + ), + ok = emqx_resource:remove_local(?LDAP_RESOURCE), + ok = emqx_cth_suite:stop(?config(apps, Config)). + +%%------------------------------------------------------------------------------ +%% Tests +%%------------------------------------------------------------------------------ + +t_create(_Config) -> + AuthConfig = raw_ldap_auth_config(), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig} + ), + + {ok, [#{provider := emqx_ldap_authn_bind}]} = emqx_authentication:list_authenticators(?GLOBAL), + emqx_authn_test_lib:delete_config(?ResourceID). + +t_create_invalid(_Config) -> + AuthConfig = raw_ldap_auth_config(), + + InvalidConfigs = + [ + AuthConfig#{<<"server">> => <<"unknownhost:3333">>}, + AuthConfig#{<<"password">> => <<"wrongpass">>} + ], + + lists:foreach( + fun(Config) -> + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, Config} + ), + emqx_authn_test_lib:delete_config(?ResourceID), + ?assertEqual( + {error, {not_found, {chain, ?GLOBAL}}}, + emqx_authentication:list_authenticators(?GLOBAL) + ) + end, + InvalidConfigs + ). + +t_authenticate(_Config) -> + ok = lists:foreach( + fun(Sample) -> + ct:pal("test_user_auth sample: ~p", [Sample]), + test_user_auth(Sample) + end, + user_seeds() + ). + +test_user_auth(#{ + credentials := Credentials0, + config_params := SpecificConfigParams, + result := Result +}) -> + AuthConfig = maps:merge(raw_ldap_auth_config(), SpecificConfigParams), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig} + ), + + Credentials = Credentials0#{ + listener => 'tcp:default', + protocol => mqtt + }, + + ?assertEqual(Result, emqx_access_control:authenticate(Credentials)), + + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL + ). + +t_destroy(_Config) -> + AuthConfig = raw_ldap_auth_config(), + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, AuthConfig} + ), + + {ok, [#{provider := emqx_ldap_authn_bind, state := State}]} = + emqx_authentication:list_authenticators(?GLOBAL), + + {ok, _} = emqx_ldap_authn_bind:authenticate( + #{ + username => <<"mqttuser0001">>, + password => <<"mqttuser0001">> + }, + State + ), + + emqx_authn_test_lib:delete_authenticators( + [authentication], + ?GLOBAL + ), + + % Authenticator should not be usable anymore + ?assertMatch( + ignore, + emqx_ldap_authn_bind:authenticate( + #{ + username => <<"mqttuser0001">>, + password => <<"mqttuser0001">> + }, + State + ) + ). + +t_update(_Config) -> + CorrectConfig = raw_ldap_auth_config(), + IncorrectConfig = + CorrectConfig#{ + <<"base_dn">> => <<"ou=testdevice,dc=emqx,dc=io">> + }, + + {ok, _} = emqx:update_config( + ?PATH, + {create_authenticator, ?GLOBAL, IncorrectConfig} + ), + + {error, _} = emqx_access_control:authenticate( + #{ + username => <<"mqttuser0001">>, + password => <<"mqttuser0001">>, + listener => 'tcp:default', + protocol => mqtt + } + ), + + % We update with config with correct query, provider should update and work properly + {ok, _} = emqx:update_config( + ?PATH, + {update_authenticator, ?GLOBAL, <<"password_based:ldap_bind">>, CorrectConfig} + ), + + {ok, _} = emqx_access_control:authenticate( + #{ + username => <<"mqttuser0001">>, + password => <<"mqttuser0001">>, + listener => 'tcp:default', + protocol => mqtt + } + ). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +raw_ldap_auth_config() -> + #{ + <<"mechanism">> => <<"password_based">>, + <<"backend">> => <<"ldap_bind">>, + <<"server">> => ldap_server(), + <<"base_dn">> => <<"uid=${username},ou=testdevice,dc=emqx,dc=io">>, + <<"username">> => <<"cn=root,dc=emqx,dc=io">>, + <<"password">> => <<"public">>, + <<"pool_size">> => 8, + <<"bind_password">> => <<"${password}">> + }. + +user_seeds() -> + New = fun(Username, Password, Result) -> + #{ + credentials => #{ + username => Username, + password => Password + }, + config_params => #{}, + result => Result + } + end, + Valid = + lists:map( + fun(Idx) -> + Username = erlang:iolist_to_binary(io_lib:format("mqttuser000~b", [Idx])), + New(Username, Username, {ok, #{is_superuser => false}}) + end, + lists:seq(1, 5) + ), + [ + %% Not exists + New(<<"notexists">>, <<"notexists">>, {error, not_authorized}), + %% Wrong Password + New(<<"mqttuser0001">>, <<"wrongpassword">>, {error, bad_username_or_password}) + | Valid + ]. + +ldap_server() -> + iolist_to_binary(io_lib:format("~s:~B", [?LDAP_HOST, ?LDAP_DEFAULT_PORT])). + +ldap_config() -> + emqx_ldap_SUITE:ldap_config([]). + +start_apps(Apps) -> + lists:foreach(fun application:ensure_all_started/1, Apps). + +stop_apps(Apps) -> + lists:foreach(fun application:stop/1, Apps). diff --git a/rel/i18n/emqx_ldap.hocon b/rel/i18n/emqx_ldap.hocon index 204431907..85c55aac9 100644 --- a/rel/i18n/emqx_ldap.hocon +++ b/rel/i18n/emqx_ldap.hocon @@ -29,4 +29,9 @@ request_timeout.desc: request_timeout.label: """Request Timeout""" +bind_password.desc: +"""The template for password to bind.""" + +bind_password.label: +"""Bind Password""" } diff --git a/rel/i18n/emqx_ldap_authn_bind.hocon b/rel/i18n/emqx_ldap_authn_bind.hocon new file mode 100644 index 000000000..66510c2a2 --- /dev/null +++ b/rel/i18n/emqx_ldap_authn_bind.hocon @@ -0,0 +1,11 @@ +emqx_ldap_authn_bind { + +ldap_bind.desc: +"""Configuration of authenticator using the LDAP bind operation as the authentication method.""" + +query_timeout.desc: +"""Timeout for the LDAP query.""" + +query_timeout.label: +"""Query Timeout""" +}