From 8a596719382a5fcfabc28d94578f865b8269e24b Mon Sep 17 00:00:00 2001 From: Hannes de Jager Date: Fri, 1 Mar 2024 11:04:05 +0100 Subject: [PATCH] Add HTTP User Detail extension This adds functionality to unFTP that allows unFTP to obtain user detail over HTTP in addition to the already existing JSON file. It uses the extact same format as the JSON file functionality. The 'usr-http-url' command line arguments activate this feature. You pass it a base URL, unFTP appends a username to it and performs a GET request to the URL. The HTTP server should respond with a 200 OK and JSON body containing an array of users which should at least contain the requested user's details. Later on we can support diffent HTTP verbs and sending the username via an HTTP header instead of the URL path or Post body. For now I'm just keeping it simple. --- Cargo.lock | 17 ++++---- Cargo.toml | 1 + src/args.rs | 9 ++++ src/auth.rs | 16 +++---- src/domain/user.rs | 45 ++++++++++++++++++-- src/infra/mod.rs | 4 +- src/infra/userdetail_http.rs | 69 ++++++++++++++++++++++++++++++ src/infra/usrdetail_json.rs | 82 ++++++++++++++++++++---------------- src/main.rs | 30 +++++++++---- 9 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 src/infra/userdetail_http.rs diff --git a/Cargo.lock b/Cargo.lock index ebfa51e..68a978f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,9 +716,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -1062,9 +1062,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -2702,6 +2702,7 @@ dependencies = [ "unftp-sbe-gcs", "unftp-sbe-restrict", "unftp-sbe-rooter", + "url", ] [[package]] @@ -2836,9 +2837,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.8" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" @@ -2881,9 +2882,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 1298f98..0867693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ unftp-auth-rest = { version = "0.2.4", optional = true } unftp-auth-jsonfile = { version = "0.3.3", optional = true } unftp-sbe-rooter = "0.2.0" unftp-sbe-restrict = "0.1.1" +url = "2.5.0" [target.'cfg(unix)'.dependencies] unftp-auth-pam = { version = "0.2.4", optional = true } diff --git a/src/args.rs b/src/args.rs index 1dba43d..981dc2c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -42,6 +42,7 @@ pub const REDIS_PORT: &str = "log-redis-port"; pub const ROOT_DIR: &str = "root-dir"; pub const STORAGE_BACKEND_TYPE: &str = "sbe-type"; pub const USR_JSON_PATH: &str = "usr-json-path"; +pub const USR_HTTP_URL: &str = "usr-http-url"; pub const VERBOSITY: &str = "verbosity"; #[derive(ArgEnum, Clone, Debug)] @@ -502,6 +503,14 @@ pub(crate) fn clap_app(tmp_dir: &str) -> clap::Command { .env("UNFTP_USR_JSON_PATH") .takes_value(true), ) + .arg( + Arg::new(USR_HTTP_URL) + .long("usr-http-url") + .value_name("URL") + .help("The URL to fetch user details from via a GET request. The username will be appended to this path.") + .env("UNFTP_USR_HTTP_URL") + .takes_value(true), + ) .arg( Arg::new(PUBSUB_BASE_URL) .long("ntf-pubsub-base-url") diff --git a/src/auth.rs b/src/auth.rs index 1fbdd53..e312306 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,4 +1,4 @@ -use crate::domain::user::{User, UserDetailProvider}; +use crate::domain::user::{User, UserDetailError, UserDetailProvider}; use async_trait::async_trait; use libunftp::auth::{AuthenticationError, Credentials, DefaultUser}; @@ -32,11 +32,10 @@ impl libunftp::auth::Authenticator for LookupAuthenticator { ) -> Result { self.inner.authenticate(username, creds).await?; let user_provider = self.usr_detail.as_ref().unwrap(); - if let Some(user) = user_provider.provide_user_detail(username) { - Ok(user) - } else { - Ok(User::with_defaults(username)) - } + Ok(user_provider + .provide_user_detail(username) + .await + .map_err(|e| AuthenticationError::with_source("error getting user detail", e))?) } async fn cert_auth_sufficient(&self, username: &str) -> bool { @@ -47,8 +46,9 @@ impl libunftp::auth::Authenticator for LookupAuthenticator { #[derive(Debug)] pub struct DefaultUserProvider {} +#[async_trait] impl UserDetailProvider for DefaultUserProvider { - fn provide_user_detail(&self, username: &str) -> Option { - Some(User::with_defaults(username)) + async fn provide_user_detail(&self, username: &str) -> Result { + Ok(User::with_defaults(username)) } } diff --git a/src/domain/user.rs b/src/domain/user.rs index 44c6a1d..27c2188 100644 --- a/src/domain/user.rs +++ b/src/domain/user.rs @@ -1,6 +1,12 @@ +//! Contains definitions pertaining to FTP User Detail +use async_trait::async_trait; use libunftp::auth::UserDetail; -use std::fmt::{Debug, Display, Formatter}; -use std::path::PathBuf; +use slog::error; +use std::{ + fmt::{Debug, Display, Formatter}, + path::PathBuf, +}; +use thiserror::Error; use unftp_sbe_restrict::{UserWithPermissions, VfsOperations}; use unftp_sbe_rooter::UserWithRoot; @@ -64,6 +70,39 @@ impl UserWithPermissions for User { /// Implementation of UserDetailProvider can look up and provide FTP user account details from /// a source. +#[async_trait] pub trait UserDetailProvider: Debug { - fn provide_user_detail(&self, username: &str) -> Option; + /// This will do the lookup. An error is returned if the user was not found or something else + /// went wrong. + async fn provide_user_detail(&self, username: &str) -> Result; +} + +/// The error type returned by [`UserDetailProvider`] +#[derive(Debug, Error)] +pub enum UserDetailError { + #[error("{0}")] + Generic(String), + #[error("user '{username:?}' not found")] + UserNotFound { username: String }, + #[error("error getting user details: {0}: {1:?}")] + ImplPropagated( + String, + #[source] Option>, + ), +} + +impl UserDetailError { + /// Creates a new domain specific error + #[allow(dead_code)] + pub fn new(s: impl Into) -> Self { + UserDetailError::ImplPropagated(s.into(), None) + } + + /// Creates a new domain specific error with the given source error. + pub fn with_source(s: impl Into, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + UserDetailError::ImplPropagated(s.into(), Some(Box::new(source))) + } } diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 87c86d8..0d82c55 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,8 +1,8 @@ //! Infra contains infrastructure specific implementations of things in the [`domain`](crate::domain) //! module. mod pubsub; -mod workload_identity; - +pub mod userdetail_http; pub mod usrdetail_json; +mod workload_identity; pub use pubsub::PubsubEventDispatcher; diff --git a/src/infra/userdetail_http.rs b/src/infra/userdetail_http.rs new file mode 100644 index 0000000..0dd2070 --- /dev/null +++ b/src/infra/userdetail_http.rs @@ -0,0 +1,69 @@ +//! A libunftp [`UserDetail`](libunftp::auth::user::UserDetail) provider that obtains user detail +//! over HTTP. + +use crate::domain::user::{User, UserDetailError, UserDetailProvider}; +use crate::infra::usrdetail_json::JsonUserProvider; +use async_trait::async_trait; +use http::{Method, Request}; +use hyper::{Body, Client}; +use url::form_urlencoded; + +/// A libunftp [`UserDetail`](libunftp::auth::user::UserDetail) provider that obtains user detail +/// over HTTP. +#[derive(Debug)] +pub struct HTTPUserDetailProvider { + url: String, + #[allow(dead_code)] + header_name: Option, +} + +impl HTTPUserDetailProvider { + /// Creates a provider that will obtain user detail from the specified URL. + pub fn new(url: impl Into) -> HTTPUserDetailProvider { + HTTPUserDetailProvider { + url: url.into(), + header_name: None, + } + } +} + +impl Default for HTTPUserDetailProvider { + fn default() -> Self { + HTTPUserDetailProvider { + url: "http://localhost:8080/users/".to_string(), + header_name: None, + } + } +} + +#[async_trait] +impl UserDetailProvider for HTTPUserDetailProvider { + async fn provide_user_detail(&self, username: &str) -> Result { + let _url_suffix: String = form_urlencoded::byte_serialize(username.as_bytes()).collect(); + let req = Request::builder() + .method(Method::GET) + .header("Content-type", "application/json") + .uri(format!("{}{}", self.url, username)) + .body(Body::empty()) + .map_err(|e| UserDetailError::with_source("error creating request", e))?; + + let client = Client::new(); + + let resp = client + .request(req) + .await + .map_err(|e| UserDetailError::with_source("error doing HTTP request", e))?; + + let body_bytes = hyper::body::to_bytes(resp.into_body()) + .await + .map_err(|e| UserDetailError::with_source("error parsing body", e))?; + + let json_str = std::str::from_utf8(body_bytes.as_ref()) + .map_err(|e| UserDetailError::with_source("body is not a valid UTF string", e))?; + + let json_usr_provider = + JsonUserProvider::from_json(json_str).map_err(UserDetailError::Generic)?; + + json_usr_provider.provide_user_detail(username).await + } +} diff --git a/src/infra/usrdetail_json.rs b/src/infra/usrdetail_json.rs index 117853b..827b83b 100644 --- a/src/infra/usrdetail_json.rs +++ b/src/infra/usrdetail_json.rs @@ -1,4 +1,5 @@ -use crate::domain::user::{User, UserDetailProvider}; +use crate::domain::user::{User, UserDetailError, UserDetailProvider}; +use async_trait::async_trait; use serde::Deserialize; use std::path::PathBuf; use unftp_sbe_restrict::VfsOperations; @@ -28,42 +29,49 @@ impl JsonUserProvider { } } +#[async_trait] impl UserDetailProvider for JsonUserProvider { - fn provide_user_detail(&self, username: &str) -> Option { - self.users.iter().find(|u| u.username == username).map(|u| { - let u = u.clone(); - User { - username: u.username, - name: u.name, - surname: u.surname, - account_enabled: u.account_enabled.unwrap_or(true), - vfs_permissions: u.vfs_perms.map_or(VfsOperations::all(), |p| { - p.iter() - .fold(VfsOperations::all(), |ops, s| match s.as_str() { - "none" => VfsOperations::empty(), - "all" => VfsOperations::all(), - "-mkdir" => ops - VfsOperations::MK_DIR, - "-rmdir" => ops - VfsOperations::RM_DIR, - "-del" => ops - VfsOperations::DEL, - "-ren" => ops - VfsOperations::RENAME, - "-md5" => ops - VfsOperations::MD5, - "-get" => ops - VfsOperations::GET, - "-put" => ops - VfsOperations::PUT, - "-list" => ops - VfsOperations::LIST, - "+mkdir" => ops | VfsOperations::MK_DIR, - "+rmdir" => ops | VfsOperations::RM_DIR, - "+del" => ops | VfsOperations::DEL, - "+ren" => ops | VfsOperations::RENAME, - "+md5" => ops | VfsOperations::MD5, - "+get" => ops | VfsOperations::GET, - "+put" => ops | VfsOperations::PUT, - "+list" => ops | VfsOperations::LIST, - _ => ops, - }) - }), - allowed_mime_types: None, - root: u.root.map(PathBuf::from), - } - }) + async fn provide_user_detail(&self, username: &str) -> Result { + self.users + .iter() + .find(|u| u.username == username) + .ok_or(UserDetailError::UserNotFound { + username: String::from(username), + }) + .map(|u| { + let u = u.clone(); + User { + username: u.username, + name: u.name, + surname: u.surname, + account_enabled: u.account_enabled.unwrap_or(true), + vfs_permissions: u.vfs_perms.map_or(VfsOperations::all(), |p| { + p.iter() + .fold(VfsOperations::all(), |ops, s| match s.as_str() { + "none" => VfsOperations::empty(), + "all" => VfsOperations::all(), + "-mkdir" => ops - VfsOperations::MK_DIR, + "-rmdir" => ops - VfsOperations::RM_DIR, + "-del" => ops - VfsOperations::DEL, + "-ren" => ops - VfsOperations::RENAME, + "-md5" => ops - VfsOperations::MD5, + "-get" => ops - VfsOperations::GET, + "-put" => ops - VfsOperations::PUT, + "-list" => ops - VfsOperations::LIST, + "+mkdir" => ops | VfsOperations::MK_DIR, + "+rmdir" => ops | VfsOperations::RM_DIR, + "+del" => ops | VfsOperations::DEL, + "+ren" => ops | VfsOperations::RENAME, + "+md5" => ops | VfsOperations::MD5, + "+get" => ops | VfsOperations::GET, + "+put" => ops | VfsOperations::PUT, + "+list" => ops | VfsOperations::LIST, + _ => ops, + }) + }), + allowed_mime_types: None, + root: u.root.map(PathBuf::from), + } + }) } } diff --git a/src/main.rs b/src/main.rs index da3da85..9dd0c2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod metrics; mod notify; mod storage; +use crate::infra::userdetail_http::HTTPUserDetailProvider; use crate::{ app::libunftp_version, args::FtpsClientAuthType, auth::DefaultUserProvider, notify::FTPListener, }; @@ -99,14 +100,27 @@ fn make_auth( Some("json") => make_json_auth(m), unknown_type => Err(format!("unknown auth type: {}", unknown_type.unwrap())), }?; - auth.set_usr_detail(match m.value_of(args::USR_JSON_PATH) { - Some(path) => { - let json: String = load_user_file(path) - .map_err(|e| format!("could not load user file '{}': {}", path, e))?; - Box::new(JsonUserProvider::from_json(json.as_str())?) - } - None => Box::new(DefaultUserProvider {}), - }); + auth.set_usr_detail( + match ( + m.value_of(args::USR_JSON_PATH), + m.value_of(args::USR_HTTP_URL), + ) { + (Some(path), None) => { + let json: String = load_user_file(path) + .map_err(|e| format!("could not load user file '{}': {}", path, e))?; + Box::new(JsonUserProvider::from_json(json.as_str())?) + } + (None, Some(url)) => Box::new(HTTPUserDetailProvider::new(url)), + (None, None) => Box::new(DefaultUserProvider {}), + _ => { + return Err(format!( + "please specify either '{}' or '{}' but not both", + args::USR_JSON_PATH, + args::USR_HTTP_URL + )) + } + }, + ); Ok(Arc::new(auth)) }