Преглед изворни кода

feat: support extracting initial client attrs from clientinfo

zmstone пре 1 година
родитељ
комит
2fd0a2cd4d

+ 78 - 3
apps/emqx/src/emqx_channel.erl

@@ -245,7 +245,7 @@ init(
             MP -> MP
         end,
     ListenerId = emqx_listeners:listener_id(Type, Listener),
-    ClientInfo = set_peercert_infos(
+    ClientInfo0 = set_peercert_infos(
         Peercert,
         #{
             zone => Zone,
@@ -259,11 +259,11 @@ init(
             mountpoint => MountPoint,
             is_bridge => false,
             is_superuser => false,
-            enable_authn => maps:get(enable_authn, Opts, true),
-            client_attrs => #{}
+            enable_authn => maps:get(enable_authn, Opts, true)
         },
         Zone
     ),
+    ClientInfo = initialize_client_attrs_from_cert(ClientInfo0, Peercert, Zone),
     {NClientInfo, NConnInfo} = take_ws_cookie(ClientInfo, ConnInfo),
     #channel{
         conninfo = NConnInfo,
@@ -1561,6 +1561,9 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
             fun set_bridge_mode/2,
             fun maybe_username_as_clientid/2,
             fun maybe_assign_clientid/2,
+            %% attr init should happen after clientid and username assign
+            fun maybe_set_client_initial_attr/2,
+            %% moutpoint fix should happen after attr init
             fun fix_mountpoint/2
         ],
         ConnPkt,
@@ -1573,6 +1576,46 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
             {error, ReasonCode, Channel#channel{clientinfo = NClientInfo}}
     end.
 
+initialize_client_attrs_from_cert(ClientInfo, Peercert, Zone) ->
+    case get_mqtt_conf(Zone, client_attrs_init) of
+        #{
+            extract_from := From,
+            extract_regexp := Regexp,
+            extract_as := AttrName
+        } when From =:= cn orelse From =:= dn ->
+            case extract_client_attr_from_cert(From, Regexp, Peercert) of
+                {ok, Value} ->
+                    ?SLOG(
+                        debug,
+                        #{
+                            msg => "client_attr_init_from_cert",
+                            extracted_as => AttrName,
+                            extracted_value => Value
+                        }
+                    ),
+                    ClientInfo#{client_attrs => #{AttrName => Value}};
+                _ ->
+                    ClientInfo#{client_attrs => #{}}
+            end;
+        _ ->
+            ClientInfo#{client_attrs => #{}}
+    end.
+
+extract_client_attr_from_cert(cn, Regexp, Peercert) ->
+    CN = esockd_peercert:common_name(Peercert),
+    re_extract(CN, Regexp);
+extract_client_attr_from_cert(dn, Regexp, Peercert) ->
+    DN = esockd_peercert:subject(Peercert),
+    re_extract(DN, Regexp).
+
+re_extract(Str, Regexp) when is_binary(Str) ->
+    case re:run(Str, Regexp, [{capture, all_but_first, list}]) of
+        {match, [_ | _] = List} -> {ok, iolist_to_binary(List)};
+        _ -> nomatch
+    end;
+re_extract(_NotStr, _Regexp) ->
+    ignored.
+
 set_username(
     #mqtt_packet_connect{username = Username},
     ClientInfo = #{username := undefined}
@@ -1613,6 +1656,38 @@ maybe_assign_clientid(#mqtt_packet_connect{clientid = <<>>}, ClientInfo) ->
 maybe_assign_clientid(#mqtt_packet_connect{clientid = ClientId}, ClientInfo) ->
     {ok, ClientInfo#{clientid => ClientId}}.
 
+maybe_set_client_initial_attr(_, #{zone := Zone} = ClientInfo) ->
+    Attrs = maps:get(client_attrs, ClientInfo, #{}),
+    Config = get_mqtt_conf(Zone, client_attrs_init),
+    case extract_attr_from_clientinfo(Config, ClientInfo) of
+        {ok, Value} ->
+            #{extract_as := Name} = Config,
+            ?SLOG(
+                debug,
+                #{
+                    msg => "client_attr_init_from_clientinfo",
+                    extracted_as => Name,
+                    extracted_value => Value
+                }
+            ),
+            {ok, ClientInfo#{client_attrs => Attrs#{Name => Value}}};
+        _ ->
+            {ok, ClientInfo}
+    end.
+
+extract_attr_from_clientinfo(#{extract_from := clientid, extract_regexp := Regexp}, #{
+    clientid := ClientId
+}) ->
+    re_extract(ClientId, Regexp);
+extract_attr_from_clientinfo(#{extract_from := username, extract_regexp := Regexp}, #{
+    username := Username
+}) when
+    Username =/= undefined
+->
+    re_extract(Username, Regexp);
+extract_attr_from_clientinfo(_Config, _CLientInfo) ->
+    ignored.
+
 fix_mountpoint(_ConnPkt, #{mountpoint := undefined}) ->
     ok;
 fix_mountpoint(_ConnPkt, ClientInfo = #{mountpoint := MountPoint}) ->

+ 32 - 1
apps/emqx/src/emqx_schema.erl

@@ -1731,7 +1731,30 @@ fields("session_persistence") ->
             )}
     ];
 fields(durable_storage) ->
-    emqx_ds_schema:schema().
+    emqx_ds_schema:schema();
+fields("client_attrs_init") ->
+    [
+        {extract_from,
+            sc(
+                hoconsc:enum([clientid, username, cn, dn]),
+                #{desc => ?DESC("client_atrs_init_extract_from")}
+            )},
+        {extract_regexp, sc(binary(), #{desc => ?DESC("client_attrs_init_extract_regexp")})},
+        {extract_as,
+            sc(binary(), #{
+                default => <<"alias">>,
+                desc => ?DESC("client_attrs_init_extract_as"),
+                validator => fun restricted_string/1
+            })}
+    ].
+
+restricted_string(undefined) ->
+    undefined;
+restricted_string(Str) ->
+    case emqx_utils:is_restricted_str(Str) of
+        true -> ok;
+        false -> {error, <<"Invalid string for attribute name">>}
+    end.
 
 mqtt_listener(Bind) ->
     base_listener(Bind) ++
@@ -3526,6 +3549,14 @@ mqtt_general() ->
                     default => disabled,
                     desc => ?DESC(mqtt_peer_cert_as_clientid)
                 }
+            )},
+        {"client_attrs_init",
+            sc(
+                hoconsc:union([disabled, ref("client_attrs_init")]),
+                #{
+                    default => disabled,
+                    desc => ?DESC("client_attrs_init")
+                }
             )}
     ].
 %% All session's importance should be lower than general part to organize document.

+ 28 - 0
apps/emqx/test/emqx_client_SUITE.erl

@@ -75,6 +75,8 @@ groups() ->
         {mqttv5, [non_parallel_tests], [t_basic_with_props_v5, t_v5_receive_maximim_in_connack]},
         {others, [non_parallel_tests], [
             t_username_as_clientid,
+            t_certcn_as_alias,
+            t_certdn_as_alias,
             t_certcn_as_clientid_default_config_tls,
             t_certcn_as_clientid_tlsv1_3,
             t_certcn_as_clientid_tlsv1_2,
@@ -384,6 +386,32 @@ t_username_as_clientid(_) ->
     end,
     emqtt:disconnect(C).
 
+t_certcn_as_alias(_) ->
+    test_cert_extraction_as_alias(cn).
+
+t_certdn_as_alias(_) ->
+    test_cert_extraction_as_alias(dn).
+
+test_cert_extraction_as_alias(Which) ->
+    %% extract the first two chars
+    Re = <<"^(..).*$">>,
+    ClientId = iolist_to_binary(["ClientIdFor_", atom_to_list(Which)]),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => Which,
+        extract_regexp => Re,
+        extract_as => <<"alias">>
+    }),
+    SslConf = emqx_common_test_helpers:client_mtls('tlsv1.2'),
+    {ok, Client} = emqtt:start_link([
+        {clientid, ClientId}, {port, 8883}, {ssl, true}, {ssl_opts, SslConf}
+    ]),
+    {ok, _} = emqtt:connect(Client),
+    %% assert only two chars are extracted
+    ?assertMatch(
+        #{clientinfo := #{client_attrs := #{alias := <<_, _>>}}}, emqx_cm:get_chan_info(ClientId)
+    ),
+    emqtt:disconnect(Client).
+
 t_certcn_as_clientid_default_config_tls(_) ->
     tls_certcn_as_clientid(default).
 

+ 31 - 0
apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl

@@ -19,6 +19,7 @@
 -compile(export_all).
 
 -include("emqx_authz.hrl").
+-include_lib("emqx/include/emqx_mqtt.hrl").
 -include_lib("emqx/include/emqx.hrl").
 -include_lib("eunit/include/eunit.hrl").
 -include_lib("common_test/include/ct.hrl").
@@ -168,6 +169,16 @@ end_per_testcase(_TestCase, _Config) ->
     )
 ).
 
+%% Allow all clients to publish or subscribe to topics with their alias as prefix.
+-define(SOURCE_FILE_CLIENT_ATTR,
+    ?SOURCE_FILE(
+        <<
+            "{allow,all,all,[\"${client_attrs.alias}/#\"]}.\n"
+            "{deny, all}."
+        >>
+    )
+).
+
 %%------------------------------------------------------------------------------
 %% Testcases
 %%------------------------------------------------------------------------------
@@ -544,6 +555,26 @@ t_publish_last_will_testament_denied_topic(_Config) ->
 
     ok.
 
+t_alias_prefix(_Config) ->
+    {ok, _} = emqx_authz:update(?CMD_REPLACE, [?SOURCE_FILE_CLIENT_ATTR]),
+    ExtractSuffix = <<"^.*-(.*)$">>,
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => clientid,
+        extract_regexp => ExtractSuffix,
+        extract_as => <<"alias">>
+    }),
+    ClientId = <<"org1-name2">>,
+    SubTopic = <<"name2/#">>,
+    SubTopicNotAllowed = <<"name3/#">>,
+    {ok, C} = emqtt:start_link([{clientid, ClientId}, {proto_ver, v5}]),
+    ?assertMatch({ok, _}, emqtt:connect(C)),
+    ?assertMatch({ok, _, [?RC_SUCCESS]}, emqtt:subscribe(C, SubTopic)),
+    ?assertMatch({ok, _, [?RC_NOT_AUTHORIZED]}, emqtt:subscribe(C, SubTopicNotAllowed)),
+    unlink(C),
+    emqtt:stop(C),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], disalbed),
+    ok.
+
 %% client is allowed by ACL to publish to its LWT topic, is connected,
 %% and then gets banned and kicked out while connected.  Should not
 %% publish LWT.

+ 45 - 5
rel/i18n/emqx_schema.hocon

@@ -1565,12 +1565,52 @@ This value specifies size of the batch.
 
 Note: larger batches generally improve the throughput and overall performance of the system, but increase RAM usage per client."""
 
-durable_storage.label:
-"""Durable storage"""
+durable_storage.label:"Durable storage"
+durable_storage.desc: """~
+    Configuration related to the EMQX durable storages.
+
+    EMQX uses durable storages to offload various data, such as MQTT messages, to disc."""
+
+client_attrs_init {
+  label: "Client attributes init"
+  desc: """~
+    Specify how to initialize client attributes.
+    One initial client attribute can be initialized as `client_attrs.NAME`,
+    where `NAME` is the name of the attribute specified in the config `extract_as`.
+    The initialized client attribute will be stored in the `client_attr` property with the specified name,
+    and can be used as a placeholder in a template.
+    For example, `${client_attrs.alias}` if `extract_as` is set to `alias`."""
+}
+
+client_attrs_init_extract_from {
+  label: "Client property to extract attribute from"
+  desc: """~
+    Specify from which client property the client attribute should be extracted.
 
-durable_storage.desc:
-"""Configuration related to the EMQX durable storages.
+    Supported values:
+    - `clientid`: Extract from the client ID.
+    - `username`: Extract from the username.
+    - `cn`: Extract from the Common Name (CN) field of the client certificate.
+    - `dn`: Extract from the Distinguished Name (DN) field of the client certficate.
+
+    NOTE: this extraction happens **after** `clientid` or `username` is initialized
+    from `peer_cert_as_clientid` or `peer_cert_as_username` config."""
+}
 
-EMQX uses durable storages to offload various data, such as MQTT messages, to disc."""
+client_attrs_init_extract_regex {
+  label: "Client attribute extract regex"
+  desc: """~
+    The regular expression to extract a client attribute from the client property specified by `client_attrs_init.extract_from` config.
+    The expression should match the entire client property value, and capturing groups are concatenated to make the client attribute.
+    For example if the client attribute is the first part of the client ID delemited by a dash, the regular expression would be `^(.+?)-.*$`.
+    Note that failure to match the regular expression will result in the client attribute being absence but not an empty string."""
+}
+
+client_attrs_init_extract_as {
+  label: "Name the extracted attribute"
+  desc: """~
+    The name of the client attribute extracted from the client property specified by `client_attrs_init.extract_from` config.
+    The extracted attribute will be stored in the `client_attr` property with this name."""
+}
 
 }