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

feat(authn): integrate the LDAP authentication

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

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

@@ -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, #{}).

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

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

+ 24 - 0
apps/emqx_authn/src/emqx_authn_enterprise.erl

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

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

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

+ 38 - 42
apps/emqx_authn/src/emqx_authn_utils.erl

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

+ 25 - 23
apps/emqx_ldap/src/emqx_ldap.erl

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

+ 273 - 0
apps/emqx_ldap/src/emqx_ldap_authn.erl

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

+ 1 - 1
rel/i18n/emqx_ldap.hocon

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

+ 30 - 0
rel/i18n/emqx_ldap_authn.hocon

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