diff --git a/Cargo.lock b/Cargo.lock index dc7a57f..9b5be8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2135563fb5c609d2b2b87c1e8ce7bc41b0b45430fa9661f457981503dd5bf0" +checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab" dependencies = [ "memchr", ] @@ -413,6 +413,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -422,6 +431,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug", +] + [[package]] name = "bmcd" version = "1.3.0" @@ -434,8 +454,10 @@ dependencies = [ "futures", "if-addrs", "log", + "mockall", "nix 0.26.4", "openssl", + "pwhash", "rand 0.8.5", "serde", "serde_json", @@ -486,6 +508,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "bytes" version = "1.5.0" @@ -529,20 +557,29 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + [[package]] name = "clap" -version = "4.4.4" +version = "4.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d7b8d5ec32af0fadc644bf1fd509a688c2103b185644bb1e29d164e0703136" +checksum = "824956d0dca8334758a5b7f7e50518d66ea319330cbceedcf76905c2f6ab30e3" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.4.4" +version = "4.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56" +checksum = "122ec64120a49b4563ccaedcbea7818d069ed8e9aa6d829b82d8a4128936b2ab" dependencies = [ "anstream", "anstyle", @@ -639,6 +676,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff07008ec701e8028e2ceb8f83f0e4274ee62bd2dbdc4fefff2e9a91824081a" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "deranged" version = "0.3.8" @@ -658,16 +705,43 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "crypto-common", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -738,6 +812,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -768,6 +851,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -951,6 +1040,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + [[package]] name = "http" version = "0.2.9" @@ -1069,6 +1168,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -1162,6 +1270,17 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "memchr" version = "2.6.3" @@ -1223,6 +1342,33 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "nix" version = "0.23.2" @@ -1249,6 +1395,12 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.16" @@ -1313,6 +1465,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "openssl" version = "0.10.57" @@ -1410,6 +1568,36 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -1429,6 +1617,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pwhash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419a3ad8fa9f9d445e69d9b185a24878ae6e6f55c96e4512f4a0e28cd3bc5c56" +dependencies = [ + "blowfish", + "byteorder", + "hmac", + "md-5", + "rand 0.8.5", + "sha-1", + "sha2", +] + [[package]] name = "quote" version = "1.0.33" @@ -1575,9 +1778,9 @@ dependencies = [ [[package]] name = "rockfile" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e492654d9f624d21df4f0eb765c9174a4a577c4d47680803ffe09b6ae55b3d02" +checksum = "7e89194db9752ec1f052472debd3990f77bec4ca0732d9dd28163f95fed1455a" dependencies = [ "bytes", ] @@ -1622,9 +1825,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.13" +version = "0.38.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" +checksum = "747c788e9ce8e92b12cd485c49ddf90723550b654b32508f979b71a7b1ecda4f" dependencies = [ "bitflags 2.4.0", "errno", @@ -1647,9 +1850,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.5" +version = "0.101.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a27e3b59326c16e23d30aeb7a36a24cc0d29e71d68ff611cdfb4a01d013bed" +checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe" dependencies = [ "ring", "untrusted", @@ -1688,9 +1891,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" +checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0" [[package]] name = "serde" @@ -1748,15 +1951,41 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha1" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -1791,9 +2020,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" +checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "socket2" @@ -1817,6 +2046,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + [[package]] name = "syn" version = "1.0.109" @@ -1855,6 +2090,12 @@ dependencies = [ "remove_dir_all", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.48" @@ -1877,9 +2118,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ "deranged", "itoa", @@ -1892,15 +2133,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ "time-core", ] @@ -1964,9 +2205,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d" dependencies = [ "bytes", "futures-core", diff --git a/bmcd/Cargo.toml b/bmcd/Cargo.toml index e368561..cd1279d 100644 --- a/bmcd/Cargo.toml +++ b/bmcd/Cargo.toml @@ -15,6 +15,8 @@ serde_yaml = "0.9.25" tpi_rs = { path = "../tpi_rs" } clap = { version = "4.4.2", features = ["cargo"] } openssl = "0.10.57" +rand = "0.8.5" +pwhash = "1.0.0" anyhow.workspace = true log.workspace = true @@ -23,4 +25,6 @@ tokio.workspace = true tokio-util.workspace = true futures.workspace = true serde.workspace = true -rand = "0.8.5" + +[dev-dependencies] +mockall = "0.11.4" diff --git a/bmcd/src/authentication/authentication_context.rs b/bmcd/src/authentication/authentication_context.rs new file mode 100644 index 0000000..28aec72 --- /dev/null +++ b/bmcd/src/authentication/authentication_context.rs @@ -0,0 +1,317 @@ +use super::authentication_errors::AuthenticationError; +use super::authentication_errors::TokenError; +use super::passwd_validator::PasswordValidator; +use super::passwd_validator::UnixValidator; +use actix_web::dev::ServiceRequest; +use actix_web::http::header; +use actix_web::http::header::HeaderValue; +use futures::StreamExt; +use rand::distributions::Alphanumeric; +use rand::thread_rng; +use rand::Rng; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::marker::PhantomData; +use tokio::{ + sync::RwLock, + time::{Duration, Instant}, +}; + +pub struct AuthenticationContext

