Просмотр исходного кода

feat(ldap-authn): add test suite for the LDAP authenticator

firest 2 лет назад
Родитель
Сommit
efff585b82

+ 1 - 1
apps/emqx_authn/src/emqx_authn.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_authn, [
     {description, "EMQX Authentication"},
-    {vsn, "0.1.23"},
+    {vsn, "0.1.24"},
     {modules, []},
     {registered, [emqx_authn_sup, emqx_authn_registry]},
     {applications, [

+ 0 - 134
apps/emqx_authn/test/data/emqx.io.ldif

@@ -1,134 +0,0 @@
-## create emqx.io
-
-dn:dc=emqx,dc=io
-objectclass: top
-objectclass: dcobject
-objectclass: organization
-dc:emqx
-o:emqx,Inc.
-
-# create testdevice.emqx.io
-dn:ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectclass:organizationalUnit
-ou:testdevice
-
-# create user admin
-dn:uid=admin,ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectClass: simpleSecurityObject
-objectClass: account
-userPassword:: e1NIQX1XNnBoNU1tNVB6OEdnaVVMYlBnekczN21qOWc9
-uid: admin
-
-## create user=mqttuser0001,
-#         password=mqttuser0001,
-#         passhash={SHA}mlb3fat40MKBTXUVZwCKmL73R/0=
-#         base64passhash=e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9
-dn:uid=mqttuser0001,ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectClass: mqttUser
-objectClass: mqttDevice
-objectClass: mqttSecurity
-uid: mqttuser0001
-isEnabled: TRUE
-mqttAccountName: user1
-mqttPublishTopic: mqttuser0001/pub/1
-mqttPublishTopic: mqttuser0001/pub/+
-mqttPublishTopic: mqttuser0001/pub/#
-mqttSubscriptionTopic: mqttuser0001/sub/1
-mqttSubscriptionTopic: mqttuser0001/sub/+
-mqttSubscriptionTopic: mqttuser0001/sub/#
-mqttPubSubTopic: mqttuser0001/pubsub/1
-mqttPubSubTopic: mqttuser0001/pubsub/+
-mqttPubSubTopic: mqttuser0001/pubsub/#
-userPassword:: e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9
-
-## create user=mqttuser0002
-#         password=mqttuser0002,
-#         passhash={SSHA}n9XdtoG4Q/TQ3TQF4Y+khJbMBH4qXj4M
-#         base64passhash=e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0=
-dn:uid=mqttuser0002,ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectClass: mqttUser
-objectClass: mqttDevice
-objectClass: mqttSecurity
-uid: mqttuser0002
-isEnabled: TRUE
-mqttAccountName: user2
-mqttPublishTopic: mqttuser0002/pub/1
-mqttPublishTopic: mqttuser0002/pub/+
-mqttPublishTopic: mqttuser0002/pub/#
-mqttSubscriptionTopic: mqttuser0002/sub/1
-mqttSubscriptionTopic: mqttuser0002/sub/+
-mqttSubscriptionTopic: mqttuser0002/sub/#
-mqttPubSubTopic: mqttuser0002/pubsub/1
-mqttPubSubTopic: mqttuser0002/pubsub/+
-mqttPubSubTopic: mqttuser0002/pubsub/#
-userPassword:: e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0=
-
-## create user mqttuser0003
-#         password=mqttuser0003,
-#         passhash={MD5}ybsPGoaK3nDyiQvveiCOIw==
-#         base64passhash=e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0=
-dn:uid=mqttuser0003,ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectClass: mqttUser
-objectClass: mqttDevice
-objectClass: mqttSecurity
-uid: mqttuser0003
-isEnabled: TRUE
-mqttPublishTopic: mqttuser0003/pub/1
-mqttPublishTopic: mqttuser0003/pub/+
-mqttPublishTopic: mqttuser0003/pub/#
-mqttSubscriptionTopic: mqttuser0003/sub/1
-mqttSubscriptionTopic: mqttuser0003/sub/+
-mqttSubscriptionTopic: mqttuser0003/sub/#
-mqttPubSubTopic: mqttuser0003/pubsub/1
-mqttPubSubTopic: mqttuser0003/pubsub/+
-mqttPubSubTopic: mqttuser0003/pubsub/#
-userPassword:: e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0=
-
-## create user mqttuser0004
-#         password=mqttuser0004,
-#         passhash={MD5}2Br6pPDSEDIEvUlu9+s+MA==
-#         base64passhash=e01ENX0yQnI2cFBEU0VESUV2VWx1OStzK01BPT0=
-dn:uid=mqttuser0004,ou=testdevice,dc=emqx,dc=io
-objectClass: top
-objectClass: mqttUser
-objectClass: mqttDevice
-objectClass: mqttSecurity
-uid: mqttuser0004
-isEnabled: TRUE
-mqttPublishTopic: mqttuser0004/pub/1
-mqttPublishTopic: mqttuser0004/pub/+
-mqttPublishTopic: mqttuser0004/pub/#
-mqttSubscriptionTopic: mqttuser0004/sub/1
-mqttSubscriptionTopic: mqttuser0004/sub/+
-mqttSubscriptionTopic: mqttuser0004/sub/#
-mqttPubSubTopic: mqttuser0004/pubsub/1
-mqttPubSubTopic: mqttuser0004/pubsub/+
-mqttPubSubTopic: mqttuser0004/pubsub/#
-userPassword: {MD5}2Br6pPDSEDIEvUlu9+s+MA==
-
-## create user mqttuser0005
-#         password=mqttuser0005,
-#         passhash={SHA}jKnxeEDGR14kE8AR7yuVFOelhz4=
-#         base64passhash=e1NIQX1qS254ZUVER1IxNGtFOEFSN3l1VkZPZWxoejQ9
-objectClass: top
-dn:uid=mqttuser0005,ou=testdevice,dc=emqx,dc=io
-objectClass: mqttUser
-objectClass: mqttDevice
-objectClass: mqttSecurity
-uid: mqttuser0005
-isEnabled: TRUE
-mqttPublishTopic: mqttuser0005/pub/1
-mqttPublishTopic: mqttuser0005/pub/+
-mqttPublishTopic: mqttuser0005/pub/#
-mqttSubscriptionTopic: mqttuser0005/sub/1
-mqttSubscriptionTopic: mqttuser0005/sub/+
-mqttSubscriptionTopic: mqttuser0005/sub/#
-mqttPubSubTopic: mqttuser0005/pubsub/1
-mqttPubSubTopic: mqttuser0005/pubsub/+
-mqttPubSubTopic: mqttuser0005/pubsub/#
-userPassword: {SHA}jKnxeEDGR14kE8AR7yuVFOelhz4=

+ 0 - 46
apps/emqx_authn/test/data/emqx.schema

@@ -1,46 +0,0 @@
-#
-# Preliminary Apple OS X Native LDAP Schema
-# This file is subject to change.
-#
-attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.1.3 NAME 'isEnabled'
-	EQUALITY booleanMatch
-	SYNTAX 1.3.6.1.4.1.1466.115.121.1.7
-	SINGLE-VALUE
-	USAGE userApplications )
-
-attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.1 NAME ( 'mqttPublishTopic' 'mpt' )
-	EQUALITY caseIgnoreMatch
-	SUBSTR caseIgnoreSubstringsMatch
-	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
-	USAGE userApplications )
-attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.2 NAME ( 'mqttSubscriptionTopic' 'mst' )
-	EQUALITY caseIgnoreMatch
-	SUBSTR caseIgnoreSubstringsMatch
-	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
-	USAGE userApplications )
-attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.3 NAME ( 'mqttPubSubTopic' 'mpst' )
-	EQUALITY caseIgnoreMatch
-	SUBSTR caseIgnoreSubstringsMatch
-	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
-	USAGE userApplications )
-attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.4 NAME ( 'mqttAccountName' 'man' )
-	EQUALITY caseIgnoreMatch
-	SUBSTR caseIgnoreSubstringsMatch
-	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
-	USAGE userApplications )
-
-
-objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4 NAME 'mqttUser'
-	AUXILIARY
-	MAY ( mqttPublishTopic $ mqttSubscriptionTopic $ mqttPubSubTopic $ mqttAccountName) )
-
-objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.2 NAME 'mqttDevice'
-	SUP top
-	STRUCTURAL
-	MUST ( uid )
-	MAY ( isEnabled ) )
-
-objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.3 NAME 'mqttSecurity'
-	SUP top
-	AUXILIARY
-	MAY ( userPassword $ userPKCS12 $ pwdAttribute $ pwdLockout ) )

