Skip to content

Commit

Permalink
Merge pull request #250 from emqx/0731-kerberos-auth
Browse files Browse the repository at this point in the history
0731 kerberos auth
  • Loading branch information
zmstone authored Aug 17, 2024
2 parents 2e3b9b8 + 78ea0f8 commit 38c3bc7
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 12 deletions.
54 changes: 48 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Client emqtt-zhouzibodeMacBook-Pro-1e4677ab46cecf1298ac sent DISCONNECT
[--will-qos [<will_qos>]]
[--will-retain [<will_retain>]]
[--enable-websocket [<enable_websocket>]]
[--enable-quic [<enable_quic>]]
[--enable-quic [<enable_quic>]]
[--enable-ssl [<enable_ssl>]]
[--tls-version [<tls_version>]]
[--CAfile <cafile>] [--cert <cert>]
Expand Down Expand Up @@ -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()}
```

<span id="client">**client()**</span>
Expand Down Expand Up @@ -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}**

&ensp;&ensp;**Types**
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
31 changes: 26 additions & 5 deletions src/emqtt.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}).

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 =
Expand All @@ -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} ->
Expand Down
192 changes: 192 additions & 0 deletions test/emqtt_kerberos_auth_SUITE.erl
Original file line number Diff line number Diff line change
@@ -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/[email protected]"
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down

0 comments on commit 38c3bc7

Please sign in to comment.