From 2680b600625da555720e67950347a113a19b2238 Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 13:54:47 +0200 Subject: [PATCH 1/6] feat: support custom-auth callback init args --- rebar.config | 1 + src/emqtt.erl | 16 +++++++++++++--- ...SUITE.erl => emqtt_sasl_scram_auth_SUITE.erl} | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) rename test/{emqtt_custom_auth_SUITE.erl => emqtt_sasl_scram_auth_SUITE.erl} (99%) diff --git a/rebar.config b/rebar.config index 0734ad2..512aff6 100644 --- a/rebar.config +++ b/rebar.config @@ -31,6 +31,7 @@ , {emqx_ds_builtin_local, {git_subdir, "https://github.com/emqx/emqx", {branch, "master"}, "apps/emqx_ds_builtin_local"}} , {proper, "1.4.0"} , {esasl, {git, "https://github.com/emqx/esasl", {tag, "0.2.1"}}} + , {sasl_auth, {git, "https://github.com/kafka4beam/sasl_auth.git", {tag, "v2.2.0"}}} ]}, {erl_opts, [debug_info]}, %% Define `TEST' in emqx to get empty `foreign_refereced_schema_apps' diff --git a/src/emqtt.erl b/src/emqtt.erl index a462429..e14cc62 100644 --- a/src/emqtt.erl +++ b/src/emqtt.erl @@ -884,11 +884,21 @@ init([{nst, Ticket} | Opts], State = #state{sock_opts = SockOpts}) when is_binar init(Opts, State#state{sock_opts = [{nst, Ticket} | SockOpts]}); init([{with_qoe_metrics, IsReportQoE} | Opts], State) when is_boolean(IsReportQoE) -> init(Opts, State#state{qoe = IsReportQoE}); -init([{custom_auth_callbacks, #{init := InitFn, handle_auth := HandleAuthFn}} | Opts], State) when is_function(InitFn, 0), is_function(HandleAuthFn, 3) -> +init([{custom_auth_callbacks, #{init := InitFn, + handle_auth := HandleAuthFn, + int_args := InitArgs + }} | Opts], State) when is_function(InitFn, 0), is_function(HandleAuthFn, 3) -> %% HandleAuthFn :: fun((State, Reason, Props) -> {continue, OutPacket, State} | {stop, Reason}). - AuthState = InitFn(), + AuthState = case erlang:fun_info(InitFn, arity) of + {arity, 0} -> + InitFn(); + {arity, 1} -> + InitFn(InitArgs) + end, Extra0 = State#state.extra, - Extra = Extra0#{auth_cb => #{init => InitFn, handle_auth => HandleAuthFn, state => AuthState}}, + Extra = Extra0#{auth_cb => #{init => InitFn, + handle_auth => HandleAuthFn, + state => AuthState}}, init(Opts, State#state{extra = Extra}); init([_Opt | Opts], State) -> init(Opts, State). diff --git a/test/emqtt_custom_auth_SUITE.erl b/test/emqtt_sasl_scram_auth_SUITE.erl similarity index 99% rename from test/emqtt_custom_auth_SUITE.erl rename to test/emqtt_sasl_scram_auth_SUITE.erl index e232c39..40298a2 100644 --- a/test/emqtt_custom_auth_SUITE.erl +++ b/test/emqtt_sasl_scram_auth_SUITE.erl @@ -14,7 +14,7 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqtt_custom_auth_SUITE). +-module(emqtt_sasl_scram_auth_SUITE). -compile(nowarn_export_all). -compile(export_all). From 884ce93f338cd0b2bcf9a02443b4bc1ce07cc483 Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 15:54:30 +0200 Subject: [PATCH 2/6] feat: support kerberos auth --- src/emqtt.erl | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/emqtt.erl b/src/emqtt.erl index e14cc62..1599ba3 100644 --- a/src/emqtt.erl +++ b/src/emqtt.erl @@ -208,7 +208,7 @@ {continue, _OutAuthPacket, custom_auth_state()} | {stop, _Reason :: term()})). -type(custom_auth_callbacks() :: #{ - init := fun(() -> custom_auth_state()), + init := fun(() -> custom_auth_state()) | {function(), list()}, handle_auth := custom_auth_handle_fn() }). @@ -885,19 +885,22 @@ init([{nst, Ticket} | Opts], State = #state{sock_opts = SockOpts}) when is_binar init([{with_qoe_metrics, IsReportQoE} | Opts], State) when is_boolean(IsReportQoE) -> init(Opts, State#state{qoe = IsReportQoE}); init([{custom_auth_callbacks, #{init := InitFn, - handle_auth := HandleAuthFn, - int_args := InitArgs - }} | Opts], State) when is_function(InitFn, 0), is_function(HandleAuthFn, 3) -> + handle_auth := HandleAuthFn + }} | Opts], State) -> %% HandleAuthFn :: fun((State, Reason, Props) -> {continue, OutPacket, State} | {stop, Reason}). - AuthState = case erlang:fun_info(InitFn, arity) of - {arity, 0} -> - InitFn(); - {arity, 1} -> - InitFn(InitArgs) - end, + {AuthInitFn, AuthInitArgs} = + case InitFn of + Fn when is_function(Fn, 0) -> + %% upgrade init callback + {fun() -> {#{}, Fn()} end, []}; + {_Fn, _Args} = FnAndArgs -> + FnAndArgs + end, + {AuthProps, AuthState} = erlang:apply(AuthInitFn, AuthInitArgs), Extra0 = State#state.extra, - Extra = Extra0#{auth_cb => #{init => InitFn, - handle_auth => HandleAuthFn, + %% TODO: to support re-authenticate, should keep the initial func and args in state + Extra = Extra0#{auth_cb => #{handle_auth => HandleAuthFn, + initial_auth_props => AuthProps, state => AuthState}}, init(Opts, State#state{extra = Extra}); init([_Opt | Opts], State) -> @@ -1017,7 +1020,10 @@ mqtt_connect(State = #state{clientid = ClientId, proto_name = ProtoName, keepalive = KeepAlive, will_msg = WillMsg, - properties = Properties}) -> + properties = Properties0, + extra = Extra + }) -> + Properties = maybe_merge_auth_props(Properties0, Extra), ?WILL_MSG(WillQoS, WillRetain, WillTopic, WillProps, WillPayload) = ensure_will_msg(WillMsg), ConnProps = emqtt_props:filter(?CONNECT, Properties), Packet = @@ -1039,6 +1045,11 @@ mqtt_connect(State = #state{clientid = ClientId, password = emqtt_secret:unwrap(Password)}), send(Packet, State). +maybe_merge_auth_props(Properties, #{auth_cb := #{initial_auth_props := AuthProps}}) -> + maps:merge(Properties, AuthProps); +maybe_merge_auth_props(Properties, _) -> + Properties. + reconnect(state_timeout, Reconnect, #state{conn_mod = CMod} = State) -> case do_connect(CMod, State) of {ok, #state{connect_timeout = Timeout} = NewState} -> From e727463841c131b343ba3c4284a86795ebefb9b8 Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 15:54:51 +0200 Subject: [PATCH 3/6] test: add kerberos auth test --- test/emqtt_sasl_gssapi_auth_SUITE.erl | 192 ++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 test/emqtt_sasl_gssapi_auth_SUITE.erl diff --git a/test/emqtt_sasl_gssapi_auth_SUITE.erl b/test/emqtt_sasl_gssapi_auth_SUITE.erl new file mode 100644 index 0000000..a8f513e --- /dev/null +++ b/test/emqtt_sasl_gssapi_auth_SUITE.erl @@ -0,0 +1,192 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2024 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. +%%-------------------------------------------------------------------- + +%% To run this test suite. +%% You will need: +%% - EMQX running with kerberos auth enabled, serving MQTT at port 3883 +%% - KDC is up and running. +%% +%% Set up test environment from EMQX CI docker-compose files: +%% - Update .ci/docker-compose-file/docker-compose.yaml to make sure erlang container is exposing port 3883:1883 +%% - Command to start KDC: docker-compose -f ./.ci/docker-compose-file/docker-compose.yaml -f ./.ci/docker-compose-file/docker-compose-kdc.yaml up -d +%% - Run EMQX in the container 'erlang.emqx.net' +%% - Configure EMQX default tcp listener with kerberos auth enabled. +%% - Copy client keytab file '/var/lib/secret/krb_authn_cli.keytab' from container 'kdc.emqx.net' to `/tmp` +%% +-module(emqtt_sasl_gssapi_auth_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include("emqtt.hrl"). + +%%------------------------------------------------------------------------------ +%% CT boilerplate +%%------------------------------------------------------------------------------ + +all() -> + emqtt_test_lib:all(?MODULE). + +init_per_suite(Config) -> + Host = os:getenv("EMQX_HOST", "localhost"), + Port = list_to_integer(os:getenv("EMQX_PORT", "1883")), + case emqtt_test_lib:is_tcp_server_available(Host, Port) of + true -> + [ {host, Host} + , {port, Port} + | Config]; + false -> + {skip, no_emqx} + end. + +end_per_suite(_Config) -> + ok. + +%%------------------------------------------------------------------------------ +%% Helper fns +%%------------------------------------------------------------------------------ + +%% This must match the server principal +%% For this test, the server principal is "mqtt/erlang.emqx.net@KDC.EMQX.NET" +server_fqdn() -> <<"erlang.emqx.net">>. + +realm() -> <<"KDC.EMQX.NET">>. + +bin(X) -> iolist_to_binary(X). + +server_principal() -> + bin(["mqtt/", server_fqdn(), "@", realm()]). + +client_principal() -> + bin(["krb_authn_cli@", realm()]). + +client_keytab() -> + <<"/tmp/krb_authn_cli.keytab">>. + +auth_init(#{client_keytab := KeytabFile, + client_principal := ClientPrincipal, + server_fqdn := ServerFQDN, + server_principal := ServerPrincipal}) -> + ok = sasl_auth:kinit(KeytabFile, ClientPrincipal), + {ok, ClientHandle} = sasl_auth:client_new(<<"mqtt">>, ServerFQDN, ServerPrincipal, <<"krb_authn_cli">>), + {ok, {sasl_continue, FirstClientToken}} = sasl_auth:client_start(ClientHandle), + InitialProps = props(FirstClientToken), + State = #{client_handle => ClientHandle, step => 1}, + {InitialProps, State}. + +auth_handle(#{step := 1, + client_handle := ClientHandle + } = AuthState, Reason, Props) -> + ct:pal("step-1: auth packet received:\n rc: ~p\n props:\n ~p", [Reason, Props]), + case {Reason, Props} of + {continue_authentication, + #{'Authentication-Data' := ServerToken}} -> + {ok, {sasl_continue, ClientToken}} = + sasl_auth:client_step(ClientHandle, ServerToken), + OutProps = props(ClientToken), + NewState = AuthState#{step := 2}, + {continue, {?RC_CONTINUE_AUTHENTICATION, OutProps}, NewState}; + _ -> + {stop, protocol_error} + end; +auth_handle(#{step := 2, + client_handle := ClientHandle + }, Reason, Props) -> + ct:pal("step-2: auth packet received:\n rc: ~p\n props:\n ~p", [Reason, Props]), + case {Reason, Props} of + {continue_authentication, + #{'Authentication-Data' := ServerToken}} -> + {ok, {sasl_ok, ClientToken}} = + sasl_auth:client_step(ClientHandle, ServerToken), + OutProps = props(ClientToken), + NewState = #{done => erlang:system_time()}, + {continue, {?RC_CONTINUE_AUTHENTICATION, OutProps}, NewState}; + _ -> + {stop, protocol_error} + end. + +props(Data) -> + #{'Authentication-Method' => <<"GSSAPI-KERBEROS">>, + 'Authentication-Data' => Data + }. + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_basic(Config) -> + ct:timetrap({seconds, 5}), + Host = ?config(host, Config), + Port = ?config(port, Config), + InitArgs = #{client_keytab => client_keytab(), + client_principal => client_principal(), + server_fqdn => server_fqdn(), + server_principal => server_principal() + }, + {ok, C} = emqtt:start_link( + #{ host => Host + , port => Port + , username => <<"myuser">> + , proto_ver => v5 + , custom_auth_callbacks => + #{ init => {fun ?MODULE:auth_init/1, [InitArgs]} + , handle_auth => fun ?MODULE:auth_handle/3 + } + }), + ?assertMatch({ok, _}, emqtt:connect(C)), + {ok, _, [0]} = emqtt:subscribe(C, <<"t/#">>), + ok. + +t_bad_method_name(Config) -> + ct:timetrap({seconds, 5}), + Host = ?config(host, Config), + Port = ?config(port, Config), + InitFn = fun() -> + KeytabFile = client_keytab(), + ClientPrincipal = client_principal(), + ServerFQDN = server_fqdn(), + ServerPrincipal = server_principal(), + ok = sasl_auth:kinit(KeytabFile, ClientPrincipal), + {ok, ClientHandle} = sasl_auth:client_new(<<"mqtt">>, ServerFQDN, ServerPrincipal, <<"krb_authn_cli">>), + {ok, {sasl_continue, FirstClientToken}} = sasl_auth:client_start(ClientHandle), + InitialProps0 = props(FirstClientToken), + %% the expected method is GSSAPI-KERBEROS, using "KERBEROS" should immediately result in a rejection + InitialProps = InitialProps0#{'Authentication-Method' => <<"KERBEROS">>}, + State = #{client_handle => ClientHandle, step => 1}, + {InitialProps, State} + end, + {ok, C} = emqtt:start_link( + #{ host => Host + , port => Port + , username => <<"myuser">> + , proto_ver => v5 + , custom_auth_callbacks => + #{init => {InitFn, []}, + handle_auth => fun ?MODULE:auth_handle/3 + } + }), + _ = monitor(process, C), + unlink(C), + _ = emqtt:connect(C), + receive + {'DOWN', _, process, C, Reason} -> + ?assertEqual({shutdown, not_authorized}, Reason); + Msg -> + ct:fail({unexpected, Msg}) + end, + ok. From 6107ed4624a4cd4673fa95fbcd29fce8e125a15d Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 16:45:27 +0200 Subject: [PATCH 4/6] docs: add doc for custom_auth_callbacks option --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3931caa..85c46d0 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Client emqtt-zhouzibodeMacBook-Pro-1e4677ab46cecf1298ac sent DISCONNECT [--will-qos []] [--will-retain []] [--enable-websocket []] - [--enable-quic []] + [--enable-quic []] [--enable-ssl []] [--tls-version []] [--CAfile ] [--cert ] @@ -395,7 +395,8 @@ option() = {name, atom()} | {auto_ack, boolean()} | {ack_timeout, pos_integer()} | {force_ping, boolean()} | - {properties, properties()} + {properties, properties()} | + {custom_auth_callbacks, map()} ``` **client()** @@ -624,6 +625,12 @@ If false (the default), if any other packet is sent during keep alive interval, Properties of CONNECT packet. +`{custom_auth_callbacks, Callbacks}` + +This configuration option enables enhanced authentication mechanisms in MQTT v5 by specifying custom callback functions. + +See [Enhanced Authentication](#EnhancedAuthentication) below for more details. + **emqtt:connect(Client) -> {ok, Properties} | {error, Reason}**   **Types** @@ -854,12 +861,47 @@ end. ok = emqtt:disconnect(ConnPid). ok = emqtt:stop(ConnPid). ``` +## Enhanced Authentication + +As a MQTT client CLI, `emqtt` currently does not support enhanced authentication. + +As a MQTT client library, `emqtt` supports enhanced authentication with caller provided +callbacks. + +The callbacks should be provided as a `start_link` option `{custom_auth_callbacks, Callbacks}`, +where the `Callbacks` parameter should be a map structured as follows: + +```erlang +#{ + init => {InitFunc :: function(), InitArgs :: list()}, + handle_auth => HandleAuth :: function() +}. +``` -## License -Apache License Version 2.0 +### InitFunc + +This function is executed with InitArgs as arguments. It must return a tuple `{AuthProps, AuthState}`, where: + +- `AuthProps` is a map containing the initial authentication properties, including `'Authentication-Method'` and `'Authentication-Data'`. + +- `AuthState` is a term that is used in subsequent authentication steps. + +### HandleAuth + +This function is responsible for handling the continuation of the authentication process. It accepts the following parameters: + +- `AuthState`: The current state of authentication. +- `continue_authentication | ErrorCode`: A directive to continue authentication or an error code indicating the failure reason. +- `AuthProps`: A map containing properties for authentication, which must always include `'Authentication-Method'` and `'Authentication-Data'` at each step of the authentication process. + +The function should return a tuple in the form of: `{continue, {?RC_CONTINUE_AUTHENTICATION, AuthProps}, AuthState}` or `{stop, Reason}` to abort. + +### Examples -## Author +For practical implementations of these callbacks, refer to the following test suites in this repository: -EMQX Team. +- `test/emqtt_sasl_scram_auth_SUITE.erl` +- `test/emqtt_sasl_gssapi_auth_SUITE.erl` +These examples demonstrate how to configure the authentication callbacks for different SASL mechanisms supported by EMQTT. From ec69fb68062b9fe6400025a6cea9eeddff7ab57f Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 16:47:16 +0200 Subject: [PATCH 5/6] refactor: reanme test modules remove 'sasl' from the module names because there is no sasl --- README.md | 4 ++-- ...sl_gssapi_auth_SUITE.erl => emqtt_kerberos_auth_SUITE.erl} | 2 +- ...t_sasl_scram_auth_SUITE.erl => emqtt_scram_auth_SUITE.erl} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename test/{emqtt_sasl_gssapi_auth_SUITE.erl => emqtt_kerberos_auth_SUITE.erl} (99%) rename test/{emqtt_sasl_scram_auth_SUITE.erl => emqtt_scram_auth_SUITE.erl} (99%) diff --git a/README.md b/README.md index 85c46d0..206a281 100644 --- a/README.md +++ b/README.md @@ -901,7 +901,7 @@ The function should return a tuple in the form of: `{continue, {?RC_CONTINUE_AUT For practical implementations of these callbacks, refer to the following test suites in this repository: -- `test/emqtt_sasl_scram_auth_SUITE.erl` -- `test/emqtt_sasl_gssapi_auth_SUITE.erl` +- `test/emqtt_scram_auth_SUITE.erl` +- `test/emqtt_kerberos_auth_SUITE.erl` These examples demonstrate how to configure the authentication callbacks for different SASL mechanisms supported by EMQTT. diff --git a/test/emqtt_sasl_gssapi_auth_SUITE.erl b/test/emqtt_kerberos_auth_SUITE.erl similarity index 99% rename from test/emqtt_sasl_gssapi_auth_SUITE.erl rename to test/emqtt_kerberos_auth_SUITE.erl index a8f513e..02a768b 100644 --- a/test/emqtt_sasl_gssapi_auth_SUITE.erl +++ b/test/emqtt_kerberos_auth_SUITE.erl @@ -26,7 +26,7 @@ %% - Configure EMQX default tcp listener with kerberos auth enabled. %% - Copy client keytab file '/var/lib/secret/krb_authn_cli.keytab' from container 'kdc.emqx.net' to `/tmp` %% --module(emqtt_sasl_gssapi_auth_SUITE). +-module(emqtt_kerberos_auth_SUITE). -compile(nowarn_export_all). -compile(export_all). diff --git a/test/emqtt_sasl_scram_auth_SUITE.erl b/test/emqtt_scram_auth_SUITE.erl similarity index 99% rename from test/emqtt_sasl_scram_auth_SUITE.erl rename to test/emqtt_scram_auth_SUITE.erl index 40298a2..99d6089 100644 --- a/test/emqtt_sasl_scram_auth_SUITE.erl +++ b/test/emqtt_scram_auth_SUITE.erl @@ -14,7 +14,7 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqtt_sasl_scram_auth_SUITE). +-module(emqtt_scram_auth_SUITE). -compile(nowarn_export_all). -compile(export_all). From 78ea0f80cccaf80606d8701ad6906b069ec34f10 Mon Sep 17 00:00:00 2001 From: zmstone Date: Fri, 16 Aug 2024 20:54:08 +0200 Subject: [PATCH 6/6] test: fix port number --- test/emqtt_kerberos_auth_SUITE.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/emqtt_kerberos_auth_SUITE.erl b/test/emqtt_kerberos_auth_SUITE.erl index 02a768b..9b692c7 100644 --- a/test/emqtt_kerberos_auth_SUITE.erl +++ b/test/emqtt_kerberos_auth_SUITE.erl @@ -44,7 +44,7 @@ all() -> init_per_suite(Config) -> Host = os:getenv("EMQX_HOST", "localhost"), - Port = list_to_integer(os:getenv("EMQX_PORT", "1883")), + Port = list_to_integer(os:getenv("EMQX_PORT", "3883")), case emqtt_test_lib:is_tcp_server_available(Host, Port) of true -> [ {host, Host}