Merge pull request #11386 from lafirest/feat/ldap_authn

feat(authn): integrate the LDAP authentication
This commit is contained in:
lafirest 2023-08-04 09:37:37 +08:00 committed by GitHub
commit 1b0b15786c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 702 additions and 480 deletions

View File

@ -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, [

View File

@ -40,7 +40,8 @@ providers() ->
{{password_based, http}, emqx_authn_http},
{jwt, emqx_authn_jwt},
{{scram, built_in_database}, emqx_enhanced_authn_scram_mnesia}
].
] ++
emqx_authn_enterprise:providers().
check_config(Config) ->
check_config(Config, #{}).

View File

@ -876,7 +876,8 @@ resource_provider() ->
emqx_authn_mongodb,
emqx_authn_redis,
emqx_authn_http
].
] ++
emqx_authn_enterprise:resource_provider().
lookup_from_local_node(ChainName, AuthenticatorID) ->
NodeId = node(self()),

View File

@ -0,0 +1,24 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%--------------------------------------------------------------------
-module(emqx_authn_enterprise).
-export([providers/0, resource_provider/0]).
-if(?EMQX_RELEASE_EDITION == ee).
providers() ->
[{{password_based, ldap}, emqx_ldap_authn}].
resource_provider() ->
[emqx_ldap_authn].
-else.
providers() ->
[].
resource_provider() ->
[].
-endif.

View File

