Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "directory"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
utils = { path = "../utils" }
|
||||
store = { path = "../store" }
|
||||
trc = { path = "../trc" }
|
||||
registry = { path = "../registry" }
|
||||
mail-parser = { version = "0.11" }
|
||||
mail-builder = { version = "1.0" }
|
||||
tokio = { version = "1.53", features = ["net"] }
|
||||
ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-aws-lc-rs"] }
|
||||
deadpool = { version = "0.13", features = ["managed", "rt_tokio_1"] }
|
||||
ahash = { version = "0.8" }
|
||||
pwhash = "1"
|
||||
argon2 = "0.6.0"
|
||||
pbkdf2 = { version = "0.13.0", features = ["phc"] }
|
||||
scrypt = { version = "0.12.0", features = ["phc"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
md5 = "0.8.1"
|
||||
serde = { version = "1.0", features = ["derive"]}
|
||||
totp-rs = { version = "6.0.0", features = ["otpauth"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] }
|
||||
serde_json = "1.0"
|
||||
base64 = "0.23"
|
||||
nohash-hasher = "0.2.0"
|
||||
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
|
||||
[features]
|
||||
test_mode = []
|
||||
enterprise = []
|
||||
mysql = []
|
||||
postgres = []
|
||||
sqlite = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings};
|
||||
use crate::Directory;
|
||||
use deadpool::{Runtime, managed::Pool};
|
||||
use ldap3::LdapConnSettings;
|
||||
use registry::schema::structs;
|
||||
|
||||
impl LdapDirectory {
|
||||
pub async fn open(config: structs::LdapDirectory) -> Result<Directory, String> {
|
||||
let bind_dn = if let Some(dn) = config.bind_dn {
|
||||
Bind::new(
|
||||
dn,
|
||||
config
|
||||
.bind_secret
|
||||
.secret()
|
||||
.await?
|
||||
.map(|v| v.into_owned())
|
||||
.ok_or_else(|| {
|
||||
"LDAP bind password is required when bind DN is set".to_string()
|
||||
})?,
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let manager = LdapConnectionManager::new(
|
||||
config.url,
|
||||
LdapConnSettings::new()
|
||||
.set_conn_timeout(config.timeout.into_inner())
|
||||
.set_starttls(config.use_tls)
|
||||
.set_no_tls_verify(config.allow_invalid_certs),
|
||||
bind_dn,
|
||||
);
|
||||
|
||||
let mut mappings = LdapMappings {
|
||||
base_dn: config.base_dn,
|
||||
filter_login: LdapFilter::new(&config.filter_login)?,
|
||||
filter_mailbox: LdapFilter::new(&config.filter_mailbox)?,
|
||||
filter_member_of: if let Some(filter) = config.filter_member_of {
|
||||
Some(LdapFilter::new(&filter)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
attr_class: config
|
||||
.attr_class
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_groups: config
|
||||
.attr_member_of
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_description: config
|
||||
.attr_description
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_secret: config
|
||||
.attr_secret
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_secret_changed: config
|
||||
.attr_secret_changed
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_email: config
|
||||
.attr_email
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
attr_email_alias: config
|
||||
.attr_email_alias
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect(),
|
||||
group_class: config.group_class,
|
||||
attrs_principal: vec![],
|
||||
};
|
||||
|
||||
let mut attrs_principal: Vec<String> = Vec::new();
|
||||
for attrs in [
|
||||
&mappings.attr_description,
|
||||
&mappings.attr_secret,
|
||||
&mappings.attr_secret_changed,
|
||||
&mappings.attr_groups,
|
||||
&mappings.attr_email_alias,
|
||||
&mappings.attr_email,
|
||||
&mappings.attr_class,
|
||||
] {
|
||||
for attr in attrs.iter().filter(|a| !a.is_empty()) {
|
||||
if !attrs_principal.contains(attr) {
|
||||
attrs_principal.push(attr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
mappings.attrs_principal = attrs_principal;
|
||||
|
||||
let pool = Pool::builder(manager)
|
||||
.runtime(Runtime::Tokio1)
|
||||
.max_size(config.pool_max_connections as usize)
|
||||
.create_timeout(config.pool_timeout_create.into_inner().into())
|
||||
.wait_timeout(config.pool_timeout_wait.into_inner().into())
|
||||
.recycle_timeout(config.pool_timeout_recycle.into_inner().into())
|
||||
.build()
|
||||
.map_err(|err| format!("Failed to build LDAP pool: {err}"))?;
|
||||
|
||||
Ok(Directory::Ldap(LdapDirectory {
|
||||
mappings,
|
||||
pool,
|
||||
auth_bind: config.bind_authentication,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl LdapFilter {
|
||||
pub(super) fn new(value: &str) -> Result<Self, String> {
|
||||
let mut filter = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut value = value.chars();
|
||||
|
||||
while let Some(ch) = value.next() {
|
||||
match ch {
|
||||
'?' => {
|
||||
// For backwards compatibility, we treat '?' as a placeholder for the full value.
|
||||
if !token.is_empty() {
|
||||
filter.push(LdapFilterItem::Static(token));
|
||||
token = String::new();
|
||||
}
|
||||
filter.push(LdapFilterItem::Full);
|
||||
}
|
||||
'{' => {
|
||||
if !token.is_empty() {
|
||||
filter.push(LdapFilterItem::Static(token));
|
||||
token = String::new();
|
||||
}
|
||||
for ch in value.by_ref() {
|
||||
if ch == '}' {
|
||||
break;
|
||||
} else {
|
||||
token.push(ch);
|
||||
}
|
||||
}
|
||||
match token.as_str() {
|
||||
"user" | "username" | "email" => filter.push(LdapFilterItem::Full),
|
||||
"local" => filter.push(LdapFilterItem::LocalPart),
|
||||
"domain" => filter.push(LdapFilterItem::DomainPart),
|
||||
_ => {
|
||||
return Err(format!("Unknown LDAP filter placeholder: {}", token));
|
||||
}
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
_ => token.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if !token.is_empty() {
|
||||
filter.push(LdapFilterItem::Static(token));
|
||||
}
|
||||
|
||||
if filter.len() >= 2 {
|
||||
Ok(LdapFilter { filter })
|
||||
} else {
|
||||
Err(format!(
|
||||
"Missing parameter placeholders in value {:?}",
|
||||
value
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{LdapDirectory, LdapMappings};
|
||||
use crate::{Account, Credentials, Group, IntoError, Recipient, core::secret::verify_secret_hash};
|
||||
use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry};
|
||||
use store::xxhash_rust;
|
||||
use utils::sanitize_email;
|
||||
|
||||
impl LdapDirectory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
let (username, secret) = match credentials {
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
} => (username, secret),
|
||||
Credentials::Bearer { token, .. } => (token, token),
|
||||
};
|
||||
if secret.is_empty() {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Empty secret rejected"));
|
||||
}
|
||||
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
|
||||
|
||||
let mut result = if self.auth_bind {
|
||||
let filter = self.mappings.filter_login.build(username);
|
||||
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
|
||||
// Perform bind auth using the found dn
|
||||
let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings(
|
||||
self.pool.manager().settings.clone(),
|
||||
&self.pool.manager().address,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
|
||||
|
||||
ldap3::drive!(auth_bind_conn);
|
||||
|
||||
if ldap
|
||||
.simple_bind(&result.dn, secret)
|
||||
.await
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?
|
||||
.success()
|
||||
.is_ok()
|
||||
{
|
||||
if result.account.email.is_empty() {
|
||||
result.account.email =
|
||||
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
|
||||
}
|
||||
result
|
||||
} else {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Secret rejected during auth bind using lookup filter")
|
||||
.details(vec![result.dn, filter]));
|
||||
}
|
||||
} else {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Auth bind lookup filter yielded no results")
|
||||
.details(vec![filter]));
|
||||
}
|
||||
} else {
|
||||
let filter = self.mappings.filter_login.build(username);
|
||||
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
|
||||
if let Some(account_secret) = &result.account.secret {
|
||||
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Invalid credentials")
|
||||
.details(vec![filter]));
|
||||
}
|
||||
} else {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Account does not have a secret")
|
||||
.details(vec![filter]));
|
||||
}
|
||||
if result.account.email.is_empty() {
|
||||
result.account.email =
|
||||
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
|
||||
}
|
||||
result
|
||||
} else {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Authentication filter yielded no results")
|
||||
.details(vec![filter]));
|
||||
}
|
||||
};
|
||||
|
||||
self.add_group_membership(&mut conn, &mut result).await?;
|
||||
|
||||
Ok(result.account)
|
||||
}
|
||||
|
||||
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
|
||||
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
|
||||
let filter = self.mappings.filter_mailbox.build(address);
|
||||
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
|
||||
if !result.is_group {
|
||||
self.add_group_membership(&mut conn, &mut result).await?;
|
||||
Ok(Recipient::Account(result.account))
|
||||
} else {
|
||||
Ok(Recipient::Group(Group {
|
||||
email: result.account.email,
|
||||
email_aliases: result.account.email_aliases,
|
||||
description: result.account.description,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Store(trc::StoreEvent::LdapWarning),
|
||||
Reason = "Mailbox filter yielded no results",
|
||||
Details = filter
|
||||
);
|
||||
Ok(Recipient::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_group_membership(
|
||||
&self,
|
||||
conn: &mut Ldap,
|
||||
result: &mut LdapResult,
|
||||
) -> trc::Result<()> {
|
||||
if let Some(group_dns) = result.account.groups.take() {
|
||||
let mut groups = Vec::new();
|
||||
for name in group_dns.into_iter().filter(|name| name.contains('=')) {
|
||||
let (rs, _res) = conn
|
||||
.search(
|
||||
&name,
|
||||
Scope::Base,
|
||||
"objectClass=*",
|
||||
&self.mappings.attr_email,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?
|
||||
.success()
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
|
||||
for entry in rs {
|
||||
'outer: for (attr, value) in SearchEntry::construct(entry).attrs {
|
||||
if self.mappings.attr_email.contains(&attr.to_lowercase())
|
||||
&& let Some(email) =
|
||||
value.first().map(|s| s.as_str()).and_then(sanitize_email)
|
||||
{
|
||||
groups.push(email);
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.account.groups = if groups.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(groups)
|
||||
};
|
||||
} else if let Some(filter) = &self.mappings.filter_member_of {
|
||||
let filter = filter.build(&result.dn);
|
||||
let rs = conn
|
||||
.search(
|
||||
&self.mappings.base_dn,
|
||||
Scope::Subtree,
|
||||
&filter,
|
||||
&self.mappings.attr_email,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?
|
||||
.success()
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?
|
||||
.0;
|
||||
let had_entries = !rs.is_empty();
|
||||
let mut groups = Vec::new();
|
||||
for entry in rs {
|
||||
for (attr, value) in SearchEntry::construct(entry).attrs {
|
||||
if self.mappings.attr_email.contains(&attr.to_lowercase()) {
|
||||
groups.extend(value.into_iter().filter_map(|v| {
|
||||
sanitize_email(&v).or_else(|| {
|
||||
trc::event!(
|
||||
Store(trc::StoreEvent::LdapWarning),
|
||||
Reason = "Group entry missing valid email attribute",
|
||||
Details = v
|
||||
);
|
||||
None
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.account.groups = if had_entries && groups.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(groups)
|
||||
};
|
||||
} else {
|
||||
result.account.groups = (!self.mappings.attr_groups.is_empty()).then(Vec::new);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LdapDirectory {
|
||||
async fn find_object(&self, conn: &mut Ldap, filter: &str) -> trc::Result<Option<LdapResult>> {
|
||||
conn.search(
|
||||
&self.mappings.base_dn,
|
||||
Scope::Subtree,
|
||||
filter,
|
||||
&self.mappings.attrs_principal,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))?
|
||||
.success()
|
||||
.map(|(rs, _)| {
|
||||
trc::event!(
|
||||
Store(trc::StoreEvent::LdapQuery),
|
||||
Details = filter.to_string(),
|
||||
Result = rs.first().map(result_to_trace).unwrap_or_default()
|
||||
);
|
||||
|
||||
rs.into_iter()
|
||||
.next()
|
||||
.map(|entry| self.mappings.map_entry(SearchEntry::construct(entry)))
|
||||
})
|
||||
.map_err(|err| err.into_error().caused_by(trc::location!()))
|
||||
}
|
||||
}
|
||||
|
||||
struct LdapResult {
|
||||
dn: String,
|
||||
account: Account,
|
||||
is_group: bool,
|
||||
}
|
||||
|
||||
impl LdapMappings {
|
||||
fn map_entry(&self, entry: SearchEntry) -> LdapResult {
|
||||
let mut account = Account::default();
|
||||
let mut is_group = false;
|
||||
|
||||
for (attr, value) in entry.attrs {
|
||||
let attr = attr.to_lowercase();
|
||||
let is_email = self.attr_email.contains(&attr);
|
||||
let is_email_alias = self.attr_email_alias.contains(&attr);
|
||||
if is_email || is_email_alias {
|
||||
let mut values = value.into_iter().filter_map(|v| sanitize_email(&v));
|
||||
if is_email
|
||||
&& account.email.is_empty()
|
||||
&& let Some(email) = values.next()
|
||||
{
|
||||
account.email = email;
|
||||
}
|
||||
if is_email_alias {
|
||||
account.email_aliases.extend(values);
|
||||
}
|
||||
} else if self.attr_secret.contains(&attr) {
|
||||
account.secret = value.into_iter().find(|secret| !secret.is_empty());
|
||||
} 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.
|
||||
if account.secret.is_none() {
|
||||
account.secret = value.into_iter().find(|item| !item.is_empty()).map(|item| {
|
||||
format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes()))
|
||||
});
|
||||
}
|
||||
} else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) {
|
||||
if (account.description.is_none() || idx == 0)
|
||||
&& let Some(desc) = value.into_iter().find(|desc| !desc.is_empty())
|
||||
{
|
||||
account.description = Some(desc);
|
||||
}
|
||||
} else if self.attr_groups.contains(&attr) {
|
||||
account.groups.get_or_insert_default().extend(value);
|
||||
} else if self.attr_class.contains(&attr) {
|
||||
for value in value {
|
||||
is_group |= value.eq_ignore_ascii_case(&self.group_class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !account.email_aliases.is_empty() {
|
||||
let mut aliases = Vec::with_capacity(account.email_aliases.len());
|
||||
for alias in std::mem::take(&mut account.email_aliases) {
|
||||
if alias != account.email && !aliases.contains(&alias) {
|
||||
aliases.push(alias);
|
||||
}
|
||||
}
|
||||
account.email_aliases = aliases;
|
||||
}
|
||||
|
||||
LdapResult {
|
||||
dn: entry.dn,
|
||||
account,
|
||||
is_group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn result_to_trace(rs: &ResultEntry) -> trc::Value {
|
||||
let se = SearchEntry::construct(rs.clone());
|
||||
se.attrs
|
||||
.into_iter()
|
||||
.map(|(k, v)| trc::Value::Array(vec![trc::Value::from(k), trc::Value::from(v.join(", "))]))
|
||||
.chain([trc::Value::from(se.dn)])
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use deadpool::managed::Pool;
|
||||
use ldap3::{LdapConnSettings, ldap_escape};
|
||||
|
||||
pub mod config;
|
||||
pub mod lookup;
|
||||
pub mod pool;
|
||||
|
||||
pub struct LdapDirectory {
|
||||
pool: Pool<LdapConnectionManager>,
|
||||
mappings: LdapMappings,
|
||||
auth_bind: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LdapMappings {
|
||||
base_dn: String,
|
||||
filter_login: LdapFilter,
|
||||
filter_mailbox: LdapFilter,
|
||||
filter_member_of: Option<LdapFilter>,
|
||||
attr_class: Vec<String>,
|
||||
attr_groups: Vec<String>,
|
||||
attr_description: Vec<String>,
|
||||
attr_secret: Vec<String>,
|
||||
attr_secret_changed: Vec<String>,
|
||||
attr_email: Vec<String>,
|
||||
attr_email_alias: Vec<String>,
|
||||
attrs_principal: Vec<String>,
|
||||
group_class: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct LdapFilter {
|
||||
filter: Vec<LdapFilterItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum LdapFilterItem {
|
||||
Static(String),
|
||||
Full,
|
||||
LocalPart,
|
||||
DomainPart,
|
||||
}
|
||||
|
||||
impl LdapFilter {
|
||||
pub fn build(&self, value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len() + 16);
|
||||
|
||||
for item in &self.filter {
|
||||
match item {
|
||||
LdapFilterItem::Static(s) => result.push_str(s),
|
||||
LdapFilterItem::Full => result.push_str(ldap_escape(value).as_ref()),
|
||||
LdapFilterItem::LocalPart => {
|
||||
result.push_str(
|
||||
ldap_escape(
|
||||
value
|
||||
.rsplit_once('@')
|
||||
.map(|(local, _)| local)
|
||||
.unwrap_or(value),
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
LdapFilterItem::DomainPart => {
|
||||
if let Some((_, domain)) = value.rsplit_once('@') {
|
||||
result.push_str(ldap_escape(domain).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LdapConnectionManager {
|
||||
address: String,
|
||||
settings: LdapConnSettings,
|
||||
bind_dn: Option<Bind>,
|
||||
}
|
||||
|
||||
pub(crate) struct Bind {
|
||||
dn: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl LdapConnectionManager {
|
||||
pub fn new(address: String, settings: LdapConnSettings, bind_dn: Option<Bind>) -> Self {
|
||||
Self {
|
||||
address,
|
||||
settings,
|
||||
bind_dn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Bind {
|
||||
pub fn new(dn: String, password: String) -> Self {
|
||||
Self { dn, password }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::LdapFilter;
|
||||
|
||||
#[test]
|
||||
fn filter_placeholders_are_escaped() {
|
||||
let filter = LdapFilter::new("(&(uid={local})(dc={domain})(mail=?))").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
filter.build("*)(uid=*@ex)(dc=*"),
|
||||
"(&(uid=\\2a\\29\\28uid=\\2a)(dc=ex\\29\\28dc=\\2a)(mail=\\2a\\29\\28uid=\\2a@ex\\29\\28dc=\\2a))"
|
||||
);
|
||||
assert_eq!(
|
||||
filter.build("[email protected]"),
|
||||
"(&(uid=john)(dc=example.com)([email protected]))"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::LdapConnectionManager;
|
||||
use deadpool::managed;
|
||||
use ldap3::{Ldap, LdapConnAsync, LdapError, exop::WhoAmI};
|
||||
|
||||
impl managed::Manager for LdapConnectionManager {
|
||||
type Type = Ldap;
|
||||
type Error = LdapError;
|
||||
|
||||
async fn create(&self) -> Result<Ldap, LdapError> {
|
||||
let (conn, mut ldap) =
|
||||
LdapConnAsync::with_settings(self.settings.clone(), &self.address).await?;
|
||||
|
||||
ldap3::drive!(conn);
|
||||
|
||||
if let Some(bind) = &self.bind_dn {
|
||||
ldap.simple_bind(&bind.dn, &bind.password)
|
||||
.await?
|
||||
.success()?;
|
||||
}
|
||||
|
||||
Ok(ldap)
|
||||
}
|
||||
|
||||
async fn recycle(
|
||||
&self,
|
||||
conn: &mut Ldap,
|
||||
_: &managed::Metrics,
|
||||
) -> managed::RecycleResult<LdapError> {
|
||||
conn.extended(WhoAmI)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(managed::RecycleError::Backend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod ldap;
|
||||
pub mod oidc;
|
||||
pub mod sql;
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::Directory;
|
||||
use crate::backend::oidc::lookup::fetch_jwks_keys;
|
||||
use crate::backend::oidc::{
|
||||
CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use registry::schema::structs;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use trc::AuthEvent;
|
||||
use utils::Client;
|
||||
|
||||
const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30);
|
||||
const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
impl OpenIdDirectory {
|
||||
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
|
||||
Self::new(OidcConfig {
|
||||
issue_url: config.issuer_url,
|
||||
require_aud: config.require_audience,
|
||||
require_scopes: config.require_scopes.into_inner(),
|
||||
claim_email: config.claim_username,
|
||||
claim_name: config.claim_name,
|
||||
claim_groups: config.claim_groups,
|
||||
default_domain: config.username_domain,
|
||||
})
|
||||
.await
|
||||
.map(Directory::OpenId)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
|
||||
let http = utils::http::http_client_builder(false)
|
||||
.user_agent("Stalwart/1.0")
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
|
||||
|
||||
let started_at = Instant::now();
|
||||
let (document, keys) = loop {
|
||||
match Self::discover(&http, &config).await {
|
||||
Ok(discovery) => break discovery,
|
||||
Err(err) if err.is_transient() && started_at.elapsed() < DISCOVERY_RETRY_FOR => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = config.issue_url.to_string(),
|
||||
Reason = format!(
|
||||
"{err}, retrying in {} seconds",
|
||||
DISCOVERY_RETRY_INTERVAL.as_secs()
|
||||
)
|
||||
);
|
||||
tokio::time::sleep(DISCOVERY_RETRY_INTERVAL).await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
discovery: OidcDiscovery {
|
||||
url: config.issue_url.clone(),
|
||||
document,
|
||||
},
|
||||
config,
|
||||
http,
|
||||
cache: RwLock::new(JwksCache {
|
||||
keys,
|
||||
last_updated: Instant::now(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn discover(
|
||||
http: &Client,
|
||||
config: &OidcConfig,
|
||||
) -> Result<(DiscoveryDocument, AHashMap<String, Arc<CachedKey>>), OidcError> {
|
||||
let discovery_url = format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
config.issue_url.trim_end_matches('/')
|
||||
);
|
||||
let discovery_bytes = http
|
||||
.get(&discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?;
|
||||
let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?;
|
||||
|
||||
let normalised_issue = config.issue_url.trim_end_matches('/');
|
||||
let normalised_issuer = discovery.issuer.trim_end_matches('/');
|
||||
if normalised_issuer != normalised_issue {
|
||||
return Err(OidcError::Config(format!(
|
||||
"Issuer mismatch: discovery document says '{}' but configured issue_url is '{}'",
|
||||
discovery.issuer, config.issue_url,
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(supported) = &discovery.scopes_supported {
|
||||
for scope in &config.require_scopes {
|
||||
if !supported.contains(scope) {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = config.issue_url.to_string(),
|
||||
Reason = format!(
|
||||
"Required scope '{}' is not in scopes_supported from the IdP",
|
||||
scope
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(supported) = &discovery.claims_supported {
|
||||
let check = |name: &str, label: &str| {
|
||||
if !supported.iter().any(|c| c == name) {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = config.issue_url.to_string(),
|
||||
Reason = format!(
|
||||
"Configured {} claim '{}' is not in claims_supported from the IdP",
|
||||
label, name
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
check(&config.claim_email, "claim_email");
|
||||
if let Some(n) = &config.claim_name {
|
||||
check(n, "claim_name");
|
||||
}
|
||||
if let Some(g) = &config.claim_groups {
|
||||
check(g, "claim_groups");
|
||||
}
|
||||
}
|
||||
|
||||
/*{
|
||||
let cache = Arc::clone(&cache);
|
||||
let http = http.clone();
|
||||
let jwks_uri = discovery.jwks_uri.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600));
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match fetch_jwks_keys(&http, &jwks_uri).await {
|
||||
Ok(new_keys) => {
|
||||
let mut guard = cache.write().await;
|
||||
guard.keys = new_keys;
|
||||
guard.last_updated = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!("Background JWKS refresh failed: {e}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}*/
|
||||
|
||||
let keys = fetch_jwks_keys(http, &discovery.jwks_uri).await?;
|
||||
|
||||
Ok((discovery, keys))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Account, Credentials,
|
||||
backend::oidc::{CachedKey, OidcError, OpenIdDirectory},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, Header, Validation, decode, decode_header,
|
||||
jwk::{self, JwkSet},
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use trc::AuthEvent;
|
||||
|
||||
impl OpenIdDirectory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
match credentials {
|
||||
Credentials::Bearer { token, .. } if token.is_empty() => {
|
||||
Err(AuthEvent::Failed.into_err().reason("Empty token rejected"))
|
||||
}
|
||||
Credentials::Bearer { token, .. } => if let Ok(header) = decode_header(token) {
|
||||
self.authenticate_jwt(token, header).await
|
||||
} else {
|
||||
#[cfg(feature = "test_mode")]
|
||||
let token = token.strip_prefix(".").unwrap_or(token);
|
||||
self.authenticate_opaque(token).await
|
||||
}
|
||||
.map_err(|err| match err {
|
||||
OidcError::AuthorizationFailed(reason) => {
|
||||
AuthEvent::Failed.into_err().reason(reason)
|
||||
}
|
||||
err => AuthEvent::Error.into_err().reason(err),
|
||||
}),
|
||||
_ => Err(AuthEvent::Error
|
||||
.into_err()
|
||||
.reason("Unsupported credentials type for OIDC backend")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate_jwt(&self, token: &str, header: Header) -> Result<Account, OidcError> {
|
||||
if matches!(
|
||||
header.alg,
|
||||
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
|
||||
) {
|
||||
return Err(OidcError::TokenValidation(
|
||||
"Unsupported algorithm".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let candidates = self.get_key(header.kid.as_deref()).await?;
|
||||
let mut last_err = None;
|
||||
for cached in &candidates {
|
||||
let dk = &cached.decoding_key;
|
||||
let alg = cached.algorithm;
|
||||
let mut validation = Validation::new(alg);
|
||||
|
||||
if let Some(aud) = &self.config.require_aud {
|
||||
validation.set_audience(&[aud]);
|
||||
} else {
|
||||
validation.validate_aud = false;
|
||||
}
|
||||
|
||||
validation.set_issuer(&[&self.discovery.document.issuer]);
|
||||
validation.leeway = 60;
|
||||
|
||||
match decode::<serde_json::Value>(token, dk, &validation) {
|
||||
Ok(token_data) => {
|
||||
if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() {
|
||||
last_err = Some(jsonwebtoken::errors::Error::from(
|
||||
jsonwebtoken::errors::ErrorKind::InvalidAudience,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
self.validate_scopes(&token_data.claims)?;
|
||||
|
||||
let mut claims = token_data.claims;
|
||||
let jwt_email = self.resolve_email(&claims).ok();
|
||||
let missing_profile =
|
||||
is_claim_missing(&claims, self.config.claim_name.as_ref())
|
||||
|| is_claim_missing(&claims, self.config.claim_groups.as_ref());
|
||||
|
||||
if jwt_email.is_none() || missing_profile {
|
||||
match self.fetch_userinfo(token).await {
|
||||
Ok(userinfo) => {
|
||||
if let (Some(base), Value::Object(extra)) =
|
||||
(claims.as_object_mut(), userinfo)
|
||||
{
|
||||
for (key, value) in extra {
|
||||
if base.get(&key).is_none_or(Value::is_null) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) if jwt_email.is_none() => return Err(err),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let email = match jwt_email {
|
||||
Some(email) => email,
|
||||
None => self.resolve_email(&claims)?,
|
||||
};
|
||||
return self.build_account(email, &claims);
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(OidcError::TokenValidation(format!(
|
||||
"JWT validation failed: {}",
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
)))
|
||||
}
|
||||
|
||||
async fn authenticate_opaque(&self, token: &str) -> Result<Account, OidcError> {
|
||||
let claims = self.fetch_userinfo(token).await?;
|
||||
self.build_account(self.resolve_email(&claims)?, &claims)
|
||||
}
|
||||
|
||||
async fn get_key(&self, kid: Option<&str>) -> Result<Vec<Arc<CachedKey>>, OidcError> {
|
||||
{
|
||||
let guard = self.cache.read().await;
|
||||
|
||||
if let Some(kid) = kid {
|
||||
if let Some(cached) = guard.keys.get(kid) {
|
||||
return Ok(vec![cached.clone()]);
|
||||
}
|
||||
|
||||
if guard.last_updated.elapsed() < Duration::from_secs(300) {
|
||||
return Err(OidcError::TokenValidation("Unknown key id".to_string()));
|
||||
}
|
||||
} else {
|
||||
let all: Vec<_> = guard.keys.values().cloned().collect();
|
||||
if !all.is_empty() {
|
||||
return Ok(all);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_keys = fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await?;
|
||||
{
|
||||
let mut guard = self.cache.write().await;
|
||||
guard.keys = new_keys;
|
||||
guard.last_updated = Instant::now();
|
||||
}
|
||||
|
||||
let guard = self.cache.read().await;
|
||||
if let Some(kid) = kid {
|
||||
if let Some(cached) = guard.keys.get(kid) {
|
||||
Ok(vec![cached.clone()])
|
||||
} else {
|
||||
Err(OidcError::TokenValidation(
|
||||
"Unknown key id after refresh".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
let all: Vec<_> = guard.keys.values().cloned().collect();
|
||||
if all.is_empty() {
|
||||
Err(OidcError::Provider(
|
||||
"JWKS contains no usable keys".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_userinfo(&self, token: &str) -> Result<serde_json::Value, OidcError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(&self.discovery.document.userinfo_endpoint)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("UserInfo request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
let reason = resp.text().await.unwrap_or_default();
|
||||
return Err(OidcError::AuthorizationFailed(format!(
|
||||
"Token rejected by UserInfo endpoint with status {status}: {reason}"
|
||||
)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(OidcError::Provider(format!(
|
||||
"UserInfo returned HTTP {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?;
|
||||
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}")))
|
||||
}
|
||||
|
||||
fn validate_scopes(&self, claims: &serde_json::Value) -> Result<(), OidcError> {
|
||||
if !self.config.require_scopes.is_empty() {
|
||||
let token_scopes = extract_scopes(claims);
|
||||
|
||||
for required in &self.config.require_scopes {
|
||||
if !token_scopes.iter().any(|s| s == required) {
|
||||
return Err(OidcError::AuthorizationFailed(format!(
|
||||
"Missing required scope '{required}', present scopes: {token_scopes:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_account(
|
||||
&self,
|
||||
email: String,
|
||||
claims: &serde_json::Value,
|
||||
) -> Result<Account, OidcError> {
|
||||
Ok(Account {
|
||||
email,
|
||||
email_aliases: Vec::new(),
|
||||
secret: None,
|
||||
groups: self
|
||||
.config
|
||||
.claim_groups
|
||||
.as_ref()
|
||||
.and_then(|groups_claim| claims.get(groups_claim))
|
||||
.and_then(extract_string_list)
|
||||
.map(|groups| {
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|group| match &self.config.default_domain {
|
||||
Some(domain) if !group.contains('@') => format!("{group}@{domain}"),
|
||||
_ => group,
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
description: self
|
||||
.config
|
||||
.claim_name
|
||||
.as_ref()
|
||||
.and_then(|name_claim| claims.get(name_claim))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_email(&self, claims: &serde_json::Value) -> Result<String, OidcError> {
|
||||
if let Some(val) = claims
|
||||
.get(&self.config.claim_email)
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if val.contains('@') {
|
||||
return Ok(val.to_string());
|
||||
}
|
||||
if let Some(domain) = &self.config.default_domain {
|
||||
return Ok(format!("{val}@{domain}"));
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.claim_email != "email"
|
||||
&& let Some(val) = claims.get("email").and_then(|v| v.as_str())
|
||||
&& val.contains('@')
|
||||
{
|
||||
return Ok(val.to_string());
|
||||
}
|
||||
|
||||
Err(OidcError::AuthorizationFailed(
|
||||
"Could not determine a valid email address for account".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_jwks_keys(
|
||||
http: &Client,
|
||||
jwks_uri: &str,
|
||||
) -> Result<AHashMap<String, Arc<CachedKey>>, OidcError> {
|
||||
let jwks_bytes = http
|
||||
.get(jwks_uri)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?;
|
||||
let jwks: JwkSet = serde_json::from_slice(&jwks_bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?;
|
||||
|
||||
let mut map = AHashMap::new();
|
||||
let mut synthetic_id: u64 = 0;
|
||||
|
||||
for key in &jwks.keys {
|
||||
if let Some(pk_use) = &key.common.public_key_use
|
||||
&& pk_use != &jwk::PublicKeyUse::Signature
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let algorithm = match &key.algorithm {
|
||||
jwk::AlgorithmParameters::RSA(_) => match key.common.key_algorithm {
|
||||
Some(jwk::KeyAlgorithm::RS256) => Algorithm::RS256,
|
||||
Some(jwk::KeyAlgorithm::RS384) => Algorithm::RS384,
|
||||
Some(jwk::KeyAlgorithm::RS512) => Algorithm::RS512,
|
||||
Some(jwk::KeyAlgorithm::PS256) => Algorithm::PS256,
|
||||
Some(jwk::KeyAlgorithm::PS384) => Algorithm::PS384,
|
||||
Some(jwk::KeyAlgorithm::PS512) => Algorithm::PS512,
|
||||
None => Algorithm::RS256,
|
||||
Some(other) => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!("Unsupported RSA key algorithm {:?}", other)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
jwk::AlgorithmParameters::EllipticCurve(ec) => match ec.curve {
|
||||
jwk::EllipticCurve::P256 => Algorithm::ES256,
|
||||
jwk::EllipticCurve::P384 => Algorithm::ES384,
|
||||
_ => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!("Unsupported EC curve {:?}", ec.curve)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
jwk::AlgorithmParameters::OctetKeyPair(_) => Algorithm::EdDSA,
|
||||
jwk::AlgorithmParameters::OctetKey(_) => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!(
|
||||
"Symmetric (HMAC) key found in JWKS (kid={:?}), skipping — HMAC is not accepted",
|
||||
key.common.key_id
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!(
|
||||
"Unrecognised key type in JWKS (kid={:?}), skipping",
|
||||
key.common.key_id
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let decoding_key = match DecodingKey::from_jwk(key) {
|
||||
Ok(decoding_key) => decoding_key,
|
||||
Err(e) => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = jwks_uri.to_string(),
|
||||
Reason = format!(
|
||||
"Failed to build DecodingKey from JWK (kid={:?}): {e}",
|
||||
key.common.key_id
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
map.insert(
|
||||
match &key.common.key_id {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
let id = format!("_synthetic_{synthetic_id}");
|
||||
synthetic_id += 1;
|
||||
id
|
||||
}
|
||||
},
|
||||
CachedKey {
|
||||
decoding_key,
|
||||
algorithm,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn extract_scopes(claims: &serde_json::Value) -> Vec<String> {
|
||||
match claims.get("scope") {
|
||||
Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(),
|
||||
Some(serde_json::Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_string_list(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => Some(
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
serde_json::Value::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool {
|
||||
claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_string_list_distinguishes_absent_from_empty() {
|
||||
assert_eq!(
|
||||
extract_string_list(&json!(["sales", "support"])),
|
||||
Some(vec!["sales".to_string(), "support".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
extract_string_list(&json!("sales support")),
|
||||
Some(vec!["sales".to_string(), "support".to_string()])
|
||||
);
|
||||
assert_eq!(extract_string_list(&json!([])), Some(vec![]));
|
||||
assert_eq!(extract_string_list(&json!("")), Some(vec![]));
|
||||
assert_eq!(extract_string_list(&json!(null)), None);
|
||||
assert_eq!(extract_string_list(&json!({"groups": []})), None);
|
||||
assert_eq!(extract_string_list(&json!(42)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_claim_missing_treats_null_as_absent() {
|
||||
let claims = json!({"groups": null, "name": "John Doe", "roles": []});
|
||||
|
||||
assert!(is_claim_missing(&claims, Some(&"groups".to_string())));
|
||||
assert!(is_claim_missing(&claims, Some(&"unknown".to_string())));
|
||||
assert!(!is_claim_missing(&claims, Some(&"name".to_string())));
|
||||
assert!(!is_claim_missing(&claims, Some(&"roles".to_string())));
|
||||
assert!(!is_claim_missing(&claims, None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use ahash::AHashMap;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, sync::Arc, time::Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use utils::Client;
|
||||
|
||||
pub mod config;
|
||||
pub mod lookup;
|
||||
|
||||
pub struct OidcConfig {
|
||||
pub issue_url: String,
|
||||
pub require_aud: Option<String>,
|
||||
pub require_scopes: Vec<String>,
|
||||
pub claim_email: String,
|
||||
pub claim_name: Option<String>,
|
||||
pub claim_groups: Option<String>,
|
||||
pub default_domain: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OidcDiscovery {
|
||||
pub url: String,
|
||||
pub document: DiscoveryDocument,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct DiscoveryDocument {
|
||||
pub issuer: String,
|
||||
pub jwks_uri: String,
|
||||
pub userinfo_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub authorization_endpoint: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_session_endpoint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claims_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_challenge_methods_supported: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
struct CachedKey {
|
||||
decoding_key: DecodingKey,
|
||||
algorithm: Algorithm,
|
||||
}
|
||||
|
||||
struct JwksCache {
|
||||
keys: AHashMap<String, Arc<CachedKey>>,
|
||||
last_updated: Instant,
|
||||
}
|
||||
|
||||
pub struct OpenIdDirectory {
|
||||
config: OidcConfig,
|
||||
pub discovery: OidcDiscovery,
|
||||
http: Client,
|
||||
cache: RwLock<JwksCache>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OidcError {
|
||||
TokenValidation(String),
|
||||
AuthorizationFailed(String),
|
||||
Network(String),
|
||||
Provider(String),
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl OidcError {
|
||||
pub fn is_transient(&self) -> bool {
|
||||
matches!(self, OidcError::Network(_) | OidcError::Provider(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OidcError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
OidcError::TokenValidation(msg) => write!(f, "Token validation error: {msg}"),
|
||||
OidcError::AuthorizationFailed(msg) => write!(f, "Authorization failed: {msg}"),
|
||||
OidcError::Network(msg) => write!(f, "Network error: {msg}"),
|
||||
OidcError::Provider(msg) => write!(f, "Provider error: {msg}"),
|
||||
OidcError::Config(msg) => write!(f, "Configuration error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OidcError {}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{SqlDirectory, SqlMappings};
|
||||
use crate::Directory;
|
||||
use registry::schema::structs;
|
||||
use store::Store;
|
||||
|
||||
#[allow(unreachable_patterns)]
|
||||
impl SqlDirectory {
|
||||
pub async fn open(
|
||||
config: structs::SqlDirectory,
|
||||
data_store: &Store,
|
||||
) -> Result<Directory, String> {
|
||||
let sql_store = match config.store {
|
||||
#[cfg(feature = "postgres")]
|
||||
structs::SqlAuthStore::PostgreSql(store) => {
|
||||
store::backend::postgres::PostgresStore::open(store).await?
|
||||
}
|
||||
#[cfg(feature = "mysql")]
|
||||
structs::SqlAuthStore::MySql(store) => {
|
||||
store::backend::mysql::MysqlStore::open(store).await?
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
structs::SqlAuthStore::Sqlite(store) => {
|
||||
store::backend::sqlite::SqliteStore::open(store)?
|
||||
}
|
||||
structs::SqlAuthStore::Default => {
|
||||
if data_store.is_sql() {
|
||||
data_store.clone()
|
||||
} else {
|
||||
return Err(concat!(
|
||||
"This directory is set to store accounts in the main data store, ",
|
||||
"but the configured data store is not an SQL database. ",
|
||||
"Either select an SQL data store or configure a separate SQL ",
|
||||
"database for this directory."
|
||||
)
|
||||
.to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"Binary not compiled with support for the selected SQL directory backend."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mappings = SqlMappings {
|
||||
query_login: config.query_login,
|
||||
query_recipient: config.query_recipient,
|
||||
query_member_of: config.query_member_of,
|
||||
query_email_aliases: config.query_email_aliases,
|
||||
column_email: config.column_email,
|
||||
column_secret: config.column_secret,
|
||||
column_type: config.column_class,
|
||||
column_description: config.column_description,
|
||||
};
|
||||
|
||||
Ok(Directory::Sql(SqlDirectory {
|
||||
sql_store,
|
||||
mappings,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{SqlDirectory, SqlMappings};
|
||||
use crate::{Account, Credentials, Recipient, core::secret::verify_secret_hash};
|
||||
use store::{NamedRows, Rows, Value};
|
||||
use trc::AddContext;
|
||||
use utils::sanitize_email;
|
||||
|
||||
impl SqlDirectory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
let (username, secret) = match credentials {
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
} => (username, secret),
|
||||
Credentials::Bearer { .. } => {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Unsupported credentials type for SQL authentication"));
|
||||
}
|
||||
};
|
||||
if secret.is_empty() {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Empty secret rejected")
|
||||
.ctx(trc::Key::AccountName, username.to_string()));
|
||||
}
|
||||
|
||||
let Recipient::Account(mut account) = self.mappings.row_to_account(
|
||||
self.sql_store
|
||||
.sql_query::<NamedRows>(&self.mappings.query_login, vec![username.into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
) else {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("SQL login query did not return an account")
|
||||
.ctx(trc::Key::AccountName, username.to_string()));
|
||||
};
|
||||
|
||||
// Validate secret
|
||||
if let Some(account_secret) = &account.secret {
|
||||
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
|
||||
return Err(trc::AuthEvent::Failed
|
||||
.into_err()
|
||||
.details("Invalid credentials")
|
||||
.ctx(trc::Key::AccountName, username.to_string()));
|
||||
}
|
||||
} else {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Account does not have a secret")
|
||||
.ctx(trc::Key::AccountName, username.to_string()));
|
||||
}
|
||||
|
||||
// Obtain members
|
||||
if let Some(query) = &self.mappings.query_member_of {
|
||||
let members = account.groups.get_or_insert_default();
|
||||
for row in self
|
||||
.sql_store
|
||||
.sql_query::<Rows>(query, vec![username.into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.rows
|
||||
{
|
||||
if let Some(Value::Text(address)) = row.values.first()
|
||||
&& let Some(email) = sanitize_email(address)
|
||||
{
|
||||
members.push(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain emails
|
||||
if let Some(query) = &self.mappings.query_email_aliases {
|
||||
account.email_aliases.extend(
|
||||
self.sql_store
|
||||
.sql_query::<Rows>(query, vec![username.into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.rows
|
||||
.into_iter()
|
||||
.flat_map(|v| {
|
||||
v.values
|
||||
.into_iter()
|
||||
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if account.email.is_empty() {
|
||||
account.email = sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
|
||||
}
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
|
||||
let recipient = self.mappings.row_to_account(
|
||||
self.sql_store
|
||||
.sql_query::<NamedRows>(&self.mappings.query_recipient, vec![address.into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
|
||||
match recipient {
|
||||
Recipient::Account(mut account) => {
|
||||
// Obtain members
|
||||
if let Some(query) = &self.mappings.query_member_of {
|
||||
let members = account.groups.get_or_insert_default();
|
||||
for row in self
|
||||
.sql_store
|
||||
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.rows
|
||||
{
|
||||
if let Some(Value::Text(address)) = row.values.first()
|
||||
&& let Some(email) = sanitize_email(address)
|
||||
{
|
||||
members.push(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain emails
|
||||
if let Some(query) = &self.mappings.query_email_aliases {
|
||||
account.email_aliases.extend(
|
||||
self.sql_store
|
||||
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.rows
|
||||
.into_iter()
|
||||
.flat_map(|v| {
|
||||
v.values
|
||||
.into_iter()
|
||||
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Recipient::Account(account))
|
||||
}
|
||||
Recipient::Group(group) => Ok(Recipient::Group(group)),
|
||||
Recipient::Invalid => Ok(Recipient::Invalid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SqlMappings {
|
||||
pub fn row_to_account(&self, rows: NamedRows) -> Recipient {
|
||||
if rows.rows.is_empty() {
|
||||
return Recipient::Invalid;
|
||||
}
|
||||
|
||||
let mut account = Account::default();
|
||||
let mut is_group = false;
|
||||
|
||||
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_email) {
|
||||
if let Value::Text(text) = value
|
||||
&& let Some(email) = sanitize_email(&text)
|
||||
{
|
||||
account.email = email;
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case(&self.column_secret) {
|
||||
if let Value::Text(text) = value
|
||||
&& !text.is_empty()
|
||||
{
|
||||
account.secret = Some(text.into_owned());
|
||||
}
|
||||
} else if let Some(column_type) = &self.column_type
|
||||
&& name.eq_ignore_ascii_case(column_type)
|
||||
{
|
||||
is_group = value.to_str().eq_ignore_ascii_case("group");
|
||||
} else if let Some(column_description) = &self.column_description
|
||||
&& name.eq_ignore_ascii_case(column_description)
|
||||
&& let Value::Text(text) = value
|
||||
&& !text.is_empty()
|
||||
{
|
||||
account.description = Some(text.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_group {
|
||||
Recipient::Account(account)
|
||||
} else {
|
||||
Recipient::Group(crate::Group {
|
||||
email: account.email,
|
||||
email_aliases: account.email_aliases,
|
||||
description: account.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use store::Store;
|
||||
|
||||
pub mod config;
|
||||
pub mod lookup;
|
||||
|
||||
pub struct SqlDirectory {
|
||||
sql_store: Store,
|
||||
mappings: SqlMappings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SqlMappings {
|
||||
query_login: String,
|
||||
query_recipient: String,
|
||||
query_member_of: Option<String>,
|
||||
query_email_aliases: Option<String>,
|
||||
column_email: String,
|
||||
column_secret: String,
|
||||
column_type: Option<String>,
|
||||
column_description: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Directories, Directory, UnavailableDirectory,
|
||||
backend::{ldap::LdapDirectory, oidc::OpenIdDirectory, sql::SqlDirectory},
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::ObjectType,
|
||||
structs::{self, Authentication},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use store::registry::bootstrap::Bootstrap;
|
||||
|
||||
impl Directories {
|
||||
pub async fn build(bp: &mut Bootstrap) -> Self {
|
||||
let mut directories = HashMap::default();
|
||||
|
||||
for directory in bp.list_infallible::<structs::Directory>().await {
|
||||
let id = directory.id;
|
||||
let directory_type = directory.object.object_type();
|
||||
let result = match directory.object {
|
||||
structs::Directory::Ldap(directory) => LdapDirectory::open(directory).await,
|
||||
structs::Directory::Sql(directory) => {
|
||||
SqlDirectory::open(directory, &bp.data_store).await
|
||||
}
|
||||
structs::Directory::Oidc(directory) => OpenIdDirectory::open(directory).await,
|
||||
};
|
||||
|
||||
let directory = match result {
|
||||
Ok(directory) => directory,
|
||||
Err(err) => {
|
||||
bp.build_error(id, err.clone());
|
||||
Directory::Unavailable(UnavailableDirectory::new(directory_type, err))
|
||||
}
|
||||
};
|
||||
directories.insert(id.id().id() as u32, Arc::new(directory));
|
||||
}
|
||||
|
||||
let auth = bp.setting_infallible::<Authentication>().await;
|
||||
let default_directory = if let Some(directory_id) = auth.directory_id {
|
||||
match directories.get(&(directory_id.id() as u32)) {
|
||||
Some(default_directory) => default_directory.clone().into(),
|
||||
None => {
|
||||
bp.build_error(
|
||||
ObjectType::Authentication.singleton(),
|
||||
format!("Default directory with ID {} not found", directory_id),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Directories {
|
||||
default_directory,
|
||||
directories,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Account, Credentials, Directory, Recipient, backend::oidc::OidcDiscovery};
|
||||
use registry::schema::enums::DirectoryType;
|
||||
use trc::AddContext;
|
||||
|
||||
impl Directory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
match &self {
|
||||
Directory::Ldap(store) => store.authenticate(credentials).await,
|
||||
Directory::Sql(store) => store.authenticate(credentials).await,
|
||||
Directory::OpenId(store) => store.authenticate(credentials).await,
|
||||
Directory::Unavailable(directory) => Err(directory.error()),
|
||||
}
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
|
||||
match &self {
|
||||
Directory::Ldap(store) => store.recipient(address).await,
|
||||
Directory::Sql(store) => store.recipient(address).await,
|
||||
Directory::OpenId(_) => Ok(Recipient::Invalid), // OIDC directories do not support recipient lookups
|
||||
Directory::Unavailable(directory) => Err(directory.error()),
|
||||
}
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
pub fn has_bearer_token_support(&self) -> bool {
|
||||
match &self {
|
||||
Directory::OpenId(_) => true,
|
||||
Directory::Unavailable(directory) => directory.directory_type() == DirectoryType::Oidc,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_lookup_recipients(&self) -> bool {
|
||||
match &self {
|
||||
Directory::OpenId(_) => false,
|
||||
Directory::Unavailable(directory) => directory.directory_type() != DirectoryType::Oidc,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oidc_discovery_document(&self) -> Option<&OidcDiscovery> {
|
||||
match &self {
|
||||
Directory::OpenId(directory) => Some(&directory.discovery),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod sasl;
|
||||
pub mod secret;
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::Credentials;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
impl Credentials {
|
||||
pub fn decode_sasl_challenge_plain(challenge: &[u8]) -> Option<Self> {
|
||||
let mut username = Vec::new();
|
||||
let mut secret = Vec::new();
|
||||
let mut arg_num = 0;
|
||||
for &ch in challenge {
|
||||
if ch != 0 {
|
||||
if arg_num == 1 {
|
||||
username.push(ch);
|
||||
} else if arg_num == 2 {
|
||||
secret.push(ch);
|
||||
}
|
||||
} else {
|
||||
arg_num += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match (String::from_utf8(username), String::from_utf8(secret)) {
|
||||
(Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => {
|
||||
Some(Credentials::Basic {
|
||||
username,
|
||||
secret,
|
||||
mfa_token: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_sasl_challenge_oauth(challenge: &[u8]) -> Option<Self> {
|
||||
extract_oauth_bearer(challenge)
|
||||
.map(|(token, username)| Credentials::Bearer { username, token })
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option<String>)> {
|
||||
let mut start_pos = 0;
|
||||
let eof = bytes.len().saturating_sub(1);
|
||||
let mut iter = bytes.iter().enumerate();
|
||||
let mut a = None;
|
||||
|
||||
while let Some((pos, ch)) = iter.next() {
|
||||
if *ch == b','
|
||||
&& bytes
|
||||
.get(pos + 1..pos + 3)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(b"a="))
|
||||
{
|
||||
let from_pos = pos + 3;
|
||||
let mut to_pos = from_pos;
|
||||
for (pos, ch) in iter.by_ref() {
|
||||
if *ch == b',' || *ch == 1 {
|
||||
to_pos = pos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if to_pos > from_pos {
|
||||
a = bytes
|
||||
.get(from_pos..to_pos)
|
||||
.and_then(|s| std::str::from_utf8(s).ok())
|
||||
.filter(|v| v.contains('@'));
|
||||
}
|
||||
} else {
|
||||
let is_separator = *ch == 1;
|
||||
if is_separator || pos == eof {
|
||||
if bytes
|
||||
.get(start_pos..start_pos + 12)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer "))
|
||||
{
|
||||
return bytes
|
||||
.get(start_pos + 12..if is_separator { pos } else { bytes.len() })
|
||||
.and_then(|s| std::str::from_utf8(s).ok())
|
||||
.map(|token| {
|
||||
(
|
||||
token.to_string(),
|
||||
a.map(|s| s.to_string())
|
||||
.or_else(|| extract_email_from_jwt(token)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
start_pos = pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct JwtClaims {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
preferred_username: Option<String>,
|
||||
#[serde(default)]
|
||||
upn: Option<String>,
|
||||
#[serde(default)]
|
||||
unique_name: Option<String>,
|
||||
#[serde(default)]
|
||||
sub: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_email_from_jwt(token: &str) -> Option<String> {
|
||||
let claims: JwtClaims =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok()?;
|
||||
[
|
||||
claims.email,
|
||||
claims.preferred_username,
|
||||
claims.upn,
|
||||
claims.unique_name,
|
||||
claims.sub,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|v| v.contains('@'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_bearer() {
|
||||
let input = b"auth=Bearer validtoken";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("validtoken".to_string(), None)));
|
||||
|
||||
let input = b"auth=Invalid validtoken";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"auth=Bearer";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"auth=Bearer token1\x01auth=Bearer token2";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("token1".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer VALIDTOKEN";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("VALIDTOKEN".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer token with spaces";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("token with spaces".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer token_with_special_chars!@#";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(("token_with_special_chars!@#".to_string(), None))
|
||||
);
|
||||
|
||||
let input = "n,[email protected],\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01";
|
||||
let result = extract_oauth_bearer(input.as_bytes());
|
||||
assert_eq!(
|
||||
result,
|
||||
Some((
|
||||
"vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(),
|
||||
Some("[email protected]".to_string())
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use argon2::Argon2;
|
||||
use argon2::PasswordHash;
|
||||
use argon2::PasswordHasher;
|
||||
use argon2::PasswordVerifier;
|
||||
use mail_builder::encoders::Base64Encoder;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use pbkdf2::Pbkdf2;
|
||||
use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt};
|
||||
use registry::schema::enums::PasswordHashAlgorithm;
|
||||
use scrypt::Scrypt;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
use sha2::Sha512;
|
||||
use tokio::sync::oneshot;
|
||||
use totp_rs::Totp;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecretVerificationResult {
|
||||
Valid,
|
||||
Invalid,
|
||||
MissingMfaToken,
|
||||
}
|
||||
|
||||
pub async fn verify_mfa_secret_hash(
|
||||
totp_uri: Option<&str>,
|
||||
totp_token: Option<&str>,
|
||||
hashed_secret: &str,
|
||||
secret: &str,
|
||||
) -> trc::Result<SecretVerificationResult> {
|
||||
if let Some(totp_uri) = totp_uri {
|
||||
if let Some(totp_token) = totp_token {
|
||||
let result = verify_secret_hash(hashed_secret, secret.as_bytes()).await?
|
||||
&& Totp::from_url(totp_uri)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details(totp_uri.to_string())
|
||||
})?
|
||||
.check_current(totp_token)
|
||||
.is_some();
|
||||
Ok(if result {
|
||||
SecretVerificationResult::Valid
|
||||
} else {
|
||||
SecretVerificationResult::Invalid
|
||||
})
|
||||
} else if !hashed_secret.is_empty()
|
||||
&& !secret.is_empty()
|
||||
&& verify_secret_hash(hashed_secret, secret.as_bytes()).await?
|
||||
{
|
||||
// Only let the client know if the TOTP code is missing
|
||||
// if the password is correct
|
||||
|
||||
Ok(SecretVerificationResult::MissingMfaToken)
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
} else if !hashed_secret.is_empty() && !secret.is_empty() {
|
||||
if verify_secret_hash(hashed_secret, secret.as_bytes()).await? {
|
||||
Ok(SecretVerificationResult::Valid)
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result<bool> {
|
||||
let is_argon = hashed_secret.starts_with("$argon2");
|
||||
let is_pbkdf2 = !is_argon && hashed_secret.starts_with("$pbkdf2");
|
||||
let is_scrypt = !is_argon && !is_pbkdf2 && hashed_secret.starts_with("$scrypt");
|
||||
|
||||
if is_argon || is_pbkdf2 || is_scrypt {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let secret = secret.to_vec();
|
||||
let hashed_secret = hashed_secret.to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) {
|
||||
Ok(hash) => {
|
||||
let result = if is_argon {
|
||||
Argon2::default().verify_password(&secret, &hash)
|
||||
} else if is_pbkdf2 {
|
||||
Pbkdf2::default().verify_password(&secret, &hash)
|
||||
} else {
|
||||
Scrypt::default().verify_password(&secret, &hash)
|
||||
};
|
||||
|
||||
tx.send(Ok(result.is_ok())).ok();
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(Err(trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details(hashed_secret)))
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)),
|
||||
}
|
||||
} else if hashed_secret.starts_with("$2") {
|
||||
// Blowfish crypt
|
||||
Ok(bcrypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$6$") {
|
||||
// SHA-512 crypt
|
||||
Ok(sha512_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$5$") {
|
||||
// SHA-256 crypt
|
||||
Ok(sha256_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$sha1") {
|
||||
// SHA-1 crypt
|
||||
Ok(sha1_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$1") {
|
||||
// MD5 based hash
|
||||
Ok(md5_crypt::verify(secret, hashed_secret))
|
||||
} else {
|
||||
Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(hashed_secret.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Result<bool> {
|
||||
if hashed_secret.starts_with('$') {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
} else if hashed_secret.starts_with('_') {
|
||||
// Enhanced DES-based hash
|
||||
Ok(bsdi_crypt::verify(secret, hashed_secret))
|
||||
} else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') {
|
||||
if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') {
|
||||
match algo.to_ascii_uppercase().as_str() {
|
||||
"ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
}
|
||||
"SHA" => {
|
||||
// SHA-1
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA" => {
|
||||
// Salted SHA-1
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..20).unwrap_or_default();
|
||||
let salt = decoded.get(20..).unwrap_or_default();
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"SHA256" => {
|
||||
// Verify hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA256" => {
|
||||
// Salted SHA-256
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..32).unwrap_or_default();
|
||||
let salt = decoded.get(32..).unwrap_or_default();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"SHA512" => {
|
||||
// SHA-512
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA512" => {
|
||||
// Salted SHA-512
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..64).unwrap_or_default();
|
||||
let salt = decoded.get(64..).unwrap_or_default();
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"MD5" => {
|
||||
// MD5
|
||||
let digest = md5::compute(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new().encode(&digest[..]).unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"CRYPT" => {
|
||||
if hashed_secret.starts_with('$') {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
} else {
|
||||
// Unix crypt
|
||||
Ok(unix_crypt::verify(secret, hashed_secret))
|
||||
}
|
||||
}
|
||||
"PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret),
|
||||
_ => Err(trc::AuthEvent::Error
|
||||
.ctx(trc::Key::Reason, "Unsupported algorithm")
|
||||
.details(hashed_secret.to_string())),
|
||||
}
|
||||
} else {
|
||||
Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(hashed_secret.to_string()))
|
||||
}
|
||||
} else if !hashed_secret.is_empty() {
|
||||
Ok(hashed_secret.as_bytes() == secret)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec<u8>) -> trc::Result<String> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = match algorithm {
|
||||
PasswordHashAlgorithm::Argon2id => {
|
||||
let hasher = Argon2::default();
|
||||
hasher
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string())
|
||||
}
|
||||
PasswordHashAlgorithm::Bcrypt => {
|
||||
return tx
|
||||
.send(bcrypt::hash(secret.as_slice()).map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details("Bcrypt hash failed")
|
||||
}))
|
||||
.ok()
|
||||
.unwrap_or(());
|
||||
}
|
||||
PasswordHashAlgorithm::Scrypt => Scrypt::default()
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string()),
|
||||
PasswordHashAlgorithm::Pbkdf2 => Pbkdf2::default()
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string()),
|
||||
};
|
||||
|
||||
tx.send(result.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details("Password hash failed")
|
||||
}))
|
||||
.ok();
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_password_hash(s: &str) -> bool {
|
||||
if s.starts_with("$argon2") || s.starts_with("$pbkdf2") || s.starts_with("$scrypt") {
|
||||
is_complete_phc(s)
|
||||
} else if s.starts_with("$2") {
|
||||
is_bcrypt_format(s)
|
||||
} else if let Some(body) = s.strip_prefix("$1$") {
|
||||
is_md5_crypt(body)
|
||||
} else if let Some(body) = s.strip_prefix("$5$") {
|
||||
is_sha_crypt(body, 43)
|
||||
} else if let Some(body) = s.strip_prefix("$6$") {
|
||||
is_sha_crypt(body, 86)
|
||||
} else if let Some(body) = s.strip_prefix("$sha1$") {
|
||||
is_sha1_crypt(body)
|
||||
} else if s.starts_with('_') {
|
||||
is_unix_des_crypt(s)
|
||||
} else if let Some(rest) = s.strip_prefix('{') {
|
||||
rest.split_once('}')
|
||||
.map(|(scheme, body)| is_ldap_hash(scheme, body))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete_phc(s: &str) -> bool {
|
||||
PasswordHash::new(s)
|
||||
.map(|h| h.hash.is_some() && h.salt.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_crypt_b64(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'.' || b == b'/'
|
||||
}
|
||||
|
||||
fn all_crypt_b64(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(is_crypt_b64)
|
||||
}
|
||||
|
||||
fn is_bcrypt_format(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 60
|
||||
|| !matches!(bytes[2], b'a' | b'b' | b'x' | b'y')
|
||||
|| bytes[3] != b'$'
|
||||
|| !bytes[4].is_ascii_digit()
|
||||
|| !bytes[5].is_ascii_digit()
|
||||
|| bytes[6] != b'$'
|
||||
{
|
||||
false
|
||||
} else {
|
||||
bytes[7..].iter().copied().all(is_crypt_b64)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_md5_crypt(body: &str) -> bool {
|
||||
let Some((salt, hash)) = body.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
!salt.is_empty()
|
||||
&& salt.len() <= 8
|
||||
&& all_crypt_b64(salt)
|
||||
&& hash.len() == 22
|
||||
&& all_crypt_b64(hash)
|
||||
}
|
||||
|
||||
fn is_sha_crypt(body: &str, hash_len: usize) -> bool {
|
||||
let remainder = if let Some(after) = body.strip_prefix("rounds=") {
|
||||
let Some((rounds, rest)) = after.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
if rounds.is_empty() || !rounds.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
rest
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let Some((salt, hash)) = remainder.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
!salt.is_empty()
|
||||
&& salt.len() <= 16
|
||||
&& all_crypt_b64(salt)
|
||||
&& hash.len() == hash_len
|
||||
&& all_crypt_b64(hash)
|
||||
}
|
||||
|
||||
fn is_sha1_crypt(body: &str) -> bool {
|
||||
let mut parts = body.splitn(3, '$');
|
||||
let Some(rounds) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(salt) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(hash) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
if rounds.is_empty()
|
||||
|| !rounds.bytes().all(|b| b.is_ascii_digit())
|
||||
|| salt.is_empty()
|
||||
|| salt.len() > 64
|
||||
|| !all_crypt_b64(salt)
|
||||
{
|
||||
false
|
||||
} else {
|
||||
hash.len() == 28 && all_crypt_b64(hash)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ldap_hash(scheme: &str, body: &str) -> bool {
|
||||
match scheme.to_ascii_uppercase().as_str() {
|
||||
"SHA" => b64_decoded_len_eq(body, 20),
|
||||
"SSHA" => b64_decoded_len_ge(body, 21),
|
||||
"SHA256" => b64_decoded_len_eq(body, 32),
|
||||
"SSHA256" => b64_decoded_len_ge(body, 33),
|
||||
"SHA512" => b64_decoded_len_eq(body, 64),
|
||||
"SSHA512" => b64_decoded_len_ge(body, 65),
|
||||
"MD5" => b64_decoded_len_eq(body, 16),
|
||||
"ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => is_complete_phc(body),
|
||||
"CRYPT" => is_password_hash(body) || is_unix_des_crypt(body),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unix_des_crypt(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
(bytes.len() == 13 && bytes.iter().copied().all(is_crypt_b64))
|
||||
|| (bytes.len() == 20 && bytes[0] == b'_' && bytes[1..].iter().copied().all(is_crypt_b64))
|
||||
}
|
||||
|
||||
fn b64_decoded_len_eq(body: &str, len: usize) -> bool {
|
||||
b64_decode_loose(body)
|
||||
.map(|d| d.len() == len)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn b64_decoded_len_ge(body: &str, min: usize) -> bool {
|
||||
b64_decode_loose(body)
|
||||
.map(|d| d.len() >= min)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn b64_decode_loose(s: &str) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::engine::general_purpose::STANDARD_NO_PAD;
|
||||
STANDARD
|
||||
.decode(s)
|
||||
.ok()
|
||||
.or_else(|| STANDARD_NO_PAD.decode(s).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
String::from_utf8(Base64Encoder::new().encode(bytes).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_phc_strings() {
|
||||
let argon = Argon2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&argon), "argon2 not detected: {argon}");
|
||||
|
||||
let pbkdf = Pbkdf2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&pbkdf), "pbkdf2 not detected: {pbkdf}");
|
||||
|
||||
let scr = Scrypt::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&scr), "scrypt not detected: {scr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_crypt_variants() {
|
||||
let bc = bcrypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&bc), "bcrypt not detected: {bc}");
|
||||
assert!(bcrypt::verify("hello", &bc));
|
||||
|
||||
let md5 = "$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0";
|
||||
assert!(is_password_hash(md5));
|
||||
assert!(md5_crypt::verify("password", md5));
|
||||
|
||||
let sha256 = "$5$WH1ABM5sKhxbkgCK$sOnTVjQn1Y3EWibd8gWqqJqjH.KaFrxJE5rijqxcPp7";
|
||||
assert!(is_password_hash(sha256));
|
||||
assert!(sha256_crypt::verify("test", sha256));
|
||||
|
||||
let sha256_rounds =
|
||||
"$5$rounds=11858$WH1ABM5sKhxbkgCK$aTQsjPkz0rBsH3lQlJxw9HDTDXPKBxC0LlVeV69P.t1";
|
||||
assert!(is_password_hash(sha256_rounds));
|
||||
assert!(sha256_crypt::verify("test", sha256_rounds));
|
||||
|
||||
let s512 = sha512_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&s512), "sha512_crypt not detected: {s512}");
|
||||
assert!(sha512_crypt::verify("hello", &s512));
|
||||
|
||||
let s1 = sha1_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&s1), "sha1_crypt not detected: {s1}");
|
||||
assert!(sha1_crypt::verify("hello", &s1));
|
||||
|
||||
let bsdi = "_J9..K0AyUubDkQmPLeM";
|
||||
assert!(is_password_hash(bsdi), "bsdi_crypt not detected: {bsdi}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_ldap_schemes() {
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
let sha = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA}}{sha}")));
|
||||
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA}}{ssha}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
let sha256 = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA256}}{sha256}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha256 = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA256}}{ssha256}")));
|
||||
|
||||
let mut h = Sha512::new();
|
||||
h.update(b"hello");
|
||||
let sha512 = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA512}}{sha512}")));
|
||||
|
||||
let mut h = Sha512::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha512 = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA512}}{ssha512}")));
|
||||
|
||||
let digest = md5::compute(b"hello");
|
||||
let md5b = b64(&digest[..]);
|
||||
assert!(is_password_hash(&format!("{{MD5}}{md5b}")));
|
||||
|
||||
let inner = sha512_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&format!("{{CRYPT}}{inner}")));
|
||||
assert!(is_password_hash(&format!("{{crypt}}{inner}")));
|
||||
|
||||
assert!(is_password_hash(
|
||||
"{CRYPT}$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0"
|
||||
));
|
||||
assert!(is_password_hash("{CRYPT}abcdefghij012"));
|
||||
assert!(is_password_hash("{CRYPT}_J9..K0AyUubDkQmPLeM"));
|
||||
|
||||
let a = Argon2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&format!("{{ARGON2ID}}{a}")));
|
||||
assert!(is_password_hash(&format!("{{ARGON2}}{a}")));
|
||||
assert!(is_password_hash(&format!("{{ARGON2I}}{a}")));
|
||||
|
||||
let p = Pbkdf2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&format!("{{PBKDF2}}{p}")));
|
||||
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
let sha_lc = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{sha}}{sha_lc}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha256_lc = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{ssha256}}{ssha256_lc}")));
|
||||
|
||||
let digest = md5::compute(b"hello");
|
||||
let md5_mc = b64(&digest[..]);
|
||||
assert!(is_password_hash(&format!("{{Md5}}{md5_mc}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_rejects_passwords() {
|
||||
let not_hashes = [
|
||||
"",
|
||||
"hello",
|
||||
"p@ssw0rd!",
|
||||
"password123",
|
||||
"correct horse battery staple",
|
||||
"$myPassword",
|
||||
"$1incomplete",
|
||||
"$1$",
|
||||
"$1$short",
|
||||
"$1$abc$tooshorthash",
|
||||
"$5$",
|
||||
"$5$nohashpart$",
|
||||
"$5$saltonly$alsotooshort",
|
||||
"$6$",
|
||||
"$$$",
|
||||
"$$argon2$",
|
||||
"$argon2id$broken",
|
||||
"$argon2id$v=19$bad",
|
||||
"$2",
|
||||
"$2y$",
|
||||
"$2y$10$short",
|
||||
"$2z$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
||||
"$sha1$",
|
||||
"$sha1$notdigits$salt$hash",
|
||||
"{",
|
||||
"{}",
|
||||
"{}foo",
|
||||
"{SHA}",
|
||||
"{SHA}not!valid!base!64",
|
||||
"{SHA}aGVsbG8=",
|
||||
"{MD5}",
|
||||
"{MD5}aGVsbG8=",
|
||||
"{SHA256}aGVsbG8=",
|
||||
"{SHA512}aGVsbG8=",
|
||||
"{SSHA}aGVsbG8=",
|
||||
"{UNKNOWN}whatever",
|
||||
"{PLAIN}stillplain",
|
||||
"{plain}stillplain",
|
||||
"{CLEAR}stillplain",
|
||||
"{clear}stillplain",
|
||||
"{CRYPT}plainpw",
|
||||
"{CRYPT}",
|
||||
"{CRYPT}toolongtobeunixcryptbutshortbsdi",
|
||||
"{ARGON2ID}notaphcstring",
|
||||
"_short",
|
||||
"_notvalidbsdi",
|
||||
"regular_password",
|
||||
"1234567890123",
|
||||
"abcdefghij012",
|
||||
"$5$rounds=$saltvalue$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK",
|
||||
];
|
||||
for p in not_hashes {
|
||||
assert!(!is_password_hash(p), "false positive: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
#![warn(clippy::large_futures)]
|
||||
|
||||
use crate::backend::oidc::OpenIdDirectory;
|
||||
use backend::{ldap::LdapDirectory, sql::SqlDirectory};
|
||||
use deadpool::managed::PoolError;
|
||||
use ldap3::LdapError;
|
||||
use registry::schema::enums::DirectoryType;
|
||||
use std::{collections::HashMap, fmt::Debug, sync::Arc};
|
||||
|
||||
pub mod backend;
|
||||
pub mod core;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
|
||||
pub enum Credentials {
|
||||
Basic {
|
||||
username: String,
|
||||
secret: String,
|
||||
mfa_token: Option<String>,
|
||||
},
|
||||
Bearer {
|
||||
username: Option<String>,
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Directory {
|
||||
Ldap(LdapDirectory),
|
||||
Sql(SqlDirectory),
|
||||
OpenId(OpenIdDirectory),
|
||||
Unavailable(UnavailableDirectory),
|
||||
}
|
||||
|
||||
pub struct UnavailableDirectory {
|
||||
directory_type: DirectoryType,
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl UnavailableDirectory {
|
||||
pub fn new(directory_type: DirectoryType, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
directory_type,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn directory_type(&self) -> DirectoryType {
|
||||
self.directory_type
|
||||
}
|
||||
|
||||
pub fn error(&self) -> trc::Error {
|
||||
trc::StoreEvent::NotConfigured
|
||||
.into_err()
|
||||
.details("Directory is unavailable because it failed to initialize")
|
||||
.reason(&self.error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Recipient {
|
||||
Account(Account),
|
||||
Group(Group),
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Account {
|
||||
pub email: String,
|
||||
pub email_aliases: Vec<String>,
|
||||
pub secret: Option<String>,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Group {
|
||||
pub email: String,
|
||||
pub email_aliases: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Directories {
|
||||
pub default_directory: Option<Arc<Directory>>,
|
||||
pub directories: HashMap<u32, Arc<Directory>, nohash_hasher::BuildNoHashHasher<u32>>,
|
||||
}
|
||||
|
||||
impl Debug for Directory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Directory").finish()
|
||||
}
|
||||
}
|
||||
|
||||
trait IntoError {
|
||||
fn into_error(self) -> trc::Error;
|
||||
}
|
||||
|
||||
impl IntoError for PoolError<LdapError> {
|
||||
fn into_error(self) -> trc::Error {
|
||||
match self {
|
||||
PoolError::Backend(error) => error.into_error(),
|
||||
PoolError::Timeout(_) => trc::StoreEvent::PoolError
|
||||
.into_err()
|
||||
.details("Connection timed out"),
|
||||
err => trc::StoreEvent::PoolError.reason(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoError for LdapError {
|
||||
fn into_error(self) -> trc::Error {
|
||||
if let LdapError::LdapResult { result } = &self {
|
||||
trc::StoreEvent::LdapError
|
||||
.ctx(trc::Key::Code, result.rc)
|
||||
.reason(self)
|
||||
} else {
|
||||
trc::StoreEvent::LdapError.reason(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user