+where + P: PasswordValidator + 'static, +{ + token_store: RwLock>, + passwds: HashMap, + password_validator: PhantomData

, + expiration_timeout: Duration, +} + +impl

AuthenticationContext

+where + P: PasswordValidator + 'static, +{ + pub fn with_unix_validator( + password_entries: impl Iterator, + expiration_timeout: Duration, + ) -> AuthenticationContext { + AuthenticationContext:: { + token_store: RwLock::new(HashMap::new()), + passwds: HashMap::from_iter(password_entries), + password_validator: PhantomData::, + expiration_timeout, + } + } + + fn parse_http_token(auth_string: &str) -> Result<&str, TokenError> { + auth_string + .split_once(' ') + .ok_or(TokenError::HttpParseError(auth_string.to_string())) + .and_then(|(_, token)| { + (!token.is_empty()) + .then_some(token) + .ok_or(TokenError::Empty) + }) + } + + async fn verify_token(&self, auth_string: &str) -> Result<(), TokenError> { + let token = Self::parse_http_token(auth_string)?; + + let store = self.token_store.read().await; + let Some(expiration) = store.get(token).copied() else { + return Err(TokenError::NoMatch(token.to_string())); + }; + + let duration = Instant::now().saturating_duration_since(expiration); + if duration > self.expiration_timeout { + // upgrade to write lock + drop(store); + self.token_store.write().await.remove(token); + return Err(TokenError::Expired(expiration)); + } + + Ok(()) + } + + pub async fn authorize_request(&self, request: &ServiceRequest) -> Result<(), TokenError> { + let auth = request + .headers() + .get(header::AUTHORIZATION) + .map(HeaderValue::to_str) + .ok_or(TokenError::Empty)?; + + self.verify_token(auth.unwrap_or_default()).await + } + + pub async fn authenticate_request( + &self, + request: &mut ServiceRequest, + ) -> Result<(String, Instant), AuthenticationError> { + let mut buffer = Vec::new(); + while let Some(Ok(bytes)) = request.parts_mut().1.next().await { + buffer.extend_from_slice(&bytes); + } + + let credentials = serde_json::from_slice::(&buffer)?; + let Some(pass) = self.passwds.get(&credentials.username) else { + log::debug!("user {} not in database",&credentials.username); + return Err(AuthenticationError::IncorrectCredentials); + }; + + P::validate(pass, &credentials.password)?; + + let token: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + let expires = Instant::now() + self.expiration_timeout; + self.token_store + .write() + .await + .insert(token.clone(), expires); + Ok((token, expires)) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Login { + username: String, + password: String, +} + +#[cfg(test)] +mod tests { + use std::ops::Sub; + + use super::*; + use actix_web::{ + http::header::AUTHORIZATION, + test::{self}, + }; + + struct DummyValidator {} + impl PasswordValidator for DummyValidator { + fn validate( + _: &str, + _: &str, + ) -> Result<(), crate::authentication::authentication_errors::AuthenticationError> { + Ok(()) + } + } + + struct FalseValidator {} + impl PasswordValidator for FalseValidator { + fn validate( + _: &str, + _: &str, + ) -> Result<(), crate::authentication::authentication_errors::AuthenticationError> { + Err(AuthenticationError::IncorrectCredentials) + } + } + + fn build_test_context( + token_data: impl IntoIterator, + user_data: impl IntoIterator, + ) -> AuthenticationContext { + AuthenticationContext { + token_store: RwLock::new(HashMap::from_iter(token_data)), + passwds: HashMap::from_iter(user_data), + password_validator: PhantomData::, + expiration_timeout: Duration::from_secs(20), + } + } + + fn build_false_context( + token_data: impl IntoIterator, + user_data: impl IntoIterator, + ) -> AuthenticationContext { + AuthenticationContext { + token_store: RwLock::new(HashMap::from_iter(token_data)), + passwds: HashMap::from_iter(user_data), + password_validator: PhantomData::, + expiration_timeout: Duration::from_secs(20), + } + } + + #[actix_web::test] + async fn test_wrongly_encoded_token() { + let context = build_test_context(Vec::new(), Vec::new()); + assert!(matches!( + context.verify_token("").await.unwrap_err(), + TokenError::HttpParseError(_), + )); + assert!(matches!( + context.verify_token("Bearernospace").await.unwrap_err(), + TokenError::HttpParseError(_), + )); + assert_eq!( + TokenError::Empty, + context.verify_token("Bearer ").await.unwrap_err() + ); + + let req = test::TestRequest::default().to_srv_request(); + assert_eq!( + TokenError::Empty, + context.authorize_request(&req).await.unwrap_err() + ); + } + + #[actix_web::test] + async fn test_token_failures() { + let context = build_test_context( + [ + ("123".to_string(), Instant::now()), + ("2".to_string(), Instant::now().sub(Duration::from_secs(20))), + ], + Vec::new(), + ); + + let req = test::TestRequest::default() + .insert_header((AUTHORIZATION, "Bearer 1234")) + .to_srv_request(); + + assert_eq!( + context.authorize_request(&req).await.unwrap_err(), + TokenError::NoMatch("1234".to_string()) + ); + + let req = test::TestRequest::default() + .insert_header((AUTHORIZATION, "Bearer 2")) + .to_srv_request(); + assert!(matches!( + context.authorize_request(&req).await.unwrap_err(), + TokenError::Expired(x) if Instant::now().saturating_duration_since(x) > Duration::from_secs(20) + )); + + // After expired error, the token gets removed. Subsequent calls for that token will + // therefore return "NoMatch" + let req = test::TestRequest::default() + .insert_header((AUTHORIZATION, "Bearer 2")) + .to_srv_request(); + assert_eq!( + context.authorize_request(&req).await.unwrap_err(), + TokenError::NoMatch("2".to_string()) + ); + } + + #[actix_web::test] + async fn test_happy_flow() { + let context = build_test_context( + [ + ("123".to_string(), Instant::now()), + ("2".to_string(), Instant::now().sub(Duration::from_secs(20))), + ], + Vec::new(), + ); + let req = test::TestRequest::default() + .insert_header((AUTHORIZATION, "Bearer 123")) + .to_srv_request(); + assert_eq!(Ok(()), context.authorize_request(&req).await); + } + + #[actix_web::test] + async fn authentication_errors() { + let context = build_test_context( + Vec::new(), + [("test_user".to_string(), "password".to_string())], + ); + + let mut req = test::TestRequest::default() + .set_payload("{not a valid json") + .to_srv_request(); + assert!(matches!( + context.authenticate_request(&mut req).await.unwrap_err(), + AuthenticationError::ParseError(_) + )); + + let mut req = test::TestRequest::default() + .set_json(Login { + username: "John".to_string(), + password: "1234".to_string(), + }) + .to_srv_request(); + assert!(matches!( + context.authenticate_request(&mut req).await.unwrap_err(), + AuthenticationError::IncorrectCredentials + )); + } + + #[actix_web::test] + async fn invalid_password() { + let context = build_false_context( + Vec::new(), + [("test_user".to_string(), "password".to_string())], + ); + let mut req = test::TestRequest::default() + .set_json(Login { + username: "test_user".to_string(), + password: "1234".to_string(), + }) + .to_srv_request(); + assert!(matches!( + context.authenticate_request(&mut req).await.unwrap_err(), + AuthenticationError::IncorrectCredentials + )); + } + + #[actix_web::test] + async fn pass_authentication() { + let context = build_test_context( + Vec::new(), + [("test_user".to_string(), "password".to_string())], + ); + let mut req = test::TestRequest::default() + .set_json(Login { + username: "test_user".to_string(), + password: "password".to_string(), + }) + .to_srv_request(); + + assert!(matches!( + context.authenticate_request(&mut req).await.unwrap(), + (token, _) if !token.is_empty() + )); + } +} diff --git a/bmcd/src/authentication/authentication_errors.rs b/bmcd/src/authentication/authentication_errors.rs new file mode 100644 index 0000000..1830558 --- /dev/null +++ b/bmcd/src/authentication/authentication_errors.rs @@ -0,0 +1,60 @@ +use std::fmt::Display; +use tokio::time::Instant; + +#[derive(Debug)] +pub enum AuthenticationError { + ParseError(serde_json::Error), + IncorrectCredentials, + InvalidToken(TokenError), +} + +impl Display for AuthenticationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthenticationError::ParseError(e) => { + write!(f, "error trying to parse credentials: {}", e) + } + AuthenticationError::IncorrectCredentials => write!(f, "credentials incorrect"), + AuthenticationError::InvalidToken(token_error) => token_error.fmt(f), + } + } +} + +impl std::error::Error for AuthenticationError {} + +impl From for AuthenticationError { + fn from(value: serde_json::Error) -> Self { + Self::ParseError(value) + } +} + +impl From for AuthenticationError { + fn from(value: TokenError) -> Self { + Self::InvalidToken(value) + } +} + +#[derive(Debug, PartialEq)] +pub enum TokenError { + Expired(Instant), + NoMatch(String), + HttpParseError(String), + Empty, +} + +impl Display for TokenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenError::Expired(instant) => write!( + f, + "token expired {}s ago", + Instant::now().duration_since(*instant).as_secs() + ), + TokenError::NoMatch(token) => write!(f, "token {} is not registerd", token), + TokenError::Empty => write!(f, "no authorization token provided"), + TokenError::HttpParseError(token) => write!(f, "cannot parse token {}", token), + } + } +} + +impl std::error::Error for TokenError {} diff --git a/bmcd/src/authentication/authentication_service.rs b/bmcd/src/authentication/authentication_service.rs new file mode 100644 index 0000000..5995f04 --- /dev/null +++ b/bmcd/src/authentication/authentication_service.rs @@ -0,0 +1,124 @@ +use super::{authentication_context::AuthenticationContext, passwd_validator::UnixValidator}; +use actix_web::{ + body::{EitherBody, MessageBody}, + dev::{Service, ServiceRequest, ServiceResponse}, + http::header, + Error, HttpRequest, HttpResponse, +}; +use futures::future::LocalBoxFuture; +use serde_json::json; +use std::{rc::Rc, sync::Arc, time::Duration}; +use tokio::time::Instant; + +#[derive(Clone)] +pub struct AuthenticationService { + service: Rc, + context: Arc>, + authentication_path: &'static str, +} + +impl AuthenticationService { + pub fn new( + service: Rc, + context: Arc>, + authentication_path: &'static str, + ) -> Self { + AuthenticationService { + service, + context, + authentication_path, + } + } +} + +impl Service for AuthenticationService +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + actix_web::dev::forward_ready!(service); + + fn call(&self, mut request: ServiceRequest) -> Self::Future { + let service = self.service.clone(); + let context = self.context.clone(); + let auth_path = self.authentication_path; + Box::pin(async move { + // authentication request + let request_clone = request.request().clone(); + if request.request().uri().path() == auth_path { + log::debug!("authentication request"); + match context.authenticate_request(&mut request).await { + Ok((token, expires)) => { + return authenticated_response( + request_clone, + token, + expires.saturating_duration_since(Instant::now()), + ); + } + Err(error) => return forbidden_response(request_clone, error), + } + } + + // authorize request + if let Err(e) = context.authorize_request(&request).await { + unauthorized_response(request_clone, e) + } else { + service + .call(request) + .await + .map(ServiceResponse::map_into_left_body) + } + }) + } +} + +fn forbidden_response( + request: HttpRequest, + response_text: E, +) -> Result>, Error> { + Ok(ServiceResponse::new( + request, + HttpResponse::Forbidden() + .body(response_text.to_string()) + .map_into_right_body(), + )) +} + +fn authenticated_response( + request: HttpRequest, + token: String, + duration: Duration, +) -> Result>, Error> { + let body = json! {{ + "access_token" : token, + "token_type" : "Bearer", + "expires_in": duration.as_secs(), + }}; + + Ok(ServiceResponse::new( + request, + HttpResponse::Ok() + .insert_header(("X-Auth-Token", token)) + .body(body.to_string()) + .map_into_right_body(), + )) +} + +fn unauthorized_response( + request: HttpRequest, + response_text: E, +) -> Result>, Error> { + let bearer_str = format!( + "Bearer error=invalid_token, error_description={}", + response_text.to_string() + ); + let response = HttpResponse::Unauthorized() + .insert_header((header::WWW_AUTHENTICATE, bearer_str)) + .body(response_text.to_string()); + Ok(ServiceResponse::new(request, response)).map(ServiceResponse::map_into_right_body) +} diff --git a/bmcd/src/authentication/linux_authenticator.rs b/bmcd/src/authentication/linux_authenticator.rs new file mode 100644 index 0000000..0e6b863 --- /dev/null +++ b/bmcd/src/authentication/linux_authenticator.rs @@ -0,0 +1,90 @@ +use super::{ + authentication_context::AuthenticationContext, authentication_service::AuthenticationService, + passwd_validator::UnixValidator, +}; +use actix_web::{ + body::{EitherBody, MessageBody}, + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + Error, +}; +use std::{ + future::{ready, Ready}, + io, + time::Duration, +}; +use std::{rc::Rc, sync::Arc}; +use tokio::{ + fs::OpenOptions, + io::{AsyncBufReadExt, BufReader}, +}; + +type LinuxContext = AuthenticationContext; + +pub struct LinuxAuthenticator { + context: Arc, + authentication_path: &'static str, +} + +impl LinuxAuthenticator { + pub async fn new(authentication_path: &'static str) -> io::Result { + let password_entries = Self::parse_shadow_file().await?; + Ok(Self { + context: Arc::new(LinuxContext::with_unix_validator( + password_entries, + Duration::from_secs(24 * 60 * 60), + )), + authentication_path, + }) + } +} + +impl LinuxAuthenticator { + async fn parse_shadow_file() -> io::Result> { + let file = OpenOptions::new().read(true).open("/etc/shadow").await?; + + let mut password_hashes: Vec<(String, String)> = Vec::new(); + let mut read_buffer = BufReader::new(file); + + loop { + let mut line = String::new(); + let bytes_read = read_buffer.read_line(&mut line).await?; + if bytes_read == 0 { + break; + } + + let mut items = line.splitn(3, ':'); + let username = items.next(); + let password = items.next(); + let (Some(user), Some(pass)) = (username, password) else { + break; + }; + + if !pass.starts_with('*') { + password_hashes.push((user.to_string(), pass.to_string())); + log::debug!("loaded user {user}"); + } + } + Ok(password_hashes.into_iter()) + } +} + +impl Transform for LinuxAuthenticator +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type InitError = (); + type Transform = AuthenticationService; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthenticationService::new( + Rc::new(service), + self.context.clone(), + self.authentication_path, + ))) + } +} diff --git a/bmcd/src/authentication/mod.rs b/bmcd/src/authentication/mod.rs new file mode 100644 index 0000000..1d78965 --- /dev/null +++ b/bmcd/src/authentication/mod.rs @@ -0,0 +1,5 @@ +pub mod authentication_context; +pub mod authentication_errors; +pub mod authentication_service; +pub mod linux_authenticator; +pub mod passwd_validator; diff --git a/bmcd/src/authentication/passwd_validator.rs b/bmcd/src/authentication/passwd_validator.rs new file mode 100644 index 0000000..70887f2 --- /dev/null +++ b/bmcd/src/authentication/passwd_validator.rs @@ -0,0 +1,21 @@ +use super::authentication_errors::AuthenticationError; + +pub trait PasswordValidator { + fn validate(hash: &str, password: &str) -> Result<(), AuthenticationError>; +} + +pub struct UnixValidator {} + +impl PasswordValidator for UnixValidator { + fn validate(hash: &str, password: &str) -> Result<(), AuthenticationError> { + log::debug!( + "computed={}", + pwhash::unix::crypt(password, hash).unwrap_or_default() + ); + if !pwhash::unix::verify(password, hash) { + Err(AuthenticationError::IncorrectCredentials) + } else { + Ok(()) + } + } +} diff --git a/bmcd/src/main.rs b/bmcd/src/main.rs index 1a073a2..88ad732 100644 --- a/bmcd/src/main.rs +++ b/bmcd/src/main.rs @@ -1,4 +1,8 @@ -use crate::{flash_service::FlashService, legacy::info_config}; +use crate::config::Config; +use crate::{ + authentication::linux_authenticator::LinuxAuthenticator, flash_service::FlashService, + legacy::info_config, +}; use actix_files::Files; use actix_web::{ http::{self, KeepAlive}, @@ -9,15 +13,21 @@ use actix_web::{ use anyhow::Context; use clap::{command, value_parser, Arg}; use log::LevelFilter; -use openssl::ssl::SslAcceptorBuilder; -use std::path::{Path, PathBuf}; +use openssl::pkey::{PKey, Private}; +use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslMethod}; +use openssl::x509::X509; +use std::fs::OpenOptions; +use std::io::Read; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use tpi_rs::app::{bmc_application::BmcApplication, event_application::run_event_listener}; +pub mod authentication; pub mod config; mod flash_service; mod into_legacy_response; mod legacy; -use crate::config::Config; -use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod}; const HTTPS_PORT: u16 = 443; const HTTP_PORT: u16 = 80; @@ -25,24 +35,21 @@ const HTTP_PORT: u16 = 80; #[actix_web::main] async fn main() -> anyhow::Result<()> { init_logger(); - - let config = Config::try_from(config_path()).context("Error parsing config file")?; - let tls = load_tls_configuration(&config.tls.private_key, &config.tls.certificate)?; - let tls6 = load_tls_configuration(&config.tls.private_key, &config.tls.certificate)?; - + let (tls, tls6) = load_config()?; let bmc = Data::new(BmcApplication::new().await?); run_event_listener(bmc.clone().into_inner())?; let flash_service = Data::new(FlashService::new()); + let authentication = Arc::new(LinuxAuthenticator::new("/api/bmc/authenticate").await?); let run_server = HttpServer::new(move || { App::new() - // Shared state: BmcApplication instance .app_data(bmc.clone()) .app_data(flash_service.clone()) - // Legacy API - .configure(legacy::config) // Enable logger .wrap(middleware::Logger::default()) + .wrap(authentication.clone()) + // Legacy API + .configure(legacy::config) // Serve a static tree of files of the web UI. Must be the last item. .service(Files::new("/", "/mnt/var/www/").index_file("index.html")) }) @@ -111,12 +118,34 @@ fn config_path() -> PathBuf { .into() } -fn load_tls_configuration>( +fn load_keys_from_pem>( private_key: P, certificate: P, -) -> anyhow::Result { - let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; - builder.set_private_key_file(private_key.as_ref(), SslFiletype::PEM)?; - builder.set_certificate_chain_file(certificate.as_ref())?; - Ok(builder) +) -> anyhow::Result<(PKey, X509)> { + let mut pkey = Vec::new(); + let mut cert = Vec::new(); + OpenOptions::new() + .read(true) + .open(private_key)? + .read_to_end(&mut pkey)?; + OpenOptions::new() + .read(true) + .open(certificate)? + .read_to_end(&mut cert)?; + + let rsa_key = PKey::private_key_from_pem(&pkey)?; + let x509 = X509::from_pem(&cert)?; + Ok((rsa_key, x509)) +} + +fn load_config() -> anyhow::Result<(SslAcceptorBuilder, SslAcceptorBuilder)> { + let config = Config::try_from(config_path()).context("Error parsing config file")?; + let (private_key, cert) = load_keys_from_pem(&config.tls.private_key, &config.tls.certificate)?; + let mut tls = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; + tls.set_private_key(&private_key)?; + tls.set_certificate(&cert)?; + let mut tls6 = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; + tls6.set_private_key(&private_key)?; + tls6.set_certificate(&cert)?; + Ok((tls, tls6)) }