Przeglądaj źródła

Merge pull request #13810 from zmstone/0910-clientinfo-authn

Clientinfo authentication
zmstone 1 rok temu
rodzic
commit
adfd69aafc

+ 1 - 0
apps/emqx_auth/include/emqx_authn.hrl

@@ -18,6 +18,7 @@
 -define(EMQX_AUTHN_HRL, true).
 
 -include("emqx_authn_chains.hrl").
+-include_lib("emqx/include/emqx_placeholder.hrl").
 
 -define(AUTHN, emqx_authn_chains).
 

+ 5 - 8
apps/emqx_auth/src/emqx_authn/emqx_authn_schema.erl

@@ -137,19 +137,16 @@ select_union_member(_Kind, Value, _Mods) ->
     throw(#{reason => "not_a_struct", value => Value}).
 
 mod_select_union_member(Kind, Value, Mod) ->
-    emqx_utils:call_first_defined([
-        {Mod, select_union_member, [Kind, Value]},
-        {Mod, select_union_member, [Value]}
-    ]).
+    Args1 = [Kind, Value],
+    Args2 = [Value],
+    ArgsL = [Args1, Args2],
+    emqx_utils:call_first_defined(Mod, select_union_member, ArgsL).
 
 config_refs(Kind, Mods) ->
     lists:append([mod_refs(Kind, Mod) || Mod <- Mods]).
 
 mod_refs(Kind, Mod) ->
-    emqx_utils:call_first_defined([
-        {Mod, refs, [Kind]},
-        {Mod, refs, []}
-    ]).
+    emqx_utils:call_first_defined(Mod, refs, [[Kind], []]).
 
 root_type() ->
     hoconsc:array(authenticator_type()).

+ 94 - 0
apps/emqx_auth_cinfo/BSL.txt

@@ -0,0 +1,94 @@
+Business Source License 1.1
+
+Licensor:             Hangzhou EMQ Technologies Co., Ltd.
+Licensed Work:        EMQX Enterprise Edition
+                      The Licensed Work is (c) 2023
+                      Hangzhou EMQ Technologies Co., Ltd.
+Additional Use Grant: Students and educators are granted right to copy,
+                      modify, and create derivative work for research
+                      or education.
+Change Date:          2028-01-26
+Change License:       Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Software,
+please contact Licensor: https://www.emqx.com/en/contact
+
+Notice
+
+The Business Source License (this document, or the “License”) is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+“Business Source License” is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License’s text to license
+your works, and to refer to it using the trademark “Business Source License”,
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License’s text and the “Business
+Source License” name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+   or a license that is compatible with GPL Version 2.0 or a later version,
+   where “compatible” means that software provided under the Change License can
+   be included in a program with software provided under GPL Version 2.0 or a
+   later version. Licensor may specify additional Change Licenses without
+   limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+   impose any additional restriction on the right granted in this License, as
+   the Additional Use Grant; or (b) insert the text “None”.
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.

+ 49 - 0
apps/emqx_auth_cinfo/README.md

@@ -0,0 +1,49 @@
+# Authenticate clients with connection information
+
+This application implements an extended authentication for EMQX Enterprise edition.
+
+Client-info (of type `cinfo`) authentication is a lightweight authentication mechanism which checks client properties and attributes against user defined rules.
+The rules make use of the Variform expression to define match conditions, and the authentication result when match is found.
+For example, to quickly fencing off clients without a username, the match condition can be `str_eq(username, '')` associated with a attributes result `deny`.
+
+The new authenticator config look is like below.
+
+```
+authentication = [
+  {
+    mechanism = cinfo
+    checks = [
+      # allow clients with username starts with 'super-'
+      {
+        is_match = "regex_match(username, '^super-.+$')"
+        result = allow
+      },
+      # deny clients with empty username and client ID starts with 'v1-'
+      {
+        # when is_match is an array, it yields 'true' if all individual checks yield 'true'
+        is_match = ["str_eq(username, '')", "str_eq(nth(1,tokens(clientid,'-')), 'v1')"]
+        result = deny
+      }
+      # if all checks are exhausted without an 'allow' or a 'deny' result, continue to the next authentication
+    ]
+  },
+  # ... more authentications ...
+  # ...
+  # if all authenticators are exhausted without an 'allow' or a 'deny' result, the client is not rejected
+]
+```
+
+More match expression examples:
+
+- TLS certificate common name is the same as username: `str_eq(cert_common_name, username)`
+- Password is the `sha1` hash of environment variable `EMQXVAR_SECRET` concatenated to client ID: `str_eq(password, hash(sha1, concat([clientid, getenv('SECRET')])))`
+- Client attributes `client_attrs.group` is not 'g0': `str_neq(client_attrs.group, 'g0')`
+- Client ID starts with zone name: `regex_match(clientid, concat(['^', zone, '.+$']))`
+
+# Contributing
+
+Please see our [contributing.md](../../CONTRIBUTING.md).
+
+# License
+
+EMQ Business Source License 1.1, refer to [LICENSE](BSL.txt).

+ 12 - 0
apps/emqx_auth_cinfo/include/emqx_auth_cinfo.hrl

@@ -0,0 +1,12 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-ifndef(EMQX_AUTH_CINFO_HRL).
+-define(EMQX_AUTH_CINFO_HRL, true).
+
+-define(AUTHN_MECHANISM, cinfo).
+-define(AUTHN_MECHANISM_BIN, <<"cinfo">>).
+-define(AUTHN_TYPE, ?AUTHN_MECHANISM).
+
+-endif.

+ 32 - 0
apps/emqx_auth_cinfo/mix.exs

@@ -0,0 +1,32 @@
+defmodule EMQXAuthCinfo.MixProject do
+  use Mix.Project
+  alias EMQXUmbrella.MixProject, as: UMP
+
+  def project do
+    [
+      app: :emqx_auth_cinfo,
+      version: "0.1.0",
+      build_path: "../../_build",
+      # config_path: "../../config/config.exs",
+      erlc_options: UMP.erlc_options(),
+      erlc_paths: UMP.erlc_paths(),
+      deps_path: "../../deps",
+      lockfile: "../../mix.lock",
+      elixir: "~> 1.14",
+      start_permanent: Mix.env() == :prod,
+      deps: deps()
+    ]
+  end
+
+  # Run "mix help compile.app" to learn about applications
+  def application do
+    [extra_applications: UMP.extra_applications(), mod: {:emqx_auth_cinfo_app, []}]
+  end
+
+  def deps() do
+    [
+      {:emqx, in_umbrella: true},
+      {:emqx_auth, in_umbrella: true}
+    ]
+  end
+end

+ 7 - 0
apps/emqx_auth_cinfo/rebar.config

@@ -0,0 +1,7 @@
+%% -*- mode: erlang -*-
+
+{deps, [
+    {emqx, {path, "../emqx"}},
+    {emqx_utils, {path, "../emqx_utils"}},
+    {emqx_auth, {path, "../emqx_auth"}}
+]}.

+ 16 - 0
apps/emqx_auth_cinfo/src/emqx_auth_cinfo.app.src

@@ -0,0 +1,16 @@
+%% -*- mode: erlang -*-
+{application, emqx_auth_cinfo, [
+    {description, "EMQX Client Information Authorization"},
+    {vsn, "0.1.0"},
+    {registered, []},
+    {mod, {emqx_auth_cinfo_app, []}},
+    {applications, [
+        kernel,
+        stdlib,
+        emqx,
+        emqx_auth
+    ]},
+    {env, []},
+    {modules, []},
+    {links, []}
+]}.

+ 20 - 0
apps/emqx_auth_cinfo/src/emqx_auth_cinfo_app.erl

@@ -0,0 +1,20 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_auth_cinfo_app).
+
+-include("emqx_auth_cinfo.hrl").
+
+-behaviour(application).
+
+-export([start/2, stop/1]).
+
+start(_StartType, _StartArgs) ->
+    {ok, Sup} = emqx_auth_cinfo_sup:start_link(),
+    ok = emqx_authn:register_provider(?AUTHN_TYPE, emqx_authn_cinfo),
+    {ok, Sup}.
+
+stop(_State) ->
+    ok = emqx_authn:deregister_provider(?AUTHN_TYPE),
+    ok.

