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.
432 lines
14 KiB
Rust
432 lines
14 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
|
use common::auth::credential::{ApiKey, AppPassword};
|
|
use jmap_proto::error::set::SetErrorType;
|
|
use registry::{
|
|
schema::{
|
|
enums::StorageQuota,
|
|
prelude::{ObjectType, Property},
|
|
structs::{
|
|
self, Account, Credential, Http, PasswordCredential, SecondaryCredential, UserAccount,
|
|
},
|
|
},
|
|
types::{EnumImpl, datetime::UTCDateTime, ipmask::IpAddrOrMask, list::List, map::Map},
|
|
};
|
|
use serde_json::json;
|
|
use std::str::FromStr;
|
|
use store::write::now;
|
|
use types::id::Id;
|
|
|
|
pub async fn test(test: &TestServer) {
|
|
println!("Running Authentication tests...");
|
|
|
|
let admin = test.account("[email protected]");
|
|
let domain_id = admin.find_or_create_domain("example.org").await;
|
|
|
|
// Enable X-Forwarded-For processing to test IP-based access restrictions
|
|
admin
|
|
.registry_update_setting(
|
|
Http {
|
|
use_x_forwarded: true,
|
|
..Default::default()
|
|
},
|
|
&[Property::UseXForwarded],
|
|
)
|
|
.await;
|
|
admin.reload_settings().await;
|
|
|
|
// Weak passwords should be rejected
|
|
admin
|
|
.registry_create_object_expect_err(Account::User(UserAccount {
|
|
name: "user".to_string(),
|
|
domain_id,
|
|
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
|
secret: "12345".to_string(),
|
|
..Default::default()
|
|
})]),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains("Password must be at least 8 characters long.");
|
|
admin
|
|
.registry_create_object_expect_err(Account::User(UserAccount {
|
|
name: "user".to_string(),
|
|
domain_id,
|
|
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
|
secret: "12345678".to_string(),
|
|
..Default::default()
|
|
})]),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains(concat!(
|
|
"Password is too weak. This is a top-10 common password. ",
|
|
"Add another word or two. Uncommon words are better."
|
|
));
|
|
|
|
// Adding secondary credentials should not be allowed
|
|
admin
|
|
.registry_create_object_expect_err(Account::User(UserAccount {
|
|
name: "user".to_string(),
|
|
domain_id,
|
|
credentials: List::from_iter([Credential::AppPassword(SecondaryCredential {
|
|
description: "Test app password".to_string(),
|
|
..Default::default()
|
|
})]),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains("Secondary credentials cannot be set directly");
|
|
admin
|
|
.registry_create_object_expect_err(Account::User(UserAccount {
|
|
name: "user".to_string(),
|
|
domain_id,
|
|
credentials: List::from_iter([Credential::ApiKey(SecondaryCredential {
|
|
description: "Test API key".to_string(),
|
|
..Default::default()
|
|
})]),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains("Secondary credentials cannot be set directly");
|
|
|
|
// Creating a user with a valid password should succeed
|
|
let user_id = admin
|
|
.registry_create_object(Account::User(UserAccount {
|
|
name: "user".to_string(),
|
|
domain_id,
|
|
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
|
secret: "this is a very strong password".to_string(),
|
|
..Default::default()
|
|
})]),
|
|
..Default::default()
|
|
}))
|
|
.await;
|
|
validate_password("[email protected]", "this is a very strong password", true).await;
|
|
validate_password("[email protected]", "wrong password", false).await;
|
|
|
|
// Change password as admin
|
|
admin
|
|
.registry_update_object_expect_err(
|
|
ObjectType::Account,
|
|
user_id,
|
|
json!({
|
|
"credentials/0/secret": "12345"
|
|
}),
|
|
)
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains("Password must be at least 8 characters long.");
|
|
admin
|
|
.registry_update_object(
|
|
ObjectType::Account,
|
|
user_id,
|
|
json!({
|
|
"credentials/0/secret": "very strong password indeed"
|
|
}),
|
|
)
|
|
.await;
|
|
validate_password("[email protected]", "this is a very strong password", false).await;
|
|
validate_password("[email protected]", "very strong password indeed", true).await;
|
|
|
|
// Set password expiration in two seconds and verify it works
|
|
admin
|
|
.registry_update_object(
|
|
ObjectType::Account,
|
|
user_id,
|
|
json!({
|
|
"credentials/0/expiresAt": UTCDateTime::from_timestamp((now() + 2) as i64)
|
|
}),
|
|
)
|
|
.await;
|
|
let mut user = crate::utils::account::Account::new(
|
|
"[email protected]",
|
|
"very strong password indeed",
|
|
&[],
|
|
"User",
|
|
user_id,
|
|
);
|
|
user.registry_query_ids(
|
|
ObjectType::PublicKey,
|
|
Vec::<(&str, &str)>::new(),
|
|
Vec::<&str>::new(),
|
|
)
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
assert_eq!(
|
|
user.registry_query(
|
|
ObjectType::PublicKey,
|
|
Vec::<(&str, &str)>::new(),
|
|
Vec::<&str>::new(),
|
|
)
|
|
.await
|
|
.method_response()
|
|
.text_field("type"),
|
|
"forbidden"
|
|
);
|
|
|
|
// Password updates should require the old password
|
|
user.registry_update_object_expect_err(
|
|
ObjectType::AccountPassword,
|
|
Id::singleton(),
|
|
json!({
|
|
Property::Secret: "12345"
|
|
}),
|
|
)
|
|
.await
|
|
.assert_type(SetErrorType::Forbidden)
|
|
.assert_description_contains(
|
|
"Current secret must be provided to change the password or OTP auth.",
|
|
);
|
|
|
|
// Password policies should be enforced when changing password
|
|
user.registry_update_object_expect_err(
|
|
ObjectType::AccountPassword,
|
|
Id::singleton(),
|
|
json!({
|
|
Property::CurrentSecret: "very strong password indeed",
|
|
Property::Secret: "12345"
|
|
}),
|
|
)
|
|
.await
|
|
.assert_type(SetErrorType::InvalidProperties)
|
|
.assert_description_contains("Password must be at least 8 characters long.");
|
|
|
|
// Perform a valid password update
|
|
user.registry_update_object(
|
|
ObjectType::AccountPassword,
|
|
Id::singleton(),
|
|
json!({
|
|
Property::CurrentSecret: "very strong password indeed",
|
|
Property::Secret: "user provided strong password"
|
|
}),
|
|
)
|
|
.await;
|
|
validate_password("[email protected]", "very strong password indeed", false).await;
|
|
validate_password("[email protected]", "user provided strong password", true).await;
|
|
user.update_secret("user provided strong password");
|
|
|
|
// After a successful password change, the user permissions should be restored
|
|
user.registry_query_ids(
|
|
ObjectType::PublicKey,
|
|
Vec::<(&str, &str)>::new(),
|
|
Vec::<&str>::new(),
|
|
)
|
|
.await;
|
|
|
|
// Limit login to specific IPs and set credential quotas
|
|
admin
|
|
.registry_update_object(
|
|
ObjectType::Account,
|
|
user_id,
|
|
json!({
|
|
"credentials/0/allowedIps": {"192.168.1.1": true},
|
|
Property::Quotas: {
|
|
StorageQuota::MaxApiKeys.as_str(): 1,
|
|
StorageQuota::MaxAppPasswords.as_str(): 1,
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
validate_password_with_ip(
|
|
"[email protected]",
|
|
"user provided strong password",
|
|
"192.168.1.1",
|
|
true,
|
|
)
|
|
.await;
|
|
validate_password_with_ip(
|
|
"[email protected]",
|
|
"user provided strong password",
|
|
"192.168.1.2",
|
|
false,
|
|
)
|
|
.await;
|
|
admin
|
|
.registry_update_object(
|
|
ObjectType::Account,
|
|
user_id,
|
|
json!({
|
|
"credentials/0/allowedIps": {},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Create an IP-restricted App Password and verify it works
|
|
let response = user
|
|
.registry_create([structs::AppPassword {
|
|
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
|
description: "My app password".to_string(),
|
|
..Default::default()
|
|
}])
|
|
.await;
|
|
let app_password = response.created(0);
|
|
let app_password_id = app_password.object_id();
|
|
let app_password_secret = app_password.text_field("secret").to_string();
|
|
let _ = AppPassword::parse(&app_password_secret).unwrap();
|
|
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", true).await;
|
|
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.3", false).await;
|
|
|
|
// Create an IP-restricted API key and verify it works
|
|
let response = user
|
|
.registry_create([structs::ApiKey {
|
|
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
|
description: "My API key".to_string(),
|
|
..Default::default()
|
|
}])
|
|
.await;
|
|
let api_key = response.created(0);
|
|
let api_key_id = api_key.object_id();
|
|
let api_key_secret = api_key.text_field("secret").to_string();
|
|
let _ = ApiKey::parse(&api_key_secret).unwrap();
|
|
validate_token_with_ip(&api_key_secret, "10.0.0.2", true).await;
|
|
validate_token_with_ip(&api_key_secret, "10.0.0.3", false).await;
|
|
|
|
// Creating more API keys or app passwords should fail due to quota
|
|
user.registry_create_object_expect_err(structs::AppPassword {
|
|
description: "Another app password".to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.assert_type(SetErrorType::OverQuota)
|
|
.assert_description_contains("You have exceeded your quota of 1 app passwords.");
|
|
user.registry_create_object_expect_err(structs::ApiKey {
|
|
description: "Another API key".to_string(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.assert_type(SetErrorType::OverQuota)
|
|
.assert_description_contains("You have exceeded your quota of 1 API keys.");
|
|
|
|
// Set a credential expiration in the past and verify it is rejected
|
|
for (credential_id, object_type) in [
|
|
(app_password_id, ObjectType::AppPassword),
|
|
(api_key_id, ObjectType::ApiKey),
|
|
] {
|
|
user.registry_update_object(
|
|
object_type,
|
|
credential_id,
|
|
json!({
|
|
Property::ExpiresAt: UTCDateTime::now()
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
|
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", false).await;
|
|
|
|
// Destroy the API key and app password, then verify they no longer work
|
|
for (credential_id, object_type) in [
|
|
(app_password_id, ObjectType::AppPassword),
|
|
(api_key_id, ObjectType::ApiKey),
|
|
] {
|
|
let response = user.registry_destroy(object_type, [credential_id]).await;
|
|
assert_eq!(
|
|
vec![credential_id],
|
|
response.destroyed_ids().collect::<Vec<_>>()
|
|
);
|
|
}
|
|
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
|
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", false).await;
|
|
validate_password("[email protected]", "user provided strong password", true).await;
|
|
|
|
// Clean up
|
|
assert_eq!(
|
|
admin
|
|
.registry_destroy(ObjectType::Account, [user_id])
|
|
.await
|
|
.destroyed_ids()
|
|
.collect::<Vec<_>>(),
|
|
vec![user_id]
|
|
);
|
|
validate_password("[email protected]", "user provided strong password", false).await;
|
|
|
|
// Disable X-Forwarded-For processing
|
|
admin
|
|
.registry_update_setting(
|
|
Http {
|
|
use_x_forwarded: false,
|
|
..Default::default()
|
|
},
|
|
&[Property::UseXForwarded],
|
|
)
|
|
.await;
|
|
admin.reload_settings().await;
|
|
|
|
test.cleanup().await;
|
|
}
|
|
|
|
pub async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
|
validate_password_with_ip(username, password, "127.0.0.1", is_valid).await;
|
|
}
|
|
|
|
pub async fn validate_password_with_ip(
|
|
username: &str,
|
|
password: &str,
|
|
remote_ip: &str,
|
|
is_valid: bool,
|
|
) {
|
|
let response = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.build()
|
|
.unwrap()
|
|
.get("https://127.0.0.1:8899/.well-known/jmap")
|
|
.basic_auth(username, Some(password))
|
|
.header("X-Forwarded-For", remote_ip)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let status = response.status();
|
|
if status.is_success() != is_valid {
|
|
let text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
|
|
panic!(
|
|
"Expected password to be {}. Server responded with status {}: {}",
|
|
if is_valid { "valid" } else { "invalid" },
|
|
status,
|
|
text
|
|
);
|
|
}
|
|
}
|
|
|
|
pub async fn validate_token_with_ip(token: &str, remote_ip: &str, is_valid: bool) {
|
|
let response = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.build()
|
|
.unwrap()
|
|
.get("https://127.0.0.1:8899/.well-known/jmap")
|
|
.bearer_auth(token)
|
|
.header("X-Forwarded-For", remote_ip)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let status = response.status();
|
|
if status.is_success() != is_valid {
|
|
let text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
|
|
panic!(
|
|
"Expected token to be {}. Server responded with status {}: {}",
|
|
if is_valid { "valid" } else { "invalid" },
|
|
status,
|
|
text
|
|
);
|
|
}
|
|
}
|