feat(authn): integrate the LDAP authentication

This commit is contained in:
firest 2023-08-02 19:19:17 +08:00
parent 22b4f4d256
commit c041216ec0
9 changed files with 397 additions and 69 deletions

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

@ -53,7 +53,8 @@
-export([
type_ro/1,
type_rw/1
type_rw/1,
salt_position/1
]).
-export([

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

@ -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,10 +172,6 @@ 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} ->
@ -184,7 +187,6 @@ on_query(
reason => Reason
}),
Error
end
end.
do_ldap_query(

View File

@ -0,0 +1,273 @@
%%--------------------------------------------------------------------
%% 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").
-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},
{salt_attribute, fun salt_attribute/1},
{salt_position, fun emqx_authn_password_hashing:salt_position/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.
salt_attribute(type) -> string();
salt_attribute(desc) -> ?DESC(?FUNCTION_NAME);
salt_attribute(default) -> <<"passwordSalt">>;
salt_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,
salt_attribute := SaltAttr,
is_superuser_attribute := IsSuperuserAttr,
query_timeout := Timeout,
resource_id := ResourceId
} = State
) ->
case
emqx_resource:simple_sync_query(
ResourceId,
{query, Credential, [PasswordAttr, SaltAttr, 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, salt_attribute, salt_position, 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.
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),
verify_password(HashType, PasswordHash, Password, Entry, State);
_Error ->
{error, invalid_hash_type}
end;
_ ->
OnFail(LDAPPassword, Password, Entry, State)
end.
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.
verify_password(ssha, PasswordData, Password, Entry, State) ->
case safe_base64_decode(PasswordData) of
{ok, <<PasswordHash:20, Salt/binary>>} ->
verify_password(sha, PasswordHash, Salt, suffix, Password, Entry, State);
{ok, _} ->
{error, invalid_ssha_password};
{error, Reason} ->
{error, {invalid_password, Reason}}
end;
verify_password(
Algorithm,
PasswordHash,
Password,
Entry,
#{salt_attribute := Attr, salt_position := Position} = State
) ->
Salt = get_bin_value(Attr, Entry#eldap_entry.attributes, <<>>),
verify_password(Algorithm, PasswordHash, Salt, Position, 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 ->
{ok, is_superuser(Entry, State)};
Error ->
Error
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)).
get_bin_value(Key, Proplists, Default) ->
[Value | _] = get_value(Key, Proplists, [Default]),
to_binary(Value).
to_binary(Value) ->
erlang:list_to_binary(Value).

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,30 @@
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"""
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."""
is_superuser_attribute.label:
"""IsSuperuser Attribute"""
query_timeout.desc:
"""Timeout for the LDAP query."""
query_timeout.label:
"""Query Timeout"""
}