From 86c963ef687d01976fead28cf7a13bf15eff8f23 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Wed, 22 Oct 2025 18:02:46 +0200 Subject: [PATCH] v0.14.0 --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- CHANGELOG.md | 19 + SECURITY.md | 3 +- crates/common/src/auth/oauth/token.rs | 2 +- crates/common/src/config/jmap/settings.rs | 4 +- crates/common/src/config/network.rs | 11 +- crates/dav/src/calendar/query.rs | 2 +- .../directory/src/backend/internal/lookup.rs | 4 +- .../directory/src/backend/internal/manage.rs | 130 ++++--- crates/directory/src/backend/internal/mod.rs | 13 +- crates/directory/src/backend/ldap/lookup.rs | 84 +++-- crates/directory/src/backend/memory/config.rs | 2 +- crates/directory/src/backend/memory/lookup.rs | 2 +- crates/directory/src/backend/oidc/lookup.rs | 2 +- crates/directory/src/backend/sql/lookup.rs | 34 +- crates/directory/src/core/principal.rs | 350 +++++++++--------- crates/directory/src/core/secret.rs | 141 ++++--- crates/directory/src/lib.rs | 9 +- crates/http/src/management/principal.rs | 23 +- crates/jmap/src/identity/set.rs | 2 +- crates/migration/src/principal_v1.rs | 14 +- crates/migration/src/principal_v2.rs | 12 +- crates/trc/src/serializers/text.rs | 10 +- tests/src/directory/internal.rs | 10 +- tests/src/directory/mod.rs | 13 +- tests/src/directory/oidc.rs | 5 +- tests/src/directory/sql.rs | 18 +- 27 files changed, 528 insertions(+), 395 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6c0e4feb..5f061aff 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,10 +31,10 @@ body: label: Version description: What version of our software are you running? options: + - v0.14.x - v0.13.x - v0.12.x - - v0.11.x - - v0.10.x or lower + - v0.11.x or lower validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 5859f9be..d33ee00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.14.0] - 2025-10-22 + +If you are upgrading from v0.13.4 and below, this version includes **breaking changes** to the internal directory, calendar and contacts. Please read the [upgrading documentation](https://stalw.art/docs/install/upgrade) for more information on how to upgrade from previous versions. + +## Added +- JMAP for Calendars ([draft-ietf-jmap-calendars](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/)). +- JMAP for Contacts ([RFC 9610](https://datatracker.ietf.org/doc/rfc9610/)). +- JMAP for File Storage ([draft-ietf-jmap-filenode](https://datatracker.ietf.org/doc/draft-ietf-jmap-filenode/)). +- JMAP Sharing ([RFC 9670](https://datatracker.ietf.org/doc/rfc9670/)) +- CalDAV: support for `supported-calendar-component-set` (#1893) +- i18n: Greek language support (contributed by @infl00p) +- i18n: Swedish language support (contributed by @purung) + +## Changed + +## Fixed +- Push Subscription: Clean-up of expired subscriptions and cluster notification of changes (#1248) +- CalDAV: Per-user CalDAV properties (#2058) + ## [0.13.4] - 2025-09-30 If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. diff --git a/SECURITY.md b/SECURITY.md index 42b0e4b8..53a4d01c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,8 @@ We provide security updates for the following versions of Stalwart: | Version | Supported | End of Support | | ------- | ------------------ | -------------- | -| 0.13.x | :white_check_mark: | TBD | +| 0.14.x | :white_check_mark: | TBD | +| 0.13.x | :white_check_mark: | 2026-02-28 | | 0.12.x | :white_check_mark: | 2025-12-31 | | 0.11.x | :white_check_mark: | 2025-12-31 | | < 0.11 | :x: | Ended | diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 51247a96..70ac1cee 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -231,7 +231,7 @@ impl Server { .data .into_iter() .filter_map(|v| { - if let PrincipalData::Secret(secret) = v { + if let PrincipalData::Password(secret) = v { Some(secret) } else { None diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 4f430db8..5a7c1a31 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -344,10 +344,10 @@ impl JmapConfig { }), contact_parse_max_items: config .property("jmap.contact.parse.max-items") - .unwrap_or(100), + .unwrap_or(10), calendar_parse_max_items: config .property("jmap.calendar.parse.max-items") - .unwrap_or(100), + .unwrap_or(10), default_folders, shared_folder, }; diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index ce55c937..4af2f3a0 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -4,12 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use super::*; use crate::expr::{if_block::IfBlock, tokenizer::TokenMap}; use ahash::AHashSet; -use std::time::Duration; +use std::{hash::Hasher, time::Duration}; use utils::config::{Config, Rate, utils::ParseValue}; - -use super::*; +use xxhash_rust::xxh3::Xxh3Builder; #[derive(Clone)] pub struct Network { @@ -377,8 +377,9 @@ impl ClusterRole { shard_id, total_shards, } => { - (ahash::RandomState::new().hash_one(item) % (*total_shards as u64)) - == (*shard_id as u64) + let mut hasher = Xxh3Builder::new().with_seed(191179).build(); + item.hash(&mut hasher); + hasher.finish() % (*total_shards as u64) == *shard_id as u64 } } } diff --git a/crates/dav/src/calendar/query.rs b/crates/dav/src/calendar/query.rs index 9dc880cc..a24a7a9b 100644 --- a/crates/dav/src/calendar/query.rs +++ b/crates/dav/src/calendar/query.rs @@ -167,8 +167,8 @@ fn extract_filter_range(query: &CalendarQuery) -> Option { fn extract_data_range(propfind: &PropFind, filter_range: Option) -> Option { let props = match propfind { - PropFind::PropName => todo!(), PropFind::AllProp(props) | PropFind::Prop(props) => props, + PropFind::PropName => &[][..], }; for prop in props { diff --git a/crates/directory/src/backend/internal/lookup.rs b/crates/directory/src/backend/internal/lookup.rs index 64c5d1b6..fa14a20a 100644 --- a/crates/directory/src/backend/internal/lookup.rs +++ b/crates/directory/src/backend/internal/lookup.rs @@ -50,7 +50,9 @@ impl DirectoryStore for Store { && let Some(mut principal) = self.get_principal(account_id).await? { if let Some(secret) = secret - && !principal.verify_secret(secret, by.only_app_pass).await? + && !principal + .verify_secret(secret, by.only_app_pass, true) + .await? { return Ok(None); } diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index e8d0e185..de912eef 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -321,7 +321,7 @@ impl ManageDirectory for Store { return Err(err_exists(PrincipalField::Name, name)); } - let mut principal_create = Principal::new(0, principal_set.typ()); + let mut create_principal = Principal::new(0, principal_set.typ()); // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC @@ -352,9 +352,9 @@ impl ManageDirectory for Store { )); } - principal_create.data.push(PrincipalData::Tenant(tenant_id)); + create_principal.data.push(PrincipalData::Tenant(tenant_id)); - if !matches!(principal_create.typ, Type::Tenant | Type::Domain) { + if !matches!(create_principal.typ, Type::Tenant | Type::Domain) { if let Some(domain) = name.try_domain_part() && self .get_principal_info(domain) @@ -377,37 +377,47 @@ impl ManageDirectory for Store { // SPDX-SnippetEnd // Set fields - principal_create.name = name; + create_principal.name = name; + let mut has_secret = false; for secret in principal_set .take_str_array(PrincipalField::Secrets) .unwrap_or_default() { - principal_create.data.push(PrincipalData::Secret(secret)); + if secret.is_otp_secret() { + create_principal.data.push(PrincipalData::OtpAuth(secret)); + } else if secret.is_app_secret() { + create_principal + .data + .push(PrincipalData::AppPassword(secret)); + } else if !has_secret { + has_secret = true; + create_principal.data.push(PrincipalData::Password(secret)); + } } if let Some(description) = principal_set.take_str(PrincipalField::Description) { - principal_create + create_principal .data .push(PrincipalData::Description(description)); } if let Some(picture) = principal_set.take_str(PrincipalField::Picture) { - principal_create.data.push(PrincipalData::Picture(picture)); + create_principal.data.push(PrincipalData::Picture(picture)); } if let Some(picture) = principal_set.take_str(PrincipalField::Locale) { - principal_create.data.push(PrincipalData::Locale(picture)); + create_principal.data.push(PrincipalData::Locale(picture)); } for url in principal_set .take_str_array(PrincipalField::Urls) .unwrap_or_default() { - principal_create.data.push(PrincipalData::Url(url)); + create_principal.data.push(PrincipalData::Url(url)); } for member in principal_set .take_str_array(PrincipalField::ExternalMembers) .unwrap_or_default() { - principal_create + create_principal .data .push(PrincipalData::ExternalMember(member)); } @@ -415,12 +425,12 @@ impl ManageDirectory for Store { for (idx, quota) in quotas.into_iter().take(Type::MAX_ID + 2).enumerate() { if quota != 0 { if idx != 0 { - principal_create.data.push(PrincipalData::DirectoryQuota { + create_principal.data.push(PrincipalData::DirectoryQuota { quota: quota as u32, typ: Type::from_u8((idx - 1) as u8), }); } else { - principal_create.data.push(PrincipalData::DiskQuota(quota)); + create_principal.data.push(PrincipalData::DiskQuota(quota)); } } } @@ -511,7 +521,7 @@ impl ManageDirectory for Store { } if !permissions.is_empty() { for (permission, v) in permissions { - principal_create.data.push(PrincipalData::Permission { + create_principal.data.push(PrincipalData::Permission { permission_id: permission.id(), grant: !v, }); @@ -519,7 +529,7 @@ impl ManageDirectory for Store { } // Make sure the e-mail is not taken and validate domain - if principal_create.typ != Type::OauthClient { + if create_principal.typ != Type::OauthClient { for (idx, email) in principal_set .take_str_array(PrincipalField::Emails) .unwrap_or_default() @@ -540,11 +550,11 @@ impl ManageDirectory for Store { .ok_or_else(|| not_found(domain.to_string()))?; } if idx == 0 { - principal_create + create_principal .data .push(PrincipalData::PrimaryEmail(email)); } else { - principal_create.data.push(PrincipalData::EmailAlias(email)); + create_principal.data.push(PrincipalData::EmailAlias(email)); } } } @@ -560,13 +570,13 @@ impl ManageDirectory for Store { .details("ID assignment failed") .caused_by(trc::location!())); } - principal_create.id = principal_id; + create_principal.id = principal_id; let mut batch = BatchBuilder::new(); - let pinfo_name = PrincipalInfo::new(principal_id, principal_create.typ, tenant_id); - let pinfo_email = PrincipalInfo::new(principal_id, principal_create.typ, None); + let pinfo_name = PrincipalInfo::new(principal_id, create_principal.typ, tenant_id); + let pinfo_email = PrincipalInfo::new(principal_id, create_principal.typ, None); // Validate object size - if principal_create.object_size() > 100_000 { + if create_principal.object_size() > 100_000 { return Err(error( "Invalid parameter", "Principal object size exceeds 100kb safety limit.".into(), @@ -574,10 +584,10 @@ impl ManageDirectory for Store { } // Serialize - principal_create.sort(); - let archiver = Archiver::new(principal_create); + create_principal.sort(); + let archiver = Archiver::new(create_principal); let principal_bytes = archiver.serialize().caused_by(trc::location!())?; - let principal_create = archiver.into_inner(); + let create_principal = archiver.into_inner(); batch .with_account_id(u32::MAX) @@ -585,11 +595,11 @@ impl ManageDirectory for Store { .create_document(principal_id) .assert_value( ValueClass::Directory(DirectoryClass::NameToId( - principal_create.name().as_bytes().to_vec(), + create_principal.name().as_bytes().to_vec(), )), (), ); - build_search_index(&mut batch, principal_id, None, Some(&principal_create)); + build_search_index(&mut batch, principal_id, None, Some(&create_principal)); batch .set( ValueClass::Directory(DirectoryClass::Principal(principal_id)), @@ -597,13 +607,13 @@ impl ManageDirectory for Store { ) .set( ValueClass::Directory(DirectoryClass::NameToId( - principal_create.name.as_bytes().to_vec(), + create_principal.name.as_bytes().to_vec(), )), pinfo_name.serialize(), ); // Write email to id mapping - for email in principal_create.email_addresses() { + for email in create_principal.email_addresses() { batch.set( ValueClass::Directory(DirectoryClass::EmailToId(email.as_bytes().to_vec())), pinfo_email.serialize(), @@ -633,7 +643,7 @@ impl ManageDirectory for Store { principal_id: member.id, member_of: principal_id, }), - vec![principal_create.typ as u8], + vec![create_principal.typ as u8], ); batch.set( ValueClass::Directory(DirectoryClass::Members { @@ -1160,11 +1170,24 @@ impl ManageDirectory for Store { ) => { // Password changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal - .data - .retain(|v| !matches!(v, PrincipalData::Secret(_))); + principal.data.retain(|v| { + !matches!( + v, + PrincipalData::Password(_) + | PrincipalData::AppPassword(_) + | PrincipalData::OtpAuth(_) + ) + }); + let mut has_secret = false; for secret in value.into_str_array() { - principal.data.push(PrincipalData::Secret(secret)); + if secret.is_otp_secret() { + principal.data.push(PrincipalData::OtpAuth(secret)); + } else if secret.is_app_secret() { + principal.data.push(PrincipalData::AppPassword(secret)); + } else if !has_secret { + has_secret = true; + principal.data.push(PrincipalData::Password(secret)); + } } } ( @@ -1172,8 +1195,26 @@ impl ManageDirectory for Store { PrincipalField::Secrets, PrincipalValue::String(secret), ) => { - if !principal.secrets().any(|v| *v == secret) { - principal.data.push(PrincipalData::Secret(secret)); + if !principal.data.iter().any(|v| match v { + PrincipalData::Password(v) + | PrincipalData::AppPassword(v) + | PrincipalData::OtpAuth(v) => *v == secret, + _ => false, + }) { + if secret.is_app_secret() { + principal.data.push(PrincipalData::AppPassword(secret)); + } else if secret.is_otp_secret() { + principal + .data + .retain(|v| !matches!(v, PrincipalData::OtpAuth(_))); + principal.data.push(PrincipalData::OtpAuth(secret)); + } else { + principal + .data + .retain(|v| !matches!(v, PrincipalData::Password(_))); + principal.data.push(PrincipalData::Password(secret)); + } + // Password changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); } @@ -1186,22 +1227,21 @@ impl ManageDirectory for Store { // Password changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - if secret.is_app_password() || secret.is_otp_auth() { + if secret.is_app_secret() || secret.is_otp_secret() { principal.data.retain(|v| match v { - PrincipalData::Secret(v) => { + PrincipalData::AppPassword(v) | PrincipalData::OtpAuth(v) => { *v != secret && !v.starts_with(secret.as_str()) } _ => true, }); } else if !secret.is_empty() { principal.data.retain(|v| match v { - PrincipalData::Secret(v) => *v != secret, + PrincipalData::Password(v) => *v != secret, _ => true, }); } else { - principal.data.retain(|v| match v { - PrincipalData::Secret(v) => !v.is_password(), - _ => true, + principal.data.retain(|v| { + !matches!(v, PrincipalData::AppPassword(_) | PrincipalData::OtpAuth(_)) }); } } @@ -1319,7 +1359,7 @@ impl ManageDirectory for Store { } for email in principal.email_addresses() { - if !emails.contains(email) { + if !emails.iter().any(|v| v == email) { batch.clear(ValueClass::Directory(DirectoryClass::EmailToId( email.as_bytes().to_vec(), ))); @@ -1351,7 +1391,7 @@ impl ManageDirectory for Store { let email = email.to_lowercase(); let mut emails_iter = principal.email_addresses().peekable(); let has_emails = emails_iter.peek().is_some(); - let email_exists = emails_iter.any(|v| v == &email); + let email_exists = emails_iter.any(|v| v == email); drop(emails_iter); if !email_exists { if validate_emails { @@ -1380,7 +1420,7 @@ impl ManageDirectory for Store { PrincipalValue::String(email), ) => { let email = email.to_lowercase(); - if principal.email_addresses().any(|v| v == &email) { + if principal.email_addresses().any(|v| v == email) { let mut deleted_primary = false; principal.data.retain(|v| match v { PrincipalData::EmailAlias(v) => v != &email, @@ -2320,7 +2360,9 @@ impl ManageDirectory for Store { result.set(PrincipalField::Description, description); } } - PrincipalData::Secret(secret) => { + PrincipalData::Password(secret) + | PrincipalData::AppPassword(secret) + | PrincipalData::OtpAuth(secret) => { if fields.is_empty() || fields.contains(&PrincipalField::Secrets) { result.append_str(PrincipalField::Secrets, secret); } diff --git a/crates/directory/src/backend/internal/mod.rs b/crates/directory/src/backend/internal/mod.rs index f413df89..f6b068eb 100644 --- a/crates/directory/src/backend/internal/mod.rs +++ b/crates/directory/src/backend/internal/mod.rs @@ -272,24 +272,19 @@ impl PrincipalField { } pub trait SpecialSecrets { - fn is_otp_auth(&self) -> bool; - fn is_app_password(&self) -> bool; - fn is_password(&self) -> bool; + fn is_otp_secret(&self) -> bool; + fn is_app_secret(&self) -> bool; } impl SpecialSecrets for T where T: AsRef, { - fn is_otp_auth(&self) -> bool { + fn is_otp_secret(&self) -> bool { self.as_ref().starts_with("otpauth://") } - fn is_app_password(&self) -> bool { + fn is_app_secret(&self) -> bool { self.as_ref().starts_with("$app$") } - - fn is_password(&self) -> bool { - !self.is_otp_auth() && !self.is_app_password() - } } diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 010f3cef..956678b9 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -4,23 +4,22 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry}; -use mail_send::Credentials; -use store::xxhash_rust; -use trc::AddContext; - +use super::{AuthBind, LdapDirectory, LdapMappings}; use crate::{ IntoError, Principal, PrincipalData, QueryBy, QueryParams, ROLE_ADMIN, ROLE_USER, Type, backend::{ RcptType, internal::{ + SpecialSecrets, lookup::DirectoryStore, manage::{self, ManageDirectory, UpdatePrincipal}, }, }, }; - -use super::{AuthBind, LdapDirectory, LdapMappings}; +use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry}; +use mail_send::Credentials; +use store::xxhash_rust; +use trc::AddContext; impl LdapDirectory { pub async fn query(&self, by: QueryParams<'_>) -> trc::Result> { @@ -186,7 +185,7 @@ impl LdapDirectory { AuthBind::None => { let filter = self.mappings.filter_name.build(username); if let Some(mut result) = self.find_principal(&mut conn, &filter).await? { - if result.principal.verify_secret(secret, false).await? { + if result.principal.verify_secret(secret, false, false).await? { if result.principal.name.is_empty() { result.principal.name = username.into(); } @@ -270,7 +269,7 @@ impl LdapDirectory { }; // Keep the internal store up to date with the LDAP server - let changes = principal.update_external(external_principal, true); + let changes = principal.update_external(external_principal); if !changes.is_empty() { self.data_store .update_principal( @@ -431,7 +430,10 @@ impl LdapMappings { let mut role = ROLE_USER; let mut member_of = vec![]; let mut description = None; - let mut has_primary_email = false; + let mut secret = None; + let mut otp_secret = None; + let mut email = None; + let mut email_aliases = Vec::new(); for (attr, value) in entry.attrs { if self.attr_name.contains(&attr) { @@ -439,16 +441,10 @@ impl LdapMappings { principal.name = value.into_iter().next().unwrap_or_default(); } else { for (idx, item) in value.into_iter().enumerate() { - if !has_primary_email { - has_primary_email = true; - principal - .data - .push(PrincipalData::PrimaryEmail(item.to_lowercase())); - } else { - principal - .data - .push(PrincipalData::EmailAlias(item.to_lowercase())); + if email.is_none() { + email = Some(item.to_lowercase()); } + if idx == 0 { principal.name = item; } @@ -456,35 +452,33 @@ impl LdapMappings { } } else if self.attr_secret.contains(&attr) { for item in value { - principal.data.push(PrincipalData::Secret(item)); + if item.is_otp_secret() { + otp_secret = Some(item); + } else if item.is_app_secret() { + principal.data.push(PrincipalData::AppPassword(item)); + } else if secret.is_none() { + secret = Some(item); + } } } else if self.attr_secret_changed.contains(&attr) { // Create a disabled AppPassword, used to indicate that the password has been changed // but cannot be used for authentication. - for item in value { - principal.data.push(PrincipalData::Secret(format!( - "$app${}$", - xxhash_rust::xxh3::xxh3_64(item.as_bytes()) - ))); + if secret.is_none() { + secret = value.into_iter().next().map(|item| { + format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes())) + }); } } else if self.attr_email_address.contains(&attr) { for item in value { - if !has_primary_email { - has_primary_email = true; - principal - .data - .push(PrincipalData::PrimaryEmail(item.to_lowercase())); + if email.is_some() { + email_aliases.push(item.to_lowercase()); } else { - principal - .data - .push(PrincipalData::EmailAlias(item.to_lowercase())); + email = Some(item.to_lowercase()); } } } else if self.attr_email_alias.contains(&attr) { for item in value { - principal - .data - .push(PrincipalData::EmailAlias(item.to_lowercase())); + email_aliases.push(item.to_lowercase()); } } else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) { if (description.is_none() || idx == 0) @@ -520,6 +514,24 @@ impl LdapMappings { } } + for alias in email_aliases { + if email.as_ref().is_none_or(|email| email != &alias) { + principal.data.push(PrincipalData::EmailAlias(alias)); + } + } + + if let Some(email) = email { + principal.data.push(PrincipalData::PrimaryEmail(email)); + } + + if let Some(secret) = secret { + principal.data.push(PrincipalData::Password(secret)); + } + + if let Some(otp_secret) = otp_secret { + principal.data.push(PrincipalData::OtpAuth(otp_secret)); + } + if let Some(desc) = description { principal.data.push(PrincipalData::Description(desc)); } diff --git a/crates/directory/src/backend/memory/config.rs b/crates/directory/src/backend/memory/config.rs index 35eec13a..0e2252d3 100644 --- a/crates/directory/src/backend/memory/config.rs +++ b/crates/directory/src/backend/memory/config.rs @@ -135,7 +135,7 @@ impl MemoryDirectory { principal.name = name.as_str().into(); for (_, secret) in config.values((prefix.as_str(), "principals", lookup_id, "secret")) { - principal.data.push(PrincipalData::Secret(secret.into())); + principal.data.push(PrincipalData::Password(secret.into())); } if let Some(description) = config.value((prefix.as_str(), "principals", lookup_id, "description")) diff --git a/crates/directory/src/backend/memory/lookup.rs b/crates/directory/src/backend/memory/lookup.rs index c17c4688..7f10fc36 100644 --- a/crates/directory/src/backend/memory/lookup.rs +++ b/crates/directory/src/backend/memory/lookup.rs @@ -35,7 +35,7 @@ impl MemoryDirectory { for principal in &self.principals { if principal.name() == username { - return if principal.verify_secret(secret, false).await? { + return if principal.verify_secret(secret, false, false).await? { Ok(Some(principal.clone())) } else { Ok(None) diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index ce7c56da..73e49e6d 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -104,7 +104,7 @@ impl OpenIdDirectory { .ok_or_else(|| manage::not_found(id).caused_by(trc::location!()))?; // Keep the internal store up to date with the OIDC server - let changes = principal.update_external(external_principal, false); + let changes = principal.update_external(external_principal); if !changes.is_empty() { self.data_store .update_principal( diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index 1e5d6877..61925106 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -10,6 +10,7 @@ use crate::{ backend::{ RcptType, internal::{ + SpecialSecrets, lookup::DirectoryStore, manage::{self, ManageDirectory, UpdatePrincipal}, }, @@ -99,17 +100,31 @@ impl SqlDirectory { for row in secrets.rows { for value in row.values { - if let Value::Text(text) = value { - principal + if let Value::Text(secret) = value { + let secret = secret.into_owned(); + + if secret.is_otp_secret() { + if !principal.data.iter().any(|data| { + matches!(data, PrincipalData::OtpAuth(_)) + }) { + principal.data.push(PrincipalData::OtpAuth(secret)); + } + } else if secret.is_app_secret() { + principal.data.push(PrincipalData::AppPassword(secret)); + } else if !principal .data - .push(PrincipalData::Secret(text.as_ref().into())); + .iter() + .any(|data| matches!(data, PrincipalData::Password(_))) + { + principal.data.push(PrincipalData::Password(secret)); + } } } } } if principal - .verify_secret(secret, false) + .verify_secret(secret, false, false) .await .caused_by(trc::location!())? { @@ -201,7 +216,7 @@ impl SqlDirectory { }; // Keep the internal store up to date with the SQL server - let changes = principal.update_external(external_principal, true); + let changes = principal.update_external(external_principal); if !changes.is_empty() { self.data_store .update_principal( @@ -281,14 +296,13 @@ impl SqlMappings { let mut principal = Principal::new(u32::MAX, Type::Individual); let mut role = ROLE_USER; let mut has_primary_email = false; + let mut secret = None; if let Some(row) = rows.rows.into_iter().next() { for (name, value) in rows.names.into_iter().zip(row.values) { if name.eq_ignore_ascii_case(&self.column_secret) { if let Value::Text(text) = value { - principal - .data - .push(PrincipalData::Secret(text.as_ref().into())); + secret = Some(text.into_owned()); } } else if name.eq_ignore_ascii_case(&self.column_type) { match value.to_str().as_ref() { @@ -330,6 +344,10 @@ impl SqlMappings { } } + if let Some(secret) = secret { + principal.data.push(PrincipalData::Password(secret)); + } + principal.data.push(PrincipalData::Role(role)); Ok(Some(principal)) diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index f896f421..31982910 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -7,9 +7,7 @@ use crate::{ ArchivedPrincipal, ArchivedPrincipalData, FALLBACK_ADMIN_ID, Permission, PermissionGrant, Principal, PrincipalData, ROLE_ADMIN, Type, - backend::internal::{ - PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue, SpecialSecrets, - }, + backend::internal::{PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue}, }; use ahash::AHashSet; use nlp::tokenizers::word::WordTokenizer; @@ -19,7 +17,7 @@ use serde::{ ser::SerializeMap, }; use serde_json::Value; -use std::{collections::hash_map::Entry, fmt, str::FromStr}; +use std::{cmp::Ordering, collections::hash_map::Entry, fmt, str::FromStr}; use store::{ U32_LEN, U64_LEN, backend::MAX_TOKEN_LENGTH, @@ -105,25 +103,14 @@ impl Principal { }) } - pub fn secrets(&self) -> impl Iterator { - let mut found_secret = false; - self.data - .iter() - .take_while(move |item| { - if matches!(item, PrincipalData::Secret(_)) { - found_secret = true; - true - } else { - !found_secret - } - }) - .filter_map(|item| { - if let PrincipalData::Secret(secret) = item { - Some(secret) - } else { - None - } - }) + pub fn secret(&self) -> Option<&str> { + if let Some(PrincipalData::Password(password)) = self.data.first() { + Some(password.as_str()) + } else if let Some(PrincipalData::Password(password)) = self.data.get(1) { + Some(password.as_str()) + } else { + None + } } pub fn primary_email(&self) -> Option<&str> { @@ -136,7 +123,7 @@ impl Principal { }) } - pub fn email_addresses(&self) -> impl Iterator { + pub fn email_addresses(&self) -> impl Iterator { let mut found_email = false; self.data .iter() @@ -154,7 +141,7 @@ impl Principal { .filter_map(|item| { if let PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) = item { - Some(email) + Some(email.as_str()) } else { None } @@ -315,124 +302,157 @@ impl Principal { }); } - pub fn update_external( - &mut self, - external: Principal, - overwrite_emails: bool, - ) -> Vec { + pub fn update_external(&mut self, external: Principal) -> Vec { let mut updates = Vec::new(); + let mut external_data = AHashSet::with_capacity(external.data.len()); + let mut has_role = false; + let mut has_member_of = false; + let mut has_quota = false; - // Add external members - for (idx, member_of) in external.member_of().enumerate() { - if idx == 0 { - self.data - .retain(|item| !matches!(item, PrincipalData::MemberOf(_))); - } - self.data.push(PrincipalData::MemberOf(member_of)); - } - - // If the principal has no roles, take the ones from the external principal - for (idx, role) in external.roles().enumerate() { - if idx == 0 && self.roles().next().is_some() { - break; - } - - self.data.push(PrincipalData::Role(role)); - } - - // Update description - match (external.description(), self.description()) { - (Some(external), current) if Some(external) != current => { - if current.is_some() { - self.data - .retain(|item| !matches!(item, PrincipalData::Description(_))); + for item in external.data { + match item { + PrincipalData::DiskQuota(_) => { + has_quota = true; + external_data.insert(item); } - self.data - .push(PrincipalData::Description(external.to_string())); - updates.push(PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(external.to_string()), - )); + PrincipalData::MemberOf(_) => { + has_member_of = true; + external_data.insert(item); + } + PrincipalData::Role(_) => { + has_role = true; + external_data.insert(item); + } + PrincipalData::Password(_) + | PrincipalData::AppPassword(_) + | PrincipalData::OtpAuth(_) + | PrincipalData::Description(_) + | PrincipalData::PrimaryEmail(_) + | PrincipalData::EmailAlias(_) => { + external_data.insert(item); + } + _ => {} } - _ => {} } - // Update secrets - if update_list(external.secrets(), self.secrets()) { - let mut new_secrets = Vec::new(); - self.data - .retain(|item| !matches!(item, PrincipalData::Secret(_))); - self.data.extend(external.secrets().map(|secret| { - new_secrets.push(secret.to_string()); - PrincipalData::Secret(secret.to_string()) - })); - updates.push(PrincipalUpdate::set( - PrincipalField::Secrets, - PrincipalValue::StringList(new_secrets), - )); - } + let mut old_data = Vec::new(); + let data_len = self.data.len(); - // Update emails - if update_list(external.email_addresses(), self.email_addresses()) { - if overwrite_emails { - let mut new_emails = Vec::new(); - self.data.retain(|item| { - !matches!( + for item in std::mem::replace(&mut self.data, Vec::with_capacity(data_len)) { + match item { + PrincipalData::Password(_) + | PrincipalData::AppPassword(_) + | PrincipalData::OtpAuth(_) + | PrincipalData::Description(_) + | PrincipalData::PrimaryEmail(_) + | PrincipalData::EmailAlias(_) + | PrincipalData::DiskQuota(_) + | PrincipalData::MemberOf(_) + | PrincipalData::Role(_) => { + if external_data.remove(&item) + || match item { + PrincipalData::EmailAlias(_) => true, + PrincipalData::Role(_) => !has_role, + PrincipalData::MemberOf(_) => !has_member_of, + PrincipalData::DiskQuota(_) => !has_quota, + _ => false, + } + { + self.data.push(item); + } else if matches!( item, - PrincipalData::PrimaryEmail(_) | PrincipalData::EmailAlias(_) - ) - }); - self.data - .extend(external.data.iter().filter_map(|v| match v { - PrincipalData::PrimaryEmail(email) => { - new_emails.push(email.clone()); - Some(PrincipalData::PrimaryEmail(email.clone())) - } - PrincipalData::EmailAlias(email) => { - new_emails.push(email.clone()); - Some(PrincipalData::EmailAlias(email.clone())) - } - _ => None, - })); - updates.push(PrincipalUpdate::set( - PrincipalField::Emails, - PrincipalValue::StringList(new_emails), - )); - } else { - // Missing emails are appended to avoid overwriting locally defined aliases - // This means that old email addresses need to be deleted either manually or using the API - let current_emails = self.email_addresses().collect::>(); - let mut new_emails = Vec::new(); - for email in external.email_addresses() { - let email = email.to_lowercase(); - if !current_emails.contains(&email) { - updates.push(PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String(email.clone()), - )); - new_emails.push(PrincipalData::EmailAlias(email)); + PrincipalData::Password(_) + | PrincipalData::AppPassword(_) + | PrincipalData::OtpAuth(_) + | PrincipalData::PrimaryEmail(_) + | PrincipalData::EmailAlias(_) + ) { + old_data.push(item); } } - self.data.extend(new_emails); + _ => { + self.data.push(item); + } } } - let external_quota = external.quota(); - let this_quota = self.quota(); - if let Some(external_quota) = external_quota - && this_quota.is_none_or(|this_quota| this_quota != external_quota) - { - if this_quota.is_some() { - self.data - .retain(|item| !matches!(item, PrincipalData::DiskQuota(_))); + // Add new data + let mut has_password = false; + let mut has_email = false; + for item in external_data { + match &item { + PrincipalData::Description(value) => { + updates.push(PrincipalUpdate::set( + PrincipalField::Description, + PrincipalValue::String(value.to_string()), + )); + } + PrincipalData::DiskQuota(value) => { + updates.push(PrincipalUpdate::set( + PrincipalField::Quota, + PrincipalValue::Integer(*value), + )); + } + PrincipalData::Password(value) + | PrincipalData::AppPassword(value) + | PrincipalData::OtpAuth(value) => { + let item = PrincipalUpdate::add_item( + PrincipalField::Secrets, + PrincipalValue::String(value.to_string()), + ); + if !has_password && !updates.is_empty() { + updates.insert(0, item); + } else { + updates.push(item); + } + has_password = true; + } + PrincipalData::PrimaryEmail(value) => { + let item = PrincipalUpdate::add_item( + PrincipalField::Emails, + PrincipalValue::String(value.to_string()), + ); + if !has_email && !updates.is_empty() { + updates.insert(0, item); + } else { + updates.push(item); + } + has_email = true; + } + PrincipalData::EmailAlias(value) => { + updates.push(PrincipalUpdate::add_item( + PrincipalField::Emails, + PrincipalValue::String(value.to_string()), + )); + } + _ => (), } - self.data.push(PrincipalData::DiskQuota(external_quota)); - updates.push(PrincipalUpdate::set( - PrincipalField::Quota, - PrincipalValue::Integer(external_quota), - )); + self.data.push(item); } + // Remove old data + for item in old_data { + match item { + PrincipalData::Password(value) + | PrincipalData::AppPassword(value) + | PrincipalData::OtpAuth(value) => { + updates.push(PrincipalUpdate::remove_item( + PrincipalField::Secrets, + PrincipalValue::String(value), + )); + } + PrincipalData::PrimaryEmail(value) | PrincipalData::EmailAlias(value) => { + updates.push(PrincipalUpdate::remove_item( + PrincipalField::Emails, + PrincipalValue::String(value), + )); + } + _ => (), + } + } + + self.sort(); + updates } @@ -452,66 +472,64 @@ impl Principal { name: "Fallback Administrator".into(), data: vec![ PrincipalData::Role(ROLE_ADMIN), - PrincipalData::Secret(fallback_pass.into()), + PrincipalData::Password(fallback_pass.into()), ], } } pub fn sort(&mut self) { - self.data.sort_unstable_by_key(|d| d.rank()); + self.data.sort_unstable(); } } impl PrincipalData { - fn rank(&self) -> usize { + fn rank(&self) -> u8 { match self { - PrincipalData::Secret(v) => { - if v.is_otp_auth() { - 0 - } else { - 1 - } - } - PrincipalData::PrimaryEmail(_) => 2, - PrincipalData::EmailAlias(_) => 3, - _ => 4, + PrincipalData::OtpAuth(_) => 0, + PrincipalData::Password(_) => 1, + PrincipalData::AppPassword(_) => 2, + PrincipalData::PrimaryEmail(_) => 3, + PrincipalData::EmailAlias(_) => 4, + _ => 5, + } + } + + fn rank_string(&self) -> Option<&str> { + match self { + PrincipalData::OtpAuth(s) + | PrincipalData::Password(s) + | PrincipalData::AppPassword(s) + | PrincipalData::PrimaryEmail(s) + | PrincipalData::EmailAlias(s) => Some(s), + _ => None, } } } -fn update_list<'x>( - new: impl Iterator, - mut current: impl Iterator, -) -> bool { - let mut new = new.peekable(); - if new.peek().is_some() { - loop { - match (new.next(), current.next()) { - (Some(n), Some(c)) => { - if n != c { - return true; - } - } - (Some(_), None) => { - return true; - } - (None, Some(_)) => { - return true; - } - (None, None) => { - return false; - } - } +impl PartialOrd for PrincipalData { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PrincipalData { + fn cmp(&self, other: &Self) -> Ordering { + match self.rank().cmp(&other.rank()) { + Ordering::Equal => match (self.rank_string(), other.rank_string()) { + (Some(a), Some(b)) => a.cmp(b), + _ => Ordering::Equal, + }, + other => other, } - } else { - false } } impl PrincipalData { pub fn object_size(&self) -> usize { match self { - PrincipalData::Secret(v) + PrincipalData::Password(v) + | PrincipalData::AppPassword(v) + | PrincipalData::OtpAuth(v) | PrincipalData::Description(v) | PrincipalData::PrimaryEmail(v) | PrincipalData::EmailAlias(v) diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index 3c9e7253..3bf2c337 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::Principal; +use crate::PrincipalData; use argon2::Argon2; use compact_str::ToCompactString; use mail_builder::encoders::base64::base64_encode; @@ -19,91 +21,80 @@ use sha2::Sha512; use tokio::sync::oneshot; use totp_rs::TOTP; -use crate::Principal; -use crate::backend::internal::SpecialSecrets; - impl Principal { - pub async fn verify_secret(&self, mut code: &str, only_app_pass: bool) -> trc::Result { - let mut totp_token = None; - let mut is_totp_token_missing = false; - let mut is_totp_required = false; - let mut is_totp_verified = false; - let mut is_authenticated = false; - let mut is_app_authenticated = false; + pub async fn verify_secret( + &self, + code: &str, + only_app_pass: bool, + is_ordered: bool, + ) -> trc::Result { + let mut seen_password = false; + let mut password = None; + let mut otp_auth = None; - for secret in self.secrets() { - if secret.is_otp_auth() { - if !is_totp_verified && !is_totp_token_missing { - is_totp_required = true; - - let totp_token = if let Some(totp_token) = totp_token { - totp_token - } else if let Some((_code, _totp_token)) = - code.rsplit_once('$').filter(|(c, t)| { - !c.is_empty() - && (6..=8).contains(&t.len()) - && t.as_bytes().iter().all(|b| b.is_ascii_digit()) - }) - { - totp_token = Some(_totp_token); - code = _code; - _totp_token - } else { - is_totp_token_missing = true; - continue; - }; - - // Token needs to validate with at least one of the TOTP secrets - is_totp_verified = TOTP::from_url(secret) - .map_err(|err| { - trc::AuthEvent::Error - .reason(err) - .details(secret.to_compact_string()) - })? - .check_current(totp_token) - .unwrap_or(false); + for item in &self.data { + match item { + PrincipalData::OtpAuth(secret) => { + if !only_app_pass { + otp_auth = Some(secret); + } + seen_password = true; } - } else if !is_authenticated && !is_app_authenticated { - if let Some((_, app_secret)) = - secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) - { - is_app_authenticated = verify_secret_hash(app_secret, code).await?; - } else if !only_app_pass { - is_authenticated = verify_secret_hash(secret, code).await?; + PrincipalData::Password(secret) => { + if !only_app_pass { + password = Some(secret); + } + seen_password = true; + } + PrincipalData::AppPassword(secret) => { + // App passwords do not require TOTP + if let Some((_, app_secret)) = + secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) + && verify_secret_hash(app_secret, code).await? + { + return Ok(true); + } + + seen_password = true; + } + _ => { + if seen_password && is_ordered { + // Password-related secrets are expected to be at the beginning of the list + break; + } } } } - if is_authenticated { - if !is_totp_required { - // Authenticated without TOTP enabled + // Validate TOTP + match (otp_auth, password) { + (Some(otp_auth), Some(password)) => { + if let Some((code, totp_token)) = code.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(password, code).await? + && TOTP::from_url(otp_auth) + .map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details(otp_auth.to_compact_string()) + })? + .check_current(totp_token) + .unwrap_or(false); + Ok(result) + } else if verify_secret_hash(password, code).await? { + // Only let the client know if the TOTP code is missing + // if the password is correct - Ok(true) - } else if is_totp_token_missing { - // Only let the client know if the TOTP code is missing - // if the password is correct - - Err(trc::AuthEvent::MissingTotp.into_err()) - } else { - // Return the TOTP verification status - - Ok(is_totp_verified) - } - } else if is_app_authenticated { - // App passwords do not require TOTP - - Ok(true) - } else { - if is_totp_verified { - // TOTP URL appeared after password hash in secrets list - for secret in self.secrets() { - if secret.is_password() && verify_secret_hash(secret, code).await? { - return Ok(true); - } + Err(trc::AuthEvent::MissingTotp.into_err()) + } else { + Ok(false) } } - - Ok(false) + (None, Some(password)) => verify_secret_hash(password, code).await, + _ => Ok(false), } } } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 17d69eac..ddb8577e 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -42,9 +42,9 @@ pub struct Principal { pub data: Vec, } -#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum PrincipalData { - Secret(String), + Password(String), // Permissions and memberships Tenant(u32), @@ -66,6 +66,10 @@ pub enum PrincipalData { ExternalMember(String), Url(String), Locale(String), + + // Secrets + AppPassword(String), + OtpAuth(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,6 +96,7 @@ pub struct MemberOf { Eq, serde::Serialize, serde::Deserialize, + Hash, )] #[serde(rename_all = "camelCase")] pub enum Type { diff --git a/crates/http/src/management/principal.rs b/crates/http/src/management/principal.rs index 42f0c77a..02978d08 100644 --- a/crates/http/src/management/principal.rs +++ b/crates/http/src/management/principal.rs @@ -6,10 +6,9 @@ use common::{KV_BAYES_MODEL_USER, Server, auth::AccessToken}; use directory::{ - DirectoryInner, Permission, QueryBy, QueryParams, Type, + DirectoryInner, Permission, PrincipalData, QueryBy, QueryParams, Type, backend::internal::{ PrincipalAction, PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue, - SpecialSecrets, lookup::DirectoryStore, manage::{ self, ChangedPrincipals, ManageDirectory, PrincipalList, UpdatePrincipal, not_found, @@ -710,13 +709,19 @@ impl PrincipalManager for Server { .await? .ok_or_else(|| trc::ManageEvent::NotFound.into_err())?; - for secret in principal.secrets() { - if secret.is_otp_auth() { - response.otp_auth = true; - } else if let Some((app_name, _)) = - secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) - { - response.app_passwords.push(app_name.into()); + for data in &principal.data { + match data { + PrincipalData::OtpAuth(_) => { + response.otp_auth = true; + } + PrincipalData::AppPassword(secret) => { + if let Some((app_name, _)) = + secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) + { + response.app_passwords.push(app_name.into()); + } + } + _ => {} } } } diff --git a/crates/jmap/src/identity/set.rs b/crates/jmap/src/identity/set.rs index a3dbb477..7be6166b 100644 --- a/crates/jmap/src/identity/set.rs +++ b/crates/jmap/src/identity/set.rs @@ -68,7 +68,7 @@ impl IdentitySet for Server { .directory() .query(QueryParams::id(account_id).with_return_member_of(false)) .await? - .is_none_or(|p| !p.email_addresses().any(|e| e == &identity.email)) + .is_none_or(|p| !p.email_addresses().any(|e| e == identity.email)) { response.not_created.append( id, diff --git a/crates/migration/src/principal_v1.rs b/crates/migration/src/principal_v1.rs index 510ab795..e25473ee 100644 --- a/crates/migration/src/principal_v1.rs +++ b/crates/migration/src/principal_v1.rs @@ -12,7 +12,7 @@ use crate::{ use common::Server; use directory::{ Permission, Principal, PrincipalData, ROLE_ADMIN, ROLE_USER, Type, - backend::internal::{PrincipalField, PrincipalSet}, + backend::internal::{PrincipalField, PrincipalSet, SpecialSecrets}, }; use nlp::tokenizers::word::WordTokenizer; use std::{slice::Iter, time::Instant}; @@ -183,11 +183,19 @@ impl FromLegacy for Principal { }; // Map fields + let mut has_secret = false; for secret in legacy .take_str_array(PrincipalField::Secrets) .unwrap_or_default() { - principal.data.push(PrincipalData::Secret(secret)); + if secret.is_otp_secret() { + principal.data.push(PrincipalData::OtpAuth(secret)); + } else if secret.is_app_secret() { + principal.data.push(PrincipalData::AppPassword(secret)); + } else if !has_secret { + principal.data.push(PrincipalData::Password(secret)); + has_secret = true; + } } for (idx, email) in legacy .take_str_array(PrincipalField::Emails) @@ -371,7 +379,7 @@ pub(crate) fn build_search_index(batch: &mut BatchBuilder, principal_id: u32, ne for word in [Some(new.name.as_str()), new.description()] .into_iter() - .chain(new.email_addresses().map(|s| Some(s.as_str()))) + .chain(new.email_addresses().map(Some)) .flatten() { new_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word)); diff --git a/crates/migration/src/principal_v2.rs b/crates/migration/src/principal_v2.rs index e702ed16..6604bdfb 100644 --- a/crates/migration/src/principal_v2.rs +++ b/crates/migration/src/principal_v2.rs @@ -7,7 +7,7 @@ use std::time::Instant; use common::Server; -use directory::{Principal, PrincipalData, Type}; +use directory::{Principal, PrincipalData, Type, backend::internal::SpecialSecrets}; use proc_macros::EnumMethods; use store::{ Serialize, ValueKey, @@ -59,8 +59,16 @@ pub(crate) async fn migrate_principals_v0_13(server: &Server) -> trc::Result Some({}),", pos, event_to_class(name)); + //println!("{} => Some({}),", pos, event_to_class(name)); //println!("{} => {},", event_to_class(name), pos); /*println!( "#[serde(rename = \"{name}\")]\n{},", event_to_webadmin_class(name) );*/ - } + //} } } diff --git a/tests/src/directory/internal.rs b/tests/src/directory/internal.rs index 0e5edb4a..73b5d0c2 100644 --- a/tests/src/directory/internal.rs +++ b/tests/src/directory/internal.rs @@ -47,7 +47,7 @@ async fn internal_directory() { TestPrincipal { name: "john".into(), description: Some("John Doe".into()), - secrets: vec!["secret".into(), "secret2".into()], + secrets: vec!["secret".into(), "$app$secret2".into()], ..Default::default() } .into(), @@ -148,7 +148,7 @@ async fn internal_directory() { TestPrincipal { name: "jane".into(), description: Some("Jane Doe".into()), - secrets: vec!["my_secret".into(), "my_secret2".into()], + secrets: vec!["my_secret".into(), "$app$my_secret2".into()], emails: vec!["jane@example.org".into()], quota: 123, ..Default::default() @@ -188,7 +188,7 @@ async fn internal_directory() { name: "jane".into(), description: Some("Jane Doe".into()), emails: vec!["jane@example.org".into()], - secrets: vec!["my_secret".into(), "my_secret2".into()], + secrets: vec!["my_secret".into(), "$app$my_secret2".into()], quota: 123, ..Default::default() }) @@ -366,7 +366,7 @@ async fn internal_directory() { id: john_id, name: "john".into(), description: Some("John Doe".into()), - secrets: vec!["secret".into(), "secret2".into()], + secrets: vec!["secret".into(), "$app$secret2".into()], emails: vec!["john@example.org".into()], member_of: vec!["sales".into(), "support".into()], lists: vec!["list".into()], @@ -411,7 +411,7 @@ async fn internal_directory() { id: john_id, name: "john".into(), description: Some("John Doe".into()), - secrets: vec!["secret".into(), "secret2".into()], + secrets: vec!["secret".into(), "$app$secret2".into()], emails: vec!["john@example.org".into()], member_of: vec!["sales".into()], lists: vec!["list".into()], diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index 0e91a74e..d80cdcb8 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -13,7 +13,7 @@ pub mod sql; use common::{Core, Server, config::smtp::session::AddressMapping}; use directory::{ - Directories, Principal, Type, + Directories, Principal, PrincipalData, Type, backend::internal::{PrincipalField, PrincipalSet, manage::ManageDirectory}, }; use mail_send::Credentials; @@ -594,7 +594,16 @@ impl From for TestPrincipal { member_of: value.member_of().map(|v| v.to_string()).collect(), roles: value.roles().map(|v| v.to_string()).collect(), lists: value.lists().map(|v| v.to_string()).collect(), - secrets: value.secrets().map(|v| v.to_string()).collect(), + secrets: value + .data + .iter() + .filter_map(|v| match v { + PrincipalData::Password(s) + | PrincipalData::AppPassword(s) + | PrincipalData::OtpAuth(s) => Some(s.to_string()), + _ => None, + }) + .collect(), emails: value.email_addresses().map(|v| v.to_string()).collect(), description: value.description().map(|v| v.to_string()), name: value.name, diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index d1bf9c9c..1363ac04 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -131,10 +131,7 @@ async fn oidc_directory() { .unwrap() .unwrap(); assert_eq!(principal.name(), "jdoe"); - assert_eq!( - principal.email_addresses().next().map(|s| s.as_str()), - Some("john@example.org") - ); + assert_eq!(principal.email_addresses().next(), Some("john@example.org")); assert_eq!(principal.description(), Some("John Doe")); } } diff --git a/tests/src/directory/sql.rs b/tests/src/directory/sql.rs index b1ef824d..aee49a5a 100644 --- a/tests/src/directory/sql.rs +++ b/tests/src/directory/sql.rs @@ -5,7 +5,7 @@ */ use directory::{ - QueryParams, ROLE_USER, Type, + QueryParams, ROLE_ADMIN, ROLE_USER, Type, backend::{RcptType, internal::manage::ManageDirectory}, }; use mail_send::Credentials; @@ -189,7 +189,7 @@ async fn sql_directory() { description: Some("Administrator".into()), secrets: vec!["very_secret".into()], typ: Type::Individual, - roles: vec![ROLE_USER.to_string()], + roles: vec![ROLE_ADMIN.to_string()], ..Default::default() } ); @@ -208,13 +208,15 @@ async fn sql_directory() { ); // Get user by name + let mut p = handle + .query(QueryParams::name("jane").with_return_member_of(true)) + .await + .unwrap() + .unwrap() + .into_test(); + p.member_of.sort(); assert_eq!( - handle - .query(QueryParams::name("jane").with_return_member_of(true)) - .await - .unwrap() - .unwrap() - .into_test(), + p, TestPrincipal { id: base_store.get_principal_id("jane").await.unwrap().unwrap(), name: "jane".into(),