diff --git a/Cargo.lock b/Cargo.lock index dc7a57f..fcc809a 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", ] @@ -353,6 +353,17 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" +[[package]] +name = "async-trait" +version = "0.1.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.37", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -413,6 +424,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 +442,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" @@ -429,6 +460,8 @@ dependencies = [ "actix-files", "actix-web", "anyhow", + "async-trait", + "base64", "build-time", "clap", "futures", @@ -436,6 +469,7 @@ dependencies = [ "log", "nix 0.26.4", "openssl", + "pwhash", "rand 0.8.5", "serde", "serde_json", @@ -486,6 +520,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 +569,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 +688,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,13 +717,22 @@ dependencies = [ "syn 1.0.109", ] +[[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", ] @@ -951,6 +1019,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" @@ -1162,6 +1240,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" @@ -1313,6 +1402,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" @@ -1429,6 +1524,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 +1685,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 +1732,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 +1757,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 +1798,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 +1858,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 +1927,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 +1953,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" @@ -1877,9 +2019,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 +2034,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 +2106,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..607a746 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,5 @@ tokio.workspace = true tokio-util.workspace = true futures.workspace = true serde.workspace = true -rand = "0.8.5" +base64 = "0.21.4" +async-trait = "0.1.73" diff --git a/bmcd/src/authentication/authentication_context.rs b/bmcd/src/authentication/authentication_context.rs new file mode 100644 index 0000000..9c7e71c --- /dev/null +++ b/bmcd/src/authentication/authentication_context.rs @@ -0,0 +1,277 @@ +use super::authentication_errors::AuthenticationError; +use super::passwd_validator::PasswordValidator; +use super::passwd_validator::UnixValidator; +use base64::{engine::general_purpose, Engine as _}; +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::Mutex; +use tokio::time::{Duration, Instant}; + +pub struct AuthenticationContext

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

, + expire_timeout: Duration, +} + +impl

AuthenticationContext

