465 lines
15 KiB
Rust
465 lines
15 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* 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::{CredentialType, StorageQuota},
|
|
prelude::{ObjectType, Property},
|
|
structs::{
|
|
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;
|
|
|
|
pub async fn test(test: &TestServer) {
|
|
println!("Running Authentication tests...");
|
|
|
|
let admin = test.account("admin@example.org");
|
|
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("user@example.org", "this is a very strong password", true).await;
|
|
validate_password("user@example.org", "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("user@example.org", "this is a very strong password", false).await;
|
|
validate_password("user@example.org", "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(
|
|
"user@example.org",
|
|
"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"
|
|
);
|
|
|
|
// Change password as user and reset expiration
|
|
let credential_id = user
|
|
.registry_query_ids(
|
|
ObjectType::Credential,
|
|
[(Property::Type, CredentialType::Password.as_str())],
|
|
Vec::<&str>::new(),
|
|
)
|
|
.await[0];
|
|
|
|
// Password updates should require the old password
|
|
user.registry_update_object_expect_err(
|
|
ObjectType::Credential,
|
|
credential_id,
|
|
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::Credential,
|
|
credential_id,
|
|
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::Credential,
|
|
credential_id,
|
|
json!({
|
|
Property::CurrentSecret: "very strong password indeed",
|
|
Property::Secret: "user provided strong password"
|
|
}),
|
|
)
|
|
.await;
|
|
validate_password("user@example.org", "very strong password indeed", false).await;
|
|
validate_password("user@example.org", "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;
|
|
|
|
// Users should not be allowed to change allowedIps or expiration
|
|
user.registry_update_object_expect_err(
|
|
ObjectType::Credential,
|
|
credential_id,
|
|
json!({
|
|
Property::CurrentSecret: "user provided strong password",
|
|
Property::ExpiresAt: "2029-01-01T00:00:00Z"
|
|
}),
|
|
)
|
|
.await
|
|
.assert_type(SetErrorType::Forbidden)
|
|
.assert_description_contains("Modifying allowed IPs or expiration is not allowed.");
|
|
|
|
user.registry_update_object_expect_err(
|
|
ObjectType::Credential,
|
|
credential_id,
|
|
json!({
|
|
Property::CurrentSecret: "user provided strong password",
|
|
Property::AllowedIps: {"192.168.1.1": true}
|
|
}),
|
|
)
|
|
.await
|
|
.assert_type(SetErrorType::Forbidden)
|
|
.assert_description_contains("Modifying allowed IPs or expiration is not allowed.");
|
|
|
|
// Users should not be allowed to destroy their own credentials
|
|
user.registry_destroy_object_expect_err(ObjectType::Credential, credential_id)
|
|
.await
|
|
.assert_type(SetErrorType::Forbidden)
|
|
.assert_description_contains("Users are not allowed to destroy their own credentials.");
|
|
|
|
// 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(
|
|
"user@example.org",
|
|
"user provided strong password",
|
|
"192.168.1.1",
|
|
true,
|
|
)
|
|
.await;
|
|
validate_password_with_ip(
|
|
"user@example.org",
|
|
"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([Credential::AppPassword(SecondaryCredential {
|
|
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("user@example.org", &app_password_secret, "10.0.0.2", true).await;
|
|
validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.3", false).await;
|
|
|
|
// Create an IP-restricted API key and verify it works
|
|
let response = user
|
|
.registry_create([Credential::ApiKey(SecondaryCredential {
|
|
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(Credential::AppPassword(SecondaryCredential {
|
|
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(Credential::ApiKey(SecondaryCredential {
|
|
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 in [app_password_id, api_key_id] {
|
|
user.registry_update_object(
|
|
ObjectType::Credential,
|
|
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("user@example.org", &app_password_secret, "10.0.0.2", false).await;
|
|
|
|
// Destroy the API key and app password, then verify they no longer work
|
|
let response = user
|
|
.registry_destroy(ObjectType::Credential, [app_password_id, api_key_id])
|
|
.await;
|
|
assert_eq!(
|
|
vec![app_password_id, api_key_id],
|
|
response.destroyed_ids().collect::<Vec<_>>()
|
|
);
|
|
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
|
validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", false).await;
|
|
validate_password("user@example.org", "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("user@example.org", "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
|
|
);
|
|
}
|
|
}
|