From 9c7d57ce48a065fb13eefd44ebb55827a81c7daa Mon Sep 17 00:00:00 2001 From: Geoff Martin Date: Thu, 29 Feb 2024 15:15:27 +0000 Subject: [PATCH 1/4] Support for user/password authentication added. --- zenoh-bridge-mqtt/src/main.rs | 4 + zenoh-plugin-mqtt/src/config.rs | 8 ++ zenoh-plugin-mqtt/src/lib.rs | 151 +++++++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 14 deletions(-) diff --git a/zenoh-bridge-mqtt/src/main.rs b/zenoh-bridge-mqtt/src/main.rs index 8ab3fed..4d1f842 100644 --- a/zenoh-bridge-mqtt/src/main.rs +++ b/zenoh-bridge-mqtt/src/main.rs @@ -106,6 +106,9 @@ r#"-r, --generalise-sub=[String]... 'A list of key expression to use for gener r#"-w, --generalise-pub=[String]... 'A list of key expression to use for generalising publications (usable multiple times).'"# )) .arg(Arg::from_usage( +r#"--dictionary-file=[FILE] 'Path to the file containing the client password dictionary.'"# + )) + .arg(Arg::from_usage( r#"--server-private-key=[FILE] 'Path to the TLS private key for the MQTT server. If specified a valid certificate for the server must also be provided.'"# ) .requires("server-certificate")) @@ -175,6 +178,7 @@ r#"--root-ca-certificate=[FILE] 'Path to the certificate of the certificate au insert_json5!(config, args, "plugins/mqtt/deny", if "deny", ); insert_json5!(config, args, "plugins/mqtt/generalise_pubs", for "generalise-pub", .collect::>()); insert_json5!(config, args, "plugins/mqtt/generalise_subs", for "generalise-sub", .collect::>()); + insert_json5!(config, args, "plugins/mqtt/auth/dictionary_file", if "dictionary-file", ); insert_json5!(config, args, "plugins/mqtt/tls/server_private_key", if "server-private-key", ); insert_json5!(config, args, "plugins/mqtt/tls/server_certificate", if "server-certificate", ); insert_json5!(config, args, "plugins/mqtt/tls/root_ca_certificate", if "root-ca-certificate", ); diff --git a/zenoh-plugin-mqtt/src/config.rs b/zenoh-plugin-mqtt/src/config.rs index d2b514f..5568882 100644 --- a/zenoh-plugin-mqtt/src/config.rs +++ b/zenoh-plugin-mqtt/src/config.rs @@ -50,6 +50,8 @@ pub struct Config { #[serde(default)] pub tls: Option, __required__: Option, + #[serde(default)] + pub auth: Option, #[serde(default, deserialize_with = "deserialize_path")] __path__: Option>, } @@ -68,6 +70,12 @@ pub struct TLSConfig { pub root_ca_certificate_base64: Option, } +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct AuthConfig { + pub dictionary_file: String, +} + fn default_mqtt_port() -> String { format!("{DEFAULT_MQTT_INTERFACE}:{DEFAULT_MQTT_PORT}") } diff --git a/zenoh-plugin-mqtt/src/lib.rs b/zenoh-plugin-mqtt/src/lib.rs index adfa22f..bb54b4d 100644 --- a/zenoh-plugin-mqtt/src/lib.rs +++ b/zenoh-plugin-mqtt/src/lib.rs @@ -14,7 +14,7 @@ use ntex::io::IoBoxed; use ntex::service::{chain_factory, fn_factory_with_config, fn_service}; use ntex::time::Deadline; -use ntex::util::Ready; +use ntex::util::{ByteString, Bytes, Ready}; use ntex::ServiceFactory; use ntex_mqtt::{v3, v5, MqttError, MqttServer}; use ntex_tls::rustls::Acceptor; @@ -22,6 +22,7 @@ use rustls::server::AllowAnyAuthenticatedClient; use rustls::{Certificate, PrivateKey, RootCertStore, ServerConfig}; use secrecy::ExposeSecret; use serde_json::Value; +use std::collections::HashMap; use std::env; use std::io::BufReader; use std::sync::Arc; @@ -41,7 +42,7 @@ extern crate zenoh_core; pub mod config; mod mqtt_helpers; mod mqtt_session_state; -use config::{Config, TLSConfig}; +use config::{AuthConfig, Config, TLSConfig}; use mqtt_session_state::MqttSessionState; macro_rules! ke_for_sure { @@ -61,6 +62,10 @@ zenoh_plugin_trait::declare_plugin!(MqttPlugin); pub struct MqttPlugin; +// Authentication types +type User = Vec; +type Password = Vec; + impl ZenohPlugin for MqttPlugin {} impl Plugin for MqttPlugin { type StartArgs = Runtime; @@ -88,7 +93,12 @@ impl Plugin for MqttPlugin { None => None, }; - async_std::task::spawn(run(runtime.clone(), config, tls_config)); + let auth_dictionary = match config.auth.as_ref() { + Some(auth) => Some(create_auth_dictionary(auth)?), + None => None, + }; + + async_std::task::spawn(run(runtime.clone(), config, tls_config, auth_dictionary)); Ok(Box::new(MqttPlugin)) } } @@ -96,7 +106,12 @@ impl Plugin for MqttPlugin { impl PluginControl for MqttPlugin {} impl RunningPluginTrait for MqttPlugin {} -async fn run(runtime: Runtime, config: Config, tls_config: Option>) { +async fn run( + runtime: Runtime, + config: Config, + tls_config: Option>, + auth_dictionary: Option>, +) { // Try to initiate login. // Required in case of dynamic lib, otherwise no logs. // But cannot be done twice in case of static link. @@ -131,8 +146,13 @@ async fn run(runtime: Runtime, config: Config, tls_config: Option { ntex::server::Server::build().bind("mqtt", config.port.clone(), move |_| { - create_mqtt_server(zsession.clone(), config.clone()) + create_mqtt_server( + zsession.clone(), + config.clone(), + auth_dictionary.clone(), + ) })? } }; @@ -284,9 +312,64 @@ fn load_trust_anchors(bytes: Vec) -> ZResult { Ok(root_cert_store) } +fn create_auth_dictionary(config: &AuthConfig) -> ZResult> { + let mut dictionary: HashMap = HashMap::new(); + let content = std::fs::read_to_string(config.dictionary_file.as_str()) + .map_err(|e| zerror!("Invalid user/password dictionary file: {}", e))?; + + // Populate the user/password dictionary + // The dictionary file is expected to be in the form of: + // usr1:pwd1 + // usr2:pwd2 + // usr3:pwd3 + for line in content.lines() { + let idx = line + .find(':') + .ok_or_else(|| zerror!("Invalid user/password dictionary file: invalid format"))?; + let user = line[..idx].as_bytes().to_owned(); + if user.is_empty() { + return Err(zerror!("Invalid user/password dictionary file: empty user").into()); + } + let password = line[idx + 1..].as_bytes().to_owned(); + if password.is_empty() { + return Err(zerror!("Invalid user/password dictionary file: empty password").into()); + } + dictionary.insert(user, password); + } + Ok(dictionary) +} + +fn is_authorized( + dictionary: Option<&HashMap>, + usr: Option<&ByteString>, + pwd: Option<&Bytes>, +) -> Result<(), String> { + match (dictionary, usr, pwd) { + // No user/password dictionary - all clients authorized to connect + (None, _, _) => Ok(()), + // User/password dictionary provided - clients must provide credentials to connect + (Some(dictionary), Some(usr), Some(pwd)) => { + match dictionary.get(&usr.as_bytes().to_vec()) { + Some(expected_pwd) => { + if pwd == expected_pwd { + Ok(()) + } else { + Err(format!("Incorrect password for user {usr:?}")) + } + } + None => Err(format!("Unknown user {usr:?}")), + } + } + (Some(_), Some(usr), None) => Err(format!("Missing password for user {usr:?}")), + (Some(_), None, Some(_)) => Err(("Missing user name").to_string()), + (Some(_), None, None) => Err(("Missing user credentials").to_string()), + } +} + fn create_mqtt_server( session: Arc, config: Arc, + auth_dictionary: Arc>>, ) -> MqttServer< impl ServiceFactory< (IoBoxed, Deadline), @@ -309,13 +392,16 @@ fn create_mqtt_server( let zs_v5 = session.clone(); let config_v3 = config.clone(); let config_v5 = config.clone(); + let auth_dictionary_v3 = auth_dictionary.clone(); + let auth_dictionary_v5 = auth_dictionary.clone(); MqttServer::new() .v3(v3::MqttServer::new(fn_factory_with_config(move |_| { let zs = zs_v3.clone(); let config = config_v3.clone(); + let auth_dictionary = auth_dictionary_v3.clone(); Ready::Ok::<_, ()>(fn_service(move |h| { - handshake_v3(h, zs.clone(), config.clone()) + handshake_v3(h, zs.clone(), config.clone(), auth_dictionary.clone()) })) })) .publish(fn_factory_with_config( @@ -335,8 +421,9 @@ fn create_mqtt_server( .v5(v5::MqttServer::new(fn_factory_with_config(move |_| { let zs = zs_v5.clone(); let config = config_v5.clone(); + let auth_dictionary = auth_dictionary_v5.clone(); Ready::Ok::<_, ()>(fn_service(move |h| { - handshake_v5(h, zs.clone(), config.clone()) + handshake_v5(h, zs.clone(), config.clone(), auth_dictionary.clone()) })) })) .publish(fn_factory_with_config( @@ -444,12 +531,30 @@ async fn handshake_v3<'a>( handshake: v3::Handshake, zsession: Arc, config: Arc, + auth_dictionary: Arc>>, ) -> Result>, MqttPluginError> { let client_id = handshake.packet().client_id.to_string(); - log::info!("MQTT client {} connects using v3", client_id); - let session = MqttSessionState::new(client_id, zsession, config, handshake.sink().into()); - Ok(handshake.ack(session, false)) + match is_authorized( + (*auth_dictionary).as_ref(), + handshake.packet().username.as_ref(), + handshake.packet().password.as_ref(), + ) { + Ok(_) => { + log::info!("MQTT client {} connects using v3", client_id); + let session = + MqttSessionState::new(client_id, zsession, config, handshake.sink().into()); + Ok(handshake.ack(session, false)) + } + Err(err) => { + log::warn!( + "MQTT client {} connect using v3 rejected: {}", + client_id, + err + ); + Ok(handshake.bad_username_or_pwd()) + } + } } async fn publish_v3( @@ -544,12 +649,30 @@ async fn handshake_v5<'a>( handshake: v5::Handshake, zsession: Arc, config: Arc, + auth_dictionary: Arc>>, ) -> Result>, MqttPluginError> { let client_id = handshake.packet().client_id.to_string(); - log::info!("MQTT client {} connects using v5", client_id); - let session = MqttSessionState::new(client_id, zsession, config, handshake.sink().into()); - Ok(handshake.ack(session)) + match is_authorized( + (*auth_dictionary).as_ref(), + handshake.packet().username.as_ref(), + handshake.packet().password.as_ref(), + ) { + Ok(_) => { + log::info!("MQTT client {} connects using v5", client_id); + let session = + MqttSessionState::new(client_id, zsession, config, handshake.sink().into()); + Ok(handshake.ack(session)) + } + Err(err) => { + log::warn!( + "MQTT client {} connect using v5 rejected: {}", + client_id, + err + ); + Ok(handshake.failed(ntex_mqtt::v5::codec::ConnectAckReason::BadUserNameOrPassword)) + } + } } async fn publish_v5( From f91d5a51aa8776d3ea1c582740a3f1e9abd3d70a Mon Sep 17 00:00:00 2001 From: Geoff Martin Date: Thu, 29 Feb 2024 16:00:42 +0000 Subject: [PATCH 2/4] Adding documentation for user/password authentication. --- DEFAULT_CONFIG.json5 | 10 ++++++++++ README.md | 34 ++++++++++++++++++++++++++++++++++ zenoh-bridge-mqtt/src/main.rs | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/DEFAULT_CONFIG.json5 b/DEFAULT_CONFIG.json5 index 049e130..fc0afc4 100644 --- a/DEFAULT_CONFIG.json5 +++ b/DEFAULT_CONFIG.json5 @@ -72,6 +72,16 @@ // // root_ca_certificate_base64: "base64-root-ca-certificate", // }, + //// + //// MQTT client authentication related configuration. + //// + // auth: { + // //// + // //// dictionary_file: Path to a file containing the MQTT client username/password dictionary. + // //// + // dictionary_file: "/path/to/dictionary-file", + // }, + }, //// diff --git a/README.md b/README.md index f767087..4d1f7a3 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The `"mqtt"` part of this same configuration file can also be used in the config - **`-r, --generalise-sub `** : A list of key expressions to use for generalising the declaration of the zenoh subscriptions, and thus minimizing the discovery traffic (usable multiple times). See [this blog](https://zenoh.io/blog/2021-03-23-discovery/#leveraging-resource-generalisation) for more details. + - **`--dictionary-file `** : Path to a file containing the MQTT client username/password dictionary. - **`--server-private-key `** : Path to the TLS private key for the MQTT server. If specified a valid certificate for the server must also be provided. - **`--server-certificate `** : Path to the TLS public certificate for the MQTT server. If specified a valid private key for the server must also be provided. - **`--root-ca-certificate `** : Path to the certificate of the certificate authority used to validate clients connecting to the MQTT server. If specified a valid private key and certificate for the server must also be provided. @@ -147,6 +148,39 @@ An example configuration file supporting server side authentication would be: ``` The standalone bridge (`zenoh-bridge-mqtt`) also allows the required file to be provided through the **`--root-ca-certificate`** command line argument. +## Username/password authentication + +The MQTT plugin and standalone bridge for Eclipse Zenoh supports basic username/password authentication of MQTT clients. Credentials are provided via a dictionary file with each line containing the username and password for a single user in the following format: + +``` +username:password +``` + +Username/passord authentication can be configured via the configuration file or, if using the standalone bridge, via command line arguments. + +In the configuration file, the required **tls** field when using a file is **root_ca_certificate**. When using base 64 encoded strings the required **tls** field when using a file is **root_ca_certificate_base64**. + +An example configuration file supporting username/password authentication would be: + +```json +{ + "plugins": { + "mqtt": { + "auth": { + "dictionary_file": "/path/to/dictionary-file", + } + } + } +} +``` +The standalone bridge (`zenoh-bridge-mqtt`) also allows the required file to be provided through the **`--dictionary-file`** command line argument. + +### Security considerations + +Usernames and passwords are sent as part of the MQTT `CONNECT` message in clear text. As such, they can potentially be viewed using tools such as [Wireshark](https://www.wireshark.org/). + +To prevent this, it is highly recommended that this feature is used in conjunction with the MQTTS feature to ensure credentials are encrypted on the wire. + ## How to install it To install the latest release of either the MQTT plugin for the Zenoh router, either the `zenoh-bridge-mqtt` standalone executable, you can do as follows: diff --git a/zenoh-bridge-mqtt/src/main.rs b/zenoh-bridge-mqtt/src/main.rs index 4d1f842..0df2e2c 100644 --- a/zenoh-bridge-mqtt/src/main.rs +++ b/zenoh-bridge-mqtt/src/main.rs @@ -106,7 +106,7 @@ r#"-r, --generalise-sub=[String]... 'A list of key expression to use for gener r#"-w, --generalise-pub=[String]... 'A list of key expression to use for generalising publications (usable multiple times).'"# )) .arg(Arg::from_usage( -r#"--dictionary-file=[FILE] 'Path to the file containing the client password dictionary.'"# +r#"--dictionary-file=[FILE] 'Path to the file containing the MQTT client username/password dictionary.'"# )) .arg(Arg::from_usage( r#"--server-private-key=[FILE] 'Path to the TLS private key for the MQTT server. If specified a valid certificate for the server must also be provided.'"# From 7981e6c04c79cc4de9e6964ea2534faab0e60503 Mon Sep 17 00:00:00 2001 From: Geoff Martin Date: Thu, 29 Feb 2024 16:06:02 +0000 Subject: [PATCH 3/4] Correction to user/password authentication documentation. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d1f7a3..361bc6f 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ username:password Username/passord authentication can be configured via the configuration file or, if using the standalone bridge, via command line arguments. -In the configuration file, the required **tls** field when using a file is **root_ca_certificate**. When using base 64 encoded strings the required **tls** field when using a file is **root_ca_certificate_base64**. +In the configuration file, the required **auth** field for configuring the dictionary file is **dictionary_file**. An example configuration file supporting username/password authentication would be: From 48e4827f50aebdfb34a0bb1d6a09ab40354563b5 Mon Sep 17 00:00:00 2001 From: Geoff Martin Date: Mon, 4 Mar 2024 17:49:24 +0000 Subject: [PATCH 4/4] Changed response to not authorized instead of bad username or pwd when user authorization fails. --- zenoh-plugin-mqtt/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zenoh-plugin-mqtt/src/lib.rs b/zenoh-plugin-mqtt/src/lib.rs index bb54b4d..fa7346b 100644 --- a/zenoh-plugin-mqtt/src/lib.rs +++ b/zenoh-plugin-mqtt/src/lib.rs @@ -552,7 +552,7 @@ async fn handshake_v3<'a>( client_id, err ); - Ok(handshake.bad_username_or_pwd()) + Ok(handshake.not_authorized()) } } } @@ -670,7 +670,7 @@ async fn handshake_v5<'a>( client_id, err ); - Ok(handshake.failed(ntex_mqtt::v5::codec::ConnectAckReason::BadUserNameOrPassword)) + Ok(handshake.failed(ntex_mqtt::v5::codec::ConnectAckReason::NotAuthorized)) } } }