diff --git a/Cargo.lock b/Cargo.lock index 7f16fddb..23987bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,6 +1038,7 @@ dependencies = [ name = "common" version = "0.10.0" dependencies = [ + "aes-gcm-siv", "ahash 0.8.11", "arc-swap", "base64 0.22.1", @@ -3242,9 +3243,9 @@ dependencies = [ "smtp-proto", "store", "tokio", - "tokio-tungstenite 0.23.1", + "tokio-tungstenite 0.24.0", "trc", - "tungstenite 0.23.0", + "tungstenite 0.24.0", "utils", "x509-parser 0.16.0", ] @@ -3803,18 +3804,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "mio" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "wasi", - "windows-sys 0.52.0", -] - [[package]] name = "mysql-common-derive" version = "0.31.1" @@ -3848,7 +3837,7 @@ dependencies = [ "keyed_priority_queue", "lazy_static", "lru", - "mio 0.8.11", + "mio", "mysql_common", "once_cell", "pem", @@ -4139,9 +4128,9 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c365a63eec4f55b7efeceb724f1336f26a9cf3427b70e59e2cd2a5b947fba96" +checksum = "803801d3d3b71cd026851a53f974ea03df3d179cb758b260136a6c9e22e196af" dependencies = [ "futures-core", "futures-sink", @@ -4153,9 +4142,9 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.13.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad31e9de44ee3538fb9d64fe3376c1362f406162434609e79aea2a41a0af78ab" +checksum = "88d8c2b76e5f7848a289aa9666dbe56b16f8a22a4c5246ef37a14941818d2913" dependencies = [ "async-trait", "bytes", @@ -4166,9 +4155,9 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" -version = "0.17.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b925a602ffb916fb7421276b86756027b37ee708f9dce2dbdcc51739f07e727" +checksum = "596b1719b3cab83addb20bcbffdf21575279d9436d9ccccfe651a3bf0ab5ab06" dependencies = [ "async-trait", "futures-core", @@ -4186,9 +4175,9 @@ dependencies = [ [[package]] name = "opentelemetry-proto" -version = "0.7.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ee9f20bff9c984511a02f082dc8ede839e4a9bf15cc2487c8d6fea5ad850d9" +checksum = "2c43620e8f93359eb7e627a3b16ee92d8585774986f24f2ab010817426c5ce61" dependencies = [ "opentelemetry", "opentelemetry_sdk", @@ -4198,15 +4187,15 @@ dependencies = [ [[package]] name = "opentelemetry-semantic-conventions" -version = "0.16.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cefe0543875379e47eb5f1e68ff83f45cc41366a92dfd0d073d513bf68e9a05" +checksum = "9b8e442487022a943e2315740e443dc5ee95fd541c18f509a5a6251b408a9f95" [[package]] name = "opentelemetry_sdk" -version = "0.24.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692eac490ec80f24a17828d49b40b60f5aeaccdfe6a503f939713afd22bc28df" +checksum = "e0da0d6b47a3dbc6e9c9e36a0520e25cf943e046843818faaa3f87365a548c82" dependencies = [ "async-trait", "futures-channel", @@ -6570,27 +6559,28 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" dependencies = [ "backtrace", "bytes", "libc", - "mio 1.0.2", + "mio", + "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", @@ -6684,14 +6674,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.23.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.23.0", + "tungstenite 0.24.0", ] [[package]] @@ -6905,9 +6895,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" dependencies = [ "byteorder", "bytes", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 6dac6a47..9c3f4d89 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -40,10 +40,10 @@ x509-parser = "0.16.0" pem = "3.0" chrono = { version = "0.4", features = ["serde"] } hyper = { version = "1.0.1", features = ["server", "http1", "http2"] } -opentelemetry = { version = "0.24" } -opentelemetry_sdk = { version = "0.24" } -opentelemetry-otlp = { version = "0.17", features = ["http-proto", "reqwest-client"] } -opentelemetry-semantic-conventions = { version = "0.16.0" } +opentelemetry = { version = "0.25" } +opentelemetry_sdk = { version = "0.25" } +opentelemetry-otlp = { version = "0.25", features = ["http-proto", "reqwest-client"] } +opentelemetry-semantic-conventions = { version = "0.25.0" } prometheus = { version = "0.13.4", default-features = false } imagesize = "0.13" sha1 = "0.10" @@ -61,6 +61,7 @@ pwhash = "1.0.0" xxhash-rust = { version = "0.8.5", features = ["xxh3"] } psl = "2" dashmap = "6.0" +aes-gcm-siv = "0.11.1" [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" @@ -70,3 +71,6 @@ libc = "0.2.126" test_mode = [] enterprise = [] foundation = [] + +[dev-dependencies] +tokio = { version = "1.23", features = ["full"] } diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 113c33d8..67ee6deb 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -104,6 +104,9 @@ impl Server { tenant, name: principal.take_str(PrincipalField::Name).unwrap_or_default(), description: principal.take_str(PrincipalField::Description), + emails: principal + .take_str_array(PrincipalField::Emails) + .unwrap_or_default(), quota: principal.quota(), permissions, }) diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 25ec299b..5a67a0e0 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -4,11 +4,19 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use directory::Permissions; +use std::{net::IpAddr, sync::Arc, time::Instant}; + +use directory::{ + core::secret::verify_secret_hash, Directory, Permission, Permissions, Principal, QueryBy, +}; use jmap_proto::types::collection::Collection; -use utils::map::{bitmap::Bitmap, vec_map::VecMap}; +use mail_send::Credentials; +use utils::map::{bitmap::Bitmap, ttl_dashmap::TtlMap, vec_map::VecMap}; + +use crate::Server; pub mod access_token; +pub mod oauth; pub mod roles; #[derive(Debug, Clone, Default)] @@ -18,6 +26,7 @@ pub struct AccessToken { pub access_to: VecMap>, pub name: String, pub description: Option, + pub emails: Vec, pub quota: u64, pub permissions: Permissions, pub tenant: Option, @@ -35,3 +44,207 @@ pub struct ResourceToken { pub quota: u64, pub tenant: Option, } + +pub struct AuthRequest<'x> { + credentials: Credentials, + session_id: u64, + remote_ip: IpAddr, + return_member_of: bool, + directory: Option<&'x Directory>, +} + +impl Server { + pub async fn authenticate(&self, req: &AuthRequest<'_>) -> trc::Result> { + // Validate credentials + match &req.credentials { + Credentials::OAuthBearer { token } => { + match self.validate_access_token("access_token", token).await { + Ok((account_id, _, _)) => self.get_cached_access_token(account_id).await, + Err(err) => Err(err), + } + } + _ => match self.authenticate_plain(req).await { + Ok(principal) => { + if let Some(access_token) = + self.inner.data.access_tokens.get_with_ttl(&principal.id()) + { + Ok(access_token) + } else { + self.build_access_token(principal) + .await + .map(|access_token| { + let access_token = Arc::new(access_token); + self.cache_access_token(access_token.clone()); + access_token + }) + } + } + Err(err) => Err(err), + }, + } + .and_then(|token| { + token + .assert_has_permission(Permission::Authenticate) + .map(|_| token) + }) + } + + async fn authenticate_plain(&self, req: &AuthRequest<'_>) -> trc::Result { + let directory = req.directory.unwrap_or(&self.core.storage.directory); + + // First try to authenticate the user against the default directory + let result = match directory + .query(QueryBy::Credentials(&req.credentials), req.return_member_of) + .await + { + Ok(Some(principal)) => { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = req.credentials.login().to_string(), + AccountId = principal.id(), + SpanId = req.session_id, + Type = principal.typ().as_str(), + ); + + return Ok(principal); + } + Ok(None) => Ok(()), + Err(err) => { + if err.matches(trc::EventType::Auth(trc::AuthEvent::MissingTotp)) { + return Err(err); + } else { + Err(err) + } + } + }; + + // Then check if the credentials match the fallback admin or master user + match ( + &self.core.jmap.fallback_admin, + &self.core.jmap.master_user, + &req.credentials, + ) { + (Some((fallback_admin, fallback_pass)), _, Credentials::Plain { username, secret }) + if username == fallback_admin => + { + if verify_secret_hash(fallback_pass, secret).await? { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = username.clone(), + SpanId = req.session_id, + ); + + return Ok(Principal::fallback_admin(fallback_pass)); + } + } + (_, Some((master_user, master_pass)), Credentials::Plain { username, secret }) + if username.ends_with(master_user) => + { + if verify_secret_hash(master_pass, secret).await? { + let username = username.strip_suffix(master_user).unwrap(); + let username = username.strip_suffix('%').unwrap_or(username); + + if let Some(principal) = directory + .query(QueryBy::Name(username), req.return_member_of) + .await? + { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = username.to_string(), + SpanId = req.session_id, + AccountId = principal.id(), + Type = principal.typ().as_str(), + ); + + return Ok(principal); + } + } + } + _ => {} + } + + if let Err(err) = result { + Err(err) + } else if self.has_auth_fail2ban() { + let login = req.credentials.login(); + if self.is_auth_fail2banned(req.remote_ip, login).await? { + Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .ctx(trc::Key::RemoteIp, req.remote_ip) + .ctx(trc::Key::AccountName, login.to_string())) + } else { + Err(trc::AuthEvent::Failed + .ctx(trc::Key::RemoteIp, req.remote_ip) + .ctx(trc::Key::AccountName, login.to_string())) + } + } else { + Err(trc::AuthEvent::Failed + .ctx(trc::Key::RemoteIp, req.remote_ip) + .ctx(trc::Key::AccountName, req.credentials.login().to_string())) + } + } + + pub fn cache_session(&self, session_id: String, access_token: &AccessToken) { + self.inner.data.http_auth_cache.insert_with_ttl( + session_id, + access_token.primary_id(), + Instant::now() + self.core.jmap.session_cache_ttl, + ); + } +} + +impl<'x> AuthRequest<'x> { + pub fn from_credentials( + credentials: Credentials, + session_id: u64, + remote_ip: IpAddr, + ) -> Self { + Self { + credentials, + session_id, + remote_ip, + return_member_of: true, + directory: None, + } + } + + pub fn from_plain( + user: impl Into, + pass: impl Into, + session_id: u64, + remote_ip: IpAddr, + ) -> Self { + Self::from_credentials( + Credentials::Plain { + username: user.into(), + secret: pass.into(), + }, + session_id, + remote_ip, + ) + } + + pub fn without_members(mut self) -> Self { + self.return_member_of = false; + self + } + + pub fn with_directory(mut self, directory: &'x Directory) -> Self { + self.directory = Some(directory); + self + } +} + +pub(crate) trait CredentialsUsername { + fn login(&self) -> &str; +} + +impl CredentialsUsername for Credentials { + fn login(&self) -> &str { + match self { + Credentials::Plain { username, .. } + | Credentials::XOauth2 { username, .. } + | Credentials::OAuthBearer { token: username } => username, + } + } +} diff --git a/crates/common/src/auth/oauth/crypto.rs b/crates/common/src/auth/oauth/crypto.rs new file mode 100644 index 00000000..a576e4b2 --- /dev/null +++ b/crates/common/src/auth/oauth/crypto.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use aes_gcm_siv::{ + aead::{generic_array::GenericArray, Aead}, + AeadInPlace, Aes256GcmSiv, KeyInit, Nonce, +}; +use store::blake3; + +pub struct SymmetricEncrypt { + aes: Aes256GcmSiv, +} + +impl SymmetricEncrypt { + pub const ENCRYPT_TAG_LEN: usize = 16; + pub const NONCE_LEN: usize = 12; + + pub fn new(key: &[u8], context: &str) -> Self { + SymmetricEncrypt { + aes: Aes256GcmSiv::new(&GenericArray::clone_from_slice( + &blake3::derive_key(context, key)[..], + )), + } + } + + #[allow(clippy::ptr_arg)] + pub fn encrypt_in_place(&self, bytes: &mut Vec, nonce: &[u8]) -> Result<(), String> { + self.aes + .encrypt_in_place(Nonce::from_slice(nonce), b"", bytes) + .map_err(|e| e.to_string()) + } + + pub fn encrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { + self.aes + .encrypt(Nonce::from_slice(nonce), bytes) + .map_err(|e| e.to_string()) + } + + pub fn decrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { + self.aes + .decrypt(Nonce::from_slice(nonce), bytes) + .map_err(|e| e.to_string()) + } +} diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs new file mode 100644 index 00000000..b58474e1 --- /dev/null +++ b/crates/common/src/auth/oauth/mod.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod crypto; +pub mod token; + +pub const DEVICE_CODE_LEN: usize = 40; +pub const USER_CODE_LEN: usize = 8; +pub const RANDOM_CODE_LEN: usize = 32; +pub const CLIENT_ID_MAX_LEN: usize = 20; + +pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 + +pub fn extract_oauth_bearer(bytes: &[u8]) -> Option<&str> { + let mut start_pos = 0; + let eof = bytes.len().saturating_sub(1); + + for (pos, ch) in bytes.iter().enumerate() { + let is_separator = *ch == 1; + if is_separator || pos == eof { + if bytes + .get(start_pos..start_pos + 12) + .map_or(false, |s| s.eq_ignore_ascii_case(b"auth=Bearer ")) + { + return bytes + .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) + .and_then(|s| std::str::from_utf8(s).ok()); + } + + start_pos = pos + 1; + } + } + + None +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_oauth_bearer() { + let input = b"auth=Bearer validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("validtoken")); + + let input = b"auth=Invalid validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b""; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer token1\x01auth=Bearer token2"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token1")); + + let input = b"auth=Bearer VALIDTOKEN"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("VALIDTOKEN")); + + let input = b"auth=Bearer token with spaces"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token with spaces")); + + let input = b"auth=Bearer token_with_special_chars!@#"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token_with_special_chars!@#")); + + let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; + let result = extract_oauth_bearer(input.as_bytes()); + assert_eq!(result, Some("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==")); + } +} diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs new file mode 100644 index 00000000..08904983 --- /dev/null +++ b/crates/common/src/auth/oauth/token.rs @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::SystemTime; + +use directory::{backend::internal::PrincipalField, QueryBy}; +use mail_builder::encoders::base64::base64_encode; +use mail_parser::decoders::base64::base64_decode; +use store::{ + blake3, + rand::{thread_rng, Rng}, +}; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; + +use crate::Server; + +use super::{crypto::SymmetricEncrypt, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN}; + +impl Server { + pub async fn issue_custom_token( + &self, + account_id: u32, + grant_type: &str, + client_id: &str, + expiry_in: u64, + ) -> trc::Result { + self.encode_access_token( + grant_type, + account_id, + &self + .password_hash(account_id) + .await + .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err))?, + client_id, + expiry_in, + ) + .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err)) + } + + pub fn encode_access_token( + &self, + grant_type: &str, + account_id: u32, + password_hash: &str, + client_id: &str, + expiry_in: u64, + ) -> Result { + // Build context + if client_id.len() > CLIENT_ID_MAX_LEN { + return Err("ClientId is too long"); + } + let key = self.core.jmap.oauth_key.clone(); + let context = format!( + "{} {} {} {}", + grant_type, client_id, account_id, password_hash + ); + let context_nonce = format!("{} nonce {}", grant_type, password_hash); + + // Set expiration time + let expiry = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + .saturating_sub(946684800) // Jan 1, 2000 + + expiry_in; + + // Calculate nonce + let mut hasher = blake3::Hasher::new(); + hasher.update(context_nonce.as_bytes()); + hasher.update(expiry.to_be_bytes().as_slice()); + let nonce = hasher + .finalize() + .as_bytes() + .iter() + .take(SymmetricEncrypt::NONCE_LEN) + .copied() + .collect::>(); + + // Encrypt random bytes + let mut token = SymmetricEncrypt::new(key.as_bytes(), &context) + .encrypt(&thread_rng().gen::<[u8; RANDOM_CODE_LEN]>(), &nonce) + .map_err(|_| "Failed to encrypt token.")?; + token.push_leb128(account_id); + token.push_leb128(expiry); + token.extend_from_slice(client_id.as_bytes()); + + Ok(String::from_utf8(base64_encode(&token).unwrap_or_default()).unwrap()) + } + + pub async fn validate_access_token( + &self, + grant_type: &str, + token_: &str, + ) -> trc::Result<(u32, String, u64)> { + // Base64 decode token + let token = base64_decode(token_.as_bytes()).ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, "Failed to decode token") + .caused_by(trc::location!()) + .details(token_.to_string()) + })?; + let (account_id, expiry, client_id) = token + .get((RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN)..) + .and_then(|bytes| { + let mut bytes = bytes.iter(); + ( + bytes.next_leb128()?, + bytes.next_leb128::()?, + bytes.copied().map(char::from).collect::(), + ) + .into() + }) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, "Failed to decode token") + .caused_by(trc::location!()) + .details(token_.to_string()) + })?; + + // Validate expiration + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + .saturating_sub(946684800); // Jan 1, 2000 + if expiry <= now { + return Err(trc::AuthEvent::TokenExpired.into_err()); + } + + // Obtain password hash + let password_hash = self + .password_hash(account_id) + .await + .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))?; + + // Build context + let key = self.core.jmap.oauth_key.clone(); + let context = format!( + "{} {} {} {}", + grant_type, client_id, account_id, password_hash + ); + let context_nonce = format!("{} nonce {}", grant_type, password_hash); + + // Calculate nonce + let mut hasher = blake3::Hasher::new(); + hasher.update(context_nonce.as_bytes()); + hasher.update(expiry.to_be_bytes().as_slice()); + let nonce = hasher + .finalize() + .as_bytes() + .iter() + .take(SymmetricEncrypt::NONCE_LEN) + .copied() + .collect::>(); + + // Decrypt + SymmetricEncrypt::new(key.as_bytes(), &context) + .decrypt( + &token[..RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN], + &nonce, + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Details, "Failed to decode token") + .caused_by(trc::location!()) + .reason(err) + })?; + + // Success + Ok((account_id, client_id, expiry - now)) + } + + pub async fn password_hash(&self, account_id: u32) -> Result { + if account_id != u32::MAX { + self.core + .storage + .directory + .query(QueryBy::Id(account_id), false) + .await + .map_err(|_| "Temporary lookup error")? + .ok_or("Account no longer exists")? + .take_str_array(PrincipalField::Secrets) + .unwrap_or_default() + .into_iter() + .next() + .ok_or("Failed to obtain password hash") + } else if let Some((_, secret)) = &self.core.jmap.fallback_admin { + Ok(secret.clone()) + } else { + Err("Invalid account id.") + } + } +} diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index 6a997ed3..5085ebbe 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -749,7 +749,7 @@ impl Default for SessionConfig { ), mechanisms: IfBlock::new::( "session.auth.mechanisms", - [("local_port != 25 && is_tls", "[plain, login]")], + [("local_port != 25 && is_tls", "[plain, login, oauthbearer]")], "false", ), require: IfBlock::new::<()>( diff --git a/crates/common/src/core.rs b/crates/common/src/core.rs index 2ebf66dd..e81d3e80 100644 --- a/crates/common/src/core.rs +++ b/crates/common/src/core.rs @@ -4,13 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{net::IpAddr, sync::Arc}; +use std::sync::Arc; -use directory::{ - backend::internal::manage::ManageDirectory, core::secret::verify_secret_hash, Directory, - Principal, QueryBy, Type, -}; -use mail_send::Credentials; +use directory::{backend::internal::manage::ManageDirectory, Directory, Type}; use sieve::Sieve; use store::{ write::{QueueClass, ValueClass}, @@ -156,106 +152,6 @@ impl Server { }) } - pub async fn authenticate( - &self, - directory: &Directory, - session_id: u64, - credentials: &Credentials, - remote_ip: IpAddr, - return_member_of: bool, - ) -> trc::Result { - // First try to authenticate the user against the default directory - let result = match directory - .query(QueryBy::Credentials(credentials), return_member_of) - .await - { - Ok(Some(principal)) => { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = credentials.login().to_string(), - AccountId = principal.id(), - SpanId = session_id, - Type = principal.typ().as_str(), - ); - - return Ok(principal); - } - Ok(None) => Ok(()), - Err(err) => { - if err.matches(trc::EventType::Auth(trc::AuthEvent::MissingTotp)) { - return Err(err); - } else { - Err(err) - } - } - }; - - // Then check if the credentials match the fallback admin or master user - match ( - &self.core.jmap.fallback_admin, - &self.core.jmap.master_user, - credentials, - ) { - (Some((fallback_admin, fallback_pass)), _, Credentials::Plain { username, secret }) - if username == fallback_admin => - { - if verify_secret_hash(fallback_pass, secret).await? { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = username.clone(), - SpanId = session_id, - ); - - return Ok(Principal::fallback_admin(fallback_pass)); - } - } - (_, Some((master_user, master_pass)), Credentials::Plain { username, secret }) - if username.ends_with(master_user) => - { - if verify_secret_hash(master_pass, secret).await? { - let username = username.strip_suffix(master_user).unwrap(); - let username = username.strip_suffix('%').unwrap_or(username); - - if let Some(principal) = directory - .query(QueryBy::Name(username), return_member_of) - .await? - { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = username.to_string(), - SpanId = session_id, - AccountId = principal.id(), - Type = principal.typ().as_str(), - ); - - return Ok(principal); - } - } - } - _ => {} - } - - if let Err(err) = result { - Err(err) - } else if self.has_auth_fail2ban() { - let login = credentials.login(); - if self.is_auth_fail2banned(remote_ip, login).await? { - Err(trc::SecurityEvent::AuthenticationBan - .into_err() - .ctx(trc::Key::RemoteIp, remote_ip) - .ctx(trc::Key::AccountName, login.to_string())) - } else { - Err(trc::AuthEvent::Failed - .ctx(trc::Key::RemoteIp, remote_ip) - .ctx(trc::Key::AccountName, login.to_string())) - } - } else { - Err(trc::AuthEvent::Failed - .ctx(trc::Key::RemoteIp, remote_ip) - .ctx(trc::Key::AccountName, credentials.login().to_string())) - } - } - pub async fn total_queued_messages(&self) -> trc::Result { let mut total = 0; self.store() @@ -303,20 +199,6 @@ impl BuildServer for Arc { } } -trait CredentialsUsername { - fn login(&self) -> &str; -} - -impl CredentialsUsername for Credentials { - fn login(&self) -> &str { - match self { - Credentials::Plain { username, .. } - | Credentials::XOauth2 { username, .. } - | Credentials::OAuthBearer { token: username } => username, - } - } -} - impl MailboxState { pub fn map_result_id(&self, document_id: u32, is_uid: bool) -> Option<(u32, ImapId)> { if let Some(imap_id) = self.id_to_imap.get(&document_id) { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f983917c..b71976b9 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -63,13 +63,12 @@ pub static DAEMON_NAME: &str = concat!("Stalwart Mail Server v", env!("CARGO_PKG pub const IPC_CHANNEL_BUFFER: usize = 1024; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct Server { pub inner: Arc, pub core: Arc, } -#[derive(Default)] pub struct Inner { pub shared_core: ArcSwap, pub data: Data, @@ -316,6 +315,29 @@ impl ConcurrencyLimiters { } } +#[cfg(feature = "test_mode")] +#[allow(clippy::derivable_impls)] +impl Default for Server { + fn default() -> Self { + Self { + inner: Default::default(), + core: Default::default(), + } + } +} + +#[cfg(feature = "test_mode")] +#[allow(clippy::derivable_impls)] +impl Default for Inner { + fn default() -> Self { + Self { + shared_core: Default::default(), + data: Default::default(), + ipc: Default::default(), + } + } +} + #[cfg(feature = "test_mode")] impl Default for Ipc { fn default() -> Self { diff --git a/crates/common/src/telemetry/tracers/otel.rs b/crates/common/src/telemetry/tracers/otel.rs index ea3be308..88cb1cdd 100644 --- a/crates/common/src/telemetry/tracers/otel.rs +++ b/crates/common/src/telemetry/tracers/otel.rs @@ -16,8 +16,7 @@ use opentelemetry::{ InstrumentationLibrary, Key, KeyValue, Value, }; use opentelemetry_sdk::{ - export::{logs::LogData, trace::SpanData}, - logs::LogRecord, + export::{logs::LogBatch, trace::SpanData}, trace::{SpanEvents, SpanLinks}, Resource, }; @@ -65,7 +64,7 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer } if otel.log_exporter_enable { - pending_logs.push(build_log_record(&event, &instrumentation)); + pending_logs.push(build_log_record(&event)); } } } @@ -97,17 +96,18 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer } if !pending_logs.is_empty() { - if let Err(err) = otel - .log_exporter - .export(std::mem::take(&mut pending_logs)) - .await - { + let logs = pending_logs + .iter() + .map(|log| (log, &instrumentation)) + .collect::>(); + if let Err(err) = otel.log_exporter.export(LogBatch::new(&logs)).await { trc::event!( Telemetry(TelemetryEvent::OtelExporterError), Details = "Failed to export logs", Reason = err.to_string() ); } + pending_logs.clear(); } } } else if !pending_logs.is_empty() || !pending_spans.is_empty() { @@ -174,12 +174,10 @@ where } } -fn build_log_record( - event: &Event, - instrumentation: &InstrumentationLibrary, -) -> Cow<'static, LogData> { - let mut record = LogRecord::default(); - record.event_name = Cow::Borrowed(event.inner.typ.name()).into(); +fn build_log_record(event: &Event) -> opentelemetry_sdk::logs::LogRecord { + use opentelemetry::logs::LogRecord; + let mut record = opentelemetry_sdk::logs::LogRecord::default(); + record.event_name = event.inner.typ.name().into(); record.severity_number = match event.inner.level { Level::Trace => Severity::Trace, Level::Debug => Severity::Debug, @@ -189,22 +187,14 @@ fn build_log_record( Level::Disable => Severity::Error, } .into(); - record.severity_text = Cow::Borrowed(event.inner.level.as_str()).into(); + record.severity_text = event.inner.level.as_str().into(); record.body = AnyValue::String(event.inner.typ.description().into()).into(); record.timestamp = (UNIX_EPOCH + Duration::from_secs(event.inner.timestamp)).into(); record.observed_timestamp = SystemTime::now().into(); - record.attributes = (!event.keys.is_empty()).then(|| { - event - .keys - .iter() - .map(|(k, v)| (build_key(k), build_any_value(v))) - .collect() - }); - - Cow::Owned(LogData { - record, - instrumentation: instrumentation.clone(), - }) + for (k, v) in &event.keys { + record.add_attribute(k.name(), build_any_value(v)); + } + record } fn build_key_value(key_value: &(trc::Key, trc::Value)) -> Option { @@ -246,11 +236,11 @@ fn build_any_value(value: &trc::Value) -> AnyValue { AnyValue::String(DateTime::from_timestamp(*v as i64).to_rfc3339().into()) } trc::Value::Duration(v) => AnyValue::Int(*v as i64), - trc::Value::Bytes(v) => AnyValue::Bytes(v.clone()), + trc::Value::Bytes(v) => AnyValue::Bytes(Box::new(v.clone())), trc::Value::Bool(v) => AnyValue::Boolean(*v), trc::Value::Ipv4(v) => AnyValue::String(v.to_string().into()), trc::Value::Ipv6(v) => AnyValue::String(v.to_string().into()), - trc::Value::Event(v) => AnyValue::Map( + trc::Value::Event(v) => AnyValue::Map(Box::new( [( Key::from_static_str("eventName"), AnyValue::String(v.inner.name().into()), @@ -262,8 +252,10 @@ fn build_any_value(value: &trc::Value) -> AnyValue { .map(|(k, v)| (build_key(k), build_any_value(v))), ) .collect(), - ), - trc::Value::Array(v) => AnyValue::ListAny(v.iter().map(build_any_value).collect()), + )), + trc::Value::Array(v) => { + AnyValue::ListAny(Box::new(v.iter().map(build_any_value).collect())) + } trc::Value::None => AnyValue::Boolean(false), } } diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index 83327535..3858b25c 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -4,16 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::listener::SessionStream; +use common::{auth::AuthRequest, listener::SessionStream}; use directory::Permission; use imap_proto::{ protocol::{authenticate::Mechanism, capability::Capability}, receiver::{self, Request}, Command, ResponseCode, StatusResponse, }; -use jmap::auth::{ - authenticate::Authenticator, oauth::token::TokenHandler, rate_limit::RateLimiter, -}; +use jmap::auth::rate_limit::RateLimiter; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; use std::sync::Arc; @@ -79,37 +77,33 @@ impl Session { .map_err(|err| err.id(tag.clone()))?; // Authenticate - let access_token = match credentials { - Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { - self.server - .authenticate_plain(&username, &secret, self.remote_addr, self.session_id) - .await - } - Credentials::OAuthBearer { token } => { - match self - .server - .validate_access_token("access_token", &token) - .await - { - Ok((account_id, _, _)) => self.server.get_access_token(account_id).await, - Err(err) => Err(err), + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + let auth_failures = self.state.auth_failures(); + if auth_failures < self.server.core.imap.max_auth_failures { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + }; + } else { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } } - } - } - .map_err(|err| { - if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { - let auth_failures = self.state.auth_failures(); - if auth_failures < self.server.core.imap.max_auth_failures { - self.state = State::NotAuthenticated { - auth_failures: auth_failures + 1, - }; - } else { - return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); - } - } - err.id(tag.clone()) - })?; + err.id(tag.clone()) + }) + .and_then(|token| { + token + .assert_has_permission(Permission::ImapAuthenticate) + .map(|_| token) + })?; // Enforce concurrency limits let in_flight = match self @@ -125,13 +119,6 @@ impl Session { } }; - // Validate access - access_token.assert_has_permission(Permission::ImapAuthenticate)?; - - // Cache access token - let access_token = Arc::new(access_token); - self.server.cache_access_token(access_token.clone()); - // Create session self.state = State::Authenticated { data: Arc::new( diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 5fcd188b..8b9441c7 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -39,8 +39,8 @@ hkdf = "0.12.3" sha1 = "0.10" sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]} -tokio-tungstenite = "0.23" -tungstenite = "0.23" +tokio-tungstenite = "0.24" +tungstenite = "0.24" chrono = "0.4" dashmap = "6.0" aes = "0.8.3" diff --git a/crates/jmap/src/api/management/enterprise/telemetry.rs b/crates/jmap/src/api/management/enterprise/telemetry.rs index 6a1ff7b2..376837fc 100644 --- a/crates/jmap/src/api/management/enterprise/telemetry.rs +++ b/crates/jmap/src/api/management/enterprise/telemetry.rs @@ -38,12 +38,9 @@ use trc::{ }; use utils::{snowflake::SnowflakeIdGenerator, url_params::UrlParams}; -use crate::{ - api::{ - http::ToHttpResponse, management::Timestamp, HttpRequest, HttpResponse, HttpResponseBody, - JsonResponse, - }, - auth::oauth::token::TokenHandler, +use crate::api::{ + http::ToHttpResponse, management::Timestamp, HttpRequest, HttpResponse, HttpResponseBody, + JsonResponse, }; pub trait TelemetryApi: Sync + Send { diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index 8009ab6d..d2f46b09 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -4,10 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{net::IpAddr, sync::Arc, time::Instant}; +use std::sync::Arc; -use common::{listener::limiter::InFlight, Server}; -use directory::Permission; +use common::{auth::AuthRequest, listener::limiter::InFlight, Server}; use hyper::header; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; @@ -18,7 +17,7 @@ use crate::api::{http::HttpSessionData, HttpRequest}; use common::auth::AccessToken; use std::future::Future; -use super::{oauth::token::TokenHandler, rate_limit::RateLimiter}; +use super::rate_limit::RateLimiter; pub trait Authenticator: Sync + Send { fn authenticate_headers( @@ -26,16 +25,6 @@ pub trait Authenticator: Sync + Send { req: &HttpRequest, session: &HttpSessionData, ) -> impl Future)>> + Send; - - fn cache_session(&self, session_id: String, access_token: &AccessToken); - - fn authenticate_plain( - &self, - username: &str, - secret: &str, - remote_ip: IpAddr, - session_id: u64, - ) -> impl Future> + Send; } impl Authenticator for Server { @@ -49,12 +38,12 @@ impl Authenticator for Server { if let Some(account_id) = self.inner.data.http_auth_cache.get_with_ttl(token) { self.get_cached_access_token(account_id).await? } else { - let access_token = if mechanism.eq_ignore_ascii_case("basic") { - // Enforce rate limit for authentication requests + let credentials = if mechanism.eq_ignore_ascii_case("basic") { + // Throttle authentication requests self.is_auth_allowed_soft(&session.remote_ip).await?; // Decode the base64 encoded credentials - if let Some((account, secret)) = base64_decode(token.as_bytes()) + if let Some((username, secret)) = base64_decode(token.as_bytes()) .and_then(|token| String::from_utf8(token).ok()) .and_then(|token| { token.split_once(':').map(|(login, secret)| { @@ -62,13 +51,7 @@ impl Authenticator for Server { }) }) { - self.authenticate_plain( - &account, - &secret, - session.remote_ip, - session.session_id, - ) - .await? + Credentials::Plain { username, secret } } else { return Err(trc::AuthEvent::Error .into_err() @@ -77,16 +60,16 @@ impl Authenticator for Server { .caused_by(trc::location!())); } } else if mechanism.eq_ignore_ascii_case("bearer") { - // Enforce anonymous rate limit for bearer auth requests + // Enforce anonymous rate limit self.is_anonymous_allowed(&session.remote_ip).await?; - let (account_id, _, _) = - self.validate_access_token("access_token", token).await?; - - self.get_access_token(account_id).await? + Credentials::OAuthBearer { + token: token.to_string(), + } } else { // Enforce anonymous rate limit self.is_anonymous_allowed(&session.remote_ip).await?; + return Err(trc::AuthEvent::Error .into_err() .reason("Unsupported authentication mechanism.") @@ -94,10 +77,26 @@ impl Authenticator for Server { .caused_by(trc::location!())); }; + // Authenticate + let access_token = match self + .authenticate(&AuthRequest::from_credentials( + credentials, + session.session_id, + session.remote_ip, + )) + .await + { + Ok(access_token) => access_token, + Err(err) => { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + let _ = self.is_auth_allowed_hard(&session.remote_ip).await; + } + return Err(err); + } + }; + // Cache session - let access_token = Arc::new(access_token); self.cache_session(token.to_string(), &access_token); - self.cache_access_token(access_token.clone()); access_token }; @@ -115,48 +114,6 @@ impl Authenticator for Server { .caused_by(trc::location!())) } } - - fn cache_session(&self, session_id: String, access_token: &AccessToken) { - self.inner.data.http_auth_cache.insert_with_ttl( - session_id, - access_token.primary_id(), - Instant::now() + self.core.jmap.session_cache_ttl, - ); - } - - async fn authenticate_plain( - &self, - username: &str, - secret: &str, - remote_ip: IpAddr, - session_id: u64, - ) -> trc::Result { - match self - .authenticate( - &self.core.storage.directory, - session_id, - &Credentials::Plain { - username: username.to_string(), - secret: secret.to_string(), - }, - remote_ip, - true, - ) - .await - { - Ok(principal) => self.build_access_token(principal).await.and_then(|token| { - token - .assert_has_permission(Permission::Authenticate) - .map(|_| token) - }), - Err(err) => { - if !err.matches(trc::EventType::Auth(trc::AuthEvent::MissingTotp)) { - let _ = self.is_auth_allowed_hard(&remote_ip).await; - } - Err(err) - } - } - } } pub trait HttpHeaders { diff --git a/crates/jmap/src/auth/mod.rs b/crates/jmap/src/auth/mod.rs index 53e062a4..884a3f6f 100644 --- a/crates/jmap/src/auth/mod.rs +++ b/crates/jmap/src/auth/mod.rs @@ -4,50 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use aes_gcm_siv::{ - aead::{generic_array::GenericArray, Aead}, - AeadInPlace, Aes256GcmSiv, KeyInit, Nonce, -}; - -use store::blake3; - pub mod acl; pub mod authenticate; pub mod oauth; pub mod rate_limit; - -pub struct SymmetricEncrypt { - aes: Aes256GcmSiv, -} - -impl SymmetricEncrypt { - pub const ENCRYPT_TAG_LEN: usize = 16; - pub const NONCE_LEN: usize = 12; - - pub fn new(key: &[u8], context: &str) -> Self { - SymmetricEncrypt { - aes: Aes256GcmSiv::new(&GenericArray::clone_from_slice( - &blake3::derive_key(context, key)[..], - )), - } - } - - #[allow(clippy::ptr_arg)] - pub fn encrypt_in_place(&self, bytes: &mut Vec, nonce: &[u8]) -> Result<(), String> { - self.aes - .encrypt_in_place(Nonce::from_slice(nonce), b"", bytes) - .map_err(|e| e.to_string()) - } - - pub fn encrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { - self.aes - .encrypt(Nonce::from_slice(nonce), bytes) - .map_err(|e| e.to_string()) - } - - pub fn decrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { - self.aes - .decrypt(Nonce::from_slice(nonce), bytes) - .map_err(|e| e.to_string()) - } -} diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 4274dc17..9cb93a3f 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -6,7 +6,13 @@ use std::sync::Arc; -use common::{auth::AccessToken, Server}; +use common::{ + auth::{ + oauth::{CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, USER_CODE_ALPHABET, USER_CODE_LEN}, + AccessToken, + }, + Server, +}; use rand::distributions::Standard; use serde_json::json; use std::future::Future; @@ -21,10 +27,7 @@ use crate::{ auth::oauth::OAuthStatus, }; -use super::{ - DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, - MAX_POST_LEN, USER_CODE_ALPHABET, USER_CODE_LEN, -}; +use super::{DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, MAX_POST_LEN}; pub trait OAuthApiHandler: Sync + Send { fn handle_oauth_api_request( diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index 7c9e7337..a7bb162d 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -21,15 +21,8 @@ pub enum OAuthStatus { Pending, } -const DEVICE_CODE_LEN: usize = 40; -const USER_CODE_LEN: usize = 8; -const RANDOM_CODE_LEN: usize = 32; -const CLIENT_ID_MAX_LEN: usize = 20; - const MAX_POST_LEN: usize = 2048; -const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 - pub struct OAuth { pub key: String, pub expiry_user_code: u64, diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index ec23b3c4..277a94ae 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -4,29 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::time::SystemTime; - use common::Server; -use directory::{backend::internal::PrincipalField, QueryBy}; use hyper::StatusCode; -use mail_builder::encoders::base64::base64_encode; -use mail_parser::decoders::base64::base64_decode; use std::future::Future; -use store::{ - blake3, - rand::{thread_rng, Rng}, - write::Bincode, -}; -use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; +use store::write::Bincode; -use crate::{ - api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, - auth::SymmetricEncrypt, -}; +use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}; use super::{ - ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, CLIENT_ID_MAX_LEN, - MAX_POST_LEN, RANDOM_CODE_LEN, + ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN, }; pub trait TokenHandler: Sync + Send { @@ -36,40 +22,12 @@ pub trait TokenHandler: Sync + Send { session_id: u64, ) -> impl Future> + Send; - fn password_hash( - &self, - account_id: u32, - ) -> impl Future> + Send; - fn issue_token( &self, account_id: u32, client_id: &str, with_refresh_token: bool, ) -> impl Future> + Send; - - fn issue_custom_token( - &self, - account_id: u32, - grant_type: &str, - client_id: &str, - expiry_in: u64, - ) -> impl Future> + Send; - - fn encode_access_token( - &self, - grant_type: &str, - account_id: u32, - password_hash: &str, - client_id: &str, - expiry_in: u64, - ) -> Result; - - fn validate_access_token( - &self, - grant_type: &str, - token_: &str, - ) -> impl Future> + Send; } impl TokenHandler for Server { @@ -222,27 +180,6 @@ impl TokenHandler for Server { .into_http_response()) } - async fn password_hash(&self, account_id: u32) -> Result { - if account_id != u32::MAX { - self.core - .storage - .directory - .query(QueryBy::Id(account_id), false) - .await - .map_err(|_| "Temporary lookup error")? - .ok_or("Account no longer exists")? - .take_str_array(PrincipalField::Secrets) - .unwrap_or_default() - .into_iter() - .next() - .ok_or("Failed to obtain password hash") - } else if let Some((_, secret)) = &self.core.jmap.fallback_admin { - Ok(secret.clone()) - } else { - Err("Invalid account id.") - } - } - async fn issue_token( &self, account_id: u32, @@ -276,162 +213,4 @@ impl TokenHandler for Server { scope: None, }) } - - async fn issue_custom_token( - &self, - account_id: u32, - grant_type: &str, - client_id: &str, - expiry_in: u64, - ) -> trc::Result { - self.encode_access_token( - grant_type, - account_id, - &self - .password_hash(account_id) - .await - .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err))?, - client_id, - expiry_in, - ) - .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err)) - } - - fn encode_access_token( - &self, - grant_type: &str, - account_id: u32, - password_hash: &str, - client_id: &str, - expiry_in: u64, - ) -> Result { - // Build context - if client_id.len() > CLIENT_ID_MAX_LEN { - return Err("ClientId is too long"); - } - let key = self.core.jmap.oauth_key.clone(); - let context = format!( - "{} {} {} {}", - grant_type, client_id, account_id, password_hash - ); - let context_nonce = format!("{} nonce {}", grant_type, password_hash); - - // Set expiration time - let expiry = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(946684800) // Jan 1, 2000 - + expiry_in; - - // Calculate nonce - let mut hasher = blake3::Hasher::new(); - hasher.update(context_nonce.as_bytes()); - hasher.update(expiry.to_be_bytes().as_slice()); - let nonce = hasher - .finalize() - .as_bytes() - .iter() - .take(SymmetricEncrypt::NONCE_LEN) - .copied() - .collect::>(); - - // Encrypt random bytes - let mut token = SymmetricEncrypt::new(key.as_bytes(), &context) - .encrypt(&thread_rng().gen::<[u8; RANDOM_CODE_LEN]>(), &nonce) - .map_err(|_| "Failed to encrypt token.")?; - token.push_leb128(account_id); - token.push_leb128(expiry); - token.extend_from_slice(client_id.as_bytes()); - - Ok(String::from_utf8(base64_encode(&token).unwrap_or_default()).unwrap()) - } - - async fn validate_access_token( - &self, - grant_type: &str, - token_: &str, - ) -> trc::Result<(u32, String, u64)> { - // Base64 decode token - let token = base64_decode(token_.as_bytes()).ok_or_else(|| { - trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Reason, "Failed to decode token") - .caused_by(trc::location!()) - .details(token_.to_string()) - })?; - let (account_id, expiry, client_id) = token - .get((RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN)..) - .and_then(|bytes| { - let mut bytes = bytes.iter(); - ( - bytes.next_leb128()?, - bytes.next_leb128::()?, - bytes.copied().map(char::from).collect::(), - ) - .into() - }) - .ok_or_else(|| { - trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Reason, "Failed to decode token") - .caused_by(trc::location!()) - .details(token_.to_string()) - })?; - - // Validate expiration - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(946684800); // Jan 1, 2000 - if expiry <= now { - return Err(trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Reason, "Token expired")); - } - - // Obtain password hash - let password_hash = self - .password_hash(account_id) - .await - .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))?; - - // Build context - let key = self.core.jmap.oauth_key.clone(); - let context = format!( - "{} {} {} {}", - grant_type, client_id, account_id, password_hash - ); - let context_nonce = format!("{} nonce {}", grant_type, password_hash); - - // Calculate nonce - let mut hasher = blake3::Hasher::new(); - hasher.update(context_nonce.as_bytes()); - hasher.update(expiry.to_be_bytes().as_slice()); - let nonce = hasher - .finalize() - .as_bytes() - .iter() - .take(SymmetricEncrypt::NONCE_LEN) - .copied() - .collect::>(); - - // Decrypt - SymmetricEncrypt::new(key.as_bytes(), &context) - .decrypt( - &token[..RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN], - &nonce, - ) - .map_err(|err| { - trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Details, "Failed to decode token") - .caused_by(trc::location!()) - .reason(err) - })?; - - // Success - Ok((account_id, client_id, expiry - now)) - } } diff --git a/crates/jmap/src/services/gossip/request.rs b/crates/jmap/src/services/gossip/request.rs index 8487a462..42cb0610 100644 --- a/crates/jmap/src/services/gossip/request.rs +++ b/crates/jmap/src/services/gossip/request.rs @@ -4,10 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::auth::SymmetricEncrypt; - use super::{EpochId, PeerStatus}; +use common::auth::oauth::crypto::SymmetricEncrypt; use std::net::IpAddr; use utils::codec::leb128::Leb128_; diff --git a/crates/jmap/src/services/gossip/spawn.rs b/crates/jmap/src/services/gossip/spawn.rs index f5d41056..15a1f9a8 100644 --- a/crates/jmap/src/services/gossip/spawn.rs +++ b/crates/jmap/src/services/gossip/spawn.rs @@ -4,10 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::auth::SymmetricEncrypt; - use super::request::Request; use super::{Gossiper, Peer, UDP_MAX_PAYLOAD}; +use common::auth::oauth::crypto::SymmetricEncrypt; use common::{Inner, IPC_CHANNEL_BUFFER}; use std::net::IpAddr; use std::time::{Duration, Instant}; diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 5157c04a..eaec3c48 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -5,6 +5,7 @@ */ use common::{ + auth::AuthRequest, listener::{limiter::ConcurrencyLimiter, SessionStream}, ConcurrencyLimiters, }; @@ -14,11 +15,8 @@ use imap_proto::{ protocol::authenticate::Mechanism, receiver::{self, Request}, }; -use jmap::auth::{ - authenticate::Authenticator, oauth::token::TokenHandler, rate_limit::RateLimiter, -}; +use jmap::auth::rate_limit::RateLimiter; use mail_parser::decoders::base64::base64_decode; -use mail_send::Credentials; use std::sync::Arc; use crate::core::{Command, Session, State, StatusResponse}; @@ -74,41 +72,37 @@ impl Session { self.server.is_auth_allowed_soft(&self.remote_addr).await?; // Authenticate - let access_token = match credentials { - Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { - self.server - .authenticate_plain(&username, &secret, self.remote_addr, self.session_id) - .await - } - Credentials::OAuthBearer { token } => { - match self - .server - .validate_access_token("access_token", &token) - .await - { - Ok((account_id, _, _)) => self.server.get_access_token(account_id).await, - Err(err) => Err(err), - } - } - } - .map_err(|err| { - if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { - match &self.state { - State::NotAuthenticated { auth_failures } - if *auth_failures < self.server.core.imap.max_auth_failures => - { - self.state = State::NotAuthenticated { - auth_failures: auth_failures + 1, - }; - } - _ => { - return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + match &self.state { + State::NotAuthenticated { auth_failures } + if *auth_failures < self.server.core.imap.max_auth_failures => + { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + }; + } + _ => { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } } } - } - err - })?; + err + }) + .and_then(|token| { + token + .assert_has_permission(Permission::SieveAuthenticate) + .map(|_| token) + })?; // Enforce concurrency limits let in_flight = match self @@ -122,13 +116,6 @@ impl Session { } }; - // Validate access - access_token.assert_has_permission(Permission::SieveAuthenticate)?; - - // Cache access token - let access_token = Arc::new(access_token); - self.server.cache_access_token(access_token.clone()); - // Create session self.state = State::Authenticated { access_token, diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index 1924d4f2..4c70afa5 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -5,14 +5,13 @@ */ use common::{ + auth::AuthRequest, listener::{limiter::ConcurrencyLimiter, SessionStream}, ConcurrencyLimiters, }; use directory::Permission; use imap::op::authenticate::{decode_challenge_oauth, decode_challenge_plain}; -use jmap::auth::{ - authenticate::Authenticator, oauth::token::TokenHandler, rate_limit::RateLimiter, -}; +use jmap::auth::rate_limit::RateLimiter; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; use std::sync::Arc; @@ -68,43 +67,39 @@ impl Session { self.server.is_auth_allowed_soft(&self.remote_addr).await?; // Authenticate - let access_token = match credentials { - Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { - self.server - .authenticate_plain(&username, &secret, self.remote_addr, self.session_id) - .await - } - Credentials::OAuthBearer { token } => { - match self - .server - .validate_access_token("access_token", &token) - .await - { - Ok((account_id, _, _)) => self.server.get_access_token(account_id).await, - Err(err) => Err(err), - } - } - } - .map_err(|err| { - if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { - match &self.state { - State::NotAuthenticated { - auth_failures, - username, - } if *auth_failures < self.server.core.imap.max_auth_failures => { - self.state = State::NotAuthenticated { - auth_failures: auth_failures + 1, - username: username.clone(), - }; - } - _ => { - return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + match &self.state { + State::NotAuthenticated { + auth_failures, + username, + } if *auth_failures < self.server.core.imap.max_auth_failures => { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + username: username.clone(), + }; + } + _ => { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } } } - } - err - })?; + err + }) + .and_then(|token| { + token + .assert_has_permission(Permission::Pop3Authenticate) + .map(|_| token) + })?; // Enforce concurrency limits let in_flight = match self @@ -118,13 +113,6 @@ impl Session { } }; - // Validate access - access_token.assert_has_permission(Permission::Pop3Authenticate)?; - - // Cache access token - let access_token = Arc::new(access_token); - self.server.cache_access_token(access_token.clone()); - // Fetch mailbox let mailbox = self.fetch_mailbox(access_token.primary_id()).await?; diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 810521a1..16483b4b 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -12,6 +12,7 @@ use std::{ }; use common::{ + auth::AccessToken, config::smtp::auth::VerifyStrategy, listener::{ limiter::{ConcurrencyLimiter, InFlight}, @@ -83,8 +84,7 @@ pub struct SessionData { pub rcpt_errors: usize, pub message: Vec, - pub authenticated_as: String, - pub authenticated_emails: Vec, + pub authenticated_as: Option>, pub auth_errors: usize, pub priority: i16, @@ -160,8 +160,7 @@ impl SessionData { helo_domain: String::new(), mail_from: None, rcpt_to: Vec::new(), - authenticated_as: String::new(), - authenticated_emails: Vec::new(), + authenticated_as: None, priority: 0, valid_until: Instant::now(), rcpt_errors: 0, @@ -312,8 +311,7 @@ impl SessionData { rcpt_to, rcpt_errors: 0, message, - authenticated_as: "local".into(), - authenticated_emails: vec![], + authenticated_as: Some(Arc::new(AccessToken::from_id(0))), auth_errors: 0, priority: 0, delivery_by: 0, diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index d1ab3470..88b32d64 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -4,12 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::listener::SessionStream; -use directory::{backend::internal::PrincipalField, Permission}; +use common::{ + auth::{oauth::extract_oauth_bearer, AuthRequest}, + listener::SessionStream, +}; +use directory::Permission; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; use smtp_proto::{IntoString, AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2}; -use trc::{AddContext, AuthEvent, SmtpEvent}; +use trc::{AuthEvent, SmtpEvent}; use crate::core::Session; @@ -109,9 +112,8 @@ impl Session { }; } (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token: token_ }) => { - let response = response.into_string(); - if response.contains("auth=") { - *token_ = response; + if let Some(bearer) = extract_oauth_bearer(&response) { + *token_ = bearer.to_string(); return self .authenticate(std::mem::take(&mut token.credentials)) .await; @@ -160,55 +162,27 @@ impl Session { pub async fn authenticate(&mut self, credentials: Credentials) -> Result { if let Some(directory) = &self.params.auth_directory { - let authenticated_as = match &credentials { - Credentials::Plain { username, .. } - | Credentials::XOauth2 { username, .. } - | Credentials::OAuthBearer { token: username } => username.to_string(), - }; - // Authenticate - let mut result = self + let result = self .server .authenticate( - directory, - self.data.session_id, - &credentials, - self.data.remote_ip, - false, + &AuthRequest::from_credentials( + credentials, + self.data.session_id, + self.data.remote_ip, + ) + .with_directory(directory), ) - .await; - - // Validate permissions - if let Ok(principal) = &result { - match self - .server - .get_cached_access_token(principal.id()) - .await - .caused_by(trc::location!()) - { - Ok(access_token) => { - if let Err(err) = access_token - .assert_has_permission(Permission::EmailSend) - .and_then(|_| { - access_token.assert_has_permission(Permission::Authenticate) - }) - { - result = Err(err); - } - } - Err(err) => { - result = Err(err); - } - } - } + .await + .and_then(|access_token| { + access_token + .assert_has_permission(Permission::EmailSend) + .map(|_| access_token) + }); match result { - Ok(principal) => { - self.data.authenticated_as = authenticated_as.to_lowercase(); - self.data.authenticated_emails = principal - .iter_str(PrincipalField::Emails) - .map(|e| e.trim().to_lowercase()) - .collect(); + Ok(access_token) => { + self.data.authenticated_as = access_token.into(); self.eval_post_auth_params().await; self.write(b"235 2.7.0 Authentication succeeded.\r\n") .await?; @@ -225,6 +199,9 @@ impl Session { .auth_error(b"535 5.7.8 Authentication credentials invalid.\r\n") .await; } + trc::EventType::Auth(trc::AuthEvent::TokenExpired) => { + return self.auth_error(b"535 5.7.8 OAuth token expired.\r\n").await; + } trc::EventType::Auth(trc::AuthEvent::MissingTotp) => { return self .auth_error( @@ -273,4 +250,26 @@ impl Session { Err(()) } } + + pub fn authenticated_as(&self) -> Option<&str> { + self.data.authenticated_as.as_ref().map(|token| { + if !token.name.is_empty() { + token.name.as_str() + } else { + "unavailable" + } + }) + } + + pub fn is_authenticated(&self) -> bool { + self.data.authenticated_as.is_some() + } + + pub fn authenticated_emails(&self) -> &[String] { + self.data + .authenticated_as + .as_ref() + .map(|token| token.emails.as_slice()) + .unwrap_or_default() + } } diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 80477eac..cfd2b996 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -702,7 +702,7 @@ impl Session { let queue_id = message.queue_id; // Queue message - let source = if self.data.authenticated_as.is_empty() { + let source = if !self.is_authenticated() { MessageSource::Unauthenticated } else { MessageSource::Authenticated @@ -928,14 +928,12 @@ impl Session { headers.extend_from_slice(b"by "); headers.extend_from_slice(self.hostname.as_bytes()); headers.extend_from_slice(b" (Stalwart SMTP) with "); - headers.extend_from_slice( - match (self.stream.is_tls(), self.data.authenticated_as.is_empty()) { - (true, true) => b"ESMTPS", - (true, false) => b"ESMTPSA", - (false, true) => b"ESMTP", - (false, false) => b"ESMTPA", - }, - ); + headers.extend_from_slice(match (self.stream.is_tls(), !self.is_authenticated()) { + (true, true) => b"ESMTPS", + (true, false) => b"ESMTPSA", + (false, true) => b"ESMTP", + (false, false) => b"ESMTPA", + }); headers.extend_from_slice(b" id "); headers.extend_from_slice(format!("{id:X}").as_bytes()); headers.extend_from_slice(b";\r\n\t"); diff --git a/crates/smtp/src/inbound/ehlo.rs b/crates/smtp/src/inbound/ehlo.rs index deeae3d1..1bdb2ea3 100644 --- a/crates/smtp/src/inbound/ehlo.rs +++ b/crates/smtp/src/inbound/ehlo.rs @@ -203,7 +203,7 @@ impl Session { } // Authentication - if self.data.authenticated_as.is_empty() { + if !self.is_authenticated() { response.auth_mechanisms = self .server .eval_if::(&ac.mechanisms, self, self.data.session_id) diff --git a/crates/smtp/src/inbound/hooks/message.rs b/crates/smtp/src/inbound/hooks/message.rs index 37720509..8b8c06c9 100644 --- a/crates/smtp/src/inbound/hooks/message.rs +++ b/crates/smtp/src/inbound/hooks/message.rs @@ -196,8 +196,8 @@ impl Session { .then(|| self.data.helo_domain.clone()), active_connections: 1, }, - sasl: (!self.data.authenticated_as.is_empty()).then(|| Sasl { - login: self.data.authenticated_as.clone(), + sasl: self.authenticated_as().map(|name| Sasl { + login: name.to_string(), method: None, }), tls: (!tls_version.is_empty()).then(|| Tls { diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index 033b7d3d..8aa74861 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -42,7 +42,7 @@ impl Session { return self .write(b"503 5.5.1 Multiple MAIL commands not allowed.\r\n") .await; - } else if self.params.auth_require && self.data.authenticated_as.is_empty() { + } else if self.params.auth_require && !self.is_authenticated() { trc::event!( Smtp(SmtpEvent::MailFromUnauthenticated), SpanId = self.data.session_id, @@ -225,32 +225,34 @@ impl Session { } // Make sure that the authenticated user is allowed to send from this address - if !self.data.authenticated_as.is_empty() && self.params.auth_match_sender { - let address_lcase = self.data.mail_from.as_ref().unwrap().address_lcase.as_str(); - if self.data.authenticated_as != address_lcase - && !self.data.authenticated_emails.iter().any(|e| { - e == address_lcase || (e.starts_with('@') && address_lcase.ends_with(e)) - }) - { - trc::event!( - Smtp(SmtpEvent::MailFromUnauthorized), - SpanId = self.data.session_id, - From = address_lcase.to_string(), - Details = [trc::Value::String(self.data.authenticated_as.to_string())] - .into_iter() - .chain( - self.data - .authenticated_emails - .iter() - .map(|e| trc::Value::String(e.to_string())) - ) - .collect::>() - ); - self.data.mail_from = None; - return self - .write(b"501 5.5.4 You are not allowed to send from this address.\r\n") - .await; + match self.authenticated_as() { + Some(authenticated_as) if self.params.auth_match_sender => { + let address_lcase = self.data.mail_from.as_ref().unwrap().address_lcase.as_str(); + if authenticated_as != address_lcase + && !self.authenticated_emails().iter().any(|e| { + e == address_lcase || (e.starts_with('@') && address_lcase.ends_with(e)) + }) + { + trc::event!( + Smtp(SmtpEvent::MailFromUnauthorized), + SpanId = self.data.session_id, + From = address_lcase.to_string(), + Details = [trc::Value::String(authenticated_as.to_string())] + .into_iter() + .chain( + self.authenticated_emails() + .iter() + .map(|e| trc::Value::String(e.to_string())) + ) + .collect::>() + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 You are not allowed to send from this address.\r\n") + .await; + } } + _ => (), } // Validate parameters diff --git a/crates/smtp/src/inbound/milter/message.rs b/crates/smtp/src/inbound/milter/message.rs index 0b369161..7832cbc6 100644 --- a/crates/smtp/src/inbound/milter/message.rs +++ b/crates/smtp/src/inbound/milter/message.rs @@ -231,10 +231,10 @@ impl Session { .mail_from( &format!("<{addr}>"), None::<&[&str]>, - if !self.data.authenticated_as.is_empty() { + if let Some(name) = self.authenticated_as() { Macros::new() .with_mail_address(addr) - .with_sasl_login_name(&self.data.authenticated_as) + .with_sasl_login_name(name) } else { Macros::new().with_mail_address(addr) }, diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs index 02648b5f..f0d1afc5 100644 --- a/crates/smtp/src/inbound/session.rs +++ b/crates/smtp/src/inbound/session.rs @@ -100,11 +100,11 @@ impl Session { ); self.write(b"503 5.5.1 AUTH not allowed.\r\n").await?; - } else if !self.data.authenticated_as.is_empty() { + } else if let Some(authenticated_as) = self.authenticated_as() { trc::event!( Smtp(SmtpEvent::AlreadyAuthenticated), SpanId = self.data.session_id, - AccountName = self.data.authenticated_as.clone(), + AccountName = authenticated_as.to_string(), ); self.write(b"503 5.5.1 Already authenticated.\r\n").await?; @@ -541,7 +541,7 @@ impl ResolveVariable for Session { .unwrap_or_default() .into(), V_HELO_DOMAIN => self.data.helo_domain.as_str().into(), - V_AUTHENTICATED_AS => self.data.authenticated_as.as_str().into(), + V_AUTHENTICATED_AS => self.authenticated_as().unwrap_or_default().into(), V_LISTENER => self.instance.id.as_str().into(), V_REMOTE_IP => self.data.remote_ip_str.as_str().into(), V_REMOTE_PORT => self.data.remote_port.into(), diff --git a/crates/smtp/src/scripts/exec.rs b/crates/smtp/src/scripts/exec.rs index 7b92ccfa..7ddc90fe 100644 --- a/crates/smtp/src/scripts/exec.rs +++ b/crates/smtp/src/scripts/exec.rs @@ -22,7 +22,10 @@ impl Session { .set_variable("remote_ip", self.data.remote_ip.to_string()) .set_variable("remote_ip.reverse", self.data.remote_ip.to_reverse_name()) .set_variable("helo_domain", self.data.helo_domain.to_lowercase()) - .set_variable("authenticated_as", self.data.authenticated_as.clone()) + .set_variable( + "authenticated_as", + self.authenticated_as().unwrap_or_default().to_string(), + ) .set_variable( "now", SystemTime::now() diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 25ddde97..2e05eee3 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -1741,6 +1741,7 @@ impl AuthEvent { AuthEvent::MissingTotp => "Missing TOTP for authentication", AuthEvent::TooManyAttempts => "Too many authentication attempts", AuthEvent::Error => "Authentication error", + AuthEvent::TokenExpired => "OAuth token expired", } } @@ -1751,6 +1752,7 @@ impl AuthEvent { AuthEvent::MissingTotp => "TOTP is missing for authentication", AuthEvent::TooManyAttempts => "Too many authentication attempts have been made", AuthEvent::Error => "An error occurred with authentication", + AuthEvent::TokenExpired => "OAuth authentication token has expired", } } } diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index b9d58117..9156adea 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -225,7 +225,7 @@ impl EventType { }, EventType::Manage(_) => Level::Debug, EventType::Auth(cause) => match cause { - AuthEvent::Failed => Level::Debug, + AuthEvent::Failed | AuthEvent::TokenExpired => Level::Debug, AuthEvent::MissingTotp => Level::Trace, AuthEvent::TooManyAttempts => Level::Warn, AuthEvent::Error => Level::Error, diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index c402d3ec..07e6c58f 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -923,6 +923,7 @@ pub enum ManageEvent { pub enum AuthEvent { Success, Failed, + TokenExpired, MissingTotp, TooManyAttempts, Error, diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 7444df67..c29d93ce 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -859,6 +859,7 @@ impl EventType { EventType::Smtp(SmtpEvent::MailFromNotAllowed) => 551, EventType::Security(SecurityEvent::Unauthorized) => 552, EventType::Limit(LimitEvent::TenantQuota) => 553, + EventType::Auth(AuthEvent::TokenExpired) => 554, } } @@ -1458,6 +1459,7 @@ impl EventType { 551 => Some(EventType::Smtp(SmtpEvent::MailFromNotAllowed)), 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), 553 => Some(EventType::Limit(LimitEvent::TenantQuota)), + 554 => Some(EventType::Auth(AuthEvent::TokenExpired)), _ => None, } } diff --git a/tests/src/jmap/auth_acl.rs b/tests/src/jmap/auth_acl.rs index 59346287..76fb9e65 100644 --- a/tests/src/jmap/auth_acl.rs +++ b/tests/src/jmap/auth_acl.rs @@ -771,6 +771,7 @@ pub async fn test(params: &mut JMAPTest) { .remove_from_group("jdoe@example.com", "sales@example.com") .await; server.inner.data.http_auth_cache.clear(); + server.inner.data.access_tokens.clear(); assert_forbidden( john_client .set_default_account_id(sales_id.to_string()) diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index fa656fcc..3f18a0cd 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; +use base64::{engine::general_purpose, Engine}; use bytes::Bytes; use jmap::auth::oauth::{ DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse, @@ -20,7 +21,9 @@ use store::ahash::AHashMap; use crate::{ directory::internal::TestInternalDirectory, - jmap::{assert_is_empty, mailbox::destroy_all_mailboxes, ManagementApi}, + jmap::{ + assert_is_empty, delivery::SmtpConnection, mailbox::destroy_all_mailboxes, ManagementApi, + }, }; use super::JMAPTest; @@ -122,6 +125,27 @@ pub async fn test(params: &mut JMAPTest) { .ids() .is_empty()); + // Try SMTP OAUTHBEARER auth + let mut smtp = SmtpConnection::connect().await; + smtp.send(&format!( + "AUTH OAUTHBEARER {}", + general_purpose::STANDARD.encode(format!( + "n,a={},\u{1}auth=Bearer {}\u{1}\u{1}", + "user@domain", "invalid_token" + )) + )) + .await; + smtp.read(1, 4).await; + smtp.send(&format!( + "AUTH OAUTHBEARER {}", + general_purpose::STANDARD.encode(format!( + "n,a={},\u{1}auth=Bearer {}\u{1}\u{1}", + "user@domain", token + )) + )) + .await; + smtp.read(1, 2).await; + // ------------------------ // Device code flow // ------------------------ diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index e8805993..ad708d52 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -131,6 +131,10 @@ directory = "'{STORE}'" total = 5 wait = "1ms" +[session.auth] +mechanisms = "[plain, login, oauthbearer]" +directory = "'{STORE}'" + [queue] path = "{TMP}" hash = 64 @@ -315,7 +319,7 @@ pub async fn jmap_tests() { ) .await; - /*webhooks::test(&mut params).await; + webhooks::test(&mut params).await; email_query::test(&mut params, delete).await; email_get::test(&mut params).await; email_set::test(&mut params).await; @@ -339,7 +343,7 @@ pub async fn jmap_tests() { websocket::test(&mut params).await; quota::test(&mut params).await; crypto::test(&mut params).await; - blob::test(&mut params).await;*/ + blob::test(&mut params).await; permissions::test(¶ms).await; purge::test(&mut params).await; enterprise::test(&mut params).await; diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index 524ae137..b8b42acc 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -3,11 +3,13 @@ use std::{ collections::HashMap, fs, path::PathBuf, + sync::Arc, time::{Duration, Instant}, }; use ahash::AHashMap; use common::{ + auth::AccessToken, scripts::{ functions::html::{get_attribute, html_attr_tokens, html_img_area, html_to_tokens}, ScriptModification, @@ -298,7 +300,10 @@ async fn antispam() { session.data.helo_domain = value.to_string(); } "authenticated_as" => { - session.data.authenticated_as = value.to_string(); + session.data.authenticated_as = Some(Arc::new(AccessToken { + name: value.to_string(), + ..Default::default() + })); } "spf.result" | "spf_ehlo.result" => { variables.insert( diff --git a/tests/src/smtp/inbound/auth.rs b/tests/src/smtp/inbound/auth.rs index 6a9679cb..5f042ebc 100644 --- a/tests/src/smtp/inbound/auth.rs +++ b/tests/src/smtp/inbound/auth.rs @@ -142,7 +142,7 @@ async fn auth() { .assert_contains("FUTURERELEASE 86400"); // Successful LOGIN authentication - session.data.authenticated_as.clear(); + session.data.authenticated_as.take(); session.cmd("AUTH LOGIN", "334").await; session.cmd("amFuZQ==", "334").await; session.cmd("cDRzc3cwcmQ=", "235 2.7.0").await; diff --git a/tests/src/smtp/inbound/mail.rs b/tests/src/smtp/inbound/mail.rs index 485c039f..2b692cdb 100644 --- a/tests/src/smtp/inbound/mail.rs +++ b/tests/src/smtp/inbound/mail.rs @@ -4,10 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - sync::Arc, - time::{Duration, Instant, SystemTime}, -}; +use std::time::{Duration, Instant, SystemTime}; use common::Core; use mail_auth::{common::parse::TxtRecordParser, spf::Spf, IprevResult, SpfResult}; @@ -106,8 +103,8 @@ async fn mail() { ); // Be rude and do not say EHLO - let core = Arc::new(core); - let mut session = Session::test(TestSMTP::from_core(core.clone()).server); + let server = TestSMTP::from_core(core).server; + let mut session = Session::test(server.clone()); session.data.remote_ip_str = "10.0.0.1".to_string(); session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); session.eval_session_params().await; @@ -197,7 +194,7 @@ async fn mail() { .unwrap(); session.response().assert_code("550 5.7.25"); session.data.iprev = None; - core.smtp.resolvers.dns.ipv4_add( + server.core.smtp.resolvers.dns.ipv4_add( "mx2.foobar.org.", vec!["10.0.0.2".parse().unwrap()], Instant::now() + Duration::from_secs(5), @@ -209,7 +206,7 @@ async fn mail() { .await .unwrap(); session.response().assert_code("550 5.7.23"); - core.smtp.resolvers.dns.txt_add( + server.core.smtp.resolvers.dns.txt_add( "foobar.org", Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(), Instant::now() + Duration::from_secs(5), diff --git a/tests/src/smtp/mod.rs b/tests/src/smtp/mod.rs index 275c42ae..f79005ae 100644 --- a/tests/src/smtp/mod.rs +++ b/tests/src/smtp/mod.rs @@ -4,13 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; use common::{ config::server::{Listeners, ServerProtocol}, ipc::{QueueEvent, ReportingEvent}, manager::boot::build_ipc, - Core, Inner, Server, + Core, Data, Inner, Server, }; use jmap::api::JmapSessionManager; @@ -140,31 +140,33 @@ path = "{TMP}/queue.db" "#; impl TestSMTP { - pub fn from_core(core: impl Into>) -> Self { - Self::from_core_and_tempdir(core, None) + pub fn from_core(core: Core) -> Self { + Self::from_core_and_tempdir(core, Default::default(), None) } - fn from_core_and_tempdir(core: impl Into>, temp_dir: Option) -> Self { - let core = core.into(); + fn from_core_and_tempdir(core: Core, data: Data, temp_dir: Option) -> Self { + let store = core.storage.data.clone(); + let blob_store = core.storage.blob.clone(); + let shared_core = core.into_shared(); let (ipc, mut ipc_rxs) = build_ipc(); TestSMTP { queue_receiver: QueueReceiver { - store: core.storage.data.clone(), - blob_store: core.storage.blob.clone(), + store, + blob_store, queue_rx: ipc_rxs.queue_rx.take().unwrap(), }, report_receiver: ReportReceiver { report_rx: ipc_rxs.report_rx.take().unwrap(), }, server: Server { + core: shared_core.load_full(), inner: Inner { - shared_core: core.as_ref().clone().into_shared(), - data: Default::default(), + shared_core, + data, ipc, } .into(), - core, }, temp_dir, } @@ -177,8 +179,9 @@ impl TestSMTP { config.resolve_all_macros().await; let stores = Stores::parse_all(&mut config).await; let core = Core::parse(&mut config, stores, Default::default()).await; + let data = Data::parse(&mut config); - Self::from_core_and_tempdir(core, Some(temp_dir)) + Self::from_core_and_tempdir(core, data, Some(temp_dir)) } pub async fn start(&self, protocols: &[ServerProtocol]) -> watch::Sender {