Registry testing - part 16
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,270 +4,155 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::directory::{
|
||||
DirectoryTest, IntoTestPrincipal, TestPrincipal, map_account_id, map_account_ids,
|
||||
use directory::{Account, Credentials, Group, Recipient, backend::ldap::LdapDirectory};
|
||||
use registry::{
|
||||
schema::structs::{self, SecretKeyOptional, SecretKeyValue},
|
||||
types::map::Map,
|
||||
};
|
||||
use mail_send::Credentials;
|
||||
use std::fmt::Debug;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ldap_directory() {
|
||||
// Enable logging
|
||||
crate::enable_logging();
|
||||
let mut config = structs::LdapDirectory {
|
||||
url: "ldap://localhost".into(),
|
||||
use_tls: false,
|
||||
attr_class: Map::new(vec!["objectClass".to_string()]),
|
||||
attr_description: Map::new(vec!["cn".to_string()]),
|
||||
attr_email: Map::new(vec!["mail".to_string()]),
|
||||
attr_email_alias: Map::new(vec!["mailAlias".to_string()]),
|
||||
attr_member_of: Map::new(vec!["memberOf".to_string()]),
|
||||
attr_secret: Map::new(vec![]),
|
||||
attr_secret_changed: Map::new(vec!["shadowLastChange".to_string()]),
|
||||
base_dn: "dc=stalwart,dc=test".into(),
|
||||
bind_dn: "cn=admin,dc=stalwart,dc=test".to_string().into(),
|
||||
bind_secret: SecretKeyOptional::Value(SecretKeyValue {
|
||||
secret: "admin".into(),
|
||||
}),
|
||||
filter_member_of: "(&(objectClass=groupOfNames)(member=?))".to_string().into(),
|
||||
filter_login: "(&(objectClass=inetOrgPerson)(mail=?))".into(),
|
||||
filter_mailbox: concat!(
|
||||
"(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))",
|
||||
"(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))"
|
||||
)
|
||||
.into(),
|
||||
group_class: "groupOfNames".into(),
|
||||
bind_authentication: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Obtain directory handle
|
||||
let mut config = DirectoryTest::new("sqlite".into()).await;
|
||||
let handle = config.directories.directories.remove("ldap").unwrap();
|
||||
let base_store = config.stores.stores.get("sqlite").unwrap();
|
||||
let core = config.server;
|
||||
|
||||
// Test authentication
|
||||
for (auth_type, handle) in [
|
||||
("Default", handle.clone()),
|
||||
(
|
||||
"Bind template",
|
||||
config
|
||||
.directories
|
||||
.directories
|
||||
.remove("ldap-bind-template")
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
"Bind lookup",
|
||||
config
|
||||
.directories
|
||||
.directories
|
||||
.remove("ldap-bind-lookup")
|
||||
.unwrap(),
|
||||
),
|
||||
] {
|
||||
println!("Testing {auth_type} LDAP authentication...");
|
||||
assert_eq!(
|
||||
handle
|
||||
.query(
|
||||
QueryParams::credentials(&Credentials::Plain {
|
||||
username: "john".into(),
|
||||
secret: "12345".into()
|
||||
})
|
||||
.with_return_member_of(true)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_test()
|
||||
.into_sorted(),
|
||||
TestPrincipal {
|
||||
id: base_store.get_principal_id("john").await.unwrap().unwrap(),
|
||||
name: "john".into(),
|
||||
description: Some("John Doe".into()),
|
||||
secrets: vec!["12345".into()],
|
||||
typ: Type::Individual,
|
||||
member_of: map_account_ids(base_store, vec!["sales"])
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect(),
|
||||
emails: vec!["john@example.org".into(), "john.doe@example.org".into()],
|
||||
roles: vec![ROLE_USER.to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.into_sorted()
|
||||
);
|
||||
assert_eq!(
|
||||
handle
|
||||
.query(
|
||||
QueryParams::credentials(&Credentials::Plain {
|
||||
username: "bill".into(),
|
||||
secret: "password".into()
|
||||
})
|
||||
.with_return_member_of(true)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_test()
|
||||
.into_sorted(),
|
||||
TestPrincipal {
|
||||
id: base_store.get_principal_id("bill").await.unwrap().unwrap(),
|
||||
name: "bill".into(),
|
||||
description: Some("Bill Foobar".into()),
|
||||
secrets: vec![
|
||||
"$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe".into()
|
||||
],
|
||||
typ: Type::Individual,
|
||||
quota: 500000,
|
||||
emails: vec!["bill@example.org".into(),],
|
||||
roles: vec![ROLE_USER.to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.into_sorted()
|
||||
);
|
||||
assert!(
|
||||
handle
|
||||
.query(
|
||||
QueryParams::credentials(&Credentials::Plain {
|
||||
username: "bill".into(),
|
||||
secret: "invalid".into()
|
||||
})
|
||||
.with_return_member_of(true)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// Get user by name
|
||||
// Test bind authentication
|
||||
let ldap = LdapDirectory::open(config.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
handle
|
||||
.query(QueryParams::name("jane").with_return_member_of(true))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_test()
|
||||
.into_sorted(),
|
||||
TestPrincipal {
|
||||
id: base_store.get_principal_id("jane").await.unwrap().unwrap(),
|
||||
name: "jane".into(),
|
||||
description: Some("Jane Doe".into()),
|
||||
typ: Type::Individual,
|
||||
secrets: vec!["abcde".into()],
|
||||
member_of: map_account_ids(base_store, vec!["sales", "support"])
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect(),
|
||||
emails: vec!["jane@example.org".into(),],
|
||||
roles: vec![ROLE_USER.to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.into_sorted()
|
||||
);
|
||||
|
||||
// Get group by name
|
||||
assert_eq!(
|
||||
handle
|
||||
.query(QueryParams::name("sales").with_return_member_of(true))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_test(),
|
||||
TestPrincipal {
|
||||
id: base_store.get_principal_id("sales").await.unwrap().unwrap(),
|
||||
name: "sales".into(),
|
||||
description: Some("sales".into()),
|
||||
typ: Type::Group,
|
||||
roles: vec![ROLE_USER.to_string()],
|
||||
..Default::default()
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "john.doe@example.org".into(),
|
||||
secret: "this is John's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "john.doe@example.org".into(),
|
||||
email_aliases: vec!["john@example.org".into()],
|
||||
secret: Some("$app$8958830913002348890$".into()),
|
||||
groups: vec!["sales@example.org".into()],
|
||||
description: Some("John Doe".into()),
|
||||
}
|
||||
);
|
||||
|
||||
// Ids by email
|
||||
assert_eq!(
|
||||
core.email_to_id(&handle, "jane@example.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(map_account_id(base_store, "jane").await),
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "jane.smith@example.org".into(),
|
||||
secret: "this is Jane's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "jane.smith@example.org".into(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("$app$4096614298472586996$".into()),
|
||||
groups: vec!["sales@example.org".into(), "corporate@example.org".into()],
|
||||
description: Some("Jane Smith".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
core.email_to_id(&handle, "jane+alias@example.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(map_account_id(base_store, "jane").await),
|
||||
);
|
||||
assert_eq!(
|
||||
core.email_to_id(&handle, "unknown@example.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
core.email_to_id(&handle, "anything@catchall.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(map_account_id(base_store, "robert").await)
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "jane.smith@example.org".into(),
|
||||
secret: "this is a wrong LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Domain validation
|
||||
assert!(handle.is_local_domain("example.org").await.unwrap());
|
||||
assert!(!handle.is_local_domain("other.org").await.unwrap());
|
||||
|
||||
// RCPT TO
|
||||
// Test direct authentication (without bind)
|
||||
config.attr_secret = Map::new(vec!["userPassword".to_string()]);
|
||||
config.attr_secret_changed = Map::new(vec![]);
|
||||
config.bind_authentication = false;
|
||||
let ldap = LdapDirectory::open(config.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "jane@example.org", 0).await.unwrap(),
|
||||
RcptType::Mailbox
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "john.doe@example.org".into(),
|
||||
secret: "this is John's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "john.doe@example.org".into(),
|
||||
email_aliases: vec!["john@example.org".into()],
|
||||
secret: Some("this is John's LDAP password".into()),
|
||||
groups: vec!["sales@example.org".into()],
|
||||
description: Some("John Doe".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "info@example.org", 0).await.unwrap(),
|
||||
RcptType::Mailbox
|
||||
);
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "jane+alias@example.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptType::Mailbox
|
||||
);
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "info+alias@example.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptType::Mailbox
|
||||
);
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "random_user@catchall.org", 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptType::Mailbox
|
||||
);
|
||||
assert_eq!(
|
||||
core.rcpt(&handle, "invalid@example.org", 0).await.unwrap(),
|
||||
RcptType::Invalid
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "john.doe@example.org".into(),
|
||||
secret: "this is a wrong LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// VRFY
|
||||
compare_sorted(
|
||||
core.vrfy(&handle, "jane", 0).await.unwrap(),
|
||||
vec!["jane@example.org".into()],
|
||||
// Test recipient lookup
|
||||
assert_eq!(
|
||||
ldap.recipient("john.doe@example.org").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "john.doe@example.org".into(),
|
||||
email_aliases: vec!["john@example.org".into()],
|
||||
secret: Some("this is John's LDAP password".into()),
|
||||
groups: vec!["sales@example.org".into()],
|
||||
description: Some("John Doe".into())
|
||||
})
|
||||
);
|
||||
compare_sorted(
|
||||
core.vrfy(&handle, "john", 0).await.unwrap(),
|
||||
vec!["john@example.org".into(), "john.doe@example.org".into()],
|
||||
assert_eq!(
|
||||
ldap.recipient("jane.smith@example.org").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "jane.smith@example.org".into(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("this is Jane's LDAP password".into()),
|
||||
groups: vec!["sales@example.org".into(), "corporate@example.org".into()],
|
||||
description: Some("Jane Smith".into())
|
||||
})
|
||||
);
|
||||
compare_sorted(
|
||||
core.vrfy(&handle, "jane+alias@example", 0).await.unwrap(),
|
||||
vec!["jane@example.org".into()],
|
||||
assert_eq!(
|
||||
ldap.recipient("sales@example.org").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "sales@example.org".into(),
|
||||
email_aliases: vec![],
|
||||
description: Some("sales".into())
|
||||
})
|
||||
);
|
||||
compare_sorted(
|
||||
core.vrfy(&handle, "info", 0).await.unwrap(),
|
||||
Vec::<String>::new(),
|
||||
assert_eq!(
|
||||
ldap.recipient("corporate@example.org").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "corporate@example.org".into(),
|
||||
email_aliases: vec!["everyone@example.org".into()],
|
||||
description: Some("corporate".into())
|
||||
})
|
||||
);
|
||||
compare_sorted(
|
||||
core.vrfy(&handle, "invalid", 0).await.unwrap(),
|
||||
Vec::<String>::new(),
|
||||
assert_eq!(
|
||||
ldap.recipient("nonexistent@example.org").await.unwrap(),
|
||||
Recipient::Invalid
|
||||
);
|
||||
|
||||
// EXPN
|
||||
// Now handled by the internal directory
|
||||
/*compare_sorted(
|
||||
core.expn(&handle, "info@example.org", 0).await.unwrap(),
|
||||
vec![
|
||||
"bill@example.org".into(),
|
||||
"jane@example.org".into(),
|
||||
"john@example.org".into(),
|
||||
],
|
||||
);
|
||||
compare_sorted(
|
||||
core.expn(&handle, "john@example.org", 0).await.unwrap(),
|
||||
Vec::<String>::new(),
|
||||
);*/
|
||||
}
|
||||
|
||||
fn compare_sorted<T: Eq + Debug>(v1: Vec<T>, v2: Vec<T>) {
|
||||
for val in v1.iter() {
|
||||
assert!(v2.contains(val), "{v1:?} != {v2:?}");
|
||||
}
|
||||
|
||||
for val in v2.iter() {
|
||||
assert!(v1.contains(val), "{v1:?} != {v2:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,781 +4,6 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod internal;
|
||||
pub mod ldap;
|
||||
pub mod oidc;
|
||||
pub mod sql;
|
||||
|
||||
use crate::{AssertConfig, store::TempDir};
|
||||
use common::{Core, Server, config::smtp::session::AddressMapping};
|
||||
use mail_send::Credentials;
|
||||
use rustls::ServerConfig;
|
||||
use rustls_pemfile::{certs, pkcs8_private_keys};
|
||||
use rustls_pki_types::PrivateKeyDer;
|
||||
use std::{borrow::Cow, io::BufReader, sync::Arc};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
const CONFIG: &str = r#"
|
||||
[directory."rocksdb"]
|
||||
type = "internal"
|
||||
store = "rocksdb"
|
||||
|
||||
[directory."foundationdb"]
|
||||
type = "internal"
|
||||
store = "foundationdb"
|
||||
|
||||
[directory."sqlite"]
|
||||
type = "sql"
|
||||
store = "sqlite"
|
||||
|
||||
[directory."sqlite".columns]
|
||||
name = "name"
|
||||
description = "description"
|
||||
secret = "secret"
|
||||
email = "address"
|
||||
quota = "quota"
|
||||
class = "type"
|
||||
|
||||
[store."rocksdb"]
|
||||
type = "rocksdb"
|
||||
path = "{TMP}/rocksdb"
|
||||
|
||||
[store."foundationdb"]
|
||||
type = "foundationdb"
|
||||
|
||||
[store."sqlite"]
|
||||
type = "sqlite"
|
||||
path = "{TMP}/auth.db"
|
||||
|
||||
[store."sqlite".query]
|
||||
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
|
||||
members = "SELECT member_of FROM group_members WHERE name = ?"
|
||||
recipients = "SELECT name FROM emails WHERE address = ? ORDER BY name ASC"
|
||||
emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC"
|
||||
verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5"
|
||||
expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50"
|
||||
domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || ? LIMIT 1"
|
||||
|
||||
[storage]
|
||||
lookup = "sqlite"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."postgresql"]
|
||||
type = "sql"
|
||||
store = "postgresql"
|
||||
|
||||
[directory."postgresql".columns]
|
||||
name = "name"
|
||||
description = "description"
|
||||
secret = "secret"
|
||||
email = "address"
|
||||
quota = "quota"
|
||||
class = "type"
|
||||
|
||||
[store."postgresql"]
|
||||
type = "postgresql"
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
database = "stalwart"
|
||||
user = "postgres"
|
||||
password = "mysecretpassword"
|
||||
|
||||
[store."postgresql".query]
|
||||
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = $1 AND active = true"
|
||||
members = "SELECT member_of FROM group_members WHERE name = $1"
|
||||
recipients = "SELECT name FROM emails WHERE address = $1 ORDER BY name ASC"
|
||||
emails = "SELECT address FROM emails WHERE name = $1 AND type != 'list' ORDER BY type DESC, address ASC"
|
||||
verify = "SELECT address FROM emails WHERE address LIKE '%' || $1 || '%' AND type = 'primary' ORDER BY address LIMIT 5"
|
||||
expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = $1 AND l.type = 'list' ORDER BY p.address LIMIT 50"
|
||||
domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || $1 LIMIT 1"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."mysql"]
|
||||
type = "sql"
|
||||
store = "mysql"
|
||||
|
||||
[directory."mysql".columns]
|
||||
name = "name"
|
||||
description = "description"
|
||||
secret = "secret"
|
||||
email = "address"
|
||||
quota = "quota"
|
||||
class = "type"
|
||||
|
||||
[store."mysql"]
|
||||
type = "mysql"
|
||||
host = "localhost"
|
||||
port = 3307
|
||||
database = "stalwart"
|
||||
user = "root"
|
||||
password = "password"
|
||||
|
||||
[store."mysql".query]
|
||||
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
|
||||
members = "SELECT member_of FROM group_members WHERE name = ?"
|
||||
recipients = "SELECT name FROM emails WHERE address = ? ORDER BY name ASC"
|
||||
emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC"
|
||||
verify = "SELECT address FROM emails WHERE address LIKE CONCAT('%', ?, '%') AND type = 'primary' ORDER BY address LIMIT 5"
|
||||
expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50"
|
||||
domains = "SELECT 1 FROM emails WHERE address LIKE CONCAT('%@', ?) LIMIT 1"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."ldap"]
|
||||
type = "ldap"
|
||||
url = "ldap://localhost:3893"
|
||||
base-dn = "dc=example,dc=org"
|
||||
|
||||
[directory."ldap".bind]
|
||||
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
|
||||
secret = "mysecret"
|
||||
|
||||
[directory."ldap".filter]
|
||||
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
|
||||
email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))"
|
||||
|
||||
[directory."ldap".attributes]
|
||||
name = "uid"
|
||||
description = ["principalName", "description"]
|
||||
secret = "userPassword"
|
||||
groups = ["memberOf", "otherGroups"]
|
||||
email = "mail"
|
||||
email-alias = "givenName"
|
||||
quota = "diskQuota"
|
||||
class = "objectClass"
|
||||
|
||||
[directory."ldap-bind-template"]
|
||||
type = "ldap"
|
||||
url = "ldap://localhost:3893"
|
||||
base-dn = "dc=example,dc=org"
|
||||
|
||||
[directory."ldap-bind-template".bind]
|
||||
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
|
||||
secret = "mysecret"
|
||||
|
||||
[directory."ldap-bind-template".bind.auth]
|
||||
method = "template"
|
||||
template = "cn={username},ou=,dc=example,dc=org"
|
||||
search = false
|
||||
|
||||
[directory."ldap-bind-template".filter]
|
||||
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
|
||||
email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))"
|
||||
|
||||
[directory."ldap-bind-template".attributes]
|
||||
name = "uid"
|
||||
description = ["principalName", "description"]
|
||||
secret = "userPassword"
|
||||
groups = ["memberOf", "otherGroups"]
|
||||
email = "mail"
|
||||
email-alias = "givenName"
|
||||
quota = "diskQuota"
|
||||
class = "objectClass"
|
||||
|
||||
[directory."ldap-bind-lookup"]
|
||||
type = "ldap"
|
||||
url = "ldap://localhost:3893"
|
||||
base-dn = "dc=example,dc=org"
|
||||
|
||||
[directory."ldap-bind-lookup".bind]
|
||||
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
|
||||
secret = "mysecret"
|
||||
|
||||
[directory."ldap-bind-lookup".bind.auth]
|
||||
method = "lookup"
|
||||
|
||||
[directory."ldap-bind-lookup".filter]
|
||||
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
|
||||
email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))"
|
||||
|
||||
[directory."ldap-bind-lookup".attributes]
|
||||
name = "uid"
|
||||
description = ["principalName", "description"]
|
||||
secret = "userPassword"
|
||||
groups = ["memberOf", "otherGroups"]
|
||||
email = "mail"
|
||||
email-alias = "givenName"
|
||||
quota = "diskQuota"
|
||||
class = "objectClass"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."imap"]
|
||||
type = "imap"
|
||||
host = "127.0.0.1"
|
||||
port = 9198
|
||||
|
||||
[directory."imap".pool]
|
||||
max-connections = 5
|
||||
|
||||
[directory."imap".tls]
|
||||
enable = true
|
||||
allow-invalid-certs = true
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."smtp"]
|
||||
type = "lmtp"
|
||||
host = "127.0.0.1"
|
||||
port = 9199
|
||||
|
||||
[directory."smtp".limits]
|
||||
auth-errors = 3
|
||||
rcpt = 5
|
||||
|
||||
[directory."smtp".pool]
|
||||
max-connections = 5
|
||||
|
||||
[directory."smtp".tls]
|
||||
enable = true
|
||||
allow-invalid-certs = true
|
||||
|
||||
[directory."smtp".cache]
|
||||
entries = 500
|
||||
ttl = {positive = '10s', negative = '5s'}
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."local"]
|
||||
type = "memory"
|
||||
|
||||
[[directory."local".principals]]
|
||||
name = "john"
|
||||
class = "individual"
|
||||
description = "John Doe"
|
||||
secret = "12345"
|
||||
email = ["john@example.org", "jdoe@example.org", "john.doe@example.org"]
|
||||
email-list = ["info@example.org"]
|
||||
member-of = ["sales"]
|
||||
|
||||
[[directory."local".principals]]
|
||||
name = "jane"
|
||||
class = "individual"
|
||||
description = "Jane Doe"
|
||||
secret = "abcde"
|
||||
email = "jane@example.org"
|
||||
email-list = ["info@example.org"]
|
||||
member-of = ["sales", "support"]
|
||||
|
||||
[[directory."local".principals]]
|
||||
name = "bill"
|
||||
class = "individual"
|
||||
description = "Bill Foobar"
|
||||
secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"
|
||||
quota = 500000
|
||||
email = "bill@example.org"
|
||||
email-list = ["info@example.org"]
|
||||
|
||||
[[directory."local".principals]]
|
||||
name = "sales"
|
||||
class = "group"
|
||||
description = "Sales Team"
|
||||
|
||||
[[directory."local".principals]]
|
||||
name = "support"
|
||||
class = "group"
|
||||
description = "Support Team"
|
||||
|
||||
##############################################################################
|
||||
|
||||
[directory."oidc-userinfo"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/userinfo"
|
||||
endpoint.method = "userinfo"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-none"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-none"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "none"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-user-token"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-user-token"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "user-token"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-token"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-token"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "token"
|
||||
auth.token = "token_of_gratitude"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
[directory."oidc-introspect-basic"]
|
||||
type = "oidc"
|
||||
store = "rocksdb"
|
||||
timeout = "1s"
|
||||
endpoint.url = "https://127.0.0.1:9090/introspect-basic"
|
||||
endpoint.method = "introspect"
|
||||
auth.method = "basic"
|
||||
auth.username = "myuser"
|
||||
auth.secret = "mypass"
|
||||
fields.email = "email"
|
||||
fields.username = "preferred_username"
|
||||
fields.full-name = "name"
|
||||
|
||||
"#;
|
||||
|
||||
pub struct DirectoryStore {
|
||||
pub store: Store,
|
||||
}
|
||||
|
||||
pub struct DirectoryTest {
|
||||
pub directories: Directories,
|
||||
pub stores: Stores,
|
||||
pub temp_dir: TempDir,
|
||||
pub server: Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct TestPrincipal {
|
||||
pub id: u32,
|
||||
pub typ: Type,
|
||||
pub quota: u64,
|
||||
pub name: String,
|
||||
pub secrets: Vec<String>,
|
||||
pub emails: Vec<String>,
|
||||
pub member_of: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub lists: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl DirectoryTest {
|
||||
pub async fn new(id_store: Option<&str>) -> DirectoryTest {
|
||||
let temp_dir = TempDir::new("directory_tests", true);
|
||||
let mut config_file = CONFIG.replace("{TMP}", &temp_dir.path.to_string_lossy());
|
||||
if id_store.is_some() {
|
||||
// Disable foundationdb store for SQL tests (the fdb select api version can only be run once per process)
|
||||
config_file = config_file
|
||||
.replace(
|
||||
"type = \"foundationdb\"",
|
||||
"type = \"foundationdb\"\ndisable = true",
|
||||
)
|
||||
.replace(
|
||||
"store = \"foundationdb\"",
|
||||
"store = \"foundationdb\"\ndisable = true",
|
||||
)
|
||||
} else {
|
||||
// Disable internal store
|
||||
config_file =
|
||||
config_file.replace("type = \"memory\"", "type = \"memory\"\ndisable = true")
|
||||
}
|
||||
let mut config = utils::config::Config::new(&config_file).unwrap();
|
||||
let stores = Stores::parse_all(&mut config, false).await;
|
||||
let directories = Directories::parse(
|
||||
&mut config,
|
||||
&stores,
|
||||
id_store
|
||||
.map(|id| stores.stores.get(id).unwrap().clone())
|
||||
.unwrap_or_default(),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
config.assert_no_errors();
|
||||
|
||||
// Enable catch-all and subaddressing
|
||||
let mut core = Core::default();
|
||||
core.smtp.session.rcpt.catch_all = AddressMapping::Enable;
|
||||
core.smtp.session.rcpt.subaddressing = AddressMapping::Enable;
|
||||
|
||||
DirectoryTest {
|
||||
directories,
|
||||
stores,
|
||||
temp_dir,
|
||||
server: Server {
|
||||
inner: Default::default(),
|
||||
core: core.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CERT: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIFCTCCAvGgAwIBAgIUCgHGQYUqtelbHGVSzCVwBL3fyEUwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDUxNjExNDAzNFoXDTIzMDUx
|
||||
NjExNDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAg8AMIICCgKCAgEAtwS0Fzl3SjaCuKEXgZ/fdWbDoj/qDphyNCAKNevQ0+D0
|
||||
STNkWCO04aFSH0zcL8zoD9gokNos0i7OU9//ZhZQmex4V6EFdZn8bFwUWN/scUvW
|
||||
HEFXVjtHldO2isZgIxH9LuwRv7KAgkISuWahqerOVDhe7SeQUV0AJGNEh3cT9PZr
|
||||
gSY931BxB7n+5k8eoSk8Z1gtBzQzL62kVGpHDKfw8yX8m65owF9eLUBrNzgxmXfC
|
||||
xpuHwj7hmVhS09PPKeN/RsFS8PsYO7bo0u8jEKalteumjRT7RyUEbioqfo6ZFOGj
|
||||
FHPIq/uKXS9zN1fpoyNh3ur5hMznQhrqlwBM9KlM7GdBJ0pZ3ad0YjT8IL/GnGKR
|
||||
85J2WZdLqaQdUZo7nV67FhqdDlNE4MdwiykTMjfmLRXGAVhAzJHKyRKNwmkI2aqe
|
||||
S7aqeNgvuDBwY80Q9a2rb5py1Aw+L8yCkUBuHboToDpxSVRDNN8DrWNmmsXnxsOG
|
||||
wRDODy4GICKyxlP+RFSM8xWSQ6y9ktS2OfDBm+Eqcw+3pZKhdz2wgxLkUBJ8X1eh
|
||||
kJrCA/6LTuhy6m6mMjAfoSOFU7fu88jxaWPgvP7GKyH+LM/t9eucobz2ks5rtSjz
|
||||
V4Dc5DCS94/OpVRHwHdaFSPbJKBN9Ev8gnNrAyx/aBPGoHBPG/QUiU7dcUNIPt0C
|
||||
AwEAAaNTMFEwHQYDVR0OBBYEFI167IxBmErB11EqiPPqFLa31ZaMMB8GA1UdIwQY
|
||||
MBaAFI167IxBmErB11EqiPPqFLa31ZaMMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
|
||||
hvcNAQELBQADggIBALU00IOiH5ubEauVCmakms5ermNTZfculnhnDfWTLMeh2+a7
|
||||
G4cqADErfMhm/mmLbrw33t9s6tCAhQltvewKR40ST9uMPSyiQbYaCXd5DXnuI6Ox
|
||||
JtNW+UOWIaMf8abnkdLvREOvb8dVQS1i3xq14tAjY5XgpGwCPP8m54b7N3Q7soLn
|
||||
e5PDhPNTnhRIn2RLuYoZmQmMA5fcqEUDYff4epUww7PhrM1QckZligI3566NlGOf
|
||||
j1G9JrivBtY0eaJtamIFnGMBT0ThDudxVja2Nv0C2Elry0p4T/o4nc4M67BJ/y1R
|
||||
vjNLAgFhbxssemU3lZqSd+pykpJBwDBjFSPrZZmQcbk7H6Uz8V1xr/xuzfw6fA13
|
||||
NWZ5vLgP/DQ13sM+XFlxThKfbPMPVe/UCTvfGtNW+3XyBgPntEkR+fNEawQmzbYl
|
||||
R+X1ymT9MZnEZqRMf7/UD/SYek1aUJefoew3upjMgxYVvh4F8dqJ+39F+xoFzIA2
|
||||
1dDAEMzXtjA3zKhZ2cycZbEzpJvYA3eGLuR16Suqfi4kPvfwK0mOhCxQmpayt7/X
|
||||
vuEzW6dPCH8Hgbb0WvsSppGOvhdbDaZFNfFc5eNSxhyKzu3H3ACNImZRtZE+yixx
|
||||
0fR8+xz9kDLf8xupV+X9heyFGHSyYU2Lveaevtr2Ij3weLRgJ6LbNALoeKXk
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
const PK: &str = "-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
XzVV5pwOxkIDBWDIqMUfwJDChBKfpw==
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
pub fn dummy_tls_acceptor() -> Arc<TlsAcceptor> {
|
||||
// Init server config builder with safe defaults
|
||||
let config = ServerConfig::builder().with_no_client_auth();
|
||||
|
||||
// load TLS key/cert files
|
||||
let cert_file = &mut BufReader::new(CERT.as_bytes());
|
||||
let key_file = &mut BufReader::new(PK.as_bytes());
|
||||
|
||||
// convert files to key/cert objects
|
||||
let cert_chain = certs(cert_file).map(|r| r.unwrap()).collect();
|
||||
let mut keys: Vec<PrivateKeyDer> = pkcs8_private_keys(key_file)
|
||||
.map(|v| PrivateKeyDer::Pkcs8(v.unwrap()))
|
||||
.collect();
|
||||
|
||||
// exit if no keys could be parsed
|
||||
if keys.is_empty() {
|
||||
panic!("Could not locate PKCS 8 private keys.");
|
||||
}
|
||||
|
||||
Arc::new(TlsAcceptor::from(Arc::new(
|
||||
config.with_single_cert(cert_chain, keys.remove(0)).unwrap(),
|
||||
)))
|
||||
}
|
||||
|
||||
trait IntoTestPrincipal {
|
||||
fn into_test(self) -> TestPrincipal;
|
||||
}
|
||||
|
||||
impl IntoTestPrincipal for PrincipalSet {
|
||||
fn into_test(self) -> TestPrincipal {
|
||||
TestPrincipal::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoTestPrincipal for Principal {
|
||||
fn into_test(self) -> TestPrincipal {
|
||||
TestPrincipal::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestPrincipal {
|
||||
pub fn into_sorted(mut self) -> Self {
|
||||
self.member_of.sort_unstable();
|
||||
self.emails.sort_unstable();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PrincipalSet> for TestPrincipal {
|
||||
fn from(mut value: PrincipalSet) -> Self {
|
||||
Self {
|
||||
id: value.id(),
|
||||
typ: value.typ(),
|
||||
quota: value.quota(),
|
||||
name: value.take_str(PrincipalField::Name).unwrap_or_default(),
|
||||
secrets: value
|
||||
.take_str_array(PrincipalField::Secrets)
|
||||
.unwrap_or_default(),
|
||||
emails: value
|
||||
.take_str_array(PrincipalField::Emails)
|
||||
.unwrap_or_default(),
|
||||
member_of: value
|
||||
.take_str_array(PrincipalField::MemberOf)
|
||||
.unwrap_or_default(),
|
||||
roles: value
|
||||
.take_str_array(PrincipalField::Roles)
|
||||
.unwrap_or_default(),
|
||||
lists: value
|
||||
.take_str_array(PrincipalField::Lists)
|
||||
.unwrap_or_default(),
|
||||
description: value.take_str(PrincipalField::Description),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Principal> for TestPrincipal {
|
||||
fn from(value: Principal) -> Self {
|
||||
Self {
|
||||
id: value.id(),
|
||||
typ: value.typ(),
|
||||
quota: value.quota().unwrap_or_default(),
|
||||
member_of: value.member_of().map(|v| v.to_string()).collect(),
|
||||
roles: value.roles().map(|v| v.to_string()).collect(),
|
||||
lists: value.lists().map(|v| v.to_string()).collect(),
|
||||
secrets: value
|
||||
.data
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
PrincipalData::Password(s)
|
||||
| PrincipalData::AppPassword(s)
|
||||
| PrincipalData::OtpAuth(s) => Some(s.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
emails: value.email_addresses().map(|v| v.to_string()).collect(),
|
||||
description: value.description().map(|v| v.to_string()),
|
||||
name: value.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TestPrincipal> for PrincipalSet {
|
||||
fn from(value: TestPrincipal) -> Self {
|
||||
PrincipalSet::new(value.id, value.typ)
|
||||
.with_field(PrincipalField::Name, value.name)
|
||||
.with_field(PrincipalField::Quota, value.quota)
|
||||
.with_field(PrincipalField::Secrets, value.secrets)
|
||||
.with_field(PrincipalField::Emails, value.emails)
|
||||
.with_field(PrincipalField::MemberOf, value.member_of)
|
||||
.with_field(PrincipalField::Lists, value.lists)
|
||||
.with_opt_field(PrincipalField::Description, value.description)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Item {
|
||||
IsAccount(String),
|
||||
Authenticate(Credentials<String>),
|
||||
Verify(String),
|
||||
Expand(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LookupResult {
|
||||
True,
|
||||
False,
|
||||
Values(Vec<String>),
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn append(&self, append: usize) -> Self {
|
||||
match self {
|
||||
Item::IsAccount(str) => Item::IsAccount(format!("{append}{str}")),
|
||||
Item::Authenticate(str) => Item::Authenticate(match str {
|
||||
Credentials::Plain { username, secret } => Credentials::Plain {
|
||||
username: username.to_string(),
|
||||
secret: format!("{append}{secret}"),
|
||||
},
|
||||
Credentials::OAuthBearer { token } => Credentials::OAuthBearer {
|
||||
token: format!("{append}{token}"),
|
||||
},
|
||||
Credentials::XOauth2 { username, secret } => Credentials::XOauth2 {
|
||||
username: username.to_string(),
|
||||
secret: format!("{append}{secret}"),
|
||||
},
|
||||
}),
|
||||
Item::Verify(str) => Item::Verify(format!("{append}{str}")),
|
||||
Item::Expand(str) => Item::Expand(format!("{append}{str}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_credentials(&self) -> &Credentials<String> {
|
||||
match self {
|
||||
Item::Authenticate(c) => c,
|
||||
_ => panic!("Item is not a Credentials"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LookupResult {
|
||||
fn append(&self, append: usize) -> Self {
|
||||
match self {
|
||||
LookupResult::True => LookupResult::True,
|
||||
LookupResult::False => LookupResult::False,
|
||||
LookupResult::Values(v) => {
|
||||
let mut r = Vec::with_capacity(v.len());
|
||||
for (pos, val) in v.iter().enumerate() {
|
||||
r.push(if pos == 0 {
|
||||
format!("{append}{val}")
|
||||
} else {
|
||||
val.to_string()
|
||||
});
|
||||
}
|
||||
LookupResult::Values(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for LookupResult {
|
||||
fn from(b: bool) -> Self {
|
||||
if b {
|
||||
LookupResult::True
|
||||
} else {
|
||||
LookupResult::False
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for LookupResult {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
LookupResult::Values(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Item {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::IsAccount(arg0) => f.debug_tuple("Rcpt").field(arg0).finish(),
|
||||
Self::Authenticate(_) => f.debug_tuple("Auth").finish(),
|
||||
Self::Expand(arg0) => f.debug_tuple("Expn").field(arg0).finish(),
|
||||
Self::Verify(arg0) => f.debug_tuple("Vrfy").field(arg0).finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn address_mappings() {
|
||||
const MAPPINGS: &str = r#"
|
||||
[enable]
|
||||
catch-all = true
|
||||
subaddressing = true
|
||||
expected-sub = "john.doe@example.org"
|
||||
expected-sub-nomatch = "jane@example.org"
|
||||
expected-catch = "@example.org"
|
||||
|
||||
[disable]
|
||||
catch-all = false
|
||||
subaddressing = false
|
||||
expected-sub = "john.doe+alias@example.org"
|
||||
expected-sub-nomatch = "jane@example.org"
|
||||
expected-catch = false
|
||||
|
||||
[custom]
|
||||
catch-all = [{if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2"}, {else = false}]
|
||||
subaddressing = [{ if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" }, {else = false}]
|
||||
expected-sub = "doe+alias@example.org"
|
||||
expected-sub-nomatch = "jane@example.org"
|
||||
expected-catch = "info@example.org"
|
||||
"#;
|
||||
|
||||
let mut config = utils::config::Config::new(MAPPINGS).unwrap();
|
||||
const ADDR: &str = "john.doe+alias@example.org";
|
||||
const ADDR_NO_MATCH: &str = "jane@example.org";
|
||||
let core = Server::default();
|
||||
|
||||
for test in ["enable", "disable", "custom"] {
|
||||
let catch_all = AddressMapping::parse(&mut config, (test, "catch-all"));
|
||||
let subaddressing = AddressMapping::parse(&mut config, (test, "subaddressing"));
|
||||
|
||||
assert_eq!(
|
||||
subaddressing.to_subaddress(&core, ADDR, 0).await,
|
||||
config.value_require((test, "expected-sub")).unwrap(),
|
||||
"failed subaddress for {test:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
subaddressing.to_subaddress(&core, ADDR_NO_MATCH, 0).await,
|
||||
config
|
||||
.value_require((test, "expected-sub-nomatch"))
|
||||
.unwrap(),
|
||||
"failed subaddress no match for {test:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
catch_all.to_catch_all(&core, ADDR, 0).await,
|
||||
config
|
||||
.property_require::<Option<String>>((test, "expected-catch"))
|
||||
.unwrap()
|
||||
.map(Cow::Owned),
|
||||
"failed catch-all for {test:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn map_account_ids(store: &Store, names: Vec<impl AsRef<str>>) -> Vec<u32> {
|
||||
let mut ids = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
ids.push(map_account_id(store, name).await);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
async fn map_account_id(store: &Store, name: impl AsRef<str>) -> u32 {
|
||||
store
|
||||
.get_principal_id(name.as_ref())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
//pub mod sql;
|
||||
|
||||
@@ -8,127 +8,135 @@
|
||||
*
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
directory::DirectoryTest,
|
||||
http_server::{HttpMessage, spawn_mock_http_server},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use http_proto::{JsonProblemResponse, JsonResponse, ToHttpResponse};
|
||||
use hyper::{Method, StatusCode};
|
||||
use mail_send::Credentials;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use trc::{AuthEvent, EventType};
|
||||
|
||||
static TEST_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
|
||||
use directory::{Account, Credentials, Directory, backend::oidc::OpenIdDirectory};
|
||||
use registry::{schema::structs, types::map::Map};
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_directory() {
|
||||
// Obtain directory handle
|
||||
let mut config = DirectoryTest::new("rocksdb".into()).await;
|
||||
let config = structs::OidcDirectory {
|
||||
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
|
||||
claim_username: "preferred_username".to_string(),
|
||||
claim_name: Some("name".to_string()),
|
||||
claim_groups: Some("groups".to_string()),
|
||||
username_domain: None,
|
||||
require_audience: Some("stalwart".to_string()),
|
||||
require_scopes: Map::new(vec![
|
||||
"email".to_string(),
|
||||
"profile".to_string(),
|
||||
"openid".to_string(),
|
||||
]),
|
||||
};
|
||||
let mut oidc = OpenIdDirectory::open(config.clone()).await.unwrap();
|
||||
let token = get_token("john.doe@example.org", "this is an OIDC password").await;
|
||||
|
||||
// Spawn mock OIDC server
|
||||
let _tx = spawn_mock_http_server(Arc::new(|req: HttpMessage| {
|
||||
let success_response = JsonResponse::new(json!({
|
||||
"email": "john@example.org",
|
||||
"preferred_username": "jdoe",
|
||||
"name": "John Doe",
|
||||
}))
|
||||
.into_http_response();
|
||||
|
||||
match (req.method.clone(), req.uri.path().split('/').nth(1)) {
|
||||
(Method::GET, Some("userinfo")) => match req.headers.get("authorization") {
|
||||
Some(auth) if auth == &format!("Bearer {TEST_TOKEN}") => success_response,
|
||||
Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-none")) => {
|
||||
assert!(req.headers.get("authorization").is_none());
|
||||
if req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) {
|
||||
success_response
|
||||
} else {
|
||||
JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response()
|
||||
}
|
||||
}
|
||||
(Method::POST, Some("introspect-user-token")) => match req.headers.get("authorization")
|
||||
{
|
||||
Some(auth)
|
||||
if auth == &format!("Bearer {TEST_TOKEN}")
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-token")) => match req.headers.get("authorization") {
|
||||
Some(auth)
|
||||
if auth == "Bearer token_of_gratitude"
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
(Method::POST, Some("introspect-basic")) => match req.headers.get("authorization") {
|
||||
Some(auth)
|
||||
if auth
|
||||
== &format!(
|
||||
"Basic {}",
|
||||
general_purpose::STANDARD.encode("myuser:mypass".as_bytes())
|
||||
)
|
||||
&& req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) =>
|
||||
{
|
||||
success_response
|
||||
}
|
||||
Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(),
|
||||
None => panic!("Missing Authorization header: {req:#?}"),
|
||||
},
|
||||
_ => panic!("Unexpected request: {:?}", req),
|
||||
// Test the userinfo endpoint
|
||||
assert_eq!(
|
||||
oidc.authenticate(&Credentials::Bearer {
|
||||
username: None,
|
||||
token: format!(".{token}"), // Prefix with '.' to force userinfo in test mode
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "john.doe@example.org".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: None,
|
||||
groups: vec!["sales@example.org".to_string()],
|
||||
description: Some("John Doe".to_string())
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
);
|
||||
|
||||
for test in [
|
||||
"oidc-userinfo",
|
||||
"oidc-introspect-none",
|
||||
"oidc-introspect-user-token",
|
||||
"oidc-introspect-token",
|
||||
"oidc-introspect-basic",
|
||||
] {
|
||||
println!("Running OIDC test {test:?}...");
|
||||
let directory = config.directories.directories.remove(test).unwrap();
|
||||
// Make sure the userinfo endpoint is not being used
|
||||
if let Directory::OpenId(directory) = &mut oidc {
|
||||
directory.discovery.userinfo_endpoint = "http://invalid".to_string();
|
||||
}
|
||||
|
||||
// Test an invalid token
|
||||
let err = directory
|
||||
.query(
|
||||
QueryParams::credentials(&Credentials::OAuthBearer {
|
||||
token: "invalid_or_expired_token".to_string(),
|
||||
})
|
||||
.with_return_member_of(false),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.matches(EventType::Auth(AuthEvent::Failed)),
|
||||
"Unexpected error: {:?}",
|
||||
err
|
||||
);
|
||||
// JWT authentication should still work without the userinfo endpoint
|
||||
assert_eq!(
|
||||
oidc.authenticate(&Credentials::Bearer {
|
||||
username: None,
|
||||
token: token.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "john.doe@example.org".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: None,
|
||||
groups: vec!["sales@example.org".to_string()],
|
||||
description: Some("John Doe".to_string())
|
||||
}
|
||||
);
|
||||
|
||||
// Test a valid token
|
||||
let principal = directory
|
||||
.query(
|
||||
QueryParams::credentials(&Credentials::OAuthBearer {
|
||||
token: TEST_TOKEN.to_string(),
|
||||
})
|
||||
.with_return_member_of(false),
|
||||
)
|
||||
// Not matching the required audience should fail
|
||||
let mut config_wrong_audience = config.clone();
|
||||
config_wrong_audience.require_audience = Some("wrong_audience".to_string());
|
||||
assert!(
|
||||
OpenIdDirectory::open(config_wrong_audience)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(principal.name(), "jdoe");
|
||||
assert_eq!(principal.email_addresses().next(), Some("john@example.org"));
|
||||
assert_eq!(principal.description(), Some("John Doe"));
|
||||
}
|
||||
.authenticate(&Credentials::Bearer {
|
||||
username: None,
|
||||
token: token.clone(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Not having the required scopes should fail
|
||||
let mut config_wrong_scopes = config.clone();
|
||||
config_wrong_scopes.require_scopes = Map::new(vec![
|
||||
"email".to_string(),
|
||||
"profile".to_string(),
|
||||
"openid".to_string(),
|
||||
"missing_scope".to_string(),
|
||||
]);
|
||||
assert!(
|
||||
OpenIdDirectory::open(config_wrong_scopes)
|
||||
.await
|
||||
.unwrap()
|
||||
.authenticate(&Credentials::Bearer {
|
||||
username: None,
|
||||
token,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Test authorization endpoint retrieval
|
||||
assert_eq!(
|
||||
oidc.oidc_authorization_endpoint(),
|
||||
Some("http://localhost:9080/realms/stalwart/protocol/openid-connect/auth".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn get_token(username: &str, password: &str) -> String {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token")
|
||||
.form(&[
|
||||
("grant_type", "password"),
|
||||
("client_id", "stalwart"),
|
||||
("client_secret", "stalwart-secret"),
|
||||
("username", username),
|
||||
("password", password),
|
||||
("scope", "openid email profile"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send token request");
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.expect("Failed to read token response");
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&body).expect("Failed to parse token response");
|
||||
|
||||
json["access_token"]
|
||||
.as_str()
|
||||
.expect("No access_token in response")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user