+ 25 - 0
apps/emqx_auth_cinfo/src/emqx_auth_cinfo_sup.erl

@@ -0,0 +1,25 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_auth_cinfo_sup).
+
+-behaviour(supervisor).
+
+-export([start_link/0]).
+
+-export([init/1]).
+
+-define(SERVER, ?MODULE).
+
+start_link() ->
+    supervisor:start_link({local, ?SERVER}, ?MODULE, []).
+
+init([]) ->
+    SupFlags = #{
+        strategy => one_for_all,
+        intensity => 0,
+        period => 1
+    },
+    ChildSpecs = [],
+    {ok, {SupFlags, ChildSpecs}}.

+ 137 - 0
apps/emqx_auth_cinfo/src/emqx_authn_cinfo.erl

@@ -0,0 +1,137 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_cinfo).
+
+-include_lib("emqx_auth/include/emqx_authn.hrl").
+-include_lib("emqx/include/logger.hrl").
+-include_lib("emqx/include/emqx_placeholder.hrl").
+-include_lib("jose/include/jose_jwk.hrl").
+
+-export([
+    create/2,
+    update/2,
+    authenticate/2,
+    destroy/1
+]).
+
+create(AuthenticatorID, #{checks := Checks}) ->
+    case compile(AuthenticatorID, Checks) of
+        {ok, Compiled} ->
+            {ok, #{
+                id => AuthenticatorID,
+                checks => Compiled
+            }};
+        {error, Reason} ->
+            {error, Reason}
+    end.
+
+compile(ID, Checks) ->
+    try
+        {ok, compile_checks(Checks, [])}
+    catch
+        throw:Error ->
+            {error, Error#{authenticator => ID}}
+    end.
+
+compile_checks([], Acc) ->
+    lists:reverse(Acc);
+compile_checks([Check | Checks], Acc) ->
+    compile_checks(Checks, [compile_check(Check) | Acc]).
+
+compile_check(#{is_match := Expressions} = C) ->
+    Compiled = compile_exprs(Expressions),
+    %% is_match being non-empty is ensured by schema module
+    Compiled =:= [] andalso error(empty),
+    C#{is_match => Compiled}.
+
+compile_exprs(Expression) when is_binary(Expression) ->
+    [compile_expr(Expression)];
+compile_exprs(Expressions) when is_list(Expressions) ->
+    lists:map(fun compile_expr/1, Expressions).
+
+compile_expr(Expression) ->
+    %% Expression not empty string is ensured by schema
+    true = (<<"">> =/= Expression),
+    %% emqx_variform:compile(Expression) return 'ok' tuple is ensured by schema
+    {ok, Compiled} = emqx_variform:compile(Expression),
+    Compiled.
+
+update(#{enable := false}, State) ->
+    {ok, State};
+update(Config, #{id := ID}) ->
+    create(ID, Config).
+
+authenticate(#{auth_method := _}, _) ->
+    %% enhanced authentication is not supported by this provider
+    ignore;
+authenticate(Credential0, #{checks := Checks}) ->
+    Credential = add_credential_aliases(Credential0),
+    check(Checks, Credential).
+
+check([], _) ->
+    ignore;
+check([Check | Rest], Credential) ->
+    case do_check(Check, Credential) of
+        nomatch ->
+            check(Rest, Credential);
+        {match, ignore} ->
+            ignore;
+        {match, allow} ->
+            {ok, #{}};
+        {match, deny} ->
+            {error, bad_username_or_password}
+    end.
+
+do_check(#{is_match := CompiledExprs, result := Result}, Credential) ->
+    case is_match(CompiledExprs, Credential) of
+        true ->
+            {match, Result};
+        false ->
+            nomatch
+    end.
+
+is_match([], _Credential) ->
+    true;
+is_match([CompiledExpr | CompiledExprs], Credential) ->
+    case emqx_variform:render(CompiledExpr, Credential) of
+        {ok, <<"true">>} ->
+            is_match(CompiledExprs, Credential);
+        {ok, <<"false">>} ->
+            false;
+        {ok, Other} ->
+            ?SLOG(debug, "clientinfo_auth_expression_yield_non_boolean", #{
+                expr => emqx_variform:decompile(CompiledExpr),
+                yield => Other
+            }),
+            false;
+        {error, Reason} ->
+            {error, #{
+                cause => "clientinfo_auth_expression_evaluation_error",
+                error => Reason
+            }}
+    end.
+
+destroy(_) ->
+    ok.
+
+%% Add aliases for credential fields
+%% - cert_common_name for cn
+%% - cert_subject for dn
+add_credential_aliases(Credential) ->
+    Aliases = [
+        {cn, cert_common_name},
+        {dn, cert_subject}
+    ],
+    add_credential_aliases(Credential, Aliases).
+
+add_credential_aliases(Credential, []) ->
+    Credential;
+add_credential_aliases(Credential, [{Field, Alias} | Rest]) ->
+    case maps:find(Field, Credential) of
+        {ok, Value} ->
+            add_credential_aliases(Credential#{Alias => Value}, Rest);
+        error ->
+            add_credential_aliases(Credential, Rest)
+    end.

+ 93 - 0
apps/emqx_auth_cinfo/src/emqx_authn_cinfo_schema.erl

@@ -0,0 +1,93 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_cinfo_schema).
+
+-behaviour(emqx_authn_schema).
+
+-export([
+    namespace/0,
+    fields/1,
+    desc/1,
+    refs/0,
+    select_union_member/1
+]).
+
+-include("emqx_auth_cinfo.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("typerefl/include/types.hrl").
+
+namespace() -> "authn".
+
+refs() ->
+    [
+        ?R_REF("cinfo")
+    ].
+
+select_union_member(#{<<"mechanism">> := ?AUTHN_MECHANISM_BIN}) ->
+    [?R_REF("cinfo")];
+select_union_member(_Value) ->
+    undefined.
+
+fields("cinfo") ->
+    [
+        {mechanism, emqx_authn_schema:mechanism(?AUTHN_MECHANISM)},
+        {checks,
+            hoconsc:mk(
+                hoconsc:array(?R_REF("cinfo_check")),
+                #{
+                    required => true,
+                    desc => ?DESC(checks),
+                    validator => fun validate_checks/1,
+                    importance => ?IMPORTANCE_HIGH
+                }
+            )}
+    ] ++ emqx_authn_schema:common_fields();
+fields("cinfo_check") ->
+    [
+        {is_match,
+            hoconsc:mk(
+                hoconsc:union([binary(), hoconsc:array(binary())]),
+                #{
+                    required => true,
+                    desc => ?DESC(is_match),
+                    importance => ?IMPORTANCE_HIGH,
+                    validator => fun validate_expressions/1
+                }
+            )},
+        {result,
+            hoconsc:mk(
+                hoconsc:enum([allow, deny, ignore]),
+                #{
+                    required => true,
+                    desc => ?DESC(result),
+                    importance => ?IMPORTANCE_HIGH
+                }
+            )}
+    ].
+
+desc("cinfo") ->
+    ?DESC("cinfo");
+desc("cinfo_check") ->
+    ?DESC("check").
+
+validate_checks([]) ->
+    throw("require_at_least_one_check");
+validate_checks(List) when is_list(List) ->
+    ok.
+
+validate_expressions(Expr) when is_binary(Expr) ->
+    validate_expression(Expr);
+validate_expressions(Exprs) when is_list(Exprs) ->
+    lists:foreach(fun validate_expression/1, Exprs).
+
+validate_expression(<<>>) ->
+    throw("should not be empty string");
+validate_expression(Expr) ->
+    case emqx_variform:compile(Expr) of
+        {ok, _} ->
+            ok;
+        {error, Reason} ->
+            throw(Reason)
+    end.

+ 170 - 0
apps/emqx_auth_cinfo/test/emqx_authn_cinfo_SUITE.erl

@@ -0,0 +1,170 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2020-2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_cinfo_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("common_test/include/ct.hrl").
+-include_lib("eunit/include/eunit.hrl").
+
+-define(AUTHN_ID, <<"mechanism:cinfo">>).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+init_per_suite(Config) ->
+    Apps = emqx_cth_suite:start([emqx, emqx_conf, emqx_auth, emqx_auth_cinfo], #{
+        work_dir => emqx_cth_suite:work_dir(Config)
+    }),
+    %% ensure module loaded
+    _ = emqx_variform_bif:module_info(),
+    [{apps, Apps} | Config].
+
+end_per_suite(Config) ->
+    ok = emqx_cth_suite:stop(?config(apps, Config)),
+    ok.
+
+end_per_testcase(_TestCase, _Config) ->
+    emqx_common_test_helpers:call_janitor(),
+    ok.
+
+%%------------------------------------------------------------------------------
+%% Tests
+%%------------------------------------------------------------------------------
+
+t_ignore_enhanced_auth(_) ->
+    ?assertEqual(ignore, emqx_authn_cinfo:authenticate(#{auth_method => <<"enhanced">>}, state)).
+
+t_username_equal_clientid(_) ->
+    Checks =
+        [
+            #{
+                is_match => <<"str_eq(username, '')">>,
+                result => deny
+            },
+            #{
+                is_match => <<"str_eq(username, clientid)">>,
+                result => allow
+            }
+        ],
+    with_checks(
+        Checks,
+        fun(State) ->
+            ?assertMatch(
+                {error, bad_username_or_password},
+                emqx_authn_cinfo:authenticate(#{username => <<>>}, State)
+            ),
+            ?assertMatch(
+                {ok, #{}},
+                emqx_authn_cinfo:authenticate(#{username => <<"a">>, clientid => <<"a">>}, State)
+            ),
+            ?assertMatch(
+                ignore,
+                emqx_authn_cinfo:authenticate(#{username => <<"a">>, clientid => <<"b">>}, State)
+            )
+        end
+    ).
+
+t_ignore_if_is_match_yield_false(_) ->
+    Checks =
+        [
+            #{
+                is_match => <<"str_eq(username, 'a')">>,
+                result => deny
+            }
+        ],
+    with_checks(
+        Checks,
+        fun(State) ->
+            ?assertEqual(ignore, emqx_authn_cinfo:authenticate(#{username => <<"b">>}, State))
+        end
+    ).
+
+t_ignore_if_is_match_yield_non_boolean(_) ->
+    Checks = [
+        #{
+            %% return 'no-identity' if both username and clientid are missing
+            %% this should lead to a 'false' result for 'is_match'
+            is_match => <<"coalesce(username,clientid,'no-identity')">>,
+            result => deny
+        }
+    ],
+    with_checks(
+        Checks,
+        fun(State) ->
+            ?assertEqual(ignore, emqx_authn_cinfo:authenticate(#{username => <<"b">>}, State))
+        end
+    ).
+
+t_multiple_is_match_expressions(_) ->
+    Checks = [
+        #{
+            %% use AND to connect multiple is_match expressions
+            %% this one means username is not empty, and clientid is 'super'
+            is_match => [
+                <<"str_neq('', username)">>, <<"str_eq(clientid, 'super')">>
+            ],
+            result => allow
+        }
+    ],
+    with_checks(
+        Checks,
+        fun(State) ->
+            ?assertEqual(
+                ignore,
+                emqx_authn_cinfo:authenticate(#{username => <<"">>, clientid => <<"super">>}, State)
+            ),
+            ?assertMatch(
+                {ok, #{}},
+                emqx_authn_cinfo:authenticate(
+                    #{username => <<"a">>, clientid => <<"super">>}, State
+                )
+            )
+        end
+    ).
+
+t_cert_fields_as_alias(_) ->
+    Checks = [
+        #{
+            is_match => [
+                <<"str_eq(clientid, coalesce(cert_common_name,''))">>
+            ],
+            result => allow
+        },
+        #{
+            is_match => <<"true">>,
+            result => deny
+        }
+    ],
+    with_checks(
+        Checks,
+        fun(State) ->
+            ?assertEqual(
+                {error, bad_username_or_password},
+                emqx_authn_cinfo:authenticate(#{username => <<"u">>, clientid => <<"c">>}, State)
+            ),
+            ?assertMatch(
+                {ok, #{}},
+                emqx_authn_cinfo:authenticate(#{cn => <<"CN1">>, clientid => <<"CN1">>}, State)
+            )
+        end
+    ).
+
+config(Checks) ->
+    #{
+        mechanism => cinfo,
+        checks => Checks
+    }.
+
+with_checks(Checks, F) ->
+    Config = config(Checks),
+    {ok, State} = emqx_authn_cinfo:create(?AUTHN_ID, Config),
+    try
+        F(State)
+    after
+        ?assertEqual(ok, emqx_authn_cinfo:destroy(State))
+    end,
+    ok.

+ 156 - 0
apps/emqx_auth_cinfo/test/emqx_authn_cinfo_int_SUITE.erl

@@ -0,0 +1,156 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2020-2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+%% end-to-end integration test
+-module(emqx_authn_cinfo_int_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("common_test/include/ct.hrl").
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("emqx_auth/include/emqx_authn.hrl").
+
+-define(AUTHN_ID, <<"mechanism:cinfo">>).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+init_per_suite(Config) ->
+    Apps = emqx_cth_suite:start(
+        [
+            emqx_conf,
+            emqx,
+            emqx_auth,
+            %% to load schema
+            {emqx_auth_cinfo, #{start => false}},
+            emqx_management,
+            {emqx_dashboard, "dashboard.listeners.http { enable = true, bind = 18083 }"}
+        ],
+        #{
+            work_dir => filename:join(?config(priv_dir, Config), ?MODULE)
+        }
+    ),
+    _ = emqx_common_test_http:create_default_app(),
+    ok = emqx_authn_chains:register_providers([{cinfo, emqx_authn_cinfo}]),
+    ?AUTHN:delete_chain(?GLOBAL),
+    {ok, Chains} = ?AUTHN:list_chains(),
+    ?assertEqual(length(Chains), 0),
+    [{apps, Apps} | Config].
+
+end_per_suite(Config) ->
+    ok = emqx_cth_suite:stop(?config(apps, Config)),
+    ok.
+
+init_per_testcase(_Case, Config) ->
+    emqx_authn_test_lib:delete_authenticators(
+        [?CONF_NS_ATOM],
+        ?GLOBAL
+    ),
+    Config.
+
+end_per_testcase(_TestCase, _Config) ->
+    ok.
+
+%%------------------------------------------------------------------------------
+%% Tests
+%%------------------------------------------------------------------------------
+
+t_create_ok(_Config) ->
+    {ok, Config} = hocon:binary(config(?FUNCTION_NAME)),
+    {ok, 200, _} = request(post, uri([?CONF_NS]), Config),
+    {ok, Client1} = emqtt:start_link([
+        {proto_ver, v5},
+        {username, <<"magic1">>},
+        {password, <<"ignore">>}
+    ]),
+    unlink(Client1),
+    {ok, Client2} = emqtt:start_link([
+        {proto_ver, v5},
+        {username, <<"magic2">>},
+        {password, <<"ignore">>}
+    ]),
+    unlink(Client2),
+    {ok, Client3} = emqtt:start_link([
+        {proto_ver, v5},
+        {username, <<"magic3">>},
+        {password, <<"ignore">>}
+    ]),
+    unlink(Client3),
+    ?assertMatch({ok, _}, emqtt:connect(Client1)),
+    ok = emqtt:disconnect(Client1),
+    ?assertMatch({error, {bad_username_or_password, #{}}}, emqtt:connect(Client2)),
+    ?assertMatch({error, {not_authorized, #{}}}, emqtt:connect(Client3)),
+    ok.
+
+t_empty_checks_is_not_allowed(_Config) ->
+    {ok, Config} = hocon:binary(config(?FUNCTION_NAME)),
+    ?assertMatch(
+        {ok, 400, _},
+        request(post, uri([?CONF_NS]), Config)
+    ),
+    ok.
+
+t_empty_is_match_not_allowed(_Config) ->
+    {ok, Config} = hocon:binary(config(?FUNCTION_NAME)),
+    ?assertMatch(
+        {ok, 400, _},
+        request(post, uri([?CONF_NS]), Config)
+    ),
+    ok.
+
+t_expression_compile_error(_Config) ->
+    {ok, Config} = hocon:binary(config(?FUNCTION_NAME)),
+    ?assertMatch(
+        {ok, 400, _},
+        request(post, uri([?CONF_NS]), Config)
+    ),
+    ok.
+
+%% erlfmt-ignore
+config(t_create_ok) ->
+    "{
+        mechanism = cinfo,
+        checks = [
+          {
+            is_match = \"str_eq(username,'magic1')\"
+            result = allow
+          },
+          {
+            is_match = \"str_eq(username, 'magic2')\"
+            result = deny
+          }
+        ]
+    }";
+config(t_empty_checks_is_not_allowed) ->
+    "{
+        mechanism = cinfo,
+        checks = []
+    }";
+config(t_empty_is_match_not_allowed) ->
+    "{
+        mechanism = cinfo,
+        checks = [
+          {
+            is_match = []
+            result = allow
+          }
+        ]
+    }";
+config(t_expression_compile_error) ->
+    "{
+        mechanism = cinfo,
+        checks = [
+          {
+            is_match = \"1\"
+            result = allow
+          }
+        ]
+    }".
+
+request(Method, Url, Body) ->
+    emqx_mgmt_api_test_util:request(Method, Url, Body).
+
+uri(Path) ->
+    emqx_mgmt_api_test_util:uri(Path).

+ 1 - 3
apps/emqx_auth_ext/src/emqx_auth_ext.app.src

@@ -1,6 +1,6 @@
 {application, emqx_auth_ext, [
     {description, "EMQX Extended Auth Library"},
-    {vsn, "0.1.0"},
+    {vsn, "0.1.1"},
     {registered, []},
     {applications, [
         kernel,
@@ -15,7 +15,5 @@
         emqx_auth_ext_tls_lib,
         emqx_auth_ext_tls_const_v1
     ]},
-
-    {licenses, ["Apache-2.0"]},
     {links, []}
 ]}.

+ 1 - 1
apps/emqx_conf/src/emqx_conf.app.src

@@ -1,6 +1,6 @@
 {application, emqx_conf, [
     {description, "EMQX configuration management"},
-    {vsn, "0.3.0"},
+    {vsn, "0.4.0"},
     {registered, []},
     {mod, {emqx_conf_app, []}},
     {applications, [kernel, stdlib]},

+ 2 - 1
apps/emqx_conf/src/emqx_conf_schema_inject.erl

@@ -65,7 +65,8 @@ authn_mods(ee) ->
         [
             emqx_gcp_device_authn_schema,
             emqx_authn_scram_restapi_schema,
-            emqx_authn_kerberos_schema
+            emqx_authn_kerberos_schema,
+            emqx_authn_cinfo_schema
         ].
 
 authz() ->

+ 2 - 1
apps/emqx_machine/priv/reboot_lists.eterm

@@ -140,7 +140,8 @@
             emqx_auth_kerberos,
             emqx_auth_ext,
             emqx_cluster_link,
-            emqx_ds_builtin_raft
+            emqx_ds_builtin_raft,
+            emqx_auth_cinfo
         ],
     %% must always be of type `load'
     ce_business_apps =>

+ 13 - 16
apps/emqx_utils/src/emqx_utils.erl

@@ -68,7 +68,7 @@
     format/1,
     format/2,
     format_mfal/2,
-    call_first_defined/1,
+    call_first_defined/3,
     ntoa/1,
     foldl_while/3,
     is_restricted_str/1
@@ -599,21 +599,18 @@ format_mfal(Data, #{with_mfa := true}) ->
 format_mfal(_, _) ->
     undefined.
 
--spec call_first_defined(list({module(), atom(), list()})) -> term() | no_return().
-call_first_defined([{Module, Function, Args} | Rest]) ->
-    try
-        apply(Module, Function, Args)
-    catch
-        error:undef:Stacktrace ->
-            case Stacktrace of
-                [{Module, Function, _, _} | _] ->
-                    call_first_defined(Rest);
-                _ ->
-                    erlang:raise(error, undef, Stacktrace)
-            end
-    end;
-call_first_defined([]) ->
-    error(none_fun_is_defined).
+-spec call_first_defined(module(), atom(), list()) -> term() | no_return().
+call_first_defined(Module, Function, []) ->
+    error({not_exported, Module, Function});
+call_first_defined(Module, Function, [Args | Rest]) ->
+    %% ensure module is loaded
+    _ = apply(Module, module_info, []),
+    case erlang:function_exported(Module, Function, length(Args)) of
+        true ->
+            apply(Module, Function, Args);
+        false ->
+            call_first_defined(Module, Function, Rest)
+    end.
 
 %%------------------------------------------------------------------------------
 %% Internal Functions

+ 17 - 2
apps/emqx_utils/src/emqx_variform.erl

@@ -42,7 +42,7 @@
         M =:= maps)
 ).
 
--define(IS_EMPTY(X), (X =:= <<>> orelse X =:= "" orelse X =:= undefined)).
+-define(IS_EMPTY(X), (X =:= <<>> orelse X =:= "" orelse X =:= undefined orelse X =:= null)).
 
 %% @doc Render a variform expression with bindings.
 %% A variform expression is a template string which supports variable substitution
@@ -113,7 +113,12 @@ compile(#{form := _} = Compiled) ->
     {ok, Compiled};
 compile(Expression) when is_binary(Expression) ->
     compile(unicode:characters_to_list(Expression));
-compile(Expression) ->
+compile(Expression) when is_list(Expression) ->
+    do_compile(Expression);
+compile(_Expression) ->
+    {error, invalid_expression}.
+
+do_compile(Expression) ->
     case emqx_variform_scan:string(Expression) of
         {ok, Tokens, _Line} ->
             case emqx_variform_parser:parse(Tokens) of
@@ -154,6 +159,8 @@ eval({call, FuncNameStr, Args}, Bindings, Opts) ->
             eval_iif(Args, Bindings, Opts);
         {?BIF_MOD, coalesce} ->
             eval_coalesce(Args, Bindings, Opts);
+        {?BIF_MOD, is_empty} ->
+            eval_is_empty(Args, Bindings, Opts);
         _ ->
             call(Mod, Fun, eval_loop(Args, Bindings, Opts))
     end;
@@ -209,6 +216,10 @@ try_eval(Arg, Bindings, Opts) ->
             <<>>
     end.
 
+eval_is_empty([Arg], Bindings, Opts) ->
+    Val = eval_coalesce_loop([Arg], Bindings, Opts),
+    ?IS_EMPTY(Val).
+
 eval_iif([Cond, If, Else], Bindings, Opts) ->
     CondVal = try_eval(Cond, Bindings, Opts),
     case is_iif_condition_met(CondVal) of
@@ -249,6 +260,7 @@ resolve_func_name(FuncNameStr) ->
                     error:badarg ->
                         throw(#{
                             reason => unknown_variform_function,
+                            module => Mod,
                             function => Fun0
                         })
                 end,
@@ -261,6 +273,7 @@ resolve_func_name(FuncNameStr) ->
                     error:badarg ->
                         throw(#{
                             reason => unknown_variform_function,
+                            module => ?BIF_MOD,
                             function => Fun
                         })
                 end,
@@ -285,6 +298,8 @@ assert_func_exported(?BIF_MOD, coalesce, _Arity) ->
     ok;
 assert_func_exported(?BIF_MOD, iif, _Arity) ->
     ok;
+assert_func_exported(?BIF_MOD, is_empty, _Arity) ->
+    ok;
 assert_func_exported(Mod, Fun, Arity) ->
     ok = try_load(Mod),
     case erlang:function_exported(Mod, Fun, Arity) of

+ 8 - 2
apps/emqx_utils/src/emqx_variform_bif.erl

@@ -75,10 +75,10 @@
 -export([hash/2, hash_to_range/3, map_to_range/3]).
 
 %% String compare functions
--export([str_comp/2, str_eq/2, str_lt/2, str_lte/2, str_gt/2, str_gte/2]).
+-export([str_comp/2, str_eq/2, str_neq/2, str_lt/2, str_lte/2, str_gt/2, str_gte/2]).
 
 %% Number compare functions
--export([num_comp/2, num_eq/2, num_lt/2, num_lte/2, num_gt/2, num_gte/2]).
+-export([num_comp/2, num_eq/2, num_neq/2, num_lt/2, num_lte/2, num_gt/2, num_gte/2]).
 
 %% System
 -export([getenv/1]).
@@ -550,6 +550,9 @@ str_comp(A0, B0) ->
 %% @doc Return 'true' if two strings are the same, otherwise 'false'.
 str_eq(A, B) -> eq =:= str_comp(A, B).
 
+%% @doc Return 'true' if two string are not the same.
+str_neq(A, B) -> eq =/= str_comp(A, B).
+
 %% @doc Return 'true' if arg-1 is ordered before arg-2, otherwise 'false'.
 str_lt(A, B) -> lt =:= str_comp(A, B).
 
@@ -572,6 +575,9 @@ num_comp(A, B) when is_number(A) andalso is_number(B) ->
 %% @doc Return 'true' if two numbers are the same, otherwise 'false'.
 num_eq(A, B) -> eq =:= num_comp(A, B).
 
+%% @doc Return 'true' if two numbers are not the same, otherwise 'false'.
+num_neq(A, B) -> eq =/= num_comp(A, B).
+
 %% @doc Return 'true' if arg-1 is ordered before arg-2, otherwise 'false'.
 num_lt(A, B) -> lt =:= num_comp(A, B).
 

+ 4 - 2
apps/emqx_utils/src/emqx_variform_parser.yrl

@@ -10,6 +10,7 @@ Terminals
     integer
     float
     string
+    boolean
     '(' ')'
     ',' '[' ']'.
 
@@ -18,8 +19,9 @@ Rootsymbol
 
 %% Grammar Rules
 
-%% Root expression: function call or variable
+%% Root expression: function call or variable or a boolean
 expr -> call_or_var : '$1'.
+expr -> boolean: element(3, '$1').
 
 %% Function call or variable
 call_or_var -> identifier '(' ')' : {call, element(3, '$1'), []}.
@@ -33,7 +35,7 @@ array -> '[' args ']' : {array, '$2'}.
 args -> arg : ['$1'].
 args -> args ',' arg : '$1' ++ ['$3'].
 
-%% Arguments can be expressions, arrays, numbers, or strings
+%% Arguments can be expressions, arrays, numbers, strings or booleans
 arg -> expr : '$1'.
 arg -> array : '$1'.
 arg -> integer: {integer, element(3, '$1')}.

+ 2 - 0
apps/emqx_utils/src/emqx_variform_scan.xrl

@@ -1,5 +1,6 @@
 Definitions.
 %% Define regular expressions for tokens
+BOOLEAN     = true|false
 IDENTIFIER  = [a-zA-Z][-a-zA-Z0-9_.]*
 SQ_STRING   = \'[^\']*\'
 DQ_STRING   = \"[^\"]*\"
@@ -14,6 +15,7 @@ WHITESPACE  = [\s\t\n]+
 
 Rules.
 {WHITESPACE} : skip_token.
+{BOOLEAN}    : {token, {boolean, TokenLine, list_to_atom(TokenChars)}}.
 {IDENTIFIER} : {token, {identifier, TokenLine, TokenChars}}.
 {SQ_STRING}  : {token, {string, TokenLine, unquote(TokenChars, $')}}.
 {DQ_STRING}  : {token, {string, TokenLine, unquote(TokenChars, $")}}.

+ 30 - 0
apps/emqx_utils/test/emqx_variform_tests.erl

@@ -182,13 +182,38 @@ coalesce_test_() ->
         end}
     ].
 
+boolean_literal_test_() ->
+    [
+        ?_assertEqual({ok, <<"true">>}, render("true", #{})),
+        ?_assertEqual({ok, <<"T">>}, render("iif(true,'T','F')", #{}))
+    ].
+
 compare_string_test_() ->
     [
+        %% is_nil test
+        ?_assertEqual({ok, <<"true">>}, render("is_empty('')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("is_empty(a)", #{<<"a">> => undefined})),
+        ?_assertEqual({ok, <<"true">>}, render("is_empty(a)", #{<<"a">> => null})),
+        ?_assertEqual({ok, <<"false">>}, render("is_empty('a')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("is_empty(a)", #{<<"a">> => "1"})),
+
         %% Testing str_eq/2
         ?_assertEqual({ok, <<"true">>}, render("str_eq('a', 'a')", #{})),
         ?_assertEqual({ok, <<"false">>}, render("str_eq('a', 'b')", #{})),
         ?_assertEqual({ok, <<"true">>}, render("str_eq('', '')", #{})),
         ?_assertEqual({ok, <<"false">>}, render("str_eq('a', '')", #{})),
+        ?_assertEqual(
+            {ok, <<"true">>}, render("str_eq(a, b)", #{<<"a">> => <<"1">>, <<"b">> => <<"1">>})
+        ),
+
+        %% Testing str_neq/2
+        ?_assertEqual({ok, <<"false">>}, render("str_neq('a', 'a')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_neq('a', 'b')", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("str_neq('', '')", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("str_neq('a', '')", #{})),
+        ?_assertEqual(
+            {ok, <<"false">>}, render("str_neq(a, b)", #{<<"a">> => <<"1">>, <<"b">> => <<"1">>})
+        ),
 
         %% Testing str_lt/2
         ?_assertEqual({ok, <<"true">>}, render("str_lt('a', 'b')", #{})),
@@ -218,6 +243,11 @@ compare_numbers_test_() ->
     [
         ?_assertEqual({ok, <<"true">>}, render("num_eq(1, 1)", #{})),
         ?_assertEqual({ok, <<"false">>}, render("num_eq(2, 1)", #{})),
+        ?_assertEqual({ok, <<"false">>}, render("num_eq(a, b)", #{<<"a">> => 1, <<"b">> => 2})),
+
+        ?_assertEqual({ok, <<"false">>}, render("num_neq(1, 1)", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("num_neq(2, 1)", #{})),
+        ?_assertEqual({ok, <<"true">>}, render("num_neq(a, b)", #{<<"a">> => 1, <<"b">> => 2})),
 
         ?_assertEqual({ok, <<"true">>}, render("num_lt(1, 2)", #{})),
         ?_assertEqual({ok, <<"false">>}, render("num_lt(2, 2)", #{})),

+ 5 - 0
changes/ee/feat-13810.en.md

@@ -0,0 +1,5 @@
+Add clinet-info authentication.
+
+Client-info (of type `cinfo`) authentication is a lightweight authentication mechanism which checks client properties and attributes against user defined rules.
+The rules make use of the Variform expression to define match conditions, and the authentication result when match is found.
+For example, to quickly fence off clients without a username, the match condition can be `str_eq(username, '')` associated with a check result `deny`.

+ 2 - 1
mix.exs

@@ -395,7 +395,8 @@ defmodule EMQXUmbrella.MixProject do
       :emqx_cluster_link,
       :emqx_ds_builtin_raft,
       :emqx_auth_kerberos,
-      :emqx_bridge_datalayers
+      :emqx_bridge_datalayers,
+      :emqx_auth_cinfo
     ])
   end
 

+ 1 - 0
rebar.config.erl

@@ -129,6 +129,7 @@ is_community_umbrella_app("apps/emqx_auth_ext") -> false;
 is_community_umbrella_app("apps/emqx_cluster_link") -> false;
 is_community_umbrella_app("apps/emqx_ds_builtin_raft") -> false;
 is_community_umbrella_app("apps/emqx_auth_kerberos") -> false;
+is_community_umbrella_app("apps/emqx_auth_cinfo") -> false;
 is_community_umbrella_app(_) -> true.
 
 %% BUILD_WITHOUT_JQ

+ 30 - 0
rel/config/ee-examples/cinfo-authn.conf

@@ -0,0 +1,30 @@
+authentication = [
+  {
+    mechanism = cinfo
+    checks = [
+      # allow clients with username starts with 'super-'
+      {
+        is_match = "regex_match(username, 'super-')"
+        result = allow
+      },
+      # deny clients with empty username and client ID starts with 'v1-'
+      {
+        # when is_match is an array, it yields 'true' if all individual checks yield 'true'
+        is_match = ["str_eq(username, '')", "str_eq(nth(1,tokens(clientid,'-')), 'v1')"]
+        result = deny
+      }
+      # if all checks are exhausted without an 'allow' or a 'deny' result, continue to the next authentication
+    ]
+  },
+  # ... more authentications ...
+  # ...
+  # if all authenticators are exhausted without an 'allow' or a 'deny' result, the client is not rejected
+]
+
+# A few more match condition examples:
+#
+# TLS certificate common name is the same as username:
+#   str_eq(cert_common_name, username)
+#
+# Password is the 'sha1' hash of environment variable 'EMQXVAR_SECRET' concatenated to client ID:
+#   str_eq(password, hash(sha1, concat([clientid, getenv('SECRET')])))

+ 49 - 0
rel/i18n/emqx_authn_cinfo_schema.hocon

@@ -0,0 +1,49 @@
+emqx_authn_cinfo_schema {
+  cinfo {
+    label: "Client Information Authentication"
+    desc: """~
+      Authenticate clients based on the client information such as username, client ID,
+      client attributes, and data extracted from TLS certificate."""
+  }
+
+  check {
+    label: "Client Information Check"
+    desc: """~
+      A check to perform on the client information.
+      It defines a match-condition and a result to return if the condition is `true`.
+      If all checks are skipped, the default result `ignore` is returned."""
+  }
+
+  checks {
+    label: "Client Information Checks"
+    desc: """~
+      A list of checks to perform on the client information.
+      If all checks are skipped, the default result `ignore` is returned.
+      The `ignore` result means to defer the authentication to the next authenticator in the chain."""
+  }
+
+  is_match {
+    label: "Match Conditions"
+    desc: """~
+      One Variform expression or an array of expressions to evaluate with a set of pre-bound variables derived from the client information.
+      Supported variables:
+      - `username`: the username of the client.
+      - `clientid`: the client ID of the client.
+      - `client_attrs.*`: the client attributes of the client.
+      - `peerhost`: the IP address of the client.
+      - `cert_subject`: the subject of the TLS certificate.
+      - `cert_common_name`: the issuer of the TLS certificate.
+      If the expression(s) all yields the string value `'true'`, then the associated `result` is returned from this authenticator.
+      If any expression yields the other than `'true'`, then the current check is skipped."""
+  }
+
+  result {
+    label: "Result"
+    desc: """~
+      The result to return if the match condition is `true`.
+      Supported results:
+      - `ignore`: defer the authentication to the next authenticator in the chain.
+      - `allow`: allow the client to connect.
+      - `deny`: deny the client to connect."""
+  }
+}