+where + P: PasswordValidator + 'static, +{ + pub fn with_unix_validator( + password_entries: impl Iterator, + expire_timeout: Duration, + ) -> AuthenticationContext { + AuthenticationContext:: { + token_store: Mutex::new(HashMap::new()), + passwds: HashMap::from_iter(password_entries), + password_validator: PhantomData::, + expire_timeout, + } + } + + /// This function piggy-backs removes of expired tokens on an authentication + /// request. This imposes a small penalty on each request. Its deemed not + /// significant enough to justify optimalization given the expected volume + /// of incoming requests. + async fn new_and_remove_expired_tokens(&self, key: String) { + let mut store = self.token_store.lock().await; + + store.retain(|_, last_access| { + let duration = Instant::now().saturating_duration_since(*last_access); + duration <= self.expire_timeout + }); + + store.insert(key, Instant::now()); + } + + async fn authorize_bearer(&self, token: &str) -> Result<(), AuthenticationError> { + let mut store = self.token_store.lock().await; + let Some(last_access) = store.get_mut(token) else { + return Err(AuthenticationError::NoMatch(token.to_string())); + }; + + let instant = *last_access; + let duration = Instant::now().saturating_duration_since(instant); + if duration < self.expire_timeout { + *last_access = Instant::now(); + return Ok(()); + } + + store.remove(token); + Err(AuthenticationError::TokenExpired(instant)) + } + + fn validate_credentials( + &self, + username: &str, + password: &str, + ) -> Result<(), AuthenticationError> { + let Some(pass) = self.passwds.get(username) else { + log::debug!("user {} not in database", username); + return Err(AuthenticationError::IncorrectCredentials); + }; + + P::validate(pass, password) + } + + async fn authorize_basic(&self, credentials: &str) -> Result<(), AuthenticationError> { + let decoded = general_purpose::STANDARD.decode(credentials)?; + let utf8 = std::str::from_utf8(&decoded)?; + let Some((user, pass)) = utf8.split_once(':') else { + return Err(AuthenticationError::IncorrectCredentials); + }; + + self.validate_credentials(user, pass) + } + + pub async fn authorize_request( + &self, + http_authorization_line: &str, + ) -> Result<(), AuthenticationError> { + match http_authorization_line.split_once(' ') { + Some(("Bearer", token)) => self.authorize_bearer(token).await, + Some(("Basic", credentials)) => self.authorize_basic(credentials).await, + Some((auth, _)) => Err(AuthenticationError::SchemeNotSupported(auth.to_string())), + None => Err(AuthenticationError::HttpParseError( + http_authorization_line.to_string(), + )), + } + } + + pub async fn authenticate_request(&self, body: &[u8]) -> Result { + let credentials = serde_json::from_slice::(body)?; + + self.validate_credentials(&credentials.username, &credentials.password)?; + + let token: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + self.new_and_remove_expired_tokens(token.clone()).await; + + Ok(Session { + id: token, // according Redfish spec, id refers to the session id. + // which is not equal to the access-token. for now use + // the token. + name: "User Session".to_string(), + description: "User Session".to_string(), + username: credentials.username, + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Session { + pub id: String, + name: String, + description: String, + username: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Login { + username: String, + password: String, +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::ops::Sub; + + pub struct DummyValidator {} + impl PasswordValidator for DummyValidator { + fn validate( + _: &str, + _: &str, + ) -> Result<(), crate::authentication::authentication_errors::AuthenticationError> { + Ok(()) + } + } + + pub struct FalseValidator {} + impl PasswordValidator for FalseValidator { + fn validate( + _: &str, + _: &str, + ) -> Result<(), crate::authentication::authentication_errors::AuthenticationError> { + Err(AuthenticationError::IncorrectCredentials) + } + } + + pub fn build_test_context( + token_data: impl IntoIterator, + user_data: impl IntoIterator, + ) -> AuthenticationContext { + AuthenticationContext { + token_store: Mutex::new(HashMap::from_iter(token_data)), + passwds: HashMap::from_iter(user_data), + password_validator: PhantomData::, + expire_timeout: Duration::from_secs(20), + } + } + + #[actix_web::test] + async fn test_token_failures() { + let now = Instant::now(); + let twenty_sec_ago = now.sub(Duration::from_secs(20)); + let context = build_test_context( + [("123".to_string(), now), ("2".to_string(), twenty_sec_ago)], + Vec::new(), + ); + + assert_eq!( + context.authorize_request("Bearer 1234").await.unwrap_err(), + AuthenticationError::NoMatch("1234".to_string()) + ); + + assert_eq!( + context.authorize_request("Bearer 2").await.unwrap_err(), + AuthenticationError::TokenExpired(twenty_sec_ago) + ); + + // After expired error, the token gets removed. Subsequent calls for that token will + // therefore return "NoMatch" + assert_eq!( + context.authorize_request("Bearer 2").await.unwrap_err(), + AuthenticationError::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(), + ); + assert_eq!(Ok(()), context.authorize_request("Bearer 123").await); + } + + #[actix_web::test] + async fn authentication_errors() { + let context = build_test_context( + Vec::new(), + [("test_user".to_string(), "password".to_string())], + ); + + assert!(matches!( + context + .authenticate_request(b"{not a valid json") + .await + .unwrap_err(), + AuthenticationError::ParseError(_) + )); + + let json = serde_json::to_vec(&Login { + username: "John".to_string(), + password: "1234".to_string(), + }) + .unwrap(); + + assert_eq!( + context.authenticate_request(&json).await.unwrap_err(), + AuthenticationError::IncorrectCredentials + ); + let json = serde_json::to_vec(&Login { + username: "test_user".to_string(), + password: "1234".to_string(), + }) + .unwrap(); + + assert_eq!( + context.authenticate_request(&json).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 json = serde_json::to_vec(&Login { + username: "test_user".to_string(), + password: "password".to_string(), + }) + .unwrap(); + + assert_eq!( + context.authenticate_request(&json).await.unwrap_err(), + AuthenticationError::IncorrectCredentials + ); + } +} diff --git a/bmcd/src/authentication/authentication_errors.rs b/bmcd/src/authentication/authentication_errors.rs new file mode 100644 index 0000000..7fab00b --- /dev/null +++ b/bmcd/src/authentication/authentication_errors.rs @@ -0,0 +1,57 @@ +use std::{fmt::Display, str::Utf8Error}; +use tokio::time::Instant; + +#[derive(Debug, PartialEq)] +pub enum AuthenticationError { + ParseError(String), + IncorrectCredentials, + TokenExpired(Instant), + NoMatch(String), + HttpParseError(String), + SchemeNotSupported(String), + Empty, +} + +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::TokenExpired(instant) => write!( + f, + "token expired {}s ago", + Instant::now().duration_since(*instant).as_secs() + ), + AuthenticationError::NoMatch(token) => write!(f, "token {} is not registered", token), + AuthenticationError::Empty => write!(f, "no authorization header provided"), + AuthenticationError::HttpParseError(token) => { + write!(f, "cannot parse authorization header: {}", token) + } + AuthenticationError::SchemeNotSupported(scheme) => { + write!(f, "{} authentication not supported", scheme) + } + } + } +} + +impl std::error::Error for AuthenticationError {} + +impl From for AuthenticationError { + fn from(value: serde_json::Error) -> Self { + Self::ParseError(value.to_string()) + } +} + +impl From for AuthenticationError { + fn from(value: base64::DecodeError) -> Self { + Self::ParseError(value.to_string()) + } +} + +impl From for AuthenticationError { + fn from(value: Utf8Error) -> Self { + Self::ParseError(value.to_string()) + } +} diff --git a/bmcd/src/authentication/authentication_service.rs b/bmcd/src/authentication/authentication_service.rs new file mode 100644 index 0000000..a480b8d --- /dev/null +++ b/bmcd/src/authentication/authentication_service.rs @@ -0,0 +1,144 @@ +use super::{ + authentication_context::AuthenticationContext, authentication_errors::AuthenticationError, + passwd_validator::UnixValidator, +}; +use actix_web::{ + body::{EitherBody, MessageBody}, + dev::{Service, ServiceRequest, ServiceResponse}, + http::header::{self}, + Error, HttpRequest, HttpResponse, +}; +use futures::future::LocalBoxFuture; +use futures::StreamExt; +use serde::Serialize; +use std::{rc::Rc, sync::Arc}; + +/// This authentication service is designed to prepare for implementing "Redfish +/// Session Login Authentication" as good as possible. Redfish is not yet +/// implemented in this product, until then this session based, token +/// authentication service is used to provide authentication. +#[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 { + let request_clone = request.request().clone(); + + if request.request().uri().path() == auth_path { + log::debug!("authentication request"); + let mut buffer = Vec::new(); + while let Some(Ok(bytes)) = request.parts_mut().1.next().await { + buffer.extend_from_slice(&bytes); + } + + let response = match context.authenticate_request(&buffer).await { + Ok(session) => { + authenticated_response(request_clone, session.id.clone(), session) + } + Err(error) => forbidden_response(request_clone, error), + }; + + return response; + } + + log::debug!("authorize request"); + let parse_result = request + .headers() + .get(header::AUTHORIZATION) + .ok_or(AuthenticationError::Empty) + .and_then(|auth| { + auth.to_str() + .map_err(|e| AuthenticationError::HttpParseError(e.to_string())) + }); + + let auth = match parse_result { + Ok(p) => p, + Err(e) => return unauthorized_response(request_clone, e), + }; + + if let Err(e) = context.authorize_request(auth).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, + body: impl Serialize, +) -> Result>, Error> { + let text = serde_json::to_string(&body)?; + Ok(ServiceResponse::new( + request, + HttpResponse::Ok() + .insert_header(("X-Auth-Token", token)) + .body(text) + .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)) }