Parcourir la source

feat(ldap): integrate authentication with LDAP bind operation

firest il y a 2 ans
Parent
commit
afbf13b8a2

+ 2 - 1
apps/emqx_authn/src/emqx_authn_enterprise.erl

@@ -11,11 +11,12 @@
 providers() ->
 providers() ->
     [
     [
         {{password_based, ldap}, emqx_ldap_authn},
         {{password_based, ldap}, emqx_ldap_authn},
+        {{password_based, ldap_bind}, emqx_ldap_authn_bind},
         {gcp_device, emqx_gcp_device_authn}
         {gcp_device, emqx_gcp_device_authn}
     ].
     ].
 
 
 resource_provider() ->
 resource_provider() ->
-    [emqx_ldap_authn].
+    [emqx_ldap_authn, emqx_ldap_authn_bind].
 
 
 -else.
 -else.
 
 

+ 26 - 5
apps/emqx_ldap/src/emqx_ldap.erl

@@ -76,7 +76,20 @@ fields(config) ->
                 desc => ?DESC(request_timeout),
                 desc => ?DESC(request_timeout),
                 default => <<"5s">>
                 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() ->
 server() ->
     Meta = #{desc => ?DESC("server")},
     Meta = #{desc => ?DESC("server")},
@@ -122,7 +135,12 @@ on_start(
 
 
     case emqx_resource_pool:start(InstId, ?MODULE, Options) of
     case emqx_resource_pool:start(InstId, ?MODULE, Options) of
         ok ->
         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} ->
         {error, Reason} ->
             ?tp(
             ?tp(
                 ldap_connector_start_failed,
                 ldap_connector_start_failed,
@@ -131,11 +149,12 @@ on_start(
             {error, Reason}
             {error, Reason}
     end.
     end.
 
 
-on_stop(InstId, _State) ->
+on_stop(InstId, State) ->
     ?SLOG(info, #{
     ?SLOG(info, #{
         msg => "stopping_ldap_connector",
         msg => "stopping_ldap_connector",
         connector => InstId
         connector => InstId
     }),
     }),
+    ok = emqx_ldap_bind_worker:on_stop(InstId, State),
     emqx_resource_pool:stop(InstId).
     emqx_resource_pool:stop(InstId).
 
 
 on_query(InstId, {query, Data}, State) ->
 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, Attrs}, State) ->
     on_query(InstId, {query, Data}, [{attributes, Attrs}], State);
     on_query(InstId, {query, Data}, [{attributes, Attrs}], State);
 on_query(InstId, {query, Data, Attrs, Timeout}, 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) ->
 on_get_status(_InstId, #{pool_name := PoolName} = _State) ->
     case emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1) of
     case emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1) of
@@ -233,7 +254,7 @@ do_ldap_query(
         {error, Reason} ->
         {error, Reason} ->
             ?SLOG(
             ?SLOG(
                 error,
                 error,
-                LogMeta#{msg => "ldap_connector_do_sql_query_failed", reason => Reason}
+                LogMeta#{msg => "ldap_connector_do_query_failed", reason => Reason}
             ),
             ),
             {error, {unrecoverable_error, Reason}}
             {error, {unrecoverable_error, Reason}}
     end.
     end.

+ 15 - 14
apps/emqx_ldap/src/emqx_ldap_authn.erl

@@ -32,7 +32,8 @@
     create/2,
     create/2,
     update/2,
     update/2,
     authenticate/2,
     authenticate/2,
-    destroy/1
+    destroy/1,
+    do_create/2
 ]).
 ]).
 
 
 -import(proplists, [get_value/2, get_value/3]).
 -import(proplists, [get_value/2, get_value/3]).
@@ -56,7 +57,9 @@ fields(ldap) ->
         {password_attribute, fun password_attribute/1},
         {password_attribute, fun password_attribute/1},
         {is_superuser_attribute, fun is_superuser_attribute/1},
         {is_superuser_attribute, fun is_superuser_attribute/1},
         {query_timeout, fun query_timeout/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) ->
     ?DESC(ldap);
     ?DESC(ldap);
@@ -86,10 +89,10 @@ refs() ->
     [hoconsc:ref(?MODULE, ldap)].
     [hoconsc:ref(?MODULE, ldap)].
 
 
 create(_AuthenticatorID, Config) ->
 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),
     {Config, State} = parse_config(Config0),
     {ok, _Data} = emqx_authn_utils:create_resource(ResourceId, emqx_ldap, Config),
     {ok, _Data} = emqx_authn_utils:create_resource(ResourceId, emqx_ldap, Config),
     {ok, State#{resource_id => ResourceId}}.
     {ok, State#{resource_id => ResourceId}}.
@@ -142,16 +145,14 @@ authenticate(
 parse_config(Config) ->
 parse_config(Config) ->
     State = lists:foldl(
     State = lists:foldl(
         fun(Key, Acc) ->
         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,
         end,
-        #{},
+        Config,
         [password_attribute, is_superuser_attribute, query_timeout]
         [password_attribute, is_superuser_attribute, query_timeout]
     ),
     ),
     {Config, State}.
     {Config, State}.

+ 121 - 0
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.

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

+ 255 - 0
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).

+ 5 - 0
rel/i18n/emqx_ldap.hocon

@@ -29,4 +29,9 @@ request_timeout.desc:
 request_timeout.label:
 request_timeout.label:
 """Request Timeout"""
 """Request Timeout"""
 
 
+bind_password.desc:
+"""The template for password to bind."""
+
+bind_password.label:
+"""Bind Password"""
 }
 }

+ 11 - 0
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"""
+}