+ 1 - 1
apps/emqx_connector/src/emqx_connector.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_connector, [
     {description, "EMQX Data Integration Connectors"},
-    {vsn, "0.1.28"},
+    {vsn, "0.1.29"},
     {registered, []},
     {mod, {emqx_connector_app, []}},
     {applications, [

+ 0 - 199
apps/emqx_connector/src/emqx_connector_ldap.erl

@@ -1,199 +0,0 @@
-%%--------------------------------------------------------------------
-%% Copyright (c) 2020-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
-%%
-%% Licensed under the Apache License, Version 2.0 (the "License");
-%% you may not use this file except in compliance with the License.
-%% You may obtain a copy of the License at
-%%
-%%     http://www.apache.org/licenses/LICENSE-2.0
-%%
-%% Unless required by applicable law or agreed to in writing, software
-%% distributed under the License is distributed on an "AS IS" BASIS,
-%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-%% See the License for the specific language governing permissions and
-%% limitations under the License.
-%%--------------------------------------------------------------------
--module(emqx_connector_ldap).
-
--include("emqx_connector.hrl").
--include_lib("typerefl/include/types.hrl").
--include_lib("emqx/include/logger.hrl").
-
--export([roots/0, fields/1]).
-
--behaviour(emqx_resource).
-
-%% callbacks of behaviour emqx_resource
--export([
-    callback_mode/0,
-    on_start/2,
-    on_stop/2,
-    on_query/3,
-    on_get_status/2
-]).
-
--export([connect/1]).
-
--export([search/4]).
-
-%% port is not expected from configuration because
-%% all servers expected to use the same port number
--define(LDAP_HOST_OPTIONS, #{no_port => true}).
-
-%%=====================================================================
-roots() ->
-    ldap_fields() ++ emqx_connector_schema_lib:ssl_fields().
-
-%% this schema has no sub-structs
-fields(_) -> [].
-
-%% ===================================================================
-callback_mode() -> always_sync.
-
-on_start(
-    InstId,
-    #{
-        servers := Servers0,
-        port := Port,
-        bind_dn := BindDn,
-        bind_password := BindPassword,
-        timeout := Timeout,
-        pool_size := PoolSize,
-        ssl := SSL
-    } = Config
-) ->
-    ?SLOG(info, #{
-        msg => "starting_ldap_connector",
-        connector => InstId,
-        config => emqx_utils:redact(Config)
-    }),
-    Servers1 = emqx_schema:parse_servers(Servers0, ?LDAP_HOST_OPTIONS),
-    Servers =
-        lists:map(
-            fun
-                (#{hostname := Host, port := Port0}) ->
-                    {Host, Port0};
-                (#{hostname := Host}) ->
-                    Host
-            end,
-            Servers1
-        ),
-    SslOpts =
-        case maps:get(enable, SSL) of
-            true ->
-                [
-                    {ssl, true},
-                    {sslopts, emqx_tls_lib:to_client_opts(SSL)}
-                ];
-            false ->
-                [{ssl, false}]
-        end,
-    Opts = [
-        {servers, Servers},
-        {port, Port},
-        {bind_dn, BindDn},
-        {bind_password, BindPassword},
-        {timeout, Timeout},
-        {pool_size, PoolSize},
-        {auto_reconnect, ?AUTO_RECONNECT_INTERVAL}
-    ],
-    case emqx_resource_pool:start(InstId, ?MODULE, Opts ++ SslOpts) of
-        ok -> {ok, #{pool_name => InstId}};
-        {error, Reason} -> {error, Reason}
-    end.
-
-on_stop(InstId, _State) ->
-    ?SLOG(info, #{
-        msg => "stopping_ldap_connector",
-        connector => InstId
-    }),
-    emqx_resource_pool:stop(InstId).
-
-on_query(InstId, {search, Base, Filter, Attributes}, #{pool_name := PoolName} = State) ->
-    Request = {Base, Filter, Attributes},
-    ?TRACE(
-        "QUERY",
-        "ldap_connector_received",
-        #{request => Request, connector => InstId, state => State}
-    ),
-    case
-        Result = ecpool:pick_and_do(
-            PoolName,
-            {?MODULE, search, [Base, Filter, Attributes]},
-            no_handover
-        )
-    of
-        {error, Reason} ->
-            ?SLOG(error, #{
-                msg => "ldap_connector_do_request_failed",
-                request => Request,
-                connector => InstId,
-                reason => Reason
-            }),
-            case Reason of
-                ecpool_empty ->
-                    {error, {recoverable_error, Reason}};
-                _ ->
-                    Result
-            end;
-        _ ->
-            Result
-    end.
-
-on_get_status(_InstId, _State) -> connected.
-
-search(Conn, Base, Filter, Attributes) ->
-    eldap2:search(Conn, [
-        {base, Base},
-        {filter, Filter},
-        {attributes, Attributes},
-        {deref, eldap2:'derefFindingBaseObj'()}
-    ]).
-
-%% ===================================================================
-connect(Opts) ->
-    Servers = proplists:get_value(servers, Opts, ["localhost"]),
-    Port = proplists:get_value(port, Opts, 389),
-    Timeout = proplists:get_value(timeout, Opts, 30),
-    BindDn = proplists:get_value(bind_dn, Opts),
-    BindPassword = proplists:get_value(bind_password, Opts),
-    SslOpts =
-        case proplists:get_value(ssl, Opts, false) of
-            true ->
-                [{sslopts, proplists:get_value(sslopts, Opts, [])}, {ssl, true}];
-            false ->
-                [{ssl, false}]
-        end,
-    LdapOpts =
-        [
-            {port, Port},
-            {timeout, Timeout}
-        ] ++ SslOpts,
-    {ok, LDAP} = eldap2:open(Servers, LdapOpts),
-    ok = eldap2:simple_bind(LDAP, BindDn, BindPassword),
-    {ok, LDAP}.
-
-ldap_fields() ->
-    [
-        {servers, servers()},
-        {port, fun port/1},
-        {pool_size, fun emqx_connector_schema_lib:pool_size/1},
-        {bind_dn, fun bind_dn/1},
-        {bind_password, fun emqx_connector_schema_lib:password/1},
-        {timeout, fun duration/1},
-        {auto_reconnect, fun emqx_connector_schema_lib:auto_reconnect/1}
-    ].
-
-servers() ->
-    emqx_schema:servers_sc(#{}, ?LDAP_HOST_OPTIONS).
-
-bind_dn(type) -> binary();
-bind_dn(default) -> 0;
-bind_dn(_) -> undefined.
-
-port(type) -> integer();
-port(default) -> 389;
-port(_) -> undefined.
-
-duration(type) -> emqx_schema:timeout_duration_ms();
-duration(_) -> undefined.

+ 2 - 1
apps/emqx_ldap/rebar.config

@@ -3,5 +3,6 @@
 {erl_opts, [debug_info]}.
 {deps, [
         {emqx_connector, {path, "../../apps/emqx_connector"}},
-        {emqx_resource, {path, "../../apps/emqx_resource"}}
+        {emqx_resource, {path, "../../apps/emqx_resource"}},
+        {emqx_authn, {path, "../../apps/emqx_authn"}}
 ]}.

+ 2 - 1
apps/emqx_ldap/src/emqx_ldap.app.src

@@ -4,7 +4,8 @@
     {registered, []},
     {applications, [
         kernel,
-        stdlib
+        stdlib,
+        emqx_authn
     ]},
     {env, []},
     {modules, []},

+ 44 - 18
apps/emqx_ldap/src/emqx_ldap_authn.erl

@@ -14,6 +14,10 @@
 
 %% a compatible attribute for version 4.x
 -define(ISENABLED_ATTR, "isEnabled").
+-define(VALID_ALGORITHMS, [md5, ssha, sha, sha256, sha384, sha512]).
+%% TODO
+%% 1. Supports more salt algorithms, SMD5 SSHA 256/384/512
+%% 2. Supports https://datatracker.ietf.org/doc/html/rfc3112
 
 -export([
     namespace/0,
@@ -152,7 +156,7 @@ parse_config(Config) ->
 
 %% To compatible v4.x
 is_enabled(Password, #eldap_entry{attributes = Attributes} = Entry, State) ->
-    IsEnabled = get_lower_bin_value(?ISENABLED_ATTR, Attributes, <<"true">>),
+    IsEnabled = get_lower_bin_value(?ISENABLED_ATTR, Attributes, "true"),
     case emqx_authn_utils:to_bool(IsEnabled) of
         true ->
             ensure_password(Password, Entry, State);
@@ -172,6 +176,8 @@ ensure_password(
             extract_hash_algorithm(LDAPPassword, Password, fun try_decode_passowrd/4, Entry, State)
     end.
 
+%% RFC 2307 format password
+%% https://datatracker.ietf.org/doc/html/rfc2307
 extract_hash_algorithm(LDAPPassword, Password, OnFail, Entry, State) ->
     case
         re:run(
@@ -184,7 +190,7 @@ extract_hash_algorithm(LDAPPassword, Password, OnFail, Entry, State) ->
             case emqx_utils:safe_to_existing_atom(string:to_lower(HashTypeStr)) of
                 {ok, HashType} ->
                     PasswordHash = to_binary(PasswordHashStr),
-                    verify_password(HashType, PasswordHash, Password, Entry, State);
+                    is_valid_algorithm(HashType, PasswordHash, Password, Entry, State);
                 _Error ->
                     {error, invalid_hash_type}
             end;
@@ -192,6 +198,14 @@ extract_hash_algorithm(LDAPPassword, Password, OnFail, Entry, State) ->
             OnFail(LDAPPassword, Password, Entry, State)
     end.
 
+is_valid_algorithm(HashType, PasswordHash, Password, Entry, State) ->
+    case lists:member(HashType, ?VALID_ALGORITHMS) of
+        true ->
+            verify_password(HashType, PasswordHash, Password, Entry, State);
+        _ ->
+            {error, {invalid_hash_type, HashType}}
+    end.
+
 %% this password is in LDIF format which is base64 encoding
 try_decode_passowrd(LDAPPassword, Password, Entry, State) ->
     case safe_base64_decode(LDAPPassword) of
@@ -209,10 +223,12 @@ try_decode_passowrd(LDAPPassword, Password, Entry, State) ->
             {error, {invalid_password, Reason}}
     end.
 
+%% sha with salt
+%% https://www.openldap.org/faq/data/cache/347.html
 verify_password(ssha, PasswordData, Password, Entry, State) ->
     case safe_base64_decode(PasswordData) of
         {ok, <<PasswordHash:20/binary, Salt/binary>>} ->
-            verify_password(sha, PasswordHash, Salt, suffix, Password, Entry, State);
+            verify_password(sha, hash, PasswordHash, Salt, suffix, Password, Entry, State);
         {ok, _} ->
             {error, invalid_ssha_password};
         {error, Reason} ->
@@ -220,29 +236,24 @@ verify_password(ssha, PasswordData, Password, Entry, State) ->
     end;
 verify_password(
     Algorithm,
-    PasswordHash,
+    Base64HashData,
     Password,
     Entry,
     State
 ) ->
-    verify_password(Algorithm, PasswordHash, <<>>, disable, Password, Entry, State).
-
-verify_password(Algorithm, PasswordHash, Salt, Position, Password, Entry, State) ->
-    Result = emqx_passwd:check_pass(
-        #{name => Algorithm, salt_position => Position},
-        Salt,
-        PasswordHash,
-        Password
-    ),
-    case Result of
-        ok ->
+    verify_password(Algorithm, base64, Base64HashData, <<>>, disable, Password, Entry, State).
+
+verify_password(Algorithm, LDAPPasswordType, LDAPPassword, Salt, Position, Password, Entry, State) ->
+    PasswordHash = hash_password(Algorithm, Salt, Position, Password),
+    case compare_password(LDAPPasswordType, LDAPPassword, PasswordHash) of
+        true ->
             {ok, is_superuser(Entry, State)};
-        Error ->
-            Error
+        _ ->
+            {error, invalid_password}
     end.
 
 is_superuser(Entry, #{is_superuser_attribute := Attr} = _State) ->
-    Value = get_lower_bin_value(Attr, Entry#eldap_entry.attributes, <<"false">>),
+    Value = get_lower_bin_value(Attr, Entry#eldap_entry.attributes, "false"),
     #{is_superuser => emqx_authn_utils:to_bool(Value)}.
 
 safe_base64_decode(Data) ->
@@ -259,3 +270,18 @@ get_lower_bin_value(Key, Proplists, Default) ->
 
 to_binary(Value) ->
     erlang:list_to_binary(Value).
+
+hash_password(Algorithm, _Salt, disable, Password) ->
+    hash_password(Algorithm, Password);
+hash_password(Algorithm, Salt, suffix, Password) ->
+    hash_password(Algorithm, <<Password/binary, Salt/binary>>).
+
+hash_password(Algorithm, Data) ->
+    crypto:hash(Algorithm, Data).
+
+compare_password(hash, PasswordHash, PasswordHash) ->
+    true;
+compare_password(base64, Base64HashData, PasswordHash) ->
+    Base64HashData =:= base64:encode(PasswordHash);
+compare_password(_, _, _) ->
+    false.

+ 17 - 0
apps/emqx_ldap/test/data/emqx.io.ldif

@@ -133,3 +133,20 @@ mqttPubSubTopic: mqttuser0005/pubsub/+
 mqttPubSubTopic: mqttuser0005/pubsub/#
 userPassword: {SHA}jKnxeEDGR14kE8AR7yuVFOelhz4=
 
+objectClass: top
+dn:uid=mqttuser0006,ou=testdevice,dc=emqx,dc=io
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0006
+isEnabled: FALSE
+userPassword: {SHA}AlNm2FUO8G5BK5pCggfrPauRqN0=
+
+objectClass: top
+dn:uid=mqttuser0007,ou=testdevice,dc=emqx,dc=io
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0007
+isSuperuser: TRUE
+userPassword: {SHA}axpQGbl00j3jvOG058y313ocnBk=

+ 8 - 2
apps/emqx_ldap/test/data/emqx.schema

@@ -8,6 +8,12 @@ attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.1.3 NAME 'isEnabled'
 	SINGLE-VALUE
 	USAGE userApplications )
 
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.1.4 NAME 'isSuperuser'
+	EQUALITY booleanMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.7
+	SINGLE-VALUE
+	USAGE userApplications )
+
 attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.1 NAME ( 'mqttPublishTopic' 'mpt' )
 	EQUALITY caseIgnoreMatch
 	SUBSTR caseIgnoreSubstringsMatch
@@ -32,7 +38,7 @@ attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.4 NAME ( 'mqttAccountName' 'ma
 
 objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4 NAME 'mqttUser'
 	AUXILIARY
-	MAY ( mqttPublishTopic $ mqttSubscriptionTopic $ mqttPubSubTopic $ mqttAccountName) )
+	MAY ( mqttPublishTopic $ mqttSubscriptionTopic $ mqttPubSubTopic $ mqttAccountName $ isSuperuser) )
 
 objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.2 NAME 'mqttDevice'
 	SUP top
@@ -43,4 +49,4 @@ objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.2 NAME 'mqttDevice'
 objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.3 NAME 'mqttSecurity'
 	SUP top
 	AUXILIARY
-	MAY ( userPassword $ userPKCS12 $ pwdAttribute $ pwdLockout ) )
+	MUST ( userPassword ) )

+ 2 - 3
apps/emqx_ldap/test/emqx_ldap_SUITE.erl

@@ -150,7 +150,6 @@ ldap_config(Config) ->
         io_lib:format(
             ""
             "\n"
-            "    auto_reconnect = true\n"
             "    username= \"cn=root,dc=emqx,dc=io\"\n"
             "    password = public\n"
             "    pool_size = 8\n"
@@ -183,10 +182,10 @@ data() ->
 
 port(tcp) -> 389;
 port(ssl) -> 636;
-port(Config) -> port(proplists:get_value(group, Config)).
+port(Config) -> port(proplists:get_value(group, Config, tcp)).
 
 ssl(Config) ->
-    case proplists:get_value(group, Config) of
+    case proplists:get_value(group, Config, tcp) of
         tcp ->
             "ssl.enable=false";
         ssl ->

+ 259 - 0
apps/emqx_ldap/test/emqx_ldap_authn_SUITE.erl

@@ -0,0 +1,259 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_ldap_authn_SUITE).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+-include_lib("emqx_authn/include/emqx_authn.hrl").
+-include_lib("eunit/include/eunit.hrl").
+
+-define(LDAP_HOST, "ldap").
+-define(LDAP_DEFAULT_PORT, 389).
+-define(LDAP_RESOURCE, <<"emqx_authn_ldap_SUITE">>).
+
+-define(PATH, [authentication]).
+-define(ResourceID, <<"password_based:ldap">>).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+init_per_testcase(_, Config) ->
+    {ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000),
+    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 ->
+            ok = emqx_common_test_helpers:start_apps([emqx_authn]),
+            ok = start_apps([emqx_resource]),
+            {ok, _} = emqx_resource:create_local(
+                ?LDAP_RESOURCE,
+                ?RESOURCE_GROUP,
+                emqx_ldap,
+                ldap_config(),
+                #{}
+            ),
+            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 = stop_apps([emqx_resource]),
+    ok = emqx_common_test_helpers:stop_apps([emqx_authn]).
+
+%%------------------------------------------------------------------------------
+%% Tests
+%%------------------------------------------------------------------------------
+
+t_create(_Config) ->
+    AuthConfig = raw_ldap_auth_config(),
+
+    {ok, _} = emqx:update_config(
+        ?PATH,
+        {create_authenticator, ?GLOBAL, AuthConfig}
+    ),
+
+    {ok, [#{provider := emqx_ldap_authn}]} = 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, state := State}]} =
+        emqx_authentication:list_authenticators(?GLOBAL),
+
+    {ok, _} = emqx_ldap_authn: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:authenticate(
+            #{
+                username => <<"mqttuser0001">>,
+                password => <<"mqttuser0001">>
+            },
+            State
+        )
+    ).
+
+t_update(_Config) ->
+    CorrectConfig = raw_ldap_auth_config(),
+    IncorrectConfig =
+        CorrectConfig#{
+            <<"base_object">> => <<"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">>, 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">>,
+        <<"server">> => ldap_server(),
+        <<"base_object">> => <<"uid=${username},ou=testdevice,dc=emqx,dc=io">>,
+        <<"username">> => <<"cn=root,dc=emqx,dc=io">>,
+        <<"password">> => <<"public">>,
+        <<"pool_size">> => 8
+    }.
+
+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, invalid_password}),
+        %% Disabled
+        New(<<"mqttuser0006">>, <<"mqttuser0006">>, {error, user_disabled}),
+        %% IsSuperuser
+        New(<<"mqttuser0007">>, <<"mqttuser0007">>, {ok, #{is_superuser => true}})
+        | 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).

+ 2 - 1
apps/emqx_machine/priv/reboot_lists.eterm

@@ -106,7 +106,8 @@
             emqx_schema_registry,
             emqx_eviction_agent,
             emqx_node_rebalance,
-            emqx_ft
+            emqx_ft,
+            emqx_ldap
         ],
     %% must always be of type `load'
     ce_business_apps =>

+ 1 - 1
apps/emqx_machine/src/emqx_machine.app.src

@@ -3,7 +3,7 @@
     {id, "emqx_machine"},
     {description, "The EMQX Machine"},
     % strict semver, bump manually!
-    {vsn, "0.2.9"},
+    {vsn, "0.2.10"},
     {modules, []},
     {registered, []},
     {applications, [kernel, stdlib, emqx_ctl]},

+ 2 - 1
mix.exs

@@ -194,7 +194,8 @@ defmodule EMQXUmbrella.MixProject do
       :emqx_schema_registry,
       :emqx_enterprise,
       :emqx_bridge_kinesis,
-      :emqx_bridge_azure_event_hub
+      :emqx_bridge_azure_event_hub,
+      :emqx_ldap
     ])
   end
 

+ 1 - 0
rebar.config.erl

@@ -106,6 +106,7 @@ is_community_umbrella_app("apps/emqx_schema_registry") -> false;
 is_community_umbrella_app("apps/emqx_enterprise") -> false;
 is_community_umbrella_app("apps/emqx_bridge_kinesis") -> false;
 is_community_umbrella_app("apps/emqx_bridge_azure_event_hub") -> false;
+is_community_umbrella_app("apps/emqx_ldap") -> false;
 is_community_umbrella_app(_) -> true.
 
 is_jq_supported() ->

+ 0 - 21
rel/i18n/emqx_connector_ldap.hocon

@@ -1,21 +0,0 @@
-emqx_connector_ldap {
-
-bind_dn.desc:
-"""LDAP's Binding Distinguished Name (DN)"""
-
-bind_dn.label:
-"""Bind DN"""
-
-port.desc:
-"""LDAP Port"""
-
-port.label:
-"""Port"""
-
-timeout.desc:
-"""LDAP's query timeout"""
-
-timeout.label:
-"""timeout"""
-
-}

+ 0 - 6
rel/i18n/emqx_ldap_authn.hocon

@@ -9,12 +9,6 @@ password_attribute.desc:
 password_attribute.label:
 """Password Attribute"""
 
-salt_attribute.desc:
-"""Indicates which attribute is used to represent the salt of the password."""
-
-salt_attribute.label:
-"""Salt Attribute"""
-
 is_superuser_attribute.desc:
 """Indicates which attribute is used to represent whether the user is a super user."""