diff --git a/Cargo.lock b/Cargo.lock index 2b6e4d87..43caee0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,6 +1763,8 @@ dependencies = [ "deadpool 0.10.0", "futures", "ldap3", + "mail-builder", + "mail-parser", "md5 0.8.0", "nlp", "password-hash", diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 60e45325..b6b74134 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -12,6 +12,7 @@ use crate::{ }, network::limiter::{ConcurrencyLimiter, LimiterResult}, }; +use ahash::AHasher; use registry::{ schema::{ enums::Permission, @@ -20,7 +21,7 @@ use registry::{ types::EnumType, }; use std::{ - hash::{DefaultHasher, Hash, Hasher}, + hash::{Hash, Hasher}, sync::Arc, }; use store::{query::acl::AclQuery, rand, write::now}; @@ -30,11 +31,12 @@ use types::{acl::Acl, collection::Collection}; use utils::map::bitmap::{Bitmap, BitmapItem}; impl Server { - async fn build_account_access_token( + async fn build_access_token( &self, account: Account, account_id: u32, revision: u64, + revision_account: u64, ) -> trc::Result { // Calculate effective permissions let (mut permissions, roles) = match account.permissions { @@ -152,8 +154,8 @@ impl Server { } let now = now(); - let app_password_scopes = account - .app_passwords + let credential_scopes = account + .credentials .into_iter() .filter_map(|pass| { let expires_at = pass @@ -173,6 +175,7 @@ impl Server { } }; Some(AccessScope { + credential_id: pass.credential_id as u32, permissions, expires_at, }) @@ -196,22 +199,20 @@ impl Server { .map(ConcurrencyLimiter::new), obj_size: 0, revision, + revision_account, account_id, tenant_id, member_of, access_to: access_to.into_boxed_slice(), - scopes: [AccessScope::new(permissions.finalize())] + scopes: [AccessScope::new(permissions.finalize(), u32::MAX)] .into_iter() - .chain(app_password_scopes) + .chain(credential_scopes) .collect::>(), } .update_size()) } - pub async fn account_access_token( - &self, - account_id: u32, - ) -> trc::Result> { + pub async fn access_token(&self, account_id: u32) -> trc::Result> { match self .inner .cache @@ -221,7 +222,6 @@ impl Server { { Ok(token) => Ok(token), Err(guard) => { - let revision = rand::random::(); let account = self .registry() .object::(account_id) @@ -233,8 +233,10 @@ impl Server { .account_id(account_id) .caused_by(trc::location!()) })?; + let revision = rand::random::(); + let revision_account = hash_account(&account); let token: Arc = self - .build_account_access_token(account, account_id, revision) + .build_access_token(account, account_id, revision, revision_account) .await? .into(); let _ = guard.insert(token.clone()); @@ -243,11 +245,12 @@ impl Server { } } - async fn access_token_from_account( + pub(crate) async fn access_token_from_account( &self, account_id: u32, account: Account, ) -> trc::Result> { + let revision_account = hash_account(&account); match self .inner .cache @@ -255,11 +258,31 @@ impl Server { .get_value_or_guard_async(&account_id) .await { - Ok(token) => Ok(token), + Ok(token) => { + if token.revision_account == revision_account { + Ok(token) + } else { + // Token is stale, rebuild it + debug_assert!( + false, + "Token is stale, invalidation should have been triggered" + ); + let revision = rand::random::(); + let token: Arc = self + .build_access_token(account, account_id, revision, revision_account) + .await? + .into(); + self.inner + .cache + .access_tokens + .update(account_id, token.clone()); + Ok(token) + } + } Err(guard) => { let revision = rand::random::(); let token: Arc = self - .build_account_access_token(account, account_id, revision) + .build_access_token(account, account_id, revision, revision_account) .await? .into(); let _ = guard.insert(token.clone()); @@ -270,9 +293,20 @@ impl Server { } impl AccessToken { + pub fn new(inner: Arc) -> Self { + AccessToken { + scope_idx: 0, + inner, + } + } + + pub fn scoped(inner: Arc, scope_idx: usize) -> Self { + AccessToken { scope_idx, inner } + } + pub fn state(&self) -> u32 { // Hash state - let mut s = DefaultHasher::new(); + let mut s = AHasher::default(); self.inner.member_of.hash(&mut s); self.inner.access_to.hash(&mut s); s.finish() as u32 @@ -335,7 +369,7 @@ impl AccessToken { pub fn has_permission(&self, permission: Permission) -> bool { self.inner .scopes - .get(self.scope_id as usize) + .get(self.scope_idx) .map_or(false, |scope| scope.permissions.get(permission as usize)) } @@ -343,17 +377,31 @@ impl AccessToken { let todo = "use this function"; self.inner .scopes - .get(self.scope_id as usize) + .get(self.scope_idx) .map_or(false, |scope| scope.expires_at > now()) } - pub fn assert_has_permission(&self, permission: Permission) -> trc::Result { + pub fn assert_has_permissions(self, permissions: &[Permission]) -> trc::Result { + for permission in permissions { + if !self.has_permission(*permission) { + return Err(trc::SecurityEvent::Unauthorized + .into_err() + .details(permission.as_str()) + .account_id(self.account_id())); + } + } + + Ok(self) + } + + pub fn assert_has_permission(self, permission: Permission) -> trc::Result { if self.has_permission(permission) { - Ok(true) + Ok(self) } else { Err(trc::SecurityEvent::Unauthorized .into_err() - .details(permission.as_str())) + .details(permission.as_str()) + .account_id(self.account_id())) } } @@ -362,7 +410,7 @@ impl AccessToken { const USIZE_MASK: u32 = USIZE_BITS as u32 - 1; let mut permissions = Vec::new(); - let Some(scope) = self.inner.scopes.get(self.scope_id as usize) else { + let Some(scope) = self.inner.scopes.get(self.scope_idx) else { return permissions; }; @@ -443,6 +491,25 @@ impl AccessToken { .as_ref() .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) } + + 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(), + }), + } + } } impl AccessTokenInner { @@ -472,21 +539,6 @@ impl AccessTokenInner { 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())]), - concurrent_http_requests: Default::default(), - concurrent_imap_requests: Default::default(), - concurrent_uploads: Default::default(), - revision: Default::default(), - obj_size: Default::default(), - } - } - pub fn update_size(mut self) -> Self { self.obj_size = (std::mem::size_of::() + (self.member_of.len() * std::mem::size_of::()) @@ -495,12 +547,17 @@ impl AccessTokenInner { as u64; self } + + pub fn is_fresh(&self, account: &Account) -> bool { + self.member_of.len() == account.member_group_ids.len() + } } impl AccessScope { - pub fn new(permissions: Permissions) -> Self { + pub fn new(permissions: Permissions, credential_id: u32) -> Self { Self { permissions, + credential_id, expires_at: u64::MAX, } } @@ -510,3 +567,40 @@ impl AccessScope { self } } + +fn hash_account(account: &Account) -> u64 { + let mut s = AHasher::default(); + account.member_tenant_id.hash(&mut s); + account.role_ids.hash(&mut s); + hash_permissions(&mut s, &account.permissions); + for credential in &account.credentials { + credential.credential_id.hash(&mut s); + credential.expires_at.hash(&mut s); + hash_permissions(&mut s, &credential.permissions); + } + s.finish() +} + +fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) { + match permissions { + structs::Permissions::Inherit => { + 0u8.hash(hasher); + } + structs::Permissions::Merge(permissions) => { + 2u8.hash(hasher); + for (perm, enabled) in permissions.permissions.iter() { + if *enabled { + perm.hash(hasher); + } + } + } + structs::Permissions::Replace(permissions) => { + 3u8.hash(hasher); + for (perm, enabled) in permissions.permissions.iter() { + if *enabled { + perm.hash(hasher); + } + } + } + } +} diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 4ec05f08..7fa8b8c3 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -4,208 +4,477 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::Server; +use crate::{ + Server, + auth::{ + AccessToken, AuthRequest, EmailCache, + credential::{ApiKey, AppPassword}, + oauth::GrantType, + }, +}; +use directory::{ + Credentials, Directory, + core::secret::{verify_mfa_secret_hash, verify_secret_hash}, +}; +use registry::schema::{ + enums::{CredentialType, Permission}, + structs, +}; +use std::{net::IpAddr, sync::Arc}; +use store::write::now; +use trc::AddContext; + +pub struct UsernameParts { + pub account: Username, + pub master_user: Option, +} + +#[derive(PartialEq, Eq)] +pub struct Username { + pub name: String, + pub domain_start: usize, +} impl Server { - pub async fn authenticate(&self, req: &AuthRequest<'_>) -> trc::Result> { - // Resolve directory - let directory = req.directory.unwrap_or(&self.core.storage.directory); - - // Validate credentials - match &req.credentials { - Credentials::OAuthBearer { token } if !directory.has_bearer_token_support() => { - match self - .validate_access_token(GrantType::AccessToken.into(), token) - .await + pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result { + match self + .route_auth_request(req) + .await + .and_then(|token| token.assert_has_permission(Permission::Authenticate)) + { + Ok(token) => Ok(token), + Err(err) => { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) + && self.has_auth_fail2ban() + && self + .is_auth_fail2banned(req.remote_ip, req.username()) + .await? { - Ok(token_into) => self.get_access_token(token_into.account_id).await, - Err(err) => Err(err), + Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .ctx(trc::Key::RemoteIp, req.remote_ip) + .ctx_opt(trc::Key::AccountName, req.username().map(|s| s.to_string()))) + } else { + Err(err.ctx(trc::Key::RemoteIp, req.remote_ip)) } } - _ => match self.authenticate_credentials(req, directory).await { - Ok(principal) => self.get_access_token(principal).await, - Err(err) => Err(err), - }, } - .and_then(|token| { - token - .assert_has_permission(Permission::Authenticate) - .map(|_| token) - }) } - async fn authenticate_credentials( - &self, - req: &AuthRequest<'_>, - directory: &Directory, - ) -> trc::Result { - // First try to authenticate the user against the default directory - let result = match directory - .query( - QueryParams::credentials(&req.credentials) - .with_return_member_of(req.return_member_of), - ) - .await - { - Ok(Some(principal)) => { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = principal.name().to_string(), - AccountId = principal.id(), - SpanId = req.session_id, - ); - - return Ok(principal); - } - Ok(None) => Ok(()), - Err(err) => { - if err.matches(trc::EventType::Auth(trc::AuthEvent::MissingTotp)) { - return Err(err); - } else { - Err(err) - } - } - }; - + async fn route_auth_request(&self, req: &AuthRequest) -> trc::Result { match &req.credentials { - Credentials::Plain { username, secret } => { - // Then check if the credentials match the fallback admin or master user - let master_user: Option<(String, String)> = None; - let todo = "implement master"; - match (&self.core.network.security.fallback_admin, &master_user) { - (Some((fallback_admin, fallback_pass)), _) if username == fallback_admin => { - if verify_secret_hash(fallback_pass, secret).await? { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = username.clone(), - SpanId = req.session_id, - ); + Credentials::Basic { username, secret } => { + let username = UsernameParts::new(username); - return Ok(Principal::fallback_admin(fallback_pass)); - } - } - (_, Some((master_user, master_pass))) if username.ends_with(master_user) => { - if verify_secret_hash(master_pass, secret).await? { - let username = username.strip_suffix(master_user).unwrap(); - let username = username.strip_suffix('%').unwrap_or(username); - - if let Some(principal) = directory - .query( - QueryParams::name(username) - .with_return_member_of(req.return_member_of), - ) - .await? + // Try to authenticate as fallback admin if configured + if let Some((fallback_user, fallback_hash)) = + &self.core.network.security.fallback_admin + && username.auth_as().address() == fallback_user + { + return if verify_secret_hash(fallback_hash, secret.as_bytes()).await? { + if username.is_master() { + let address = username.account().address(); + if let Some(EmailCache::Account(account_id)) = + self.email(address).await? { trc::event!( Auth(trc::AuthEvent::Success), - AccountName = username.to_string(), + AccountName = address.to_string(), + AccountId = account_id, SpanId = req.session_id, - AccountId = principal.id(), - Type = principal.typ().description(), + Details = fallback_user.to_string(), ); - return Ok(principal); + self.access_token(account_id).await.map(AccessToken::new) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .reason("Master user account not found for fallback admin authentication")) } - } - } - _ => { - // Validate API credentials - if req.allow_api_access - && let Ok(Some(principal)) = self - .store() - .query( - QueryParams::credentials(&req.credentials) - .with_return_member_of(req.return_member_of), - ) - .await - && principal.typ == Type::ApiKey - { + } else { trc::event!( Auth(trc::AuthEvent::Success), - AccountName = principal.name().to_string(), - AccountId = principal.id(), + AccountName = fallback_user.to_string(), SpanId = req.session_id, ); - return Ok(principal); + Ok(AccessToken::new_admin()) + } + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, fallback_user.to_string()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Fallback admin authentication failed")) + }; + } + + let auth_as = username.auth_as(); + + // Authenticate app passwords + if let Some(app_pass) = AppPassword::parse(secret) { + let account_name = auth_as.address(); + return if let Some(EmailCache::Account(account_id)) = + self.email(account_name).await? + { + self.validate_credential( + account_id, + app_pass.credential_id, + app_pass.secret.as_ref(), + req.session_id, + ) + .await + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account_name.to_string()) + .reason("App password authentication failed: account not found")) + }; + } + + // Obtain external directory, if any + let address = auth_as.address(); + let directory = self + .directory_for_domain(address, auth_as.domain()) + .await + .caused_by(trc::location!())?; + + let mut is_alias_login = false; + let token = if let Some(directory) = directory { + let directory_account = directory.authenticate(&req.credentials).await?; + is_alias_login = directory_account.email != address; + self.update_registry(directory_account).await + } else if let Some(EmailCache::Account(account_id)) = self.email(address).await? { + if let Some(account) = self + .registry() + .object::(account_id) + .await? + { + if verify_mfa_secret_hash( + account.otp_auth.as_deref(), + account.secret.as_str(), + secret, + ) + .await? + { + is_alias_login = account.name != address; + self.access_token(account_id).await.map(AccessToken::new) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Authentication failed")) + } + } else { + Err(trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .reason("Account not found in registry")) + } + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .reason("Account not found")) + }?; + + // Enforce alias login restrictions + if is_alias_login && !token.has_permission(Permission::AuthenticateAlias) { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .ctx(trc::Key::AccountId, token.account_id()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); + } + + // Validate master user access + if username.is_master() { + token.assert_has_permissions(&[ + Permission::Impersonate, + Permission::Authenticate, + ])?; + let address = username.account().address(); + let master_address = username.account().address(); + if let Some(EmailCache::Account(account_id)) = self.email(address).await? { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = address.to_string(), + AccountId = account_id, + SpanId = req.session_id, + Details = master_address.to_string(), + ); + + self.access_token(account_id).await.map(AccessToken::new) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .details(master_address.to_string()) + .reason("Master user account not found")) + } + } else { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = address.to_string(), + AccountId = token.account_id(), + SpanId = req.session_id, + ); + + Ok(token) + } + } + Credentials::Bearer { username, token } => { + // Handle API key authentication + if let Some(key) = ApiKey::parse(token) { + return self + .validate_credential( + key.account_id, + key.credential_id, + key.secret.as_ref(), + req.session_id, + ) + .await; + } + + // Obtain external directory, if any + let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) + { + let auth_as = username.auth_as(); + self.directory_for_domain(auth_as.address(), auth_as.domain()) + .await + .caused_by(trc::location!())? + } else { + self.get_default_directory() + }; + if let Some(directory) = directory + && directory.has_bearer_token_support() + { + match directory.authenticate(&req.credentials).await { + Ok(result) => { + return self.update_registry(result).await; + } + Err(err) => { + if !err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + return Err(err); + } } } } - } - Credentials::OAuthBearer { token } if directory.has_bearer_token_support() => { - // Check for bearer tokens issued locally - if let Ok(token_info) = self - .validate_access_token(GrantType::AccessToken.into(), token) - .await - { - let principal = if token_info.account_id != FALLBACK_ADMIN_ID { - directory - .query( - QueryParams::id(token_info.account_id) - .with_return_member_of(req.return_member_of), - ) - .await - .unwrap_or_default() - } else if let Some((_, fallback_pass)) = - &self.core.network.security.fallback_admin - { - Principal::fallback_admin(fallback_pass).into() - } else { - None - }; - if let Some(principal) = principal { - trc::event!( - Auth(trc::AuthEvent::Success), - AccountName = principal.name().to_string(), - AccountId = principal.id(), - SpanId = req.session_id, - ); - return Ok(principal); + // Internal OAuth + let token_info = self + .validate_access_token(GrantType::AccessToken.into(), token) + .await?; + self.access_token(token_info.account_id) + .await + .map(AccessToken::new) + } + } + } + + async fn validate_credential( + &self, + account_id: u32, + credential_id: u32, + secret: &[u8], + span_id: u64, + ) -> trc::Result { + if let Some(account) = self + .registry() + .object::(account_id) + .await? + { + // Find credential by credential_id + for credential in account.credentials.iter() { + if credential.credential_id as u32 == credential_id { + if !verify_secret_hash(&credential.secret, secret).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account.name) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Invalid credential secret")); } + + if credential + .expires_at + .as_ref() + .is_some_and(|exp| exp.timestamp() < now() as i64) + { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account.name) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential has expired")); + } + + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = account.name.clone(), + AccountId = account_id, + Id = credential_id, + SpanId = span_id, + Details = match credential.credential_type { + CredentialType::AppPassword => "Authenticated with app password", + CredentialType::ApiKey => "Authenticated with API key", + } + ); + + let token = self.access_token_from_account(account_id, account).await?; + let scope_idx = token + .scopes + .iter() + .position(|scope| scope.credential_id == credential_id) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential not found in access token scopes") + })?; + + return Ok(AccessToken::scoped(token, scope_idx)); } } - _ => (), - }; - if let Err(err) = result { - Err(err) - } else if self.has_auth_fail2ban() { - let login = req.credentials.login(); - if self.is_auth_fail2banned(req.remote_ip, login).await? { - Err(trc::SecurityEvent::AuthenticationBan - .into_err() - .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx_opt(trc::Key::AccountName, login.map(|s| s.to_string()))) - } else { - Err(trc::AuthEvent::Failed - .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx_opt(trc::Key::AccountName, login.map(|s| s.to_string()))) - } + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential not found for account")) } else { Err(trc::AuthEvent::Failed - .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx_opt( - trc::Key::AccountName, - req.credentials.login().map(|s| s.to_string()), - )) + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Account not found for credential")) } } + + async fn directory_for_domain( + &self, + address: &str, + domain_name: Option<&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() { + if let Some(domain_name) = domain_name { + if let Some(domain) = self.domain(domain_name).await? { + if domain.id_directory != u32::MAX { + if let Some(directory) = + self.core.storage.directories.get(&domain.id_directory) + { + return Ok(Some(directory)); + } else { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = address.to_string(), + Domain = domain_name.to_string(), + Id = domain.id_directory, + Reason = "Directory not found for domain", + ); + } + } + } else { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = address.to_string(), + Reason = "Domain not found", + ); + } + } else { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = address.to_string(), + Reason = "No domain in username", + ); + } + } + // SPDX-SnippetEnd + + Ok(self.get_default_directory()) + } + + async fn update_registry(&self, account: directory::Account) -> trc::Result { + todo!() + } } -impl<'x> AuthRequest<'x> { - pub fn from_credentials( - credentials: Credentials, - session_id: u64, - remote_ip: IpAddr, - ) -> Self { +impl UsernameParts { + pub fn new(address: &str) -> Self { + let mut account = Username { + name: String::with_capacity(address.len()), + domain_start: usize::MAX, + }; + let mut master_user = None; + + for ch in address.chars() { + if ch == '%' { + master_user = Some(Username { + name: String::with_capacity(address.len()), + domain_start: usize::MAX, + }); + } else { + let target = master_user.as_mut().unwrap_or(&mut account); + if ch != '@' { + for lower in ch.to_lowercase() { + target.name.push(lower); + } + } else { + target.name.push(ch); + target.domain_start = target.name.len(); + } + } + } + + UsernameParts { + master_user: master_user.filter(|u| u != &account), + account, + } + } + + pub fn auth_as(&self) -> &Username { + self.master_user.as_ref().unwrap_or(&self.account) + } + + pub fn account(&self) -> &Username { + &self.account + } + + pub fn is_master(&self) -> bool { + self.master_user.is_some() + } +} + +impl Username { + pub fn address(&self) -> &str { + self.name.as_str() + } + + pub fn domain(&self) -> Option<&str> { + self.name.get(self.domain_start..) + } +} + +impl AuthRequest { + pub fn from_credentials(credentials: Credentials, session_id: u64, remote_ip: IpAddr) -> Self { Self { credentials, session_id, remote_ip, - return_member_of: true, - directory: None, - allow_api_access: false, } } @@ -216,7 +485,7 @@ impl<'x> AuthRequest<'x> { remote_ip: IpAddr, ) -> Self { Self::from_credentials( - Credentials::Plain { + Credentials::Basic { username: user.into(), secret: pass.into(), }, @@ -225,33 +494,10 @@ impl<'x> AuthRequest<'x> { ) } - pub fn without_members(mut self) -> Self { - self.return_member_of = false; - self - } - - pub fn with_directory(mut self, directory: &'x Directory) -> Self { - self.directory = Some(directory); - self - } - - pub fn with_api_access(mut self, allow_api_access: bool) -> Self { - self.allow_api_access = allow_api_access; - self - } -} - -pub(crate) trait CredentialsUsername { - fn login(&self) -> Option<&str>; -} - -impl CredentialsUsername for Credentials { - fn login(&self) -> Option<&str> { - match self { - Credentials::Plain { username, .. } | Credentials::XOauth2 { username, .. } => { - username.as_str().into() - } - Credentials::OAuthBearer { .. } => None, + pub fn username(&self) -> Option<&str> { + match &self.credentials { + Credentials::Basic { username, .. } => Some(username.as_str()), + Credentials::Bearer { username, .. } => username.as_deref(), } } } diff --git a/crates/common/src/auth/credential.rs b/crates/common/src/auth/credential.rs new file mode 100644 index 00000000..185da748 --- /dev/null +++ b/crates/common/src/auth/credential.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use std::io::Write; +use store::{ + U32_LEN, + rand::{self}, +}; +use utils::codec::base32_custom::{Base32Reader, Base32Writer}; + +pub struct ApiKey { + pub account_id: u32, + pub credential_id: u32, + pub secret: [u8; 20], +} + +pub struct AppPassword { + pub credential_id: u32, + pub secret: [u8; 18], +} + +impl ApiKey { + pub fn new(account_id: u32, credential_id: u32) -> Self { + ApiKey { + account_id, + credential_id, + secret: rand::random::<[u8; 20]>(), + } + } + + pub fn parse(token: &str) -> Option { + let decoded = URL_SAFE_NO_PAD.decode(token.strip_prefix("API_")?).ok()?; + + Some(ApiKey { + account_id: u32::from_be_bytes(decoded.get(0..U32_LEN)?.try_into().ok()?), + credential_id: u32::from_be_bytes(decoded.get(U32_LEN..U32_LEN * 2)?.try_into().ok()?), + secret: decoded.get(U32_LEN * 2..)?.try_into().ok()?, + }) + } + + pub fn build(&self) -> String { + let mut bytes = Vec::with_capacity(U32_LEN * 2 + self.secret.len()); + bytes.extend_from_slice(&self.account_id.to_be_bytes()); + bytes.extend_from_slice(&self.credential_id.to_be_bytes()); + bytes.extend_from_slice(&self.secret); + format!("API_{}", URL_SAFE_NO_PAD.encode(bytes)) + } +} + +impl AppPassword { + pub fn new(credential_id: u32) -> Self { + AppPassword { + credential_id, + secret: rand::random::<[u8; 18]>(), + } + } + + pub fn parse(token: &str) -> Option { + let token = token.strip_prefix("app ")?; + let mut reader = Base32Reader::new(token.as_bytes()); + let mut credential_id = [0u8; 4]; + let mut secret = [0u8; 18]; + + for byte in credential_id.iter_mut() { + *byte = reader.next()?; + } + + for byte in secret.iter_mut() { + *byte = reader.next()?; + } + + if reader.next().is_none() { + Some(AppPassword { + credential_id: u32::from_be_bytes(credential_id), + secret, + }) + } else { + None + } + } + + pub fn build(&self) -> String { + let mut writer = Base32Writer::with_capacity(std::mem::size_of::().div_ceil(5) * 8); + writer.push_string("app "); + let _ = writer.write(&self.credential_id.to_be_bytes()); + let _ = writer.write_all(&self.secret); + writer.finalize() + } +} diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 278aa1a4..3da8c9c3 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -23,10 +23,10 @@ use utils::{cache::CacheItemWeight, map::bitmap::Bitmap}; pub mod access_token; pub mod authentication; +pub mod credential; pub mod oauth; pub mod permissions; pub mod rate_limit; -pub mod sasl; pub const FALLBACK_ADMIN_ID: u32 = u32::MAX; const PERMISSIONS_BITSET_SIZE: usize = Permission::COUNT.div_ceil(std::mem::size_of::()); @@ -107,7 +107,7 @@ pub struct PermissionsGroup { #[derive(Debug, Default)] pub struct AccessToken { - scope_id: u32, + scope_idx: usize, inner: Arc, } @@ -121,13 +121,15 @@ pub struct AccessTokenInner { pub concurrent_http_requests: Option, pub concurrent_imap_requests: Option, pub concurrent_uploads: Option, + pub revision_account: u64, pub revision: u64, pub obj_size: u64, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Hash)] struct AccessScope { pub permissions: Permissions, + pub credential_id: u32, pub expires_at: u64, } @@ -141,8 +143,6 @@ pub struct AuthRequest { credentials: Credentials, session_id: u64, remote_ip: IpAddr, - return_member_of: bool, - allow_api_access: bool, } impl CacheItemWeight for AccessTokenInner { diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 5a583f47..c36eaacf 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -228,7 +228,6 @@ impl Server { .details("Account no longer exists") }) } else if let Some((_, secret)) = &self.core.network.security.fallback_admin { - let todo = "api keys?"; Ok(secret.into()) } else { Err(trc::AuthEvent::Error diff --git a/crates/common/src/auth/sasl.rs b/crates/common/src/auth/sasl.rs deleted file mode 100644 index a1d4ff09..00000000 --- a/crates/common/src/auth/sasl.rs +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use mail_send::Credentials; - -pub fn sasl_decode_challenge_plain(challenge: &[u8]) -> Option> { - let mut username = Vec::new(); - let mut secret = Vec::new(); - let mut arg_num = 0; - for &ch in challenge { - if ch != 0 { - if arg_num == 1 { - username.push(ch); - } else if arg_num == 2 { - secret.push(ch); - } - } else { - arg_num += 1; - } - } - - match (String::from_utf8(username), String::from_utf8(secret)) { - (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { - Some((username, secret).into()) - } - _ => None, - } -} - -pub fn sasl_decode_challenge_oauth(challenge: &[u8]) -> Option> { - extract_oauth_bearer(challenge).map(|s| Credentials::OAuthBearer { token: s.into() }) -} - -fn extract_oauth_bearer(bytes: &[u8]) -> Option<&str> { - let mut start_pos = 0; - let eof = bytes.len().saturating_sub(1); - - for (pos, ch) in bytes.iter().enumerate() { - let is_separator = *ch == 1; - if is_separator || pos == eof { - if bytes - .get(start_pos..start_pos + 12) - .is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer ")) - { - return bytes - .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) - .and_then(|s| std::str::from_utf8(s).ok()); - } - - start_pos = pos + 1; - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_oauth_bearer() { - let input = b"auth=Bearer validtoken"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("validtoken")); - - let input = b"auth=Invalid validtoken"; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b"auth=Bearer"; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b""; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b"auth=Bearer token1\x01auth=Bearer token2"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token1")); - - let input = b"auth=Bearer VALIDTOKEN"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("VALIDTOKEN")); - - let input = b"auth=Bearer token with spaces"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token with spaces")); - - let input = b"auth=Bearer token_with_special_chars!@#"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token_with_special_chars!@#")); - - let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; - let result = extract_oauth_bearer(input.as_bytes()); - assert_eq!(result, Some("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==")); - } -} diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index affb27e3..54a8a518 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -11,6 +11,8 @@ trc = { path = "../trc" } nlp = { path = "../nlp" } types = { path = "../types" } registry = { path = "../registry" } +mail-parser = { version = "0.11" } +mail-builder = { version = "0.4" } tokio = { version = "1.47", features = ["net"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } rustls = { version = "0.23.5", default-features = false, features = ["std", "ring", "tls12"] } diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 3eea4d1e..f240e1ee 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -5,16 +5,19 @@ */ use super::{LdapDirectory, LdapMappings}; -use crate::{Account, Credentials, Group, IntoError, Recipient, backend::ldap::AuthBind}; +use crate::{ + Account, Credentials, Group, IntoError, Recipient, backend::ldap::AuthBind, + core::secret::verify_secret_hash, +}; use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry}; use store::xxhash_rust; use utils::sanitize_email; impl LdapDirectory { - pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result> { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { let (username, secret) = match credentials { Credentials::Basic { username, secret } => (username, secret), - Credentials::Bearer { token } => (token, token), + Credentials::Bearer { token, .. } => (token, token), }; let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; @@ -41,12 +44,10 @@ impl LdapDirectory { .success() .is_err() { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Secret rejected during auth bind using template", - Details = dn - ); - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials for auth bind using template") + .details(dn)); } let filter = self.mappings.filter_login.build(username); @@ -61,7 +62,6 @@ impl LdapDirectory { if result.account.email.is_empty() { result.account.email = username.into(); } - result.account.is_authenticated = true; result.account } Err(err) @@ -71,21 +71,16 @@ impl LdapDirectory { .and_then(|v| v.to_uint()) .is_some_and(|rc| [49, 50].contains(&rc)) => { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Error codes 49 or 50 returned by LDAP server", - Details = vec![dn, filter] - ); - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Error codes 49 or 50 returned by LDAP server") + .details(vec![dn, filter])); } Ok(None) => { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Auth bind successful but filter yielded no results", - Details = vec![dn, filter] - ); - - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Auth bind successful but filter yielded no results") + .details(vec![dn, filter])); } Err(err) => return Err(err), } @@ -113,36 +108,43 @@ impl LdapDirectory { if result.account.email.is_empty() { result.account.email = username.into(); } - result.account.is_authenticated = true; result.account } else { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Secret rejected during auth bind using lookup filter", - Details = vec![result.dn, filter] - ); - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Secret rejected during auth bind using lookup filter") + .details(vec![result.dn, filter])); } } else { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Auth bind lookup filter yielded no results", - Details = filter - ); - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Auth bind lookup filter yielded no results") + .details(vec![filter])); } } AuthBind::None => { let filter = self.mappings.filter_login.build(username); if let Some(result) = self.find_object(&mut conn, &filter).await? { + if let Some(account_secret) = &result.account.secret { + if !verify_secret_hash(account_secret, secret.as_bytes()).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials") + .details(vec![filter])); + } + } else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account does not have a secret") + .details(vec![filter])); + } + result.account } else { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Authentication filter yielded no results", - Details = filter - ); - return Ok(None); + return Err(trc::AuthEvent::Failed + .into_err() + .details("Authentication filter yielded no results") + .details(vec![filter])); } } }; @@ -177,7 +179,7 @@ impl LdapDirectory { } } - Ok(Some(account)) + Ok(account) } pub async fn recipient(&self, address: &str) -> trc::Result { diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index ea0d6cd5..2c082f3e 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -14,13 +14,13 @@ use utils::sanitize_email; type OpenIdResponse = HashMap; impl OpenIdDirectory { - pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result> { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { let token = match credentials { - Credentials::Bearer { token } => token, + Credentials::Bearer { token, .. } => token, _ => { return Err(AuthEvent::Error .into_err() - .details("Unsupported credentials type for OIDC authentication")); + .details("Unsupported credentials type for OIDC backend")); } }; let email; @@ -116,7 +116,7 @@ impl OpenIdDirectory { if value == *required_aud { aud_matched = true; } else { - return Err(AuthEvent::Error + return Err(AuthEvent::Failed .into_err() .details("Audience claim does not match")); } @@ -127,7 +127,7 @@ impl OpenIdDirectory { if value == *required_iss { iss_matched = true; } else { - return Err(AuthEvent::Error + return Err(AuthEvent::Failed .into_err() .details("Issuer claim does not match")); } @@ -156,8 +156,7 @@ impl OpenIdDirectory { .into_err() .details("One or more required scopes not found in OIDC response")) } else if !account.email.is_empty() { - account.is_authenticated = true; - Ok(Some(account)) + Ok(account) } else { Err(trc::AuthEvent::Error .into_err() diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index 8b0647b9..923762f3 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -5,16 +5,20 @@ */ use super::{SqlDirectory, SqlMappings}; -use crate::{Account, Credentials, Recipient}; +use crate::{Account, Credentials, Recipient, core::secret::verify_secret_hash}; use store::{NamedRows, Rows, Value}; use trc::AddContext; use utils::sanitize_email; impl SqlDirectory { - pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result> { - let username = match credentials { - Credentials::Basic { username, .. } => username, - Credentials::Bearer { token } => token, + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { + let (username, secret) = match credentials { + Credentials::Basic { username, secret } => (username, secret), + Credentials::Bearer { .. } => { + return Err(trc::AuthEvent::Error + .into_err() + .details("Unsupported credentials type for SQL authentication")); + } }; let Recipient::Account(mut account) = self.mappings.row_to_account( @@ -23,9 +27,27 @@ impl SqlDirectory { .await .caused_by(trc::location!())?, ) else { - return Ok(None); + return Err(trc::AuthEvent::Error + .into_err() + .details("SQL login query did not return an account") + .ctx(trc::Key::AccountName, username.to_string())); }; + // Validate secret + if let Some(account_secret) = &account.secret { + if !verify_secret_hash(account_secret, secret.as_bytes()).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials") + .ctx(trc::Key::AccountName, username.to_string())); + } + } else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account does not have a secret") + .ctx(trc::Key::AccountName, username.to_string())); + } + // Obtain members if let Some(query) = &self.mappings.query_member_of { for row in self @@ -60,7 +82,7 @@ impl SqlDirectory { ); } - Ok(Some(account)) + Ok(account) } pub async fn recipient(&self, address: &str) -> trc::Result { @@ -142,10 +164,9 @@ impl SqlMappings { is_group = value.to_str().eq_ignore_ascii_case("group"); } else if let Some(column_description) = &self.column_description && name.eq_ignore_ascii_case(column_description) + && let Value::Text(text) = value { - if let Value::Text(text) = value { - account.description = Some(text.into_owned()); - } + account.description = Some(text.into_owned()); } } } diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs index 05bfa769..e7b2ebea 100644 --- a/crates/directory/src/core/dispatch.rs +++ b/crates/directory/src/core/dispatch.rs @@ -8,7 +8,7 @@ use crate::{Account, Credentials, Directory, Recipient}; use trc::AddContext; impl Directory { - pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result> { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { match &self { Directory::Ldap(store) => store.authenticate(credentials).await, Directory::Sql(store) => store.authenticate(credentials).await, diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs index 8e9fa4b3..799b5fd9 100644 --- a/crates/directory/src/core/mod.rs +++ b/crates/directory/src/core/mod.rs @@ -6,3 +6,5 @@ pub mod config; pub mod dispatch; +pub mod sasl; +pub mod secret; diff --git a/crates/directory/src/core/sasl.rs b/crates/directory/src/core/sasl.rs new file mode 100644 index 00000000..e2fca071 --- /dev/null +++ b/crates/directory/src/core/sasl.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Credentials; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + +impl Credentials { + pub fn decode_sasl_challenge_plain(challenge: &[u8]) -> Option { + let mut username = Vec::new(); + let mut secret = Vec::new(); + let mut arg_num = 0; + for &ch in challenge { + if ch != 0 { + if arg_num == 1 { + username.push(ch); + } else if arg_num == 2 { + secret.push(ch); + } + } else { + arg_num += 1; + } + } + + 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 }) + } + _ => None, + } + } + + pub fn decode_sasl_challenge_oauth(challenge: &[u8]) -> Option { + extract_oauth_bearer(challenge) + .map(|(token, username)| Credentials::Bearer { username, token }) + } +} + +fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option)> { + let mut start_pos = 0; + let eof = bytes.len().saturating_sub(1); + let mut iter = bytes.iter().enumerate(); + let mut a = None; + + while let Some((pos, ch)) = iter.next() { + if *ch == b',' + && bytes + .get(pos + 1..pos + 3) + .is_some_and(|s| s.eq_ignore_ascii_case(b"a=")) + { + let from_pos = pos + 3; + let mut to_pos = from_pos; + for (pos, ch) in iter.by_ref() { + if *ch == b',' || *ch == 1 { + to_pos = pos; + break; + } + } + + if to_pos > from_pos { + a = bytes + .get(from_pos..to_pos) + .and_then(|s| std::str::from_utf8(s).ok()) + .filter(|v| v.contains('@')); + } + } else { + let is_separator = *ch == 1; + if is_separator || pos == eof { + if bytes + .get(start_pos..start_pos + 12) + .is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer ")) + { + return bytes + .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(|token| { + ( + token.to_string(), + a.map(|s| s.to_string()) + .or_else(|| extract_email_from_jwt(token)), + ) + }); + } + + start_pos = pos + 1; + } + } + } + + None +} + +#[derive(Debug, serde::Deserialize)] +struct JwtClaims { + #[serde(default)] + email: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + upn: Option, + #[serde(default)] + unique_name: Option, + #[serde(default)] + sub: Option, +} + +fn extract_email_from_jwt(token: &str) -> Option { + let claims: JwtClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok()?; + [ + claims.email, + claims.preferred_username, + claims.upn, + claims.unique_name, + claims.sub, + ] + .into_iter() + .flatten() + .find(|v| v.contains('@')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_oauth_bearer() { + let input = b"auth=Bearer validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("validtoken".to_string(), None))); + + let input = b"auth=Invalid validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b""; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer token1\x01auth=Bearer token2"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("token1".to_string(), None))); + + let input = b"auth=Bearer VALIDTOKEN"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("VALIDTOKEN".to_string(), None))); + + let input = b"auth=Bearer token with spaces"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("token with spaces".to_string(), None))); + + let input = b"auth=Bearer token_with_special_chars!@#"; + let result = extract_oauth_bearer(input); + assert_eq!( + result, + Some(("token_with_special_chars!@#".to_string(), None)) + ); + + let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; + let result = extract_oauth_bearer(input.as_bytes()); + assert_eq!( + result, + Some(( + "vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(), + Some("user@example.com".to_string()) + )) + ); + } +} diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs new file mode 100644 index 00000000..4d7b0443 --- /dev/null +++ b/crates/directory/src/core/secret.rs @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use argon2::Argon2; +use mail_builder::encoders::base64::base64_encode; +use mail_parser::decoders::base64::base64_decode; +use password_hash::PasswordHash; +use pbkdf2::Pbkdf2; +use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt}; +use scrypt::Scrypt; +use sha1::Digest; +use sha1::Sha1; +use sha2::Sha256; +use sha2::Sha512; +use tokio::sync::oneshot; +use totp_rs::TOTP; + +pub async fn verify_mfa_secret_hash( + otp_auth: 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) + .map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details(otp_auth.to_string()) + })? + .check_current(totp_token) + .unwrap_or(false); + Ok(result) + } else if !hashed_secret.is_empty() + && !secret.is_empty() + && verify_secret_hash(hashed_secret, secret.as_bytes()).await? + { + // Only let the client know if the TOTP code is missing + // if the password is correct + + Err(trc::AuthEvent::MissingTotp.into_err()) + } else { + Ok(false) + } + } else if !hashed_secret.is_empty() && !secret.is_empty() { + verify_secret_hash(hashed_secret, secret.as_bytes()).await + } else { + Ok(false) + } +} + +async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { + if hashed_secret.starts_with("$argon2") + || hashed_secret.starts_with("$pbkdf2") + || hashed_secret.starts_with("$scrypt") + { + let (tx, rx) = oneshot::channel(); + let secret = secret.to_vec(); + let hashed_secret = hashed_secret.to_string(); + + tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) { + Ok(hash) => { + tx.send(Ok(hash + .verify_password(&[&Argon2::default(), &Pbkdf2, &Scrypt], &secret) + .is_ok())) + .ok(); + } + Err(err) => { + tx.send(Err(trc::AuthEvent::Error + .reason(err) + .details(hashed_secret))) + .ok(); + } + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err)), + } + } else if hashed_secret.starts_with("$2") { + // Blowfish crypt + Ok(bcrypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$6$") { + // SHA-512 crypt + Ok(sha512_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$5$") { + // SHA-256 crypt + Ok(sha256_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$sha1") { + // SHA-1 crypt + Ok(sha1_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$1") { + // MD5 based hash + Ok(md5_crypt::verify(secret, hashed_secret)) + } else { + Err(trc::AuthEvent::Error + .into_err() + .details(hashed_secret.to_string())) + } +} + +pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Result { + if hashed_secret.starts_with('$') { + verify_hash_prefix(hashed_secret, secret).await + } else if hashed_secret.starts_with('_') { + // Enhanced DES-based hash + Ok(bsdi_crypt::verify(secret, hashed_secret)) + } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { + if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { + match algo { + "ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => { + verify_hash_prefix(hashed_secret, secret).await + } + "SHA" => { + // SHA-1 + let mut hasher = Sha1::new(); + hasher.update(secret); + Ok( + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + == hashed_secret, + ) + } + "SSHA" => { + // Salted SHA-1 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..20).unwrap_or_default(); + let salt = decoded.get(20..).unwrap_or_default(); + let mut hasher = Sha1::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "SHA256" => { + // Verify hash + let mut hasher = Sha256::new(); + hasher.update(secret); + Ok( + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + == hashed_secret, + ) + } + "SSHA256" => { + // Salted SHA-256 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..32).unwrap_or_default(); + let salt = decoded.get(32..).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "SHA512" => { + // SHA-512 + let mut hasher = Sha512::new(); + hasher.update(secret); + Ok( + String::from_utf8( + base64_encode(&hasher.finalize()[..]).unwrap_or_default(), + ) + .unwrap() + == hashed_secret, + ) + } + "SSHA512" => { + // Salted SHA-512 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..64).unwrap_or_default(); + let salt = decoded.get(64..).unwrap_or_default(); + let mut hasher = Sha512::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "MD5" => { + // MD5 + let digest = md5::compute(secret); + Ok( + String::from_utf8(base64_encode(&digest[..]).unwrap_or_default()).unwrap() + == hashed_secret, + ) + } + "CRYPT" | "crypt" => { + if hashed_secret.starts_with('$') { + verify_hash_prefix(hashed_secret, secret).await + } else { + // Unix crypt + Ok(unix_crypt::verify(secret, hashed_secret)) + } + } + "PLAIN" | "plain" | "CLEAR" | "clear" => Ok(hashed_secret.as_bytes() == secret), + _ => Err(trc::AuthEvent::Error + .ctx(trc::Key::Reason, "Unsupported algorithm") + .details(hashed_secret.to_string())), + } + } else { + Err(trc::AuthEvent::Error + .into_err() + .details(hashed_secret.to_string())) + } + } else if !hashed_secret.is_empty() { + Ok(hashed_secret.as_bytes() == secret) + } else { + Ok(false) + } +} diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 89f52f65..96b09cea 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -18,8 +18,14 @@ pub mod backend; pub mod core; pub enum Credentials { - Basic { username: String, secret: String }, - Bearer { token: String }, + Basic { + username: String, + secret: String, + }, + Bearer { + username: Option, + token: String, + }, } pub enum Directory { @@ -40,7 +46,6 @@ pub struct Account { pub email: String, pub email_aliases: Vec, pub secret: Option, - pub is_authenticated: bool, pub groups: Vec, pub description: Option, } diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index eb84bc7c..ce04446e 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -6,7 +6,7 @@ // This file is auto-generated. Do not edit directly. -pub const TOTAL_EVENT_COUNT: usize = 595; +pub const TOTAL_EVENT_COUNT: usize = 596; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EventType { @@ -111,6 +111,7 @@ pub enum AuthEvent { TooManyAttempts = 38, ClientRegistration = 555, Error = 34, + Warning = 595, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 48796eb5..de7cea16 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -48,6 +48,7 @@ impl EventType { b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), b"auth.error" => EventType::Auth(AuthEvent::Error), + b"auth.warning" => EventType::Auth(AuthEvent::Warning), b"calendar.rule-expansion-error" => EventType::Calendar(CalendarEvent::RuleExpansionError), b"calendar.alarm-sent" => EventType::Calendar(CalendarEvent::AlarmSent), b"calendar.alarm-skipped" => EventType::Calendar(CalendarEvent::AlarmSkipped), @@ -649,6 +650,7 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", EventType::Auth(AuthEvent::Error) => "auth.error", + EventType::Auth(AuthEvent::Warning) => "auth.warning", EventType::Calendar(CalendarEvent::RuleExpansionError) => { "calendar.rule-expansion-error" } @@ -1365,6 +1367,7 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => 38, EventType::Auth(AuthEvent::ClientRegistration) => 555, EventType::Auth(AuthEvent::Error) => 34, + EventType::Auth(AuthEvent::Warning) => 595, EventType::Calendar(CalendarEvent::RuleExpansionError) => 576, EventType::Calendar(CalendarEvent::AlarmSent) => 579, EventType::Calendar(CalendarEvent::AlarmSkipped) => 580, @@ -1965,6 +1968,7 @@ impl EventType { 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), 34 => Some(EventType::Auth(AuthEvent::Error)), + 595 => Some(EventType::Auth(AuthEvent::Warning)), 576 => Some(EventType::Calendar(CalendarEvent::RuleExpansionError)), 579 => Some(EventType::Calendar(CalendarEvent::AlarmSent)), 580 => Some(EventType::Calendar(CalendarEvent::AlarmSkipped)), @@ -2938,6 +2942,7 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", EventType::Auth(AuthEvent::Error) => "Authentication error", + EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Calendar(CalendarEvent::RuleExpansionError) => { "Calendar rule expansion error" } @@ -3672,6 +3677,7 @@ impl EventType { "OAuth client successfully registered" } EventType::Auth(AuthEvent::Error) => "An error occurred with authentication", + EventType::Auth(AuthEvent::Warning) => "A warning occurred with authentication", EventType::Calendar(CalendarEvent::RuleExpansionError) => { "An error occurred while expanding calendar recurrences" } @@ -5046,6 +5052,7 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts), EventType::Auth(AuthEvent::ClientRegistration), EventType::Auth(AuthEvent::Error), + EventType::Auth(AuthEvent::Warning), EventType::Calendar(CalendarEvent::RuleExpansionError), EventType::Calendar(CalendarEvent::AlarmSent), EventType::Calendar(CalendarEvent::AlarmSkipped), diff --git a/crates/trc/src/ipc/bitset.rs b/crates/trc/src/ipc/bitset.rs index bd8449d7..05fed94d 100644 --- a/crates/trc/src/ipc/bitset.rs +++ b/crates/trc/src/ipc/bitset.rs @@ -6,7 +6,7 @@ use super::{USIZE_BITS, USIZE_BITS_MASK}; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Bitset(pub(crate) [usize; N]); impl Bitset { diff --git a/crates/utils/src/codec/base32_custom.rs b/crates/utils/src/codec/base32_custom.rs index 518fcca9..ce19d36a 100644 --- a/crates/utils/src/codec/base32_custom.rs +++ b/crates/utils/src/codec/base32_custom.rs @@ -4,9 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{io::Write, slice::Iter}; - use super::leb128::{Leb128Iterator, Leb128Writer}; +use std::{io::Write, slice::Iter}; pub static BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz792013"; pub static BASE32_INVERSE: [u8; 256] = [