From d8a73cd0e462774c349c15902848816a6e27a86a Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 28 Jun 2024 12:12:51 +0200 Subject: [PATCH] TOTP 2FA, App passwords and account disable support (closes #436 closes #479) --- Cargo.lock | 114 ++++++++++++------ README.md | 2 +- crates/cli/Cargo.toml | 2 +- crates/cli/src/modules/account.rs | 4 +- crates/common/src/lib.rs | 6 +- crates/directory/Cargo.toml | 4 +- crates/directory/src/backend/imap/pool.rs | 2 - .../directory/src/backend/internal/lookup.rs | 2 +- .../directory/src/backend/internal/manage.rs | 29 +++++ crates/directory/src/backend/ldap/lookup.rs | 2 +- crates/directory/src/backend/ldap/pool.rs | 2 - crates/directory/src/backend/memory/lookup.rs | 2 +- crates/directory/src/backend/smtp/pool.rs | 8 +- crates/directory/src/backend/sql/lookup.rs | 2 +- crates/directory/src/core/secret.rs | 44 ++++++- crates/directory/src/lib.rs | 5 + crates/imap/Cargo.toml | 2 +- crates/imap/src/op/authenticate.rs | 17 ++- crates/jmap/Cargo.toml | 8 +- crates/jmap/src/api/autoconfig.rs | 2 +- crates/jmap/src/api/management/principal.rs | 4 +- crates/jmap/src/auth/authenticate.rs | 18 ++- crates/main/Cargo.toml | 2 +- crates/managesieve/src/op/authenticate.rs | 12 +- crates/pop3/src/op/authenticate.rs | 12 +- crates/pop3/src/op/delete.rs | 6 +- crates/smtp/Cargo.toml | 4 +- crates/smtp/src/inbound/auth.rs | 14 +++ crates/utils/Cargo.toml | 2 +- tests/Cargo.toml | 2 +- 30 files changed, 250 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a617d37..2255547d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,6 +480,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.11.0" @@ -632,7 +638,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.0", ] [[package]] @@ -1116,6 +1122,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6051f239ecec86fde3410901ab7860d458d160371533842974fc61f96d15879b" +[[package]] +name = "constant_time_eq" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" + [[package]] name = "constant_time_eq" version = "0.3.0" @@ -1401,11 +1413,12 @@ dependencies = [ [[package]] name = "dashmap" -version = "5.5.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28" dependencies = [ "cfg-if", + "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -1427,18 +1440,6 @@ dependencies = [ "generic-array 0.14.7", ] -[[package]] -name = "deadpool" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" -dependencies = [ - "async-trait", - "deadpool-runtime", - "num_cpus", - "tokio", -] - [[package]] name = "deadpool" version = "0.12.1" @@ -1457,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ab8a4ea925ce79678034870834602a2980f4b88c09e97feb266496dbb4493d2" dependencies = [ "async-trait", - "deadpool 0.12.1", + "deadpool", "getrandom", "tokio", "tokio-postgres", @@ -1617,8 +1618,7 @@ version = "0.8.2" dependencies = [ "ahash 0.8.11", "argon2", - "async-trait", - "deadpool 0.10.0", + "deadpool", "futures", "jmap_proto", "ldap3", @@ -1642,6 +1642,7 @@ dependencies = [ "store", "tokio", "tokio-rustls 0.25.0", + "totp-rs", "tracing", "utils", ] @@ -3158,7 +3159,7 @@ dependencies = [ "nlp", "p256", "pkcs8", - "quick-xml 0.31.0", + "quick-xml 0.34.0", "rand", "rasn", "rasn-cms", @@ -3176,9 +3177,9 @@ dependencies = [ "smtp-proto", "store", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.23.1", "tracing", - "tungstenite", + "tungstenite 0.23.0", "utils", "x509-parser 0.16.0", ] @@ -3202,7 +3203,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.21.0", ] [[package]] @@ -4645,15 +4646,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quick-xml" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.32.0" @@ -4664,6 +4656,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.2" @@ -6131,7 +6132,7 @@ dependencies = [ "bitpacking", "blake3", "bytes", - "deadpool 0.12.1", + "deadpool", "deadpool-postgres", "elasticsearch", "farmhash", @@ -6613,10 +6614,22 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.25.0", - "tungstenite", + "tungstenite 0.21.0", "webpki-roots 0.26.3", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.23.0", +] + [[package]] name = "tokio-util" version = "0.7.11" @@ -6674,6 +6687,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "totp-rs" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f" +dependencies = [ + "base32", + "constant_time_eq 0.2.6", + "hmac 0.12.1", + "sha1", + "sha2 0.10.8", + "url", + "urlencoding", +] + [[package]] name = "tower" version = "0.4.13" @@ -6847,6 +6875,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.1.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "twofish" version = "0.7.1" @@ -7700,7 +7746,7 @@ dependencies = [ "aes", "arbitrary", "bzip2", - "constant_time_eq", + "constant_time_eq 0.3.0", "crc32fast", "crossbeam-utils", "deflate64", diff --git a/README.md b/README.md index ddbbf513..82b9d7ee 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ All documentation is available at [stalw.art/docs/get-started](https://stalw.art If you are having problems running Stalwart Mail Server, you found a bug or just have a question, do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions), [Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org). -Additionally you may become a sponsor to obtain priority support from Stalwart Labs Ltd. +Additionally you may purchase a subscription to obtain priority support from Stalwart Labs Ltd. ## Roadmap diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0bfbfcdf..02bbadab 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -2,7 +2,7 @@ name = "stalwart-cli" description = "Stalwart Mail Server CLI" authors = ["Stalwart Labs Ltd. "] -license = "AGPL-3.0-only" +license = "AGPL-3.0-only OR LicenseRef-SEL" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" version = "0.8.2" diff --git a/crates/cli/src/modules/account.rs b/crates/cli/src/modules/account.rs index 4204d132..093a6811 100644 --- a/crates/cli/src/modules/account.rs +++ b/crates/cli/src/modules/account.rs @@ -66,9 +66,9 @@ impl AccountCommands { )); } if let Some(password) = password { - changes.push(PrincipalUpdate::set( + changes.push(PrincipalUpdate::add_item( PrincipalField::Secrets, - PrincipalValue::StringList(vec![sha512_crypt::hash(password).unwrap()]), + PrincipalValue::String(sha512_crypt::hash(password).unwrap()), )); } if let Some(description) = description { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index edc8ca3f..c19bccda 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -20,7 +20,9 @@ use config::{ storage::Storage, tracers::{OtelTracer, Tracer, Tracers}, }; -use directory::{core::secret::verify_secret_hash, Directory, Principal, QueryBy, Type}; +use directory::{ + core::secret::verify_secret_hash, Directory, DirectoryError, Principal, QueryBy, Type, +}; use expr::if_block::IfBlock; use listener::{ blocked::{AllowedIps, BlockedIps}, @@ -94,6 +96,7 @@ pub enum AuthResult { Success(T), Failure, Banned, + MissingTotp, } #[derive(Debug)] @@ -267,6 +270,7 @@ impl Core { return Ok(AuthResult::Success(principal)); } Ok(None) => Ok(()), + Err(DirectoryError::MissingTotpCode) => return Ok(AuthResult::MissingTotp), Err(err) => Err(err), }; diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 9b07415c..dfe7694a 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -17,9 +17,8 @@ tokio-rustls = { version = "0.25.0"} rustls = "0.22" rustls-pki-types = { version = "1" } ldap3 = { version = "0.11.1", default-features = false, features = ["tls-rustls"] } -deadpool = { version = "0.10.0", features = ["managed", "rt_tokio_1"] } +deadpool = { version = "0.12", features = ["managed", "rt_tokio_1"] } parking_lot = "0.12" -async-trait = "0.1.68" ahash = { version = "0.8" } tracing = "0.1" lru-cache = "0.1.2" @@ -34,6 +33,7 @@ md5 = "0.7.0" futures = "0.3" regex = "1.7.0" serde = { version = "1.0", features = ["derive"]} +totp-rs = { version = "5.5.1", features = ["otpauth"] } [dev-dependencies] tokio = { version = "1.23", features = ["full"] } diff --git a/crates/directory/src/backend/imap/pool.rs b/crates/directory/src/backend/imap/pool.rs index 927da682..5fffd84c 100644 --- a/crates/directory/src/backend/imap/pool.rs +++ b/crates/directory/src/backend/imap/pool.rs @@ -6,14 +6,12 @@ use std::sync::atomic::Ordering; -use async_trait::async_trait; use deadpool::managed; use tokio::net::TcpStream; use tokio_rustls::client::TlsStream; use super::{ImapClient, ImapConnectionManager, ImapError}; -#[async_trait] impl managed::Manager for ImapConnectionManager { type Type = ImapClient>; type Error = ImapError; diff --git a/crates/directory/src/backend/internal/lookup.rs b/crates/directory/src/backend/internal/lookup.rs index defdc30e..daa02fd2 100644 --- a/crates/directory/src/backend/internal/lookup.rs +++ b/crates/directory/src/backend/internal/lookup.rs @@ -59,7 +59,7 @@ impl DirectoryStore for Store { .await?, secret, ) { - (Some(mut principal), Some(secret)) if principal.verify_secret(secret).await => { + (Some(mut principal), Some(secret)) if principal.verify_secret(secret).await? => { if return_member_of { principal.member_of = self.get_member_of(principal.id).await?; } diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 9588c91e..80d8a28f 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -414,6 +414,35 @@ impl ManageDirectory for Store { ) => { principal.inner.secrets = secrets; } + ( + PrincipalAction::AddItem, + PrincipalField::Secrets, + PrincipalValue::String(secret), + ) => { + let mut do_add = true; + let mut new_secrets = Vec::with_capacity(principal.inner.secrets.len() + 1); + for prev_secret in principal.inner.secrets { + if prev_secret == secret { + do_add = false; + } else if prev_secret.starts_with("otpauth://") + || prev_secret == "$disabled$" + || prev_secret.starts_with("$app$") + { + new_secrets.push(prev_secret); + } + } + if do_add { + new_secrets.push(secret); + } + principal.inner.secrets = new_secrets; + } + ( + PrincipalAction::RemoveItem, + PrincipalField::Secrets, + PrincipalValue::String(secret), + ) => { + principal.inner.secrets.retain(|v| *v != secret); + } ( PrincipalAction::Set, PrincipalField::Description, diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index d46ff631..339f6e75 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -87,7 +87,7 @@ impl LdapDirectory { .find_principal(&mut conn, &self.mappings.filter_name.build(username)) .await? { - if principal.verify_secret(secret).await { + if principal.verify_secret(secret).await? { principal } else { tracing::debug!( diff --git a/crates/directory/src/backend/ldap/pool.rs b/crates/directory/src/backend/ldap/pool.rs index 7e55fa40..bf2629dd 100644 --- a/crates/directory/src/backend/ldap/pool.rs +++ b/crates/directory/src/backend/ldap/pool.rs @@ -4,13 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use async_trait::async_trait; use deadpool::managed; use ldap3::{exop::WhoAmI, Ldap, LdapConnAsync, LdapError}; use super::LdapConnectionManager; -#[async_trait] impl managed::Manager for LdapConnectionManager { type Type = Ldap; type Error = LdapError; diff --git a/crates/directory/src/backend/memory/lookup.rs b/crates/directory/src/backend/memory/lookup.rs index 6109bacd..b4cc5450 100644 --- a/crates/directory/src/backend/memory/lookup.rs +++ b/crates/directory/src/backend/memory/lookup.rs @@ -36,7 +36,7 @@ impl MemoryDirectory { for principal in &self.principals { if &principal.name == username { - return if principal.verify_secret(secret).await { + return if principal.verify_secret(secret).await? { Ok(Some(principal.clone())) } else { Ok(None) diff --git a/crates/directory/src/backend/smtp/pool.rs b/crates/directory/src/backend/smtp/pool.rs index c2bdf5a7..094a7a8e 100644 --- a/crates/directory/src/backend/smtp/pool.rs +++ b/crates/directory/src/backend/smtp/pool.rs @@ -4,13 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use async_trait::async_trait; use deadpool::managed; use mail_send::{smtp::AssertReply, Error}; use super::{SmtpClient, SmtpConnectionManager}; -#[async_trait] impl managed::Manager for SmtpConnectionManager { type Type = SmtpClient; type Error = Error; @@ -45,8 +43,10 @@ impl managed::Manager for SmtpConnectionManager { .map(|_| ()) .map_err(managed::RecycleError::Backend) } else { - Err(managed::RecycleError::StaticMessage( - "No longer valid: Too many authentication failures", + Err(managed::RecycleError::Message( + "No longer valid: Too many authentication failures" + .to_string() + .into(), )) } } diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index bb0e6f03..e49c5dab 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -68,7 +68,7 @@ impl SqlDirectory { // Validate password if let Some(secret) = secret { - if !principal.verify_secret(secret).await { + if !principal.verify_secret(secret).await? { tracing::debug!( context = "directory", event = "invalid_password", diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index ef017620..783e69ab 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -16,17 +16,51 @@ use sha1::Sha1; use sha2::Sha256; use sha2::Sha512; use tokio::sync::oneshot; +use totp_rs::TOTP; +use crate::DirectoryError; use crate::Principal; impl Principal { - pub async fn verify_secret(&self, secret: &str) -> bool { - for hashed_secret in &self.secrets { - if verify_secret_hash(hashed_secret, secret).await { - return true; + pub async fn verify_secret(&self, mut code: &str) -> crate::Result { + let mut totp_token = None; + + for secret in &self.secrets { + let mut secret = secret.as_str(); + + if secret == "$disabled$" { + return Ok(false); + } else if secret.starts_with("otpauth://") && totp_token.is_none() { + let totp_token = if let Some(totp_token) = totp_token { + totp_token + } else { + let (_code, _totp_token) = code + .rsplit_once('$') + .filter(|(c, t)| !c.is_empty() && !t.is_empty()) + .ok_or(DirectoryError::MissingTotpCode)?; + totp_token = Some(_totp_token); + code = _code; + _totp_token + }; + if !TOTP::from_url(secret) + .map_err(DirectoryError::InvalidTotpUrl)? + .check_current(totp_token) + .unwrap_or(false) + { + return Ok(false); + } + } else if let Some((_, app_secret)) = + secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) + { + secret = app_secret; + } + + if verify_secret_hash(secret, code).await { + return Ok(true); } } - false + + Ok(false) } } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 60c80358..a11f7978 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -23,6 +23,7 @@ use deadpool::managed::PoolError; use ldap3::LdapError; use mail_send::Credentials; use store::Store; +use totp_rs::TotpUrlError; pub mod backend; pub mod core; @@ -81,6 +82,8 @@ pub enum DirectoryError { Management(ManagementError), TimedOut, Unsupported, + InvalidTotpUrl(TotpUrlError), + MissingTotpCode, } #[derive(Debug, PartialEq, Eq)] @@ -309,6 +312,8 @@ impl Display for DirectoryError { Self::Management(error) => write!(f, "Management error: {:?}", error), Self::TimedOut => write!(f, "Directory timed out"), Self::Unsupported => write!(f, "Method not supported by directory"), + Self::InvalidTotpUrl(error) => write!(f, "Invalid TOTP URL: {}", error), + Self::MissingTotpCode => write!(f, "Missing TOTP code"), } } } diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 87d948d8..b06e8462 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -23,7 +23,7 @@ parking_lot = "0.12" tracing = "0.1" ahash = { version = "0.8" } md5 = "0.7.0" -dashmap = "5.4" +dashmap = "6.0" rand = "0.8.5" [features] diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index cafcc615..d1bed741 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -101,6 +101,7 @@ impl Session { } // Authenticate + let mut is_totp_error = false; let access_token = match credentials { Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { match self @@ -110,6 +111,10 @@ impl Session { { AuthResult::Success(token) => Some(token), AuthResult::Failure => None, + AuthResult::MissingTotp => { + is_totp_error = true; + None + } AuthResult::Banned => return Err(()), } } @@ -174,10 +179,14 @@ impl Session { Ok(()) } else { self.write_bytes( - StatusResponse::no("Authentication failed") - .with_tag(tag) - .with_code(ResponseCode::AuthenticationFailed) - .into_bytes(), + StatusResponse::no(if is_totp_error { + "Missing TOTP code, try with 'secret$totp_code'." + } else { + "Authentication failed." + }) + .with_tag(tag) + .with_code(ResponseCode::AuthenticationFailed) + .into_bytes(), ) .await?; diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 727e4510..5b1cca6a 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -39,10 +39,10 @@ hkdf = "0.12.3" sha1 = "0.10" sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]} -tokio-tungstenite = "0.21" -tungstenite = "0.21" +tokio-tungstenite = "0.23" +tungstenite = "0.23" chrono = "0.4" -dashmap = "5.4" +dashmap = "6.0" aes = "0.8.3" cbc = { version = "0.1.2", features = ["alloc"] } sequoia-openpgp = { version = "1.16", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] } @@ -56,7 +56,7 @@ async-trait = "0.1.68" lz4_flex = { version = "0.11", default-features = false } rev_lines = "0.3.0" x509-parser = "0.16.0" -quick-xml = "0.31" +quick-xml = "0.34" [features] test_mode = [] diff --git a/crates/jmap/src/api/autoconfig.rs b/crates/jmap/src/api/autoconfig.rs index e51b006d..c280e3f4 100644 --- a/crates/jmap/src/api/autoconfig.rs +++ b/crates/jmap/src/api/autoconfig.rs @@ -221,7 +221,7 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result { } let mut reader = Reader::from_reader(bytes); - reader.trim_text(true); + reader.config_mut().trim_text(true); let mut buf = Vec::with_capacity(128); 'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] { diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 6ad7b548..03969bc2 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -331,9 +331,9 @@ impl JMAP { .data .update_account( QueryBy::Id(access_token.primary_id()), - vec![PrincipalUpdate::set( + vec![PrincipalUpdate::add_item( PrincipalField::Secrets, - PrincipalValue::StringList(vec![new_password]), + PrincipalValue::String(new_password), )], ) .await diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index f28253d1..7a2d99d0 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -46,13 +46,22 @@ impl JMAP { }) }) { - if let AuthResult::Success(access_token) = self + match self .authenticate_plain(&account, &secret, remote_ip, ServerProtocol::Http) .await { - Some(access_token) - } else { - None + AuthResult::Success(access_token) => Some(access_token), + AuthResult::MissingTotp => { + return Err(RequestError::blank( + 401, + "TOTP code required", + concat!( + "A TOTP code is required to authenticate this account. ", + "Try authenticating again using 'secret$totp_token'." + ), + )); + } + _ => None, } } else { tracing::debug!( @@ -161,6 +170,7 @@ impl JMAP { AuthResult::Failure } Ok(AuthResult::Banned) => AuthResult::Banned, + Ok(AuthResult::MissingTotp) => AuthResult::MissingTotp, Err(_) => AuthResult::Failure, } } diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index c79d78ca..5d5f1bec 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/jmap-server" homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] -license = "AGPL-3.0-only" +license = "AGPL-3.0-only OR LicenseRef-SEL" version = "0.8.2" edition = "2021" resolver = "2" diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 2be23968..c44f4892 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -79,6 +79,7 @@ impl Session { } // Authenticate + let mut is_totp_error = false; let access_token = match credentials { Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { match self @@ -93,6 +94,10 @@ impl Session { { AuthResult::Success(token) => Some(token), AuthResult::Failure => None, + AuthResult::MissingTotp => { + is_totp_error = true; + None + } AuthResult::Banned => { return Err(StatusResponse::bye( "Too many authentication requests from this IP address.", @@ -156,7 +161,12 @@ impl Session { self.state = State::NotAuthenticated { auth_failures: auth_failures + 1, }; - Ok(StatusResponse::no("Authentication failed").into_bytes()) + Ok(StatusResponse::no(if is_totp_error { + "Missing TOTP code, try with 'secret$totp_code'." + } else { + "Authentication failed." + }) + .into_bytes()) } _ => { tracing::debug!( diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index c378a822..d302e9a1 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -83,6 +83,7 @@ impl Session { } // Authenticate + let mut is_totp_error = false; let access_token = match credentials { Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => { match self @@ -92,6 +93,10 @@ impl Session { { AuthResult::Success(token) => Some(token), AuthResult::Failure => None, + AuthResult::MissingTotp => { + is_totp_error = true; + None + } AuthResult::Banned => { self.write_err("Too many authentication requests from this IP address.") .await?; @@ -164,7 +169,12 @@ impl Session { auth_failures: auth_failures + 1, username: username.clone(), }; - self.write_err("Authentication failed").await + self.write_err(if is_totp_error { + "Missing TOTP code, try with 'secret$totp_code'." + } else { + "Authentication failed." + }) + .await } _ => { tracing::debug!( diff --git a/crates/pop3/src/op/delete.rs b/crates/pop3/src/op/delete.rs index 4d245058..7f127b0a 100644 --- a/crates/pop3/src/op/delete.rs +++ b/crates/pop3/src/op/delete.rs @@ -90,10 +90,8 @@ impl Session { } } } else { - self.write_ok(format!( - "Stalwart POP3 bids you farewell (no messages deleted)." - )) - .await?; + self.write_ok("Stalwart POP3 bids you farewell (no messages deleted).") + .await?; } } else { self.write_ok("Stalwart POP3 bids you farewell.").await?; diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 7a0ee239..d62c285f 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/smtp-server" homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] -license = "AGPL-3.0-only" +license = "AGPL-3.0-only OR LicenseRef-SEL" version = "0.8.2" edition = "2021" resolver = "2" @@ -41,7 +41,7 @@ rayon = "1.5" tracing = "0.1" parking_lot = "0.12" regex = "1.7.0" -dashmap = "5.4" +dashmap = "6.0" blake3 = "1.3" lru-cache = "0.1.2" rand = "0.8.5" diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 34cf4422..15ef30f3 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -217,6 +217,20 @@ impl Session { return Err(()); } + Ok(AuthResult::MissingTotp) => { + tracing::debug!( + parent: &self.span, + context = "auth", + event = "authenticate", + result = "missing-totp" + ); + + return self + .auth_error( + b"334 5.7.8 Missing TOTP token, try with 'secret$totp_code'.\r\n", + ) + .await; + } _ => (), } } else { diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 4cdf5390..f6a10b87 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -15,7 +15,7 @@ tracing = "0.1" mail-auth = { version = "0.4" } smtp-proto = { version = "0.1" } mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] } -dashmap = "5.4" +dashmap = "6.0" ahash = { version = "0.8" } chrono = "0.4" rand = "0.8.5" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 45120716..270826ff 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -55,7 +55,7 @@ hyper = { version = "1.0.1", features = ["server", "http1", "http2"] } hyper-util = { version = "0.1.1", features = ["tokio"] } http-body-util = "0.1.0" base64 = "0.22" -dashmap = "5.4" +dashmap = "6.0" ahash = { version = "0.8" } serial_test = "3.0.0" num_cpus = "1.15.0"