feat(ldap): integrate authentication with LDAP bind operation

This commit is contained in:
firest 2023-09-14 13:53:02 +08:00
parent f2346b2e9a
commit afbf13b8a2
8 changed files with 543 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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) ->
<<InstId/binary, "-", ?POOL_NAME_SUFFIX>>.

View File

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

View File

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

View File

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