diff --git a/apps/emqx_authn/src/emqx_authn.erl b/apps/emqx_authn/src/emqx_authn.erl index 50287941e..ed6f0a095 100644 --- a/apps/emqx_authn/src/emqx_authn.erl +++ b/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, #{}). diff --git a/apps/emqx_authn/src/emqx_authn_api.erl b/apps/emqx_authn/src/emqx_authn_api.erl index 35d2caa96..fa9f6c820 100644 --- a/apps/emqx_authn/src/emqx_authn_api.erl +++ b/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()), diff --git a/apps/emqx_authn/src/emqx_authn_enterprise.erl b/apps/emqx_authn/src/emqx_authn_enterprise.erl new file mode 100644 index 000000000..b50ec2c17 --- /dev/null +++ b/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. diff --git a/apps/emqx_authn/src/emqx_authn_password_hashing.erl b/apps/emqx_authn/src/emqx_authn_password_hashing.erl index 4954cd66e..80f6cfdcb 100644 --- a/apps/emqx_authn/src/emqx_authn_password_hashing.erl +++ b/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([ diff --git a/apps/emqx_authn/src/emqx_authn_utils.erl b/apps/emqx_authn/src/emqx_authn_utils.erl index 8e168bb5d..7fc20995a 100644 --- a/apps/emqx_authn/src/emqx_authn_utils.erl +++ b/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 %%-------------------------------------------------------------------- diff --git a/apps/emqx_ldap/src/emqx_ldap.erl b/apps/emqx_ldap/src/emqx_ldap.erl index 7cc9a6be0..d505f92d0 100644 --- a/apps/emqx_ldap/src/emqx_ldap.erl +++ b/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( diff --git a/apps/emqx_ldap/src/emqx_ldap_authn.erl b/apps/emqx_ldap/src/emqx_ldap_authn.erl new file mode 100644 index 000000000..6ad441c45 --- /dev/null +++ b/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, <>} -> + 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). diff --git a/rel/i18n/emqx_ldap.hocon b/rel/i18n/emqx_ldap.hocon index 9f3d9216b..cd2865d85 100644 --- a/rel/i18n/emqx_ldap.hocon +++ b/rel/i18n/emqx_ldap.hocon @@ -3,7 +3,7 @@ emqx_ldap { server.desc: """The IPv4 or IPv6 address or the hostname to connect to.
A host entry has the following form: `Host[:Port]`.
-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""" diff --git a/rel/i18n/emqx_ldap_authn.hocon b/rel/i18n/emqx_ldap_authn.hocon new file mode 100644 index 000000000..0cdae89b3 --- /dev/null +++ b/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""" + +}