diff --git a/README.md b/README.md index 3931caa..206a281 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_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/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..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() }). @@ -884,11 +884,24 @@ 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 + }} | Opts], State) -> %% HandleAuthFn :: fun((State, Reason, Props) -> {continue, OutPacket, State} | {stop, Reason}). - AuthState = InitFn(), + {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, state => AuthState}}, + %% 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) -> init(Opts, State). @@ -1007,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 = @@ -1029,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} -> diff --git a/test/emqtt_kerberos_auth_SUITE.erl b/test/emqtt_kerberos_auth_SUITE.erl new file mode 100644 index 0000000..9b692c7 --- /dev/null +++ b/test/emqtt_kerberos_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_kerberos_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", "3883")), + 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. diff --git a/test/emqtt_custom_auth_SUITE.erl b/test/emqtt_scram_auth_SUITE.erl similarity index 99% rename from test/emqtt_custom_auth_SUITE.erl rename to test/emqtt_scram_auth_SUITE.erl index e232c39..99d6089 100644 --- a/test/emqtt_custom_auth_SUITE.erl +++ b/test/emqtt_scram_auth_SUITE.erl @@ -14,7 +14,7 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqtt_custom_auth_SUITE). +-module(emqtt_scram_auth_SUITE). -compile(nowarn_export_all). -compile(export_all).