Registry testing - part 5

This commit is contained in:
mdecimus
2026-03-15 18:01:12 +01:00
parent 4b5688fd57
commit e0105bc43b
46 changed files with 1764 additions and 710 deletions

View File

@@ -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>(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::<u64>();
let revision_account = hash_account(&account);
let token: Arc<AccessTokenInner> = 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<AccessTokenInner> = if let Some(account) =
self.registry().object::<Account>(account_id.into()).await?
{
let revision = rand::random::<u64>();
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::<u64>();
let token: Arc<AccessTokenInner> = 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 {

View File

@@ -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<AccessToken> {
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<Option<&Arc<Directory>>> {
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// 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<Directory>> {
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// 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,

View File

@@ -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 {

View File

@@ -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>(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>(account_id.into()).await?
else {
return Ok(None);
@@ -566,8 +632,14 @@ impl Server {
pub async fn role(&self, id: u32) -> trc::Result<Arc<RoleCache>> {
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::<Role>(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<Arc<TenantCache>> {
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::<Tenant>(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<Option<Arc<MailingListCache>>> {
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::<MailingList>(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::<Vec<Id>>(

View File

@@ -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 {

View File

@@ -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}"))