diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 2eed0ce2..e78415dc 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -28,7 +28,7 @@ use std::{ }; use store::{query::acl::AclQuery, rand, write::now}; use tinyvec::TinyVec; -use trc::AddContext; +use trc::{AddContext, StoreEvent}; use types::{acl::Acl, collection::Collection}; use utils::map::bitmap::{Bitmap, BitmapItem}; @@ -249,25 +249,40 @@ impl Server { .get_value_or_guard_async(&account_id) .await { - Ok(token) => Ok(token), + Ok(token) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "accessToken", + ); + + Ok(token) + } Err(guard) => { - let account = self - .registry() - .object::(account_id.into()) - .await? - .ok_or_else(|| { - trc::SecurityEvent::Unauthorized - .into_err() - .details("Account not found") - .account_id(account_id) - .caused_by(trc::location!()) - })?; - let revision = rand::random::(); - let revision_account = hash_account(&account); - let token: Arc = self - .build_access_token(account, account_id, revision, revision_account) - .await? - .into(); + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "accessToken", + ); + + let token: Arc = if let Some(account) = + self.registry().object::(account_id.into()).await? + { + let revision = rand::random::(); + let revision_account = hash_account(&account); + self.build_access_token(account, account_id, revision, revision_account) + .await? + .into() + } else if account_id == FALLBACK_ADMIN_ID { + AccessTokenInner::new_admin().into() + } else { + return Err(trc::SecurityEvent::Unauthorized + .into_err() + .details("Account not found") + .account_id(account_id) + .caused_by(trc::location!())); + }; + let _ = guard.insert(token.clone()); Ok(token) } @@ -289,9 +304,21 @@ impl Server { { Ok(token) => { if token.revision_account == revision_account { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "accessToken", + ); + Ok(token) } else { // Token is stale, rebuild it + trc::event!( + Store(StoreEvent::CacheStale), + Key = account_id, + Collection = "accessToken", + ); + debug_assert!( false, "Token is stale, invalidation should have been triggered" @@ -309,6 +336,12 @@ impl Server { } } Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "accessToken", + ); + let revision = rand::random::(); let token: Arc = self .build_access_token(account, account_id, revision, revision_account) @@ -598,19 +631,7 @@ impl AccessToken { pub fn new_admin() -> AccessToken { AccessToken { scope_idx: 0, - inner: Arc::new(AccessTokenInner { - account_id: FALLBACK_ADMIN_ID, - tenant_id: Default::default(), - member_of: Default::default(), - access_to: Default::default(), - scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]), - concurrent_http_requests: Default::default(), - concurrent_imap_requests: Default::default(), - concurrent_uploads: Default::default(), - revision: Default::default(), - revision_account: Default::default(), - obj_size: Default::default(), - }), + inner: Arc::new(AccessTokenInner::new_admin()), } } @@ -666,6 +687,30 @@ impl AccessTokenInner { as u64; self } + + pub fn new_admin() -> Self { + AccessTokenInner { + account_id: FALLBACK_ADMIN_ID, + tenant_id: Default::default(), + member_of: Default::default(), + access_to: Default::default(), + scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]), + concurrent_http_requests: Default::default(), + concurrent_imap_requests: Default::default(), + concurrent_uploads: Default::default(), + revision: Default::default(), + revision_account: Default::default(), + obj_size: Default::default(), + } + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn revision_account(&self) -> u64 { + self.revision_account + } } impl AccessScope { diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 24cd4c30..bba8c9be 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -13,8 +13,8 @@ use crate::{ }, }; use directory::{ - Credentials, - core::secret::{verify_mfa_secret_hash, verify_secret_hash}, + Credentials, Directory, + core::secret::{SecretVerificationResult, verify_mfa_secret_hash, verify_secret_hash}, }; use registry::schema::{ enums::Permission, @@ -63,7 +63,11 @@ impl Server { async fn route_auth_request(&self, req: &AuthRequest) -> trc::Result { match &req.credentials { - Credentials::Basic { username, secret } => { + Credentials::Basic { + username, + secret, + mfa_token, + } => { let username = UsernameParts::new(username); // Try to authenticate as fallback admin if configured @@ -140,13 +144,8 @@ impl Server { } // Obtain external directory, if any - let directory = domain - .id_directory - .and_then(|domain_id| self.core.storage.directories.get(&domain_id)) - .or_else(|| self.get_default_directory()); - let mut is_alias_login = false; - let token = if let Some(directory) = directory { + let token = if let Some(directory) = self.get_directory_for_cached_domain(&domain) { let directory_account = directory.authenticate(&req.credentials).await?; is_alias_login = directory_account.email != auth_as_address; @@ -160,37 +159,55 @@ impl Server { .await? .and_then(|account| account.into_user()) { - if let Some(credential) = account.password_credential() - && verify_mfa_secret_hash( - credential.otp_auth.as_deref(), - credential.secret.as_str(), - secret, - ) - .await? - { - if credential - .expires_at - .as_ref() - .is_none_or(|exp| exp.timestamp() > now() as i64) - { - is_alias_login = account.name != auth_as_address; - self.access_token(account_id).await.map(AccessToken::new) - } else { - Err(trc::AuthEvent::Failed - .into_err() - .ctx(trc::Key::AccountName, account.name.to_string()) - .ctx(trc::Key::AccountId, account_id) - .ctx(trc::Key::Id, credential.credential_id.id()) - .ctx(trc::Key::SpanId, req.session_id) - .reason("Password credential has expired")) - } - } else { - Err(trc::AuthEvent::Failed + let Some(credential) = account.password_credential() else { + return Err(trc::AuthEvent::Failed .into_err() .ctx(trc::Key::AccountName, auth_as_address.to_string()) .ctx(trc::Key::AccountId, account_id) .ctx(trc::Key::SpanId, req.session_id) - .reason("Authentication failed")) + .reason("Password credential not found for account")); + }; + + match verify_mfa_secret_hash( + credential.otp_auth.as_deref(), + mfa_token.as_deref(), + credential.secret.as_str(), + secret, + ) + .await? + { + SecretVerificationResult::Valid => { + if credential + .expires_at + .as_ref() + .is_none_or(|exp| exp.timestamp() > now() as i64) + { + is_alias_login = account.name != auth_as_address; + self.access_token(account_id).await.map(AccessToken::new) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account.name.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential.credential_id.id()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Password credential has expired")) + } + } + SecretVerificationResult::Invalid => Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Authentication failed")), + SecretVerificationResult::MissingMfaToken => { + Err(trc::AuthEvent::MfaRequired + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("MFA token required")) + } } } else { Err(trc::AuthEvent::Error @@ -270,11 +287,7 @@ impl Server { let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) { if let Some(domain_name) = username.auth_as().domain() { - self.domain(domain_name) - .await - .caused_by(trc::location!())? - .and_then(|domain| self.core.storage.directories.get(&domain.id)) - .or_else(|| self.get_default_directory()) + self.get_directory_for_domain(domain_name).await? } else { self.get_default_directory() } @@ -434,6 +447,43 @@ impl Server { .await .map(AccessToken::new) } + + pub async fn get_directory_for_domain( + &self, + domain_name: &str, + ) -> trc::Result>> { + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] + if self.core.is_enterprise_edition() { + return Ok(self + .domain(domain_name) + .await + .caused_by(trc::location!())? + .and_then(|domain| self.core.storage.directories.get(&domain.id)) + .or_else(|| self.get_default_directory())); + } + // SPDX-SnippetEnd + + Ok(self.get_default_directory()) + } + + pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc> { + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] + if self.core.is_enterprise_edition() { + return domain + .id_directory + .and_then(|domain_id| self.core.storage.directories.get(&domain_id)) + .or_else(|| self.get_default_directory()); + } + // SPDX-SnippetEnd + + self.get_default_directory() + } } impl UsernameParts { @@ -517,6 +567,7 @@ impl AuthRequest { Credentials::Basic { username: user.into(), secret: pass.into(), + mfa_token: None, }, session_id, remote_ip, diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 237897ab..fe54b081 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -168,9 +168,9 @@ pub struct AccountTenantIds { } pub struct AuthRequest { - credentials: Credentials, - session_id: u64, - remote_ip: IpAddr, + pub credentials: Credentials, + pub session_id: u64, + pub remote_ip: IpAddr, } impl CacheItemWeight for AccessTokenInner { diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index e34bf25b..f4b96429 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -39,7 +39,7 @@ use store::{ registry::{RegistryQuery, bootstrap::Bootstrap}, write::{key::KeySerializer, now}, }; -use trc::AddContext; +use trc::{AddContext, StoreEvent}; use types::id::Id; impl Server { @@ -47,6 +47,12 @@ impl Server { let domain_names = &self.inner.cache.domain_names; if let Some(domain_id) = domain_names.get(domain) { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.to_string(), + Collection = "domainName", + ); + let result = self.domain_by_id(domain_id).await?; if result.is_none() { // Domain no longer exists, remove from name cache @@ -83,9 +89,22 @@ impl Server { (), self.inner.cache.negative_cache_ttl, ); + + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain.to_string(), + Collection = "domainName", + ); + Ok(None) } } else { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.to_string(), + Collection = "domainNameNegative", + ); + Ok(None) } } @@ -99,8 +118,21 @@ impl Server { .get_value_or_guard_async(&domain_id) .await { - Ok(domain) => Ok(Some(domain)), + Ok(domain) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain_id, + Collection = "domainId", + ); + + Ok(Some(domain)) + } Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain_id, + Collection = "domainId", + ); let Some(domain) = self.registry().object::(domain_id.into()).await? else { return Ok(None); }; @@ -162,6 +194,13 @@ impl Server { let emails = &self.inner.cache.emails; if let Some(email) = emails.get(&EmailAddressRef::new(local_part, domain_id)) { + trc::event!( + Store(StoreEvent::CacheHit), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "email", + ); + Ok(Some(email)) } else { let emails_negative = &self.inner.cache.emails_negative; @@ -169,6 +208,13 @@ impl Server { .get(&EmailAddressRef::new(local_part, domain_id)) .is_none() { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "email", + ); + if let Some(object) = self .registry() .primary_key( @@ -209,6 +255,12 @@ impl Server { Ok(None) } } else { + trc::event!( + Store(StoreEvent::CacheHit), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "emailNegative", + ); Ok(None) } } @@ -265,8 +317,22 @@ impl Server { .get_value_or_guard_async(&account_id) .await { - Ok(account) => Ok(Some(account)), + Ok(account) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "account", + ); + + Ok(Some(account)) + } Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "account", + ); + let Some(account) = self.registry().object::(account_id.into()).await? else { return Ok(None); @@ -566,8 +632,14 @@ impl Server { pub async fn role(&self, id: u32) -> trc::Result> { let cache = &self.inner.cache.roles; match cache.get_value_or_guard_async(&id).await { - Ok(role) => Ok(role), + Ok(role) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "role"); + + Ok(role) + } Err(guard) => { + trc::event!(Store(StoreEvent::CacheMiss), Key = id, Collection = "role"); + let Some(role) = self.registry().object::(id.into()).await? else { return Err(trc::AuthEvent::Error .into_err() @@ -602,8 +674,18 @@ impl Server { pub async fn tenant(&self, id: u32) -> trc::Result> { let cache = &self.inner.cache.tenants; match cache.get_value_or_guard_async(&id).await { - Ok(tenant) => Ok(tenant), + Ok(tenant) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "tenant"); + + Ok(tenant) + } Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = id, + Collection = "tenant" + ); + let Some(tenant) = self.registry().object::(id.into()).await? else { return Err(trc::AuthEvent::Error .into_err() @@ -663,8 +745,14 @@ impl Server { pub async fn try_list(&self, id: u32) -> trc::Result>> { let cache = &self.inner.cache.lists; match cache.get_value_or_guard_async(&id).await { - Ok(list) => Ok(Some(list)), + Ok(list) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "list"); + + Ok(Some(list)) + } Err(guard) => { + trc::event!(Store(StoreEvent::CacheMiss), Key = id, Collection = "list"); + let Some(list) = self.registry().object::(id.into()).await? else { return Ok(None); }; @@ -683,8 +771,22 @@ impl Server { }; let cache = &self.inner.cache.dkim_signers; match cache.get_value_or_guard_async(&domain.id).await { - Ok(signers) => Ok(Some(signers)), + Ok(signers) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.id, + Collection = "dkimSigners", + ); + + Ok(Some(signers)) + } Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain.id, + Collection = "dkimSigners", + ); + let ids = self .registry() .query::>( diff --git a/crates/common/src/network/mta.rs b/crates/common/src/network/mta.rs index f5311ed6..924a54a4 100644 --- a/crates/common/src/network/mta.rs +++ b/crates/common/src/network/mta.rs @@ -132,10 +132,8 @@ impl Server { } // Obtain external directory, if configured - if let Some(directory) = domain - .id_directory - .and_then(|id| self.core.storage.directories.get(&id)) - .or_else(|| self.get_default_directory()) + if let Some(directory) = self + .get_directory_for_cached_domain(&domain) .filter(|directory| directory.can_lookup_recipients()) { let address = if local_part.as_ref() == local_part_orig { diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index 315fa686..be85e039 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -12,7 +12,7 @@ use crate::{ use ahash::AHashSet; use registry::{ schema::{ - enums::{BlockReason, PasswordHashAlgorithm}, + enums::{BlockReason, PasswordHashAlgorithm, PasswordStrength}, prelude::{Object, ObjectType}, structs::{self, AllowedIp, BlockedIp, Rate, SystemSettings}, }, @@ -29,6 +29,7 @@ use store::{ use trc::AddContext; use types::id::Id; use utils::glob::{GlobPattern, MatchType}; +use zxcvbn::Score; #[derive(Debug, Clone)] pub struct Security { @@ -51,7 +52,7 @@ pub struct Security { pub password_hash_algorithm: PasswordHashAlgorithm, pub password_max_length: u32, pub password_min_length: u32, - pub password_min_strength: u8, + pub password_min_strength: Score, } #[derive(Default)] @@ -153,7 +154,13 @@ impl Security { password_hash_algorithm: auth.password_hash_algorithm, password_max_length: auth.password_max_length as u32, password_min_length: auth.password_min_length as u32, - password_min_strength: auth.password_min_strength as u8, + password_min_strength: match auth.password_min_strength { + PasswordStrength::Zero => Score::Zero, + PasswordStrength::One => Score::One, + PasswordStrength::Two => Score::Two, + PasswordStrength::Three => Score::Three, + PasswordStrength::Four => Score::Four, + }, } } } @@ -342,9 +349,9 @@ impl Server { "Password must be at least {} characters long.", self.core.network.security.password_min_length )) - } else if self.core.network.security.password_min_strength > 0 { + } else if self.core.network.security.password_min_strength > Score::Zero { let entropy = zxcvbn::zxcvbn(password, user_inputs); - if u8::from(entropy.score()) >= self.core.network.security.password_min_strength { + if entropy.score() >= self.core.network.security.password_min_strength { Ok(()) } else if let Some(feedback) = entropy.feedback() { Err(format!("Password is too weak. {feedback}")) diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 39173810..1219fe0d 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -16,7 +16,9 @@ use utils::sanitize_email; impl LdapDirectory { pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { let (username, secret) = match credentials { - Credentials::Basic { username, secret } => (username, secret), + Credentials::Basic { + username, secret, .. + } => (username, secret), Credentials::Bearer { token, .. } => (token, token), }; let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index 0a37845b..31c1bfc6 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -13,7 +13,9 @@ use utils::sanitize_email; impl SqlDirectory { pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { let (username, secret) = match credentials { - Credentials::Basic { username, secret } => (username, secret), + Credentials::Basic { + username, secret, .. + } => (username, secret), Credentials::Bearer { .. } => { return Err(trc::AuthEvent::Error .into_err() diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs index e7b2ebea..b0fc8117 100644 --- a/crates/directory/src/core/dispatch.rs +++ b/crates/directory/src/core/dispatch.rs @@ -33,4 +33,9 @@ impl Directory { pub fn can_lookup_recipients(&self) -> bool { !matches!(self, Directory::OpenId(_)) } + + pub fn oidc_authorization_endpoint(&self) -> Option { + let todo = "implement"; + None + } } diff --git a/crates/directory/src/core/sasl.rs b/crates/directory/src/core/sasl.rs index e2fca071..9e7763ea 100644 --- a/crates/directory/src/core/sasl.rs +++ b/crates/directory/src/core/sasl.rs @@ -26,7 +26,11 @@ impl Credentials { match (String::from_utf8(username), String::from_utf8(secret)) { (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { - Some(Credentials::Basic { username, secret }) + Some(Credentials::Basic { + username, + secret, + mfa_token: None, + }) } _ => None, } diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index a64ebeb2..639b9a2a 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -22,27 +22,35 @@ use sha2::Sha512; use tokio::sync::oneshot; use totp_rs::TOTP; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretVerificationResult { + Valid, + Invalid, + MissingMfaToken, +} + pub async fn verify_mfa_secret_hash( - otp_auth: Option<&str>, + totp_uri: Option<&str>, + totp_token: Option<&str>, hashed_secret: &str, secret: &str, -) -> trc::Result { - if let Some(otp_auth) = otp_auth { - if let Some((code, totp_token)) = secret.rsplit_once('$').filter(|(c, t)| { - !c.is_empty() - && (6..=8).contains(&t.len()) - && t.as_bytes().iter().all(|b| b.is_ascii_digit()) - }) { - let result = verify_secret_hash(hashed_secret, code.as_bytes()).await? - && TOTP::from_url(otp_auth) +) -> trc::Result { + if let Some(totp_uri) = totp_uri { + if let Some(totp_token) = totp_token { + let result = verify_secret_hash(hashed_secret, secret.as_bytes()).await? + && TOTP::from_url(totp_uri) .map_err(|err| { trc::AuthEvent::Error .reason(err) - .details(otp_auth.to_string()) + .details(totp_uri.to_string()) })? .check_current(totp_token) .unwrap_or(false); - Ok(result) + Ok(if result { + SecretVerificationResult::Valid + } else { + SecretVerificationResult::Invalid + }) } else if !hashed_secret.is_empty() && !secret.is_empty() && verify_secret_hash(hashed_secret, secret.as_bytes()).await? @@ -50,18 +58,22 @@ pub async fn verify_mfa_secret_hash( // Only let the client know if the TOTP code is missing // if the password is correct - Err(trc::AuthEvent::MissingTotp.into_err()) + Ok(SecretVerificationResult::MissingMfaToken) } else { - Ok(false) + Ok(SecretVerificationResult::Invalid) } } else if !hashed_secret.is_empty() && !secret.is_empty() { - verify_secret_hash(hashed_secret, secret.as_bytes()).await + if verify_secret_hash(hashed_secret, secret.as_bytes()).await? { + Ok(SecretVerificationResult::Valid) + } else { + Ok(SecretVerificationResult::Invalid) + } } else { - Ok(false) + Ok(SecretVerificationResult::Invalid) } } -pub fn verify_otp_auth(otp_auth: Option<&str>, otp_code: Option<&str>) -> trc::Result { +/*pub fn verify_otp_auth(otp_auth: Option<&str>, otp_code: Option<&str>) -> trc::Result { if let Some(otp_auth) = otp_auth { if let Some(otp_code) = otp_code { TOTP::from_url(otp_auth) @@ -82,7 +94,7 @@ pub fn verify_otp_auth(otp_auth: Option<&str>, otp_code: Option<&str>) -> trc::R } else { Ok(true) } -} +}*/ async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { if hashed_secret.starts_with("$argon2") @@ -247,7 +259,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu } } -pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: String) -> trc::Result { +pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> trc::Result { let (tx, rx) = oneshot::channel(); tokio::task::spawn_blocking(move || { @@ -257,12 +269,12 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: String) -> tr PasswordHashAlgorithm::Argon2id => { let hasher = Argon2::default(); hasher - .hash_password(secret.as_bytes(), &salt) + .hash_password(secret.as_slice(), &salt) .map(|h| h.to_string()) } PasswordHashAlgorithm::Bcrypt => { return tx - .send(bcrypt::hash(secret.as_bytes()).map_err(|err| { + .send(bcrypt::hash(secret.as_slice()).map_err(|err| { trc::AuthEvent::Error .reason(err) .details("Bcrypt hash failed") @@ -271,10 +283,10 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: String) -> tr .unwrap_or(()); } PasswordHashAlgorithm::Scrypt => Scrypt - .hash_password(secret.as_bytes(), &salt) + .hash_password(secret.as_slice(), &salt) .map(|h| h.to_string()), PasswordHashAlgorithm::Pbkdf2 => Pbkdf2 - .hash_password(secret.as_bytes(), &salt) + .hash_password(secret.as_slice(), &salt) .map(|h| h.to_string()), }; diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 553b70eb..84c47089 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -20,6 +20,7 @@ pub enum Credentials { Basic { username: String, secret: String, + mfa_token: Option, }, Bearer { username: Option, diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index c2d429fa..160d5ed2 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -31,7 +31,7 @@ impl Authenticator for Server { // Check if the credentials are cached if let Some(http_cache) = self.inner.cache.http_auth.get(token) { // Make sure the revision is still valid - if http_cache.expires <= Instant::now() { + if http_cache.expires > Instant::now() { let access_token = AccessToken::renew( self.access_token(http_cache.account_id).await?, http_cache.credential_id, @@ -152,6 +152,7 @@ fn decode_plain_auth(token: &str) -> Option { .map(|(login, secret)| Credentials::Basic { username: login.trim().to_lowercase(), secret: secret.to_string(), + mfa_token: None, }) }) } diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index 7d665342..27a9fb40 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -4,18 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode, OAuthCodeRequest}; +use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode}; use crate::auth::oauth::OAuthStatus; use common::{ KV_OAUTH, Server, auth::{ - AccessToken, + AuthRequest, oauth::{CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, USER_CODE_ALPHABET, USER_CODE_LEN}, }, }; +use directory::Credentials; use http_proto::*; -use serde::Deserialize; -use serde_json::json; use std::future::Future; use store::{ Serialize, @@ -31,8 +30,9 @@ use store::{ write::AlignedBytes, }; use trc::AddContext; +use utils::DomainPart; -#[derive(Debug, serde::Serialize, Deserialize)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct OAuthMetadata { pub issuer: String, pub token_endpoint: String, @@ -46,10 +46,10 @@ pub struct OAuthMetadata { } pub trait OAuthApiHandler: Sync + Send { - fn handle_oauth_api_request( + fn handle_login_request( &self, - access_token: &AccessToken, - body: Option>, + session: HttpSessionData, + body: Vec, ) -> impl Future> + Send; fn handle_device_auth( @@ -65,20 +65,78 @@ pub trait OAuthApiHandler: Sync + Send { ) -> impl Future> + Send; } +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum LoginRequest { + Discovery { + account_name: String, + }, + AuthCode { + account_name: String, + account_secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + mfa_token: Option, + client_id: String, + #[serde(default)] + redirect_uri: Option, + #[serde(default)] + nonce: Option, + }, + AuthDevice { + account_name: String, + account_secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + mfa_token: Option, + code: String, + }, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum LoginResponse { + Local, + External { endpoint: String }, + Authenticated { client_code: String }, + Verified, + MfaRequired, + Failure, +} + impl OAuthApiHandler for Server { - async fn handle_oauth_api_request( + async fn handle_login_request( &self, - access_token: &AccessToken, - body: Option>, + session: HttpSessionData, + body: Vec, ) -> trc::Result { - let request = - serde_json::from_slice::(body.as_deref().unwrap_or_default()) - .map_err(|err| { - trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) - })?; + let request = serde_json::from_slice::(&body).map_err(|err| { + trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) + })?; let response = match request { - OAuthCodeRequest::Code { + LoginRequest::Discovery { account_name } => { + let account_name = account_name.trim().to_lowercase(); + if let Some(domain_name) = account_name.try_domain_part() { + if let Some(endpoint) = self + .get_directory_for_domain(domain_name) + .await? + .and_then(|directory| directory.oidc_authorization_endpoint()) + { + LoginResponse::External { endpoint } + } else { + LoginResponse::Local + } + } else { + LoginResponse::Local + } + } + LoginRequest::AuthCode { + account_name, + account_secret, + mfa_token, client_id, redirect_uri, nonce, @@ -97,56 +155,76 @@ impl OAuthApiHandler for Server { .details("Redirect URI must be HTTPS.")); } - // Generate client code - let client_code = rng() - .sample_iter(Alphanumeric) - .take(DEVICE_CODE_LEN) - .map(char::from) - .collect::(); + // Authenticate + match self + .authenticate(&AuthRequest { + credentials: Credentials::Basic { + username: account_name, + secret: account_secret, + mfa_token, + }, + session_id: session.session_id, + remote_ip: session.remote_ip, + }) + .await + { + Ok(access_token) => { + // Generate client code + let client_code = rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); - // Serialize OAuth code - let value = Archiver::new(OAuthCode { - status: OAuthStatus::Authorized, - account_id: access_token.account_id(), - client_id, - nonce, - params: redirect_uri.unwrap_or_default(), - }) - .untrusted() - .serialize() - .caused_by(trc::location!())?; + // Serialize OAuth code + let value = Archiver::new(OAuthCode { + status: OAuthStatus::Authorized, + account_id: access_token.account_id(), + client_id, + nonce, + params: redirect_uri.unwrap_or_default(), + }) + .untrusted() + .serialize() + .caused_by(trc::location!())?; - // Insert client code - self.in_memory_store() - .key_set( - KeyValue::with_prefix(KV_OAUTH, client_code.as_bytes(), value) - .expires(self.core.oauth.oauth_expiry_auth_code), - ) - .await?; + // Insert client code + self.in_memory_store() + .key_set( + KeyValue::with_prefix(KV_OAUTH, client_code.as_bytes(), value) + .expires(self.core.oauth.oauth_expiry_auth_code), + ) + .await?; - #[cfg(not(feature = "enterprise"))] - let is_enterprise = false; - - // SPDX-SnippetBegin - // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - // SPDX-License-Identifier: LicenseRef-SEL - #[cfg(feature = "enterprise")] - let is_enterprise = self.core.is_enterprise_edition(); - // SPDX-SnippetEnd - - json!({ - "data": { - "code": client_code, - "permissions": access_token.permissions(), - "version": env!("CARGO_PKG_VERSION"), - "isEnterprise": is_enterprise, + LoginResponse::Authenticated { client_code } + } + Err(err) => match *err.as_ref() { + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::MfaRequired + } + trc::EventType::Auth(_) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::Failure + } + trc::EventType::Security(_) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::Failure + } + _ => { + return Err(err); + } }, - }) + } } - OAuthCodeRequest::Device { code } => { - let mut success = false; - + LoginRequest::AuthDevice { + account_name, + account_secret, + mfa_token, + code, + } => { // Obtain code + let mut result = LoginResponse::Failure; if let Some(auth_code_) = self .in_memory_store() .key_get::>(KeyValue::<()>::build_key( @@ -159,40 +237,75 @@ impl OAuthApiHandler for Server { .unarchive::() .caused_by(trc::location!())?; if oauth.status == OAuthStatus::Pending { - let new_oauth_code = OAuthCode { - status: OAuthStatus::Authorized, - account_id: access_token.account_id(), - client_id: oauth.client_id.to_string(), - nonce: oauth.nonce.as_ref().map(|s| s.to_string()), - params: Default::default(), - }; - success = true; + // Authenticate + match self + .authenticate(&AuthRequest { + credentials: Credentials::Basic { + username: account_name, + secret: account_secret, + mfa_token, + }, + session_id: session.session_id, + remote_ip: session.remote_ip, + }) + .await + { + Ok(access_token) => { + let new_oauth_code = OAuthCode { + status: OAuthStatus::Authorized, + account_id: access_token.account_id(), + client_id: oauth.client_id.to_string(), + nonce: oauth.nonce.as_ref().map(|s| s.to_string()), + params: Default::default(), + }; - // Delete issued user code - self.in_memory_store() - .key_delete(KeyValue::<()>::build_key(KV_OAUTH, code.as_bytes())) - .await?; + // Delete issued user code + self.in_memory_store() + .key_delete(KeyValue::<()>::build_key( + KV_OAUTH, + code.as_bytes(), + )) + .await?; - // Update device code status - self.in_memory_store() - .key_set( - KeyValue::with_prefix( - KV_OAUTH, - oauth.params.as_bytes(), - Archiver::new(new_oauth_code) - .untrusted() - .serialize() - .caused_by(trc::location!())?, - ) - .expires(self.core.oauth.oauth_expiry_auth_code), - ) - .await?; + // Update device code status + self.in_memory_store() + .key_set( + KeyValue::with_prefix( + KV_OAUTH, + oauth.params.as_bytes(), + Archiver::new(new_oauth_code) + .untrusted() + .serialize() + .caused_by(trc::location!())?, + ) + .expires(self.core.oauth.oauth_expiry_auth_code), + ) + .await?; + + result = LoginResponse::Verified; + } + Err(err) => match *err.as_ref() { + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::MfaRequired; + } + trc::EventType::Auth(_) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::Failure; + } + trc::EventType::Security(_) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::Failure; + } + _ => { + return Err(err); + } + }, + } } } - json!({ - "data": success, - }) + result } }; @@ -257,6 +370,8 @@ impl OAuthApiHandler for Server { ) .await?; + let c = println!("Expires in: {}", self.core.oauth.oauth_expiry_user_code); + // Insert user code self.in_memory_store() .key_set( diff --git a/crates/http/src/auth/oauth/mod.rs b/crates/http/src/auth/oauth/mod.rs index fd1c24e0..99f4ded8 100644 --- a/crates/http/src/auth/oauth/mod.rs +++ b/crates/http/src/auth/oauth/mod.rs @@ -152,21 +152,6 @@ pub enum ErrorType { ExpiredToken, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -#[serde(rename_all = "camelCase")] -pub enum OAuthCodeRequest { - Code { - client_id: String, - redirect_uri: Option, - #[serde(default)] - nonce: Option, - }, - Device { - code: String, - }, -} - impl TokenResponse { pub fn error(error: ErrorType) -> Self { TokenResponse::Error { error } diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs index 45eec1ce..4bf783b8 100644 --- a/crates/http/src/auth/oauth/registration.rs +++ b/crates/http/src/auth/oauth/registration.rs @@ -14,18 +14,15 @@ use common::{ }, }; use http_proto::{request::fetch_body, *}; -use registry::{ - schema::{ - enums::Permission, - prelude::{ObjectType, Property}, - structs::OAuthClient, - }, - types::datetime::UTCDateTime, +use registry::schema::{ + enums::Permission, + prelude::{ObjectType, Property}, + structs::OAuthClient, }; use std::future::Future; use store::{ rand::{Rng, distr::Alphanumeric, rng}, - registry::{RegistryQuery, write::RegistryWrite}, + registry::write::{RegistryWrite, RegistryWriteResult}, }; use trc::{AddContext, AuthEvent}; use types::id::Id; @@ -79,11 +76,11 @@ impl ClientRegistrationHandler for Server { .map(|ch| char::from(ch.to_ascii_lowercase())) .collect::(); - self.registry() + let result = self + .registry() .write(RegistryWrite::insert( &OAuthClient { client_id: client_id.clone(), - created_at: UTCDateTime::now(), description: request.client_name.clone(), contacts: request.contacts.clone().into(), member_tenant_id: tenant_id.map(|id| Id::new(id as u64)), @@ -96,6 +93,14 @@ impl ClientRegistrationHandler for Server { .await .caused_by(trc::location!())?; + if !matches!(result, RegistryWriteResult::Success(_)) { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to register OAuth client.") + .reason(result.to_string()) + .caused_by(trc::location!())); + } + trc::event!( Auth(AuthEvent::ClientRegistration), Id = client_id.to_string(), @@ -124,23 +129,24 @@ impl ClientRegistrationHandler for Server { // Fetch client registration let found_registration = if let Some(client_id) = self .registry() - .query::>( - RegistryQuery::new(ObjectType::OAuthClient).equal(Property::ClientId, client_id), + .primary_key( + ObjectType::OAuthClient.into(), + Property::ClientId, + client_id.as_bytes().to_vec(), ) .await? - .first() { if let Some(redirect_uri) = redirect_uri { let client = self .registry() - .object::(*client_id) + .object::(client_id.id()) .await? .ok_or_else(|| { trc::StoreEvent::UnexpectedError .into_err() .details("OAuth client not found.") .caused_by(trc::location!()) - .ctx(trc::Key::Id, client_id.id()) + .ctx(trc::Key::Id, client_id.id().id()) })?; if client.redirect_uris.iter().any(|uri| uri == redirect_uri) { return Ok(None); diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 69f59911..299a5106 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -332,6 +332,16 @@ impl ParseHttp for Server { _ => (), }, "auth" => match (path.next().unwrap_or_default(), req.method()) { + ("login", &Method::POST) => { + self.is_http_anonymous_request_allowed(&session.remote_ip) + .await?; + + let bytes = fetch_body(&mut req, 4096, session.session_id) + .await + .ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?; + + return self.handle_login_request(session, bytes).await; + } ("device", &Method::POST) => { self.is_http_anonymous_request_allowed(&session.remote_ip) .await?; diff --git a/crates/imap/src/op/login.rs b/crates/imap/src/op/login.rs index 417701e8..f3dec4ae 100644 --- a/crates/imap/src/op/login.rs +++ b/crates/imap/src/op/login.rs @@ -17,6 +17,7 @@ impl Session { Credentials::Basic { username: arguments.username.to_string(), secret: arguments.password.to_string(), + mfa_token: None, }, arguments.tag, ) diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 71aa896e..a23b7438 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -97,8 +97,8 @@ impl ToRequestError for trc::Error { trc::LimitEvent::TooManyRequests => RequestError::too_many_requests(), }, trc::EventType::Auth(cause) => match cause { - trc::AuthEvent::MissingTotp => { - RequestError::blank(402, "TOTP code required", self.as_ref().message()) + trc::AuthEvent::MfaRequired => { + RequestError::blank(402, "MFA code required", self.as_ref().message()) } trc::AuthEvent::TooManyAttempts => RequestError::too_many_auth_attempts(), _ => RequestError::unauthorized(), diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs index bb0cbfe3..68e2da28 100644 --- a/crates/jmap/src/registry/mapping/account.rs +++ b/crates/jmap/src/registry/mapping/account.rs @@ -24,7 +24,7 @@ use common::{ cache::invalidate::CacheInvalidationBuilder, ipc::CacheInvalidation, }; -use directory::core::secret::{hash_secret, verify_otp_auth, verify_secret_hash}; +use directory::core::secret::{SecretVerificationResult, hash_secret, verify_mfa_secret_hash}; use jmap_proto::{error::set::SetError, types::state::State}; use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value}; use registry::{ @@ -143,36 +143,25 @@ pub(crate) async fn account_set( 'outer: for (id, value) in set.create.drain() { let mut credential = Credential::default(); - for (key, value) in value.into_expanded_object() { - let Key::Property(prop) = key else { + // Patch object + match credential.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + Ok(MaybeUnpatched::Patched) => {} + Ok( + MaybeUnpatched::Unpatched { .. } | MaybeUnpatched::UnpatchedMany { .. }, + ) => { set.response.not_created.append( id, - SetError::invalid_properties().with_property(key.into_owned()), + SetError::invalid_properties() + .with_description("Cannot set property during creation."), ); continue 'outer; - }; - let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]); - - // Patch object - match credential.patch(JsonPointerPatch::new(&ptr).with_create(true), value) - { - Ok(MaybeUnpatched::Patched) => {} - Ok( - MaybeUnpatched::Unpatched { .. } - | MaybeUnpatched::UnpatchedMany { .. }, - ) => { - set.response.not_created.append( - id, - SetError::invalid_properties() - .with_property(prop) - .with_description("Cannot set property during creation."), - ); - continue 'outer; - } - Err(err) => { - set.response.not_created.append(id, err.into()); - continue 'outer; - } + } + Err(err) => { + set.response.not_created.append(id, err.into()); + continue 'outer; } } @@ -202,23 +191,27 @@ pub(crate) async fn account_set( credential.credential_id = last_credential_id.into(); // Generate App password and hash secret - let app_ass = AppPassword::new(last_credential_id as u32).build(); + let app_pass = AppPassword::new(last_credential_id as u32); credential.secret = hash_secret( set.server.core.network.security.password_hash_algorithm, - app_ass.clone(), + app_pass.secret.to_vec(), ) .await .caused_by(trc::location!())?; + set.response.created.insert( id, Value::Object(Map::from(vec![ ( - Key::Property(Property::Secret), + Key::Property(Property::Id), Value::Element(RegistryValue::Id( last_credential_id.into(), )), ), - (Key::Property(Property::Secret), Value::Str(app_ass.into())), + ( + Key::Property(Property::Secret), + Value::Str(app_pass.build().into()), + ), ])), ); } @@ -246,24 +239,27 @@ pub(crate) async fn account_set( credential.credential_id = last_credential_id.into(); // Generate API key and hash secret - let api_key = - ApiKey::new(set.account_id, last_credential_id as u32).build(); + let api_key = ApiKey::new(set.account_id, last_credential_id as u32); credential.secret = hash_secret( set.server.core.network.security.password_hash_algorithm, - api_key.clone(), + api_key.secret.to_vec(), ) .await .caused_by(trc::location!())?; + set.response.created.insert( id, Value::Object(Map::from(vec![ ( - Key::Property(Property::Secret), + Key::Property(Property::Id), Value::Element(RegistryValue::Id( last_credential_id.into(), )), ), - (Key::Property(Property::Secret), Value::Str(api_key.into())), + ( + Key::Property(Property::Secret), + Value::Str(api_key.build().into()), + ), ])), ); } @@ -273,19 +269,23 @@ pub(crate) async fn account_set( SetError::forbidden() .with_description("Cannot create a password credential."), ); + continue 'outer; } } + + // Add credential to account + account.credentials.push(credential); } } // Process updates 'outer: for (id, value) in set.update.drain(..) { - if let Some(credential) = account + if let Some(mut old_credential) = account .credentials .values_mut() .find(|credential| credential.credential_id() == id) { - let old_credential = credential.clone(); + let mut credential = old_credential.clone(); let mut unpatched_properties = VecMap::new(); for (key, value) in value.into_expanded_object() { @@ -318,12 +318,12 @@ pub(crate) async fn account_set( } } - if credential == &old_credential { + if &credential == old_credential { set.response.updated.append(id, None); continue 'outer; } - match (credential, old_credential) { + match (&mut credential, &mut old_credential) { ( Credential::Password(credential), Credential::Password(old_credential), @@ -361,9 +361,9 @@ pub(crate) async fn account_set( .server .domain_by_id(account.domain_id.document_id()) .await? - .and_then(|domain| domain.id_directory) - .and_then(|domain_id| set.server.get_directory(&domain_id)) - .or_else(|| set.server.get_default_directory()) + .and_then(|domain| { + set.server.get_directory_for_cached_domain(&domain) + }) .is_some() { set.response.not_updated.append( @@ -396,61 +396,58 @@ pub(crate) async fn account_set( .and_then(|v| v.as_str()) .filter(|v| !v.is_empty()) { - if !verify_secret_hash( + match verify_mfa_secret_hash( + old_credential.otp_auth.as_deref(), + current_otp_code.as_deref(), &old_credential.secret, - current_secret.as_bytes(), + current_secret.as_ref(), ) .await? - || !verify_otp_auth( - old_credential.otp_auth.as_deref(), - current_otp_code.as_deref(), - )? { - let account = set.server.account(set.account_id).await?; - if set.server.has_auth_fail2ban() - && set - .server - .is_auth_fail2banned( - set.remote_ip, - account.name().into(), - ) - .await? - { - return Err(trc::SecurityEvent::AuthenticationBan - .into_err() - .details( - "Too many failed password change attempts.", - ) - .ctx(trc::Key::RemoteIp, set.remote_ip) - .ctx( - trc::Key::AccountName, - account.name().to_string(), - )); - } else { + SecretVerificationResult::Valid => {} + SecretVerificationResult::Invalid => { + let account = + set.server.account(set.account_id).await?; + if set.server.has_auth_fail2ban() + && set + .server + .is_auth_fail2banned( + set.remote_ip, + account.name().into(), + ) + .await? + { + return Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .details( + "Too many failed password change attempts.", + ) + .ctx(trc::Key::RemoteIp, set.remote_ip) + .ctx( + trc::Key::AccountName, + account.name().to_string(), + )); + } else { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current secret is incorrect.", + ), + ); + continue 'outer; + } + } + SecretVerificationResult::MissingMfaToken => { set.response.not_updated.append( id, SetError::forbidden().with_description( - "Current secret is incorrect.", + "Current OTP code is required to change the password or OTP auth.", ), ); continue 'outer; } } - if credential.otp_auth != old_credential.otp_auth - && !verify_otp_auth( - credential.otp_auth.as_deref(), - current_otp_code.as_deref(), - )? - { - set.response.not_updated.append( - id, - SetError::forbidden() - .with_description("OTP URL or token is invalid."), - ); - continue 'outer; - } - if credential.secret != old_credential.secret { if let Err(err) = set.server.is_secure_password(&credential.secret, &[]) @@ -469,7 +466,7 @@ pub(crate) async fn account_set( .network .security .password_hash_algorithm, - std::mem::take(&mut credential.secret), + std::mem::take(&mut credential.secret).into_bytes(), ) .await .caused_by(trc::location!())?; @@ -504,6 +501,8 @@ pub(crate) async fn account_set( _ => {} } + *old_credential = credential; + set.response.updated.append(id, None); } else { set.response.not_updated.append(id, SetError::not_found()); @@ -512,11 +511,25 @@ pub(crate) async fn account_set( // Process deletions for id in set.destroy.drain(..) { - if let Some(idx) = account.credentials.0.inner.iter_mut().position(|c| { - c.value.credential_id() == id && !matches!(c.value, Credential::Password(_)) - }) { - account.credentials.inner_mut().inner.remove(idx); - set.response.destroyed.push(id); + if let Some(idx) = account + .credentials + .0 + .inner + .iter_mut() + .position(|c| c.value.credential_id() == id) + { + let credentials = &mut account.credentials.inner_mut().inner; + if !matches!(credentials[idx].value, Credential::Password(_)) { + credentials.remove(idx); + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append( + id, + SetError::forbidden().with_description( + "Users are not allowed to destroy their own credentials.", + ), + ); + } } else { set.response.not_destroyed.append(id, SetError::not_found()); } diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs index 19657613..15e98cc0 100644 --- a/crates/jmap/src/registry/mapping/action.rs +++ b/crates/jmap/src/registry/mapping/action.rs @@ -12,7 +12,7 @@ use common::{ psl, }; use jmap_proto::error::set::{SetError, SetErrorType}; -use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use jmap_tools::{JsonPointer, Key}; use mail_auth::{ AuthenticatedMessage, DkimResult, DmarcResult, dmarc::verify::DmarcParameters, spf::verify::SpfParameters, @@ -45,19 +45,12 @@ pub(crate) async fn action_set( // Process creations 'outer: for (id, value) in set.create.drain() { let mut action = Action::default(); - for (key, value) in value.into_expanded_object() { - let Key::Property(prop) = key else { - set.response.not_created.append( - id, - SetError::invalid_properties().with_property(key.into_owned()), - ); - continue 'outer; - }; - let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]); - if let Err(err) = action.patch(JsonPointerPatch::new(&ptr).with_create(true), value) { - set.response.not_created.append(id, err.into()); - continue 'outer; - } + if let Err(err) = action.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; } let mut validation_errors = Vec::new(); diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index 4a01ea9f..a7b13d2e 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -51,9 +51,7 @@ pub(crate) async fn validate_account( set.server .domain_by_id(account.domain_id.document_id()) .await? - .and_then(|domain| domain.id_directory) - .and_then(|domain_id| set.server.get_directory(&domain_id)) - .or_else(|| set.server.get_default_directory()) + .and_then(|domain| set.server.get_directory_for_cached_domain(&domain)) .is_some() } else { false @@ -114,7 +112,7 @@ pub(crate) async fn validate_account( credential.secret = hash_secret( set.server.core.network.security.password_hash_algorithm, - std::mem::take(&mut credential.secret), + std::mem::take(&mut credential.secret).into_bytes(), ) .await .caused_by(trc::location!())?; @@ -245,7 +243,7 @@ async fn validate_credential_creation( } else { credential.secret = hash_secret( server.core.network.security.password_hash_algorithm, - std::mem::take(&mut credential.secret), + std::mem::take(&mut credential.secret).into_bytes(), ) .await .caused_by(trc::location!())?; diff --git a/crates/jmap/src/registry/mapping/queued_message.rs b/crates/jmap/src/registry/mapping/queued_message.rs index ba61758e..e8c5bb68 100644 --- a/crates/jmap/src/registry/mapping/queued_message.rs +++ b/crates/jmap/src/registry/mapping/queued_message.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::str::FromStr; - use crate::{ api::query::QueryResponseBuilder, registry::{ @@ -38,6 +36,7 @@ use smtp::queue::{ FROM_UNAUTHENTICATED_DMARC, Message, MessageWrapper, RCPT_DSN_SENT, RCPT_SPAM_PAYLOAD, Schedule, Status, spool::SmtpSpool, }; +use std::str::FromStr; use store::{ Deserialize, IterateParams, U64_LEN, ValueKey, ahash::AHashSet, diff --git a/crates/jmap/src/registry/mapping/spam_sample.rs b/crates/jmap/src/registry/mapping/spam_sample.rs index 5ebf83d8..6e4cf621 100644 --- a/crates/jmap/src/registry/mapping/spam_sample.rs +++ b/crates/jmap/src/registry/mapping/spam_sample.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::str::FromStr; - use crate::{ api::query::QueryResponseBuilder, blob::download::BlobDownload, @@ -15,7 +13,7 @@ use crate::{ }, }; use jmap_proto::{error::set::SetError, types::state::State}; -use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use jmap_tools::JsonPointer; use mail_parser::{MessageParser, parsers::fields::thread::thread_name}; use registry::{ jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch}, @@ -27,6 +25,7 @@ use registry::{ }, types::{EnumImpl, datetime::UTCDateTime, id::ObjectId}, }; +use std::str::FromStr; use store::{ SerializeInfallible, ValueKey, registry::RegistryQuery, @@ -63,27 +62,13 @@ pub(crate) async fn spam_sample_set( ); continue; }; - - for (key, value) in value.into_expanded_object() { - let Key::Property(prop) = key else { - set.response.not_created.append( - id, - SetError::invalid_properties().with_property(key.into_owned()), - ); - continue 'outer; - }; - - if let Err(err) = sample.patch( - JsonPointerPatch::new(&JsonPointer::new(vec![JsonPointerItem::Key( - Key::Property(prop), - )])) - .with_create(true), - value, - ) { - set.response.not_created.append(id, err.into()); - continue 'outer; - }; - } + if let Err(err) = sample.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; + }; if sample.blob_id.hash.is_empty() { set.response.not_created.append( diff --git a/crates/jmap/src/registry/mapping/task.rs b/crates/jmap/src/registry/mapping/task.rs index 70110a66..e94a5c63 100644 --- a/crates/jmap/src/registry/mapping/task.rs +++ b/crates/jmap/src/registry/mapping/task.rs @@ -51,19 +51,12 @@ pub(crate) async fn task_set( // Process creations 'outer: for (id, value) in set.create.drain() { let mut task = Task::default(); - for (key, value) in value.into_expanded_object() { - let Key::Property(prop) = key else { - set.response.not_created.append( - id, - SetError::invalid_properties().with_property(key.into_owned()), - ); - continue 'outer; - }; - let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]); - if let Err(err) = task.patch(JsonPointerPatch::new(&ptr).with_create(true), value) { - set.response.not_created.append(id, err.into()); - continue 'outer; - } + if let Err(err) = task.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; } let mut validation_errors = Vec::new(); diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 1beb3307..064a8496 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; - use crate::registry::mapping::{ ObjectResponse, RegistrySetResponse, account::account_set, @@ -47,6 +45,7 @@ use registry::{ }, types::id::ObjectId, }; +use std::borrow::Cow; use store::registry::{ bootstrap::Bootstrap, write::{RegistryWrite, RegistryWriteResult}, diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs index e8687421..2a6c8eaa 100644 --- a/crates/pop3/src/client.rs +++ b/crates/pop3/src/client.rs @@ -103,6 +103,7 @@ impl Session { self.handle_auth(Credentials::Basic { username, secret: string, + mfa_token: None, }) .await .map(|_| SessionResult::Continue) diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 354e2c02..180fac51 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -26,6 +26,7 @@ impl SaslToken { credentials: Credentials::Basic { username: String::new(), secret: String::new(), + mfa_token: None, }, } .into(), @@ -54,7 +55,12 @@ impl Session { self.write(b"334 Go ahead.\r\n").await?; return Ok(true); } - (AUTH_LOGIN, Credentials::Basic { username, secret }) => { + ( + AUTH_LOGIN, + Credentials::Basic { + username, secret, .. + }, + ) => { if username.is_empty() && secret.is_empty() { self.write(b"334 VXNlcm5hbWU6\r\n").await?; return Ok(true); @@ -69,7 +75,12 @@ impl Session { return self.authenticate(credentials).await; } } - (AUTH_LOGIN, Credentials::Basic { username, secret }) => { + ( + AUTH_LOGIN, + Credentials::Basic { + username, secret, .. + }, + ) => { return if username.is_empty() { *username = response.into_string(); self.write(b"334 UGFzc3dvcmQ6\r\n").await?; @@ -81,6 +92,7 @@ impl Session { Credentials::Basic { username: String::new(), secret: String::new(), + mfa_token: None, }, )) .await @@ -137,10 +149,14 @@ impl Session { 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) => { + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { return self .auth_error( - b"334 5.7.8 Missing TOTP token, try with 'secret$totp_code'.\r\n", + concat!( + "334 5.7.8 This account requires multi-factor authentication. ", + "Alternatively, you can use an app password if your account has one.\r\n" + ) + .as_bytes(), ) .await; } diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index 8c8abc07..b64d5b04 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -106,7 +106,7 @@ pub enum AuthEvent { Success = 37, Failed = 35, TokenExpired = 554, - MissingTotp = 36, + MfaRequired = 36, TooManyAttempts = 38, ClientRegistration = 555, Error = 34, diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index f397b50f..332b7b79 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -44,7 +44,7 @@ impl EventType { b"auth.success" => EventType::Auth(AuthEvent::Success), b"auth.failed" => EventType::Auth(AuthEvent::Failed), b"auth.token-expired" => EventType::Auth(AuthEvent::TokenExpired), - b"auth.missing-totp" => EventType::Auth(AuthEvent::MissingTotp), + b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired), b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), b"auth.error" => EventType::Auth(AuthEvent::Error), @@ -646,7 +646,7 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "auth.success", EventType::Auth(AuthEvent::Failed) => "auth.failed", EventType::Auth(AuthEvent::TokenExpired) => "auth.token-expired", - EventType::Auth(AuthEvent::MissingTotp) => "auth.missing-totp", + EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required", EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", EventType::Auth(AuthEvent::Error) => "auth.error", @@ -1373,7 +1373,7 @@ impl EventType { EventType::Auth(AuthEvent::Success) => 37, EventType::Auth(AuthEvent::Failed) => 35, EventType::Auth(AuthEvent::TokenExpired) => 554, - EventType::Auth(AuthEvent::MissingTotp) => 36, + EventType::Auth(AuthEvent::MfaRequired) => 36, EventType::Auth(AuthEvent::TooManyAttempts) => 38, EventType::Auth(AuthEvent::ClientRegistration) => 555, EventType::Auth(AuthEvent::Error) => 34, @@ -1974,7 +1974,7 @@ impl EventType { 37 => Some(EventType::Auth(AuthEvent::Success)), 35 => Some(EventType::Auth(AuthEvent::Failed)), 554 => Some(EventType::Auth(AuthEvent::TokenExpired)), - 36 => Some(EventType::Auth(AuthEvent::MissingTotp)), + 36 => Some(EventType::Auth(AuthEvent::MfaRequired)), 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), 34 => Some(EventType::Auth(AuthEvent::Error)), @@ -2832,7 +2832,7 @@ impl EventType { EventType::TlsRpt(TlsRptEvent::RecordFetchError) => Level::Info, EventType::TlsRpt(TlsRptEvent::RecordNotFound) => Level::Info, EventType::Ai(AiEvent::LlmResponse) => Level::Trace, - EventType::Auth(AuthEvent::MissingTotp) => Level::Trace, + EventType::Auth(AuthEvent::MfaRequired) => Level::Trace, EventType::Cluster(ClusterEvent::MessageReceived) => Level::Trace, EventType::Cluster(ClusterEvent::MessageSkipped) => Level::Trace, EventType::Delivery(DeliveryEvent::RawInput) => Level::Trace, @@ -2955,7 +2955,7 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "Authentication successful", EventType::Auth(AuthEvent::Failed) => "Authentication failed", EventType::Auth(AuthEvent::TokenExpired) => "OAuth token expired", - EventType::Auth(AuthEvent::MissingTotp) => "Missing TOTP for authentication", + EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", EventType::Auth(AuthEvent::Error) => "Authentication error", @@ -3698,7 +3698,9 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "Successful authentication", EventType::Auth(AuthEvent::Failed) => "Failed authentication", EventType::Auth(AuthEvent::TokenExpired) => "OAuth authentication token has expired", - EventType::Auth(AuthEvent::MissingTotp) => "TOTP is missing for authentication", + EventType::Auth(AuthEvent::MfaRequired) => { + "MFA token is required for authentication but was not provided" + } EventType::Auth(AuthEvent::TooManyAttempts) => { "Too many authentication attempts have been made" } @@ -4783,7 +4785,7 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "Authentication error", EventType::Auth(AuthEvent::Failed) => "Authentication failed", EventType::Auth(AuthEvent::TokenExpired) => "Authentication error", - EventType::Auth(AuthEvent::MissingTotp) => "A TOTP code is required to authenticate this account. Try authenticating again using 'secret$totp_token'.", + EventType::Auth(AuthEvent::MfaRequired) => "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one.", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error", @@ -5078,7 +5080,7 @@ impl EventType { EventType::Auth(AuthEvent::Success), EventType::Auth(AuthEvent::Failed), EventType::Auth(AuthEvent::TokenExpired), - EventType::Auth(AuthEvent::MissingTotp), + EventType::Auth(AuthEvent::MfaRequired), EventType::Auth(AuthEvent::TooManyAttempts), EventType::Auth(AuthEvent::ClientRegistration), EventType::Auth(AuthEvent::Error), diff --git a/tests/src/imap/antispam.rs b/tests/src/imap/antispam.rs index add3dab5..27163392 100644 --- a/tests/src/imap/antispam.rs +++ b/tests/src/imap/antispam.rs @@ -245,23 +245,6 @@ pub async fn spam_training_samples(server: &Server) -> TrainingSamples { samples } -impl ImapConnection { - async fn append(&mut self, mailbox: &str, message: &str) { - self.send_ok(&format!( - "APPEND {:?} {{{}+}}\r\n{}", - mailbox, - message.len(), - message - )) - .await; - } - - async fn send_ok(&mut self, cmd: &str) { - self.send(cmd).await; - self.assert_read(Type::Tagged, ResponseType::Ok).await; - } -} - pub const SPAM: [&str; 10] = [ concat!( "Subject: save up to = on life insurance\r\n\r\n wh", diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index a9a78b66..52356c25 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -300,125 +300,6 @@ async fn init_imap_tests(delete_if_exists: bool) -> IMAPTest { } } -pub struct ImapConnection { - tag: &'static [u8], - reader: Lines>>, - writer: WriteHalf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Type { - Tagged, - Untagged, - Continuation, - Status, -} - -impl ImapConnection { - pub async fn connect(tag: &'static [u8]) -> Self { - Self::connect_to(tag, "127.0.0.1:9991").await - } - - pub async fn connect_to(tag: &'static [u8], addr: impl AsRef) -> Self { - let (reader, writer) = tokio::io::split(TcpStream::connect(addr.as_ref()).await.unwrap()); - ImapConnection { - tag, - reader: BufReader::new(reader).lines(), - writer, - } - } - - pub async fn assert_read(&mut self, t: Type, rt: ResponseType) -> Vec { - let lines = self.read(t).await; - let mut buf = Vec::with_capacity(10); - buf.extend_from_slice(match t { - Type::Tagged => self.tag, - Type::Untagged | Type::Status => b"* ", - Type::Continuation => b"+ ", - }); - if !matches!(t, Type::Continuation | Type::Status) { - rt.serialize(&mut buf); - } - if lines - .last() - .unwrap() - .starts_with(&String::from_utf8(buf).unwrap()) - { - lines - } else { - panic!("Expected {:?}/{:?} from server but got: {:?}", t, rt, lines); - } - } - - pub async fn assert_disconnect(&mut self) { - match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { - Ok(Ok(None)) => {} - Ok(Ok(Some(line))) => { - panic!("Expected connection to be closed, but got {:?}", line); - } - Ok(Err(err)) => { - panic!("Connection broken: {:?}", err); - } - Err(_) => panic!("Timeout while waiting for server response."), - } - } - - pub async fn read(&mut self, t: Type) -> Vec { - let mut lines = Vec::new(); - loop { - match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { - Ok(Ok(Some(line))) => { - let is_done = line.starts_with(match t { - Type::Tagged => std::str::from_utf8(self.tag).unwrap(), - Type::Untagged | Type::Status => "* ", - Type::Continuation => "+ ", - }); - //let c = println!("<- {:?}", line); - lines.push(line); - if is_done { - return lines; - } - } - Ok(Ok(None)) => { - panic!("Invalid response: {:?}.", lines); - } - Ok(Err(err)) => { - panic!("Connection broken: {} ({:?})", err, lines); - } - Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), - } - } - } - - pub async fn authenticate(&mut self, user: &str, pass: &str) { - let creds = general_purpose::STANDARD.encode(format!("\0{user}\0{pass}")); - self.send(&format!( - "AUTHENTICATE PLAIN {{{}+}}\r\n{creds}", - creds.len() - )) - .await; - self.assert_read(Type::Tagged, ResponseType::Ok).await; - } - - pub async fn send(&mut self, text: &str) { - //let c = println!("-> {}{:?}", std::str::from_utf8(self.tag).unwrap(), text); - self.writer.write_all(self.tag).await.unwrap(); - self.writer.write_all(text.as_bytes()).await.unwrap(); - self.writer.write_all(b"\r\n").await.unwrap(); - } - - pub async fn send_untagged(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - self.writer.write_all(b"\r\n").await.unwrap(); - } - - pub async fn send_raw(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - } -} - pub trait AssertResult: Sized { fn assert_folders<'x>( self, diff --git a/tests/src/imap/pop.rs b/tests/src/imap/pop.rs index 0424f7dd..89f13146 100644 --- a/tests/src/imap/pop.rs +++ b/tests/src/imap/pop.rs @@ -187,91 +187,3 @@ pub async fn test() { .assert_contains("+OK 0 0"); pop3.send("QUIT").await; } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ResponseType { - Ok, - Multiline, - Err, -} - -pub struct Pop3Connection { - reader: Lines>>>, - writer: WriteHalf>, -} - -impl Pop3Connection { - pub async fn connect() -> Self { - let (reader, writer) = tokio::io::split( - build_tls_connector(true) - .connect( - ServerName::try_from("pop3.example.org").unwrap().to_owned(), - TcpStream::connect("127.0.0.1:4110").await.unwrap(), - ) - .await - .unwrap(), - ); - Pop3Connection { - reader: BufReader::new(reader).lines(), - writer, - } - } - - pub async fn connect_and_login() -> Self { - let mut pop3 = Self::connect().await; - pop3.assert_read(ResponseType::Ok).await; - pop3.send("AUTH PLAIN AHBvcHBlckBleGFtcGxlLmNvbQBzZWNyZXQ=") - .await; - pop3.assert_read(ResponseType::Ok).await; - pop3 - } - - pub async fn assert_read(&mut self, rt: ResponseType) -> Vec { - let lines = self.read(matches!(rt, ResponseType::Multiline)).await; - if lines.last().unwrap().starts_with(match rt { - ResponseType::Ok => "+OK", - ResponseType::Multiline => ".", - ResponseType::Err => "-ERR", - }) { - lines - } else { - panic!("Expected {:?} from server but got: {:?}", rt, lines); - } - } - - pub async fn read(&mut self, is_multiline: bool) -> Vec { - let mut lines = Vec::new(); - loop { - match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { - Ok(Ok(Some(line))) => { - let is_done = (!is_multiline && line.starts_with("+OK")) - || (is_multiline && line == ".") - || line.starts_with("-ERR"); - //let c = println!("<- {:?}", line); - lines.push(line); - if is_done { - return lines; - } - } - Ok(Ok(None)) => { - panic!("Invalid response: {:?}.", lines); - } - Ok(Err(err)) => { - panic!("Connection broken: {} ({:?})", err, lines); - } - Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), - } - } - } - - pub async fn send(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - self.writer.write_all(b"\r\n").await.unwrap(); - } - - pub async fn send_raw(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - } -} diff --git a/tests/src/system/authentication.rs b/tests/src/system/authentication.rs index 928cbdde..a629f227 100644 --- a/tests/src/system/authentication.rs +++ b/tests/src/system/authentication.rs @@ -4,23 +4,38 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use common::auth::credential::{ApiKey, AppPassword}; use jmap_proto::error::set::SetErrorType; use registry::{ schema::{ - enums::CredentialType, + enums::{CredentialType, StorageQuota}, prelude::{ObjectType, Property}, - structs::{Account, Credential, PasswordCredential, SecondaryCredential, UserAccount}, + structs::{ + Account, Credential, Http, PasswordCredential, SecondaryCredential, UserAccount, + }, }, - types::{EnumImpl, list::List}, + types::{EnumImpl, ipmask::IpAddrOrMask, list::List, map::Map}, }; use serde_json::json; - -use crate::utils::server::TestServer; +use std::str::FromStr; pub async fn test(test: &TestServer) { let admin = test.account("admin@example.org"); let domain_id = admin.find_or_create_domain("example.org").await; + // Enable X-Forwarded-For processing to test IP-based access restrictions + admin + .registry_update_setting( + Http { + use_x_forwarded: true, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin.reload_settings().await; + // Weak passwords should be rejected admin .registry_create_object_expect_err(Account::User(UserAccount { @@ -120,7 +135,7 @@ pub async fn test(test: &TestServer) { validate_password("user@example.org", "very strong password indeed", true).await; // Change password as user - let user = crate::utils::account::Account::new( + let mut user = crate::utils::account::Account::new( "user@example.org", "very strong password indeed", &[], @@ -149,15 +164,8 @@ pub async fn test(test: &TestServer) { "Current secret must be provided to change the password or OTP auth.", ); - user.registry_query( - ObjectType::Credential, - [(Property::Type, CredentialType::Password.as_str())], - Vec::<&str>::new(), - ) - .await[0]; - // Password policies should be enforced when changing password - /*user.registry_update_object_expect_err( + user.registry_update_object_expect_err( ObjectType::Credential, credential_id, json!({ @@ -167,16 +175,178 @@ pub async fn test(test: &TestServer) { ) .await .assert_type(SetErrorType::InvalidProperties) - .assert_description_contains("Password must be at least 8 characters long.");*/ + .assert_description_contains("Password must be at least 8 characters long."); + + // Perform a valid password update + user.registry_update_object( + ObjectType::Credential, + credential_id, + json!({ + Property::CurrentSecret: "very strong password indeed", + Property::Secret: "user provided strong password" + }), + ) + .await; + validate_password("user@example.org", "very strong password indeed", false).await; + validate_password("user@example.org", "user provided strong password", true).await; + user.update_secret("user provided strong password"); + + // Users should not be allowed to change allowedIps of expiration + user.registry_update_object_expect_err( + ObjectType::Credential, + credential_id, + json!({ + Property::CurrentSecret: "user provided strong password", + Property::ExpiresAt: "2029-01-01T00:00:00Z" + }), + ) + .await + .assert_type(SetErrorType::Forbidden) + .assert_description_contains("Modifying allowed IPs or expiration is not allowed."); + + user.registry_update_object_expect_err( + ObjectType::Credential, + credential_id, + json!({ + Property::CurrentSecret: "user provided strong password", + Property::AllowedIps: {"192.168.1.1": true} + }), + ) + .await + .assert_type(SetErrorType::Forbidden) + .assert_description_contains("Modifying allowed IPs or expiration is not allowed."); + + // Users should not be allowed to destroy their own credentials + user.registry_destroy_object_expect_err(ObjectType::Credential, credential_id) + .await + .assert_type(SetErrorType::Forbidden) + .assert_description_contains("Users are not allowed to destroy their own credentials."); + + // Limit login to specific IPs and set credential quotas + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/allowedIps": {"192.168.1.1": true}, + Property::Quotas: { + StorageQuota::MaxApiKeys.as_str(): 1, + StorageQuota::MaxAppPasswords.as_str(): 1, + } + }), + ) + .await; + validate_password_with_ip( + "user@example.org", + "user provided strong password", + "192.168.1.1", + true, + ) + .await; + validate_password_with_ip( + "user@example.org", + "user provided strong password", + "192.168.1.2", + false, + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/allowedIps": {}, + }), + ) + .await; + + // Create an IP-restricted App Password and verify it works + let response = user + .registry_create([Credential::AppPassword(SecondaryCredential { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]), + description: "My app password".to_string(), + ..Default::default() + })]) + .await; + let app_password = response.created(0); + let app_password_id = app_password.object_id(); + let app_password_secret = app_password.text_field("secret").to_string(); + let _ = AppPassword::parse(&app_password_secret).unwrap(); + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", true).await; + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.3", false).await; + + // Create an IP-restricted API key and verify it works + let response = user + .registry_create([Credential::ApiKey(SecondaryCredential { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]), + description: "My API key".to_string(), + ..Default::default() + })]) + .await; + let api_key = response.created(0); + let api_key_id = api_key.object_id(); + let api_key_secret = api_key.text_field("secret").to_string(); + let _ = ApiKey::parse(&api_key_secret).unwrap(); + validate_token_with_ip(&api_key_secret, "10.0.0.2", true).await; + validate_token_with_ip(&api_key_secret, "10.0.0.3", false).await; + + // Creating more API keys or app passwords should fail due to quota + user.registry_create_object_expect_err(Credential::AppPassword(SecondaryCredential { + description: "Another app password".to_string(), + ..Default::default() + })) + .await + .assert_type(SetErrorType::OverQuota) + .assert_description_contains("You have exceeded your quota of 1 app passwords."); + user.registry_create_object_expect_err(Credential::ApiKey(SecondaryCredential { + description: "Another API key".to_string(), + ..Default::default() + })) + .await + .assert_type(SetErrorType::OverQuota) + .assert_description_contains("You have exceeded your quota of 1 API keys."); + + // Destroy the API key and app password, then verify they no longer work + let response = user + .registry_destroy(ObjectType::Credential, [app_password_id, api_key_id]) + .await; + assert_eq!( + vec![app_password_id, api_key_id], + response.destroyed_ids().collect::>() + ); + validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await; + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", false).await; + validate_password("user@example.org", "user provided strong password", true).await; + + // Clean up + assert_eq!( + admin + .registry_destroy(ObjectType::Account, [user_id]) + .await + .destroyed_ids() + .collect::>(), + vec![user_id] + ); + validate_password("user@example.org", "user provided strong password", false).await; } -pub async fn validate_password(username: &str, password: &str, is_valid: bool) { +async fn validate_password(username: &str, password: &str, is_valid: bool) { + validate_password_with_ip(username, password, "127.0.0.1", is_valid).await; +} + +async fn validate_password_with_ip( + username: &str, + password: &str, + remote_ip: &str, + is_valid: bool, +) { let response = reqwest::Client::builder() .danger_accept_invalid_certs(true) .build() .unwrap() .get("https://127.0.0.1:8899/.well-known/jmap") .basic_auth(username, Some(password)) + .header("X-Forwarded-For", remote_ip) .send() .await .unwrap(); @@ -196,3 +366,31 @@ pub async fn validate_password(username: &str, password: &str, is_valid: bool) { ); } } + +async fn validate_token_with_ip(token: &str, remote_ip: &str, is_valid: bool) { + let response = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .get("https://127.0.0.1:8899/.well-known/jmap") + .bearer_auth(token) + .header("X-Forwarded-For", remote_ip) + .send() + .await + .unwrap(); + + let status = response.status(); + if status.is_success() != is_valid { + let text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + panic!( + "Expected token to be {}. Server responded with status {}: {}", + if is_valid { "valid" } else { "invalid" }, + status, + text + ); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index b5c6bd36..71077fd8 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -6,6 +6,7 @@ pub mod authentication; pub mod directory; +pub mod oidc; use crate::utils::server::TestServerBuilder; @@ -32,5 +33,6 @@ pub async fn system_tests() { .await; //directory::test(&test).await; - authentication::test(&test).await; + //authentication::test(&test).await; + oidc::test(&mut test).await; } diff --git a/tests/src/jmap/auth/oauth.rs b/tests/src/system/oidc.rs similarity index 68% rename from tests/src/jmap/auth/oauth.rs rename to tests/src/system/oidc.rs index 05f36488..cf08e198 100644 --- a/tests/src/jmap/auth/oauth.rs +++ b/tests/src/system/oidc.rs @@ -4,12 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{ - imap::{ - ImapConnection, Type, - pop::{self, Pop3Connection}, - }, - jmap::{JMAPTest, ManagementApi, mail::delivery::SmtpConnection}, +use crate::utils::{ + http::HttpRequest, + imap::{ImapConnection, Type}, + pop3::Pop3Connection, + server::TestServer, + smtp::SmtpConnection, }; use base64::{Engine, engine::general_purpose}; use biscuit::{JWT, SingleOrMultiple, jwk::JWKSet}; @@ -20,7 +20,8 @@ use common::auth::oauth::{ registration::{ClientRegistrationRequest, ClientRegistrationResponse}, }; use http::auth::oauth::{ - DeviceAuthResponse, ErrorType, OAuthCodeRequest, TokenResponse, auth::OAuthMetadata, + DeviceAuthResponse, ErrorType, TokenResponse, + auth::{LoginRequest, LoginResponse, OAuthMetadata}, openid::OpenIdMetadata, }; use imap_proto::ResponseType; @@ -28,27 +29,77 @@ use jmap_client::{ client::{Client, Credentials}, mailbox::query::Filter, }; +use registry::{ + schema::{ + enums::JwtSignatureAlgorithm, + prelude::{ObjectType, Property}, + structs::{ + Account, Credential, OidcProvider, PasswordCredential, SecretText, SecretTextValue, + UserAccount, + }, + }, + types::list::List, +}; use serde::{Serialize, de::DeserializeOwned}; use std::time::{Duration, Instant}; use store::ahash::AHashMap; -#[derive(serde::Deserialize, Debug)] -#[allow(dead_code)] -struct OAuthCodeResponse { - pub code: String, - #[serde(rename = "isEnterprise")] - pub is_enterprise: bool, -} +pub async fn test(test: &mut TestServer) { + println!("Running OIDC tests..."); -pub async fn test(params: &mut JMAPTest) { - println!("Running OAuth tests..."); + let admin = test.account("admin@example.org"); + let domain_id = admin.find_or_create_domain("example.org").await; + + // Set test parameters + let settings = OidcProvider { + access_token_expiry: registry::schema::prelude::Duration::from_millis(1000), + auth_code_expiry: registry::schema::prelude::Duration::from_millis(1000), + auth_code_max_attempts: 1, + user_code_expiry: registry::schema::prelude::Duration::from_millis(1000), + refresh_token_expiry: registry::schema::prelude::Duration::from_millis(3000), + refresh_token_renewal: registry::schema::prelude::Duration::from_millis(2000), + anonymous_client_registration: true, + require_client_registration: true, + signature_algorithm: JwtSignatureAlgorithm::Rs256, + signature_key: SecretText::Text(SecretTextValue { + secret: OIDC_SIGNATURE_KEY_RS256.to_string(), + }), + ..Default::default() + }; + admin + .registry_update_setting( + settings, + &[ + Property::AccessTokenExpiry, + Property::AuthCodeExpiry, + Property::AuthCodeMaxAttempts, + Property::UserCodeExpiry, + Property::RefreshTokenExpiry, + Property::RefreshTokenRenewal, + Property::AnonymousClientRegistration, + Property::RequireClientRegistration, + Property::SignatureAlgorithm, + Property::SignatureKey, + ], + ) + .await; + admin.reload_settings().await; // Create test account - let server = params.server.clone(); - let account = params.account("jdoe@example.com"); + let user_id = admin + .registry_create_object(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await; // Build API - let api = ManagementApi::new(8899, "jdoe@example.com", "12345"); + let http = HttpRequest::new(); // Obtain OAuth metadata let metadata: OAuthMetadata = @@ -78,25 +129,27 @@ pub async fn test(params: &mut JMAPTest) { // ------------------------ // Authenticate with the correct password - let response = api - .post::( - "/api/oauth", - &OAuthCodeRequest::Code { + let response = http + .post::( + "/auth/login", + &LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, client_id: client_id.to_string(), redirect_uri: "https://localhost".to_string().into(), nonce: "abc1234".to_string().into(), }, ) .await - .unwrap() - .unwrap_data(); + .unwrap(); // Both client_id and redirect_uri have to match let mut token_params = AHashMap::from_iter([ ("client_id".to_string(), "invalid_client".to_string()), ("redirect_uri".to_string(), "https://localhost".to_string()), ("grant_type".to_string(), "authorization_code".to_string()), - ("code".to_string(), response.code), + ("code".to_string(), response.unwrap_code()), ]); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, @@ -129,7 +182,7 @@ pub async fn test(params: &mut JMAPTest) { .connect("https://127.0.0.1:8899") .await .unwrap(); - assert_eq!(john_client.default_account_id(), account.id_string()); + assert_eq!(john_client.default_account_id(), user_id.to_string()); assert!( !john_client .mailbox_query(None::, None::>) @@ -149,7 +202,7 @@ pub async fn test(params: &mut JMAPTest) { assert_eq!(registered_claims.issuer, Some(oidc_metadata.issuer)); assert_eq!( registered_claims.subject, - Some(account.id().document_id().to_string()) + Some(user_id.document_id().to_string()) ); assert_eq!( registered_claims.audience, @@ -158,9 +211,9 @@ pub async fn test(params: &mut JMAPTest) { assert_eq!(private_claims.nonce, Some("abc1234".into())); assert_eq!( private_claims.preferred_username, - Some("jdoe@example.com".into()) + Some("user@example.org".into()) ); - assert_eq!(private_claims.email, Some("jdoe@example.com".into())); + assert_eq!(private_claims.email, Some("user@example.org".into())); // Introspect token let access_introspect: OAuthIntrospect = post_with_auth::( @@ -169,7 +222,7 @@ pub async fn test(params: &mut JMAPTest) { &AHashMap::from_iter([("token".to_string(), token.to_string())]), ) .await; - assert_eq!(access_introspect.username.unwrap(), "jdoe@example.com"); + assert_eq!(access_introspect.username.unwrap(), "user@example.org"); assert_eq!(access_introspect.token_type.unwrap(), "bearer"); assert_eq!(access_introspect.client_id.unwrap(), client_id); assert!(access_introspect.active); @@ -179,7 +232,7 @@ pub async fn test(params: &mut JMAPTest) { &AHashMap::from_iter([("token".to_string(), refresh_token.unwrap())]), ) .await; - assert_eq!(refresh_introspect.username.unwrap(), "jdoe@example.com"); + assert_eq!(refresh_introspect.username.unwrap(), "user@example.org"); assert_eq!(refresh_introspect.client_id.unwrap(), client_id); assert!(refresh_introspect.active); assert_eq!( @@ -213,10 +266,10 @@ pub async fn test(params: &mut JMAPTest) { // Try POP3 OAUTHBEARER auth let mut pop3 = Pop3Connection::connect().await; - pop3.assert_read(pop::ResponseType::Ok).await; + pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await; pop3.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}")) .await; - pop3.assert_read(pop::ResponseType::Ok).await; + pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await; // ------------------------ // Device code flow @@ -250,17 +303,19 @@ pub async fn test(params: &mut JMAPTest) { // Let the code expire and make sure it's invalidated tokio::time::sleep(Duration::from_secs(1)).await; - assert!( - !api.post::( - "/api/oauth", - &OAuthCodeRequest::Device { + assert_eq!( + http.post::( + "/auth/login", + &LoginRequest::AuthDevice { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, code: device_response.user_code.clone(), }, ) .await - .unwrap() - .unwrap_data(), - "Code should be expired" + .unwrap(), + LoginResponse::Failure ); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, @@ -276,17 +331,19 @@ pub async fn test(params: &mut JMAPTest) { "device_code".to_string(), device_response.device_code.to_string(), ); - assert!( - api.post::( - "/api/oauth", - &OAuthCodeRequest::Device { + assert_eq!( + http.post::( + "/auth/login", + &LoginRequest::AuthDevice { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, code: device_response.user_code.clone(), }, ) .await - .unwrap() - .unwrap_data(), - "Code is invalid" + .unwrap(), + LoginResponse::Verified ); // Obtain token @@ -311,7 +368,7 @@ pub async fn test(params: &mut JMAPTest) { .connect("https://127.0.0.1:8899") .await .unwrap(); - assert_eq!(john_client.default_account_id(), account.id_string()); + assert_eq!(john_client.default_account_id(), user_id.to_string()); assert!( !john_client .mailbox_query(None::, None::>) @@ -378,16 +435,16 @@ pub async fn test(params: &mut JMAPTest) { } ); - // Destroy test accounts - server - .core - .storage - .lookup - .purge_in_memory_store() - .await - .unwrap(); - params.destroy_all_mailboxes(account).await; - params.assert_is_empty().await; + // Clean up + assert_eq!( + admin + .registry_destroy(ObjectType::Account, [user_id]) + .await + .destroyed_ids() + .collect::>(), + vec![user_id] + ); + test.assert_is_empty().await; } async fn post_bytes( @@ -518,3 +575,54 @@ fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option panic!("Expected granted, got {:?}", error), } } + +pub trait LoginResponseTest { + fn unwrap_code(self) -> String; +} + +impl LoginResponseTest for LoginResponse { + fn unwrap_code(self) -> String { + match self { + LoginResponse::Authenticated { client_code } => client_code, + _ => panic!("Expected auth code response, got {:?}", self), + } + } +} + +const OIDC_SIGNATURE_KEY_RS256: &str = "-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF +Ze/6493VjL+jHkFMP2Pc7fLwRF1fhkuIdYTp69LabzrSEJCRCz0UI2NHqPOgtOta ++zRHKAMr7c7Z6uKO0K+aXiQYHw4Y70uSG8CnmNl7kb4OM/CAcoO6fePmvBsyESfn +TmkJ5bfHEZQFDQEAoDlDjtjxuwYsAQQVQXuAydi8j8pyTWKAJ1RDgnUT+HbOub7j +JrQ7sPe6MPCjXv5N76v9RMHKktfYwRNMlkLkxImQU55+vlvghNztgFlIlJDFfNiy +UQPV5FTEZJli9BzMoj1JQK3sZyV8WV0W1zN41QQ+glAAC6+K7iTDPRMINBSwbHyn +6Lb9Q6U7AgMBAAECggEAB93qZ5xrhYgEFeoyKO4mUdGsu4qZyJB0zNeWGgdaXCfZ +zC4l8zFM+R6osix0EY6lXRtC95+6h9hfFQNa5FWseupDzmIQiEnim1EowjWef87l +Eayi0nDRB8TjqZKjR/aLOUhzrPlXHKrKEUk/RDkacCiDklwz9S0LIfLOSXlByBDM +/n/eczfX2gUATexMHSeIXs8vN2jpuiVv0r+FPXcRvqdzDZnYSzS8BJ9k6RYXVQ4o +NzCbfqgFIpVryB7nHgSTrNX9G7299If8/dXmesXWSFEJvvDSSpcBoINKbfgSlrxd +6ubjiotcEIBUSlbaanRrydwShhLHnXyupNAb7tlvyQKBgQDsIipSK4+H9FGl1rAk +Gg9DLJ7P/94sidhoq1KYnj/CxwGLoRq22khZEUYZkSvYXDu1Qkj9Avi3TRhw8uol +l2SK1VylL5FQvTLKhWB7b2hjrUd5llMRgS3/NIdLhOgDMB7w3UxJnCA/df/Rj+dM +WhkyS1f0x3t7XPLwWGurW0nJcwKBgQDdjhrNfabrK7OQvDpAvNJizuwZK9WUL7CD +rR0V0MpDGYW12BTEOY6tUK6XZgiRitAXf4EkEI6R0Q0bFzwDDLrg7TvGdTuzNeg/ +8vm8IlRlOkrdihtHZI4uRB7Ytmz24vzywEBE0p6enA7v4oniscUks/KKmDGr0V90 +yT9gIVrjGQKBgQCjnWC5otlHGLDiOgm+WhgtMWOxN9dYAQNkMyF+Alinu4CEoVKD +VGhA3sk1ufMpbW8pvw4X0dFIITFIQeift3DBCemxw23rBc2FqjkaDi3EszINO22/ +eUTHyjvcxfCFFPi7aHsNnhJyJm7lY9Kegudmg/Ij93zGE7d5darVBuHvpQKBgBBY +YovUgFMLR1UfPeD2zUKy52I4BKrJFemxBNtOKw3mPSIcTfPoFymcMTVENs+eARoq +svlZK1uAo8ni3e+Pqd3cQrOyhHQFPxwwrdH+amGJemp7vOV4erDZH7l3Q/S27Fhw +bI1nSIKFGukBupB58wRxLiyha9C0QqmYC0/pRg5JAn8Rbj5tP26oVCXjZEfWJL8J +axxSxsGA4Vol6i6LYnVgZG+1ez2rP8vUORo1lRzmdeP4o1BSJf9TPwXkuppE5J+t +UZVKtYGlEn1RqwGNd8I9TiWvU84rcY9nsxlDR86xwKRWFvYqVOiGYtzRyewYRdjU +rTs9aqB3v1+OVxGxR6Na +-----END PRIVATE KEY----- +"; + +#[allow(dead_code)] +const OIDC_SIGNATURE_KEY_ES256: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggybcqc86ulFFiOon +WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn +0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA +-----END PRIVATE KEY----- +"; diff --git a/tests/src/utils/account.rs b/tests/src/utils/account.rs index 94a5b6f9..76c4e004 100644 --- a/tests/src/utils/account.rs +++ b/tests/src/utils/account.rs @@ -117,6 +117,10 @@ impl Account { } } + pub fn update_secret(&mut self, new_secret: &'static str) { + self.secret = new_secret; + } + pub fn id(&self) -> &Id { &self.id } diff --git a/tests/src/utils/http.rs b/tests/src/utils/http.rs new file mode 100644 index 00000000..c7eae2e3 --- /dev/null +++ b/tests/src/utils/http.rs @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use hyper::Method; +use serde::{Serialize, de::DeserializeOwned}; +use std::time::Duration; + +pub struct HttpRequest { + pub port: u16, + pub username: Option, + pub password: Option, +} + +impl Default for HttpRequest { + fn default() -> Self { + Self { + port: 8899, + username: None, + password: None, + } + } +} + +impl HttpRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn with_credentials(port: u16, username: &str, password: &str) -> Self { + Self { + port, + username: Some(username.to_string()), + password: Some(password.to_string()), + } + } + + pub async fn post( + &self, + query: &str, + body: &impl Serialize, + ) -> Result { + self.request_raw( + Method::POST, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn patch( + &self, + query: &str, + body: &impl Serialize, + ) -> Result { + self.request_raw( + Method::PATCH, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn delete(&self, query: &str) -> Result { + self.request_raw(Method::DELETE, query, None) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn get(&self, query: &str) -> Result { + self.request_raw(Method::GET, query, None) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + pub async fn request( + &self, + method: Method, + query: &str, + ) -> Result { + self.request_raw(method, query, None).await.map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + async fn request_raw( + &self, + method: Method, + query: &str, + body: Option, + ) -> Result { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request(method, format!("https://127.0.0.1:{}{query}", self.port)); + + if let Some(body) = body { + request = request.body(body); + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + request = request.basic_auth(username, Some(password)); + } + + request + .send() + .await + .map_err(|err| err.to_string())? + .bytes() + .await + .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) + .map_err(|err| err.to_string()) + } +} diff --git a/tests/src/utils/imap.rs b/tests/src/utils/imap.rs new file mode 100644 index 00000000..008e138b --- /dev/null +++ b/tests/src/utils/imap.rs @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use imap_proto::ResponseType; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; + +pub struct ImapConnection { + tag: &'static [u8], + reader: Lines>>, + writer: WriteHalf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Type { + Tagged, + Untagged, + Continuation, + Status, +} + +impl ImapConnection { + pub async fn connect(tag: &'static [u8]) -> Self { + Self::connect_to(tag, "127.0.0.1:9991").await + } + + pub async fn connect_to(tag: &'static [u8], addr: impl AsRef) -> Self { + let (reader, writer) = tokio::io::split(TcpStream::connect(addr.as_ref()).await.unwrap()); + ImapConnection { + tag, + reader: BufReader::new(reader).lines(), + writer, + } + } + + pub async fn assert_read(&mut self, t: Type, rt: ResponseType) -> Vec { + let lines = self.read(t).await; + let mut buf = Vec::with_capacity(10); + buf.extend_from_slice(match t { + Type::Tagged => self.tag, + Type::Untagged | Type::Status => b"* ", + Type::Continuation => b"+ ", + }); + if !matches!(t, Type::Continuation | Type::Status) { + rt.serialize(&mut buf); + } + if lines + .last() + .unwrap() + .starts_with(&String::from_utf8(buf).unwrap()) + { + lines + } else { + panic!("Expected {:?}/{:?} from server but got: {:?}", t, rt, lines); + } + } + + pub async fn assert_disconnect(&mut self) { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(None)) => {} + Ok(Ok(Some(line))) => { + panic!("Expected connection to be closed, but got {:?}", line); + } + Ok(Err(err)) => { + panic!("Connection broken: {:?}", err); + } + Err(_) => panic!("Timeout while waiting for server response."), + } + } + + pub async fn read(&mut self, t: Type) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = line.starts_with(match t { + Type::Tagged => std::str::from_utf8(self.tag).unwrap(), + Type::Untagged | Type::Status => "* ", + Type::Continuation => "+ ", + }); + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn authenticate(&mut self, user: &str, pass: &str) { + let creds = general_purpose::STANDARD.encode(format!("\0{user}\0{pass}")); + self.send(&format!( + "AUTHENTICATE PLAIN {{{}+}}\r\n{creds}", + creds.len() + )) + .await; + self.assert_read(Type::Tagged, ResponseType::Ok).await; + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {}{:?}", std::str::from_utf8(self.tag).unwrap(), text); + self.writer.write_all(self.tag).await.unwrap(); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_untagged(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } + + pub async fn append(&mut self, mailbox: &str, message: &str) { + self.send_ok(&format!( + "APPEND {:?} {{{}+}}\r\n{}", + mailbox, + message.len(), + message + )) + .await; + } + + pub async fn send_ok(&mut self, cmd: &str) { + self.send(cmd).await; + self.assert_read(Type::Tagged, ResponseType::Ok).await; + } +} diff --git a/tests/src/utils/jmap.rs b/tests/src/utils/jmap.rs index 8189d67c..2a14e6ca 100644 --- a/tests/src/utils/jmap.rs +++ b/tests/src/utils/jmap.rs @@ -616,6 +616,12 @@ pub trait JmapUtils { self.text_field("id") } + fn object_id(&self) -> Id { + self.id() + .parse() + .unwrap_or_else(|_| panic!("Invalid id {} in object", self.id())) + } + fn blob_id(&self) -> &str { self.text_field("blobId") } diff --git a/tests/src/utils/mod.rs b/tests/src/utils/mod.rs index 1f1d580f..c7b28293 100644 --- a/tests/src/utils/mod.rs +++ b/tests/src/utils/mod.rs @@ -6,7 +6,11 @@ pub mod account; pub mod cleanup; +pub mod http; +pub mod imap; pub mod jmap; +pub mod pop3; pub mod registry; pub mod server; +pub mod smtp; pub mod storage; diff --git a/tests/src/utils/pop3.rs b/tests/src/utils/pop3.rs new file mode 100644 index 00000000..af368926 --- /dev/null +++ b/tests/src/utils/pop3.rs @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_send::smtp::tls::build_tls_connector; +use rustls_pki_types::ServerName; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; +use tokio_rustls::client::TlsStream; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseType { + Ok, + Multiline, + Err, +} + +pub struct Pop3Connection { + reader: Lines>>>, + writer: WriteHalf>, +} + +impl Pop3Connection { + pub async fn connect() -> Self { + let (reader, writer) = tokio::io::split( + build_tls_connector(true) + .connect( + ServerName::try_from("pop3.example.org").unwrap().to_owned(), + TcpStream::connect("127.0.0.1:4110").await.unwrap(), + ) + .await + .unwrap(), + ); + Pop3Connection { + reader: BufReader::new(reader).lines(), + writer, + } + } + + pub async fn connect_and_login() -> Self { + let mut pop3 = Self::connect().await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("AUTH PLAIN AHBvcHBlckBleGFtcGxlLmNvbQBzZWNyZXQ=") + .await; + pop3.assert_read(ResponseType::Ok).await; + pop3 + } + + pub async fn assert_read(&mut self, rt: ResponseType) -> Vec { + let lines = self.read(matches!(rt, ResponseType::Multiline)).await; + if lines.last().unwrap().starts_with(match rt { + ResponseType::Ok => "+OK", + ResponseType::Multiline => ".", + ResponseType::Err => "-ERR", + }) { + lines + } else { + panic!("Expected {:?} from server but got: {:?}", rt, lines); + } + } + + pub async fn read(&mut self, is_multiline: bool) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = (!is_multiline && line.starts_with("+OK")) + || (is_multiline && line == ".") + || line.starts_with("-ERR"); + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } +} diff --git a/tests/src/utils/registry.rs b/tests/src/utils/registry.rs index de2e8f45..a8fc3697 100644 --- a/tests/src/utils/registry.rs +++ b/tests/src/utils/registry.rs @@ -9,7 +9,10 @@ use crate::utils::{ jmap::{JmapResponse, JmapSetError}, }; use registry::{ - schema::prelude::ObjectType, + schema::{ + prelude::{ObjectType, Property}, + structs::Action, + }, types::{EnumImpl, ObjectImpl}, }; use serde_json::{Value, json}; @@ -125,6 +128,29 @@ impl Account { .updated_id(id); } + pub async fn registry_update_setting( + &self, + setting: T, + properties: &[Property], + ) { + let mut item = serde_json::to_value(setting).expect("Failed to serialize setting to JSON"); + + if !properties.is_empty() { + // Only include the specified properties in the update + if let Value::Object(obj) = &mut item { + obj.retain(|k, _| properties.iter().any(|p| p.as_str() == k)); + } + } + + self.registry_update(T::OBJECT, [(Id::singleton(), item)]) + .await + .updated_id(Id::singleton()); + } + + pub async fn reload_settings(&self) { + self.registry_create_object(Action::ReloadSettings).await; + } + pub async fn registry_update_object_expect_err( &self, object: ObjectType, @@ -138,6 +164,19 @@ impl Account { .to_string(); serde_json::from_str(&v).expect("Failed to deserialize set error") } + + pub async fn registry_destroy_object_expect_err( + &self, + object: ObjectType, + id: Id, + ) -> JmapSetError { + let v = self + .registry_destroy(object, [id]) + .await + .not_destroyed(&id.to_string()) + .to_string(); + serde_json::from_str(&v).expect("Failed to deserialize set error") + } } impl JmapResponse { diff --git a/tests/src/utils/server.rs b/tests/src/utils/server.rs index 4d938fa2..36303c2e 100644 --- a/tests/src/utils/server.rs +++ b/tests/src/utils/server.rs @@ -103,7 +103,7 @@ impl TestServerBuilder { (NetworkListenerProtocol::Imap, "imaptls", 9992, true), (NetworkListenerProtocol::ManageSieve, "sieve", 4190, true), (NetworkListenerProtocol::Pop3, "pop3", 4110, true), - (NetworkListenerProtocol::Lmtp, "lmtp-debug", 11201, false), + (NetworkListenerProtocol::Lmtp, "lmtp-debug", 11200, false), ] { this = this.with_listener(protocol, name, port, use_tls).await; } diff --git a/tests/src/utils/smtp.rs b/tests/src/utils/smtp.rs new file mode 100644 index 00000000..11894fd5 --- /dev/null +++ b/tests/src/utils/smtp.rs @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; + +pub struct SmtpConnection { + reader: Lines>>, + writer: WriteHalf, +} + +impl SmtpConnection { + pub async fn ingest_with_code( + &mut self, + from: &str, + recipients: &[&str], + message: &str, + code: u8, + ) -> Vec { + self.mail_from(from, 2).await; + for recipient in recipients { + self.rcpt_to(recipient, 2).await; + } + self.data(3).await; + let result = self.data_bytes(message, recipients.len(), code).await; + tokio::time::sleep(Duration::from_millis(500)).await; + result + } + + pub async fn ingest(&mut self, from: &str, recipients: &[&str], message: &str) { + self.ingest_with_code(from, recipients, message, 2).await; + } + + pub async fn ingest_chunked( + &mut self, + from: &str, + recipients: &[&str], + message: &str, + chunk_size: usize, + ) { + self.mail_from(from, 2).await; + for recipient in recipients { + self.rcpt_to(recipient, 2).await; + } + for chunk in message.as_bytes().chunks(chunk_size) { + self.bdat(std::str::from_utf8(chunk).unwrap(), 2).await; + } + self.bdat_last("", recipients.len(), 2).await; + tokio::time::sleep(Duration::from_millis(500)).await; + } + + pub async fn connect() -> Self { + SmtpConnection::connect_port(11200).await + } + + pub async fn connect_port(port: u16) -> Self { + let (reader, writer) = tokio::io::split( + TcpStream::connect(&format!("127.0.0.1:{port}")) + .await + .unwrap(), + ); + let mut conn = SmtpConnection { + reader: BufReader::new(reader).lines(), + writer, + }; + conn.read(1, 2).await; + conn.lhlo().await; + conn + } + + pub async fn lhlo(&mut self) -> Vec { + self.send("LHLO localhost").await; + self.read(1, 2).await + } + + pub async fn mail_from(&mut self, sender: &str, code: u8) -> Vec { + self.send(&format!("MAIL FROM:<{}>", sender)).await; + self.read(1, code).await + } + + pub async fn rcpt_to(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("RCPT TO:<{}>", rcpt)).await; + self.read(1, code).await + } + + pub async fn vrfy(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("VRFY {}", rcpt)).await; + self.read(1, code).await + } + + pub async fn expn(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("EXPN {}", rcpt)).await; + self.read(1, code).await + } + + pub async fn data(&mut self, code: u8) -> Vec { + self.send("DATA").await; + self.read(1, code).await + } + + pub async fn data_bytes( + &mut self, + message: &str, + num_responses: usize, + code: u8, + ) -> Vec { + self.send_raw(message).await; + self.send_raw("\r\n.\r\n").await; + self.read(num_responses, code).await + } + + pub async fn bdat(&mut self, chunk: &str, code: u8) -> Vec { + self.send_raw(&format!("BDAT {}\r\n{}", chunk.len(), chunk)) + .await; + self.read(1, code).await + } + + pub async fn bdat_last(&mut self, chunk: &str, num_responses: usize, code: u8) -> Vec { + self.send_raw(&format!("BDAT {} LAST\r\n{}", chunk.len(), chunk)) + .await; + self.read(num_responses, code).await + } + + pub async fn rset(&mut self) -> Vec { + self.send("RSET").await; + self.read(1, 2).await + } + + pub async fn noop(&mut self) -> Vec { + self.send("NOOP").await; + self.read(1, 2).await + } + + pub async fn quit(&mut self) -> Vec { + self.send("QUIT").await; + self.read(1, 2).await + } + + pub async fn read(&mut self, mut num_responses: usize, code: u8) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = line.as_bytes()[3] == b' '; + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + num_responses -= 1; + if num_responses != 0 { + continue; + } + + if code != u8::MAX { + for line in &lines { + if line.as_bytes()[0] - b'0' != code { + panic!("Expected completion code {}, got {:?}.", code, lines); + } + } + } + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + self.writer.flush().await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } +} diff --git a/tests/src/utils/storage.rs b/tests/src/utils/storage.rs index 444f14b0..0fdacd0c 100644 --- a/tests/src/utils/storage.rs +++ b/tests/src/utils/storage.rs @@ -21,6 +21,7 @@ use registry::{ }, types::{EnumImpl, duration::Duration}, }; +use store::U64_LEN; use store::{ Deserialize, IterateParams, ValueKey, write::{TaskQueueClass, ValueClass}, @@ -167,8 +168,10 @@ pub async fn wait_for_index(server: &Server) { ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), ) .ascending(), - |_, value| { - has_index_tasks = Some(Task::deserialize(value)?); + |key, value| { + if key.len() == U64_LEN { + has_index_tasks = Some(Task::deserialize(value)?); + } Ok(false) },