@ -35,7 +35,8 @@
ensure_apps_started/1,
cleanup_resources/0,
make_resource_id/1,
without_password/1
without_password/1,
to_bool/1
]).
-define(AUTHN_PLACEHOLDERS, [
@ -144,47 +145,8 @@ render_sql_params(ParamList, Credential) ->
#{return => rawlist, var_trans => fun handle_sql_var/2}
).
%% true
is_superuser(#{<<"is_superuser">> := <<"true">>}) ->
#{is_superuser => true};
is_superuser(#{<<"is_superuser">> := true}) ->
#{is_superuser => true};
is_superuser(#{<<"is_superuser">> := <<"1">>}) ->
#{is_superuser => true};
is_superuser(#{<<"is_superuser">> := I}) when
is_integer(I) andalso I >= 1
->
#{is_superuser => true};
%% false
is_superuser(#{<<"is_superuser">> := <<"">>}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := <<"0">>}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := 0}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := null}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := undefined}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := <<"false">>}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := false}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := MaybeBinInt}) when
is_binary(MaybeBinInt)
->
try binary_to_integer(MaybeBinInt) of
Int when Int >= 1 ->
#{is_superuser => true};
Int when Int =< 0 ->
#{is_superuser => false}
catch
error:badarg ->
#{is_superuser => false}
end;
%% fallback to default
is_superuser(#{<<"is_superuser">> := _}) ->
#{is_superuser => false};
is_superuser(#{<<"is_superuser">> := Value}) ->
#{is_superuser => to_bool(Value)};
is_superuser(#{}) ->
#{is_superuser => false}.
@ -211,6 +173,40 @@ make_resource_id(Name) ->
without_password(Credential) ->
without_password(Credential, [password, <<"password">>]).
to_bool(<<"true">>) ->
true;
to_bool(true) ->
true;
to_bool(<<"1">>) ->
true;
to_bool(I) when is_integer(I) andalso I >= 1 ->
true;
%% false
to_bool(<<"">>) ->
false;
to_bool(<<"0">>) ->
false;
to_bool(0) ->
false;
to_bool(null) ->
false;
to_bool(undefined) ->
false;
to_bool(<<"false">>) ->
false;
to_bool(false) ->
false;
to_bool(MaybeBinInt) when is_binary(MaybeBinInt) ->
try
binary_to_integer(MaybeBinInt) >= 1
catch
error:badarg ->
false
end;
%% fallback to default
to_bool(_) ->
false.
%%--------------------------------------------------------------------
%% Internal functions
%%--------------------------------------------------------------------

View File

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

View File

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

View File

@ -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, [

View File

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

View File

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

View File

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

View File

@ -57,11 +57,18 @@ fields(config) ->
{base_object,
?HOCON(binary(), #{
desc => ?DESC(base_object),
required => true
required => true,
validator => fun emqx_schema:non_empty_string/1
})},
{filter,
?HOCON(binary(), #{desc => ?DESC(filter), default => <<"(objectClass=mqttUser)">>})},
{auto_reconnect, fun ?ECS:auto_reconnect/1}
?HOCON(
binary(),
#{
desc => ?DESC(filter),
default => <<"(objectClass=mqttUser)">>,
validator => fun emqx_schema:non_empty_string/1
}
)}
] ++ emqx_connector_schema_lib:ssl_fields().
server() ->
@ -165,26 +172,21 @@ on_query(
#{base_tokens := BaseTks, filter_tokens := FilterTks} = State
) ->
Base = emqx_placeholder:proc_tmpl(BaseTks, Data),
case FilterTks of
[] ->
do_ldap_query(InstId, [{base, Base} | SearchOptions], State);
_ ->
FilterBin = emqx_placeholder:proc_tmpl(FilterTks, Data),
case emqx_ldap_filter_parser:scan_and_parse(FilterBin) of
{ok, Filter} ->
do_ldap_query(
InstId,
[{base, Base}, {filter, Filter} | SearchOptions],
State
);
{error, Reason} = Error ->
?SLOG(error, #{
msg => "filter_parse_failed",
filter => FilterBin,
reason => Reason
}),
Error
end
FilterBin = emqx_placeholder:proc_tmpl(FilterTks, Data),
case emqx_ldap_filter_parser:scan_and_parse(FilterBin) of
{ok, Filter} ->
do_ldap_query(
InstId,
[{base, Base}, {filter, Filter} | SearchOptions],
State
);
{error, Reason} = Error ->
?SLOG(error, #{
msg => "filter_parse_failed",
filter => FilterBin,
reason => Reason
}),
Error
end.
do_ldap_query(

View File

@ -0,0 +1,287 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%--------------------------------------------------------------------
-module(emqx_ldap_authn).
-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).
%% 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,
tags/0,
roots/0,
fields/1,
desc/1
]).
-export([
refs/0,
create/2,
update/2,
authenticate/2,
destroy/1
]).
-import(proplists, [get_value/2, get_value/3]).
%%------------------------------------------------------------------------------
%% Hocon Schema
%%------------------------------------------------------------------------------
namespace() -> "authn".
tags() ->
[<<"Authentication">>].
%% used for config check when the schema module is resolved
roots() ->
[{?CONF_NS, hoconsc:mk(hoconsc:ref(?MODULE, mysql))}].
fields(ldap) ->
[
{mechanism, emqx_authn_schema:mechanism(password_based)},
{backend, emqx_authn_schema:backend(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).
desc(ldap) ->
?DESC(ldap);
desc(_) ->
undefined.
password_attribute(type) -> string();
password_attribute(desc) -> ?DESC(?FUNCTION_NAME);
password_attribute(default) -> <<"userPassword">>;
password_attribute(_) -> undefined.
is_superuser_attribute(type) -> string();
is_superuser_attribute(desc) -> ?DESC(?FUNCTION_NAME);
is_superuser_attribute(default) -> <<"isSuperuser">>;
is_superuser_attribute(_) -> undefined.
query_timeout(type) -> emqx_schema:duration_ms();
query_timeout(desc) -> ?DESC(?FUNCTION_NAME);
query_timeout(default) -> <<"5s">>;
query_timeout(_) -> undefined.
%%------------------------------------------------------------------------------
%% APIs
%%------------------------------------------------------------------------------
refs() ->
[hoconsc:ref(?MODULE, ldap)].
create(_AuthenticatorID, Config) ->
create(Config).
create(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}}.
update(Config0, #{resource_id := ResourceId} = _State) ->
{Config, NState} = parse_config(Config0),
case emqx_authn_utils:update_resource(emqx_ldap, Config, ResourceId) of
{error, Reason} ->
error({load_config_error, Reason});
{ok, _} ->
{ok, NState#{resource_id => ResourceId}}
end.
destroy(#{resource_id := ResourceId}) ->
_ = emqx_resource:remove_local(ResourceId),
ok.
authenticate(#{auth_method := _}, _) ->
ignore;
authenticate(
#{password := Password} = Credential,
#{
password_attribute := PasswordAttr,
is_superuser_attribute := IsSuperuserAttr,
query_timeout := Timeout,
resource_id := ResourceId
} = State
) ->
case
emqx_resource:simple_sync_query(
ResourceId,
{query, Credential, [PasswordAttr, IsSuperuserAttr, ?ISENABLED_ATTR], Timeout}
)
of
{ok, []} ->
ignore;
{ok, [Entry | _]} ->
is_enabled(Password, Entry, State);
{error, Reason} ->
?TRACE_AUTHN_PROVIDER(error, "ldap_query_failed", #{
resource => ResourceId,
timeout => Timeout,
reason => Reason
}),
ignore
end.
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}
end,
#{},
[password_attribute, is_superuser_attribute, query_timeout]
),
{Config, State}.
%% To compatible v4.x
is_enabled(Password, #eldap_entry{attributes = Attributes} = Entry, State) ->
IsEnabled = get_lower_bin_value(?ISENABLED_ATTR, Attributes, "true"),
case emqx_authn_utils:to_bool(IsEnabled) of
true ->
ensure_password(Password, Entry, State);
_ ->
{error, user_disabled}
end.
ensure_password(
Password,
#eldap_entry{attributes = Attributes} = Entry,
#{password_attribute := PasswordAttr} = State
) ->
case get_value(PasswordAttr, Attributes) of
undefined ->
{error, no_password};
[LDAPPassword | _] ->
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(
LDAPPassword,
"{([^{}]+)}(.+)",
[{capture, all_but_first, list}, global]
)
of
{match, [[HashTypeStr, PasswordHashStr]]} ->
case emqx_utils:safe_to_existing_atom(string:to_lower(HashTypeStr)) of
{ok, HashType} ->
PasswordHash = to_binary(PasswordHashStr),
is_valid_algorithm(HashType, PasswordHash, Password, Entry, State);
_Error ->
{error, invalid_hash_type}
end;
_ ->
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
{ok, Decode} ->
extract_hash_algorithm(
Decode,
Password,
fun(_, _, _, _) ->
{error, invalid_password}
end,
Entry,
State
);
{error, Reason} ->
{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, hash, PasswordHash, Salt, suffix, Password, Entry, State);
{ok, _} ->
{error, invalid_ssha_password};
{error, Reason} ->
{error, {invalid_password, Reason}}
end;
verify_password(
Algorithm,
Base64HashData,
Password,
Entry,
State
) ->
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, invalid_password}
end.
is_superuser(Entry, #{is_superuser_attribute := Attr} = _State) ->
Value = get_lower_bin_value(Attr, Entry#eldap_entry.attributes, "false"),
#{is_superuser => emqx_authn_utils:to_bool(Value)}.
safe_base64_decode(Data) ->
try
{ok, base64:decode(Data)}
catch
_:Reason ->
{error, {invalid_base64_data, Reason}}
end.
get_lower_bin_value(Key, Proplists, Default) ->
[Value | _] = get_value(Key, Proplists, [Default]),
to_binary(string:to_lower(Value)).
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]},

View File

@ -0,0 +1 @@
Integrated the LDAP as a new authenticator.

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ emqx_ldap {
server.desc:
"""The IPv4 or IPv6 address or the hostname to connect to.<br/>
A host entry has the following form: `Host[:Port]`.<br/>
The MySQL default port 3306 is used if `[:Port]` is not specified."""
The LDAP default port 389 is used if `[:Port]` is not specified."""
server.label:
"""Server Host"""

View File

@ -0,0 +1,24 @@
emqx_ldap_authn {
ldap.desc:
"""Configuration of authenticator using LDAP as authentication data source."""
password_attribute.desc:
"""Indicates which attribute is used to represent the user's password."""
password_attribute.label:
"""Password Attribute"""
is_superuser_attribute.desc:
"""Indicates which attribute is used to represent whether the user is a super user."""
is_superuser_attribute.label:
"""IsSuperuser Attribute"""
query_timeout.desc:
"""Timeout for the LDAP query."""
query_timeout.label:
"""Query Timeout"""
}