mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-12-09 12:55:57 +08:00
v0.14.0
This commit is contained in:
parent
49b384d740
commit
86c963ef68
27 changed files with 528 additions and 395 deletions
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
19
CHANGELOG.md
19
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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,8 +167,8 @@ fn extract_filter_range(query: &CalendarQuery) -> Option<TimeRange> {
|
|||
|
||||
fn extract_data_range(propfind: &PropFind, filter_range: Option<TimeRange>) -> Option<TimeRange> {
|
||||
let props = match propfind {
|
||||
PropFind::PropName => todo!(),
|
||||
PropFind::AllProp(props) | PropFind::Prop(props) => props,
|
||||
PropFind::PropName => &[][..],
|
||||
};
|
||||
|
||||
for prop in props {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <hello@stalw.art>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> SpecialSecrets for T
|
||||
where
|
||||
T: AsRef<str>,
|
||||
{
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Option<Principal>> {
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<Item = &String> {
|
||||
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<Item = &String> {
|
||||
pub fn email_addresses(&self) -> impl Iterator<Item = &str> {
|
||||
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<PrincipalUpdate> {
|
||||
pub fn update_external(&mut self, external: Principal) -> Vec<PrincipalUpdate> {
|
||||
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::<AHashSet<_>>();
|
||||
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<Item = &'x String>,
|
||||
mut current: impl Iterator<Item = &'x String>,
|
||||
) -> 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<Ordering> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<bool> {
|
||||
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<bool> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ pub struct Principal {
|
|||
pub data: Vec<PrincipalData>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<Roa
|
|||
data: Vec::new(),
|
||||
};
|
||||
|
||||
let mut has_secret = false;
|
||||
for secret in old_principal.secrets {
|
||||
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 old_principal.emails.into_iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -432,19 +432,19 @@ mod tests {
|
|||
// sort by name
|
||||
names.sort_by(|a, b| a.0.cmp(b.0));
|
||||
|
||||
/*for (name, description, level) in names {
|
||||
for (name, description, level) in names {
|
||||
//println!("{:?},", name);
|
||||
println!("|`{name}`|{description}|`{level}`|")
|
||||
}*/
|
||||
}
|
||||
|
||||
for (pos, (name, _, _)) in names.iter().enumerate() {
|
||||
//for (pos, (name, _, _)) in names.iter().enumerate() {
|
||||
//println!("{:?},", name);
|
||||
println!("{} => 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)
|
||||
);*/
|
||||
}
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
|
|
|||
|
|
@ -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<Principal> 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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue