From 2fd0a2cd4de42070faa65840f5116b0d57d1bc00 Mon Sep 17 00:00:00 2001 From: zmstone Date: Wed, 20 Mar 2024 17:04:20 +0100 Subject: [PATCH] feat: support extracting initial client attrs from clientinfo --- apps/emqx/src/emqx_channel.erl | 81 ++++++++++++++++++- apps/emqx/src/emqx_schema.erl | 33 +++++++- apps/emqx/test/emqx_client_SUITE.erl | 28 +++++++ .../test/emqx_authz/emqx_authz_SUITE.erl | 31 +++++++ rel/i18n/emqx_schema.hocon | 50 ++++++++++-- 5 files changed, 214 insertions(+), 9 deletions(-) diff --git a/apps/emqx/src/emqx_channel.erl b/apps/emqx/src/emqx_channel.erl index 871de163e..5d466f098 100644 --- a/apps/emqx/src/emqx_channel.erl +++ b/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}) -> diff --git a/apps/emqx/src/emqx_schema.erl b/apps/emqx/src/emqx_schema.erl index 7889a13de..e71126dca 100644 --- a/apps/emqx/src/emqx_schema.erl +++ b/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. diff --git a/apps/emqx/test/emqx_client_SUITE.erl b/apps/emqx/test/emqx_client_SUITE.erl index d9218b0dd..077a48593 100644 --- a/apps/emqx/test/emqx_client_SUITE.erl +++ b/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). diff --git a/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl b/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl index c9d5933bb..c88dcc244 100644 --- a/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl +++ b/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. diff --git a/rel/i18n/emqx_schema.hocon b/rel/i18n/emqx_schema.hocon index 0a0f71cfe..a90ecdffc 100644 --- a/rel/i18n/emqx_schema.hocon +++ b/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. -durable_storage.desc: -"""Configuration related to the EMQX durable storages. + EMQX uses durable storages to offload various data, such as MQTT messages, to disc.""" -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. + + 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.""" +} + +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.""" +} }