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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+940
View File
@@ -0,0 +1,940 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::AccessToken;
use crate::{
Server,
auth::{
AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, Permissions, RECOVERY_ADMIN_ID,
permissions::{BuildPermissions, PermissionsListBuilder},
},
network::limiter::{ConcurrencyLimiter, LimiterResult},
};
use ahash::AHasher;
use registry::{
schema::{
enums::Permission,
structs::{self, Account, Roles, UserRoles},
},
types::EnumImpl,
};
use std::{
hash::{Hash, Hasher},
net::IpAddr,
sync::Arc,
};
use store::{query::acl::AclQuery, rand, write::now};
use tinyvec::TinyVec;
use trc::{AddContext, StoreEvent};
use types::{acl::Acl, collection::Collection};
use utils::map::bitmap::{Bitmap, BitmapItem};
use xxhash_rust::xxh3;
impl Server {
async fn build_access_token(
&self,
account: Account,
account_id: u32,
revision: u64,
revision_account: u64,
) -> trc::Result<AccessTokenInner> {
match account {
Account::User(account) => {
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
let permissions = self
.effective_permissions(
&account.permissions,
match &account.roles {
UserRoles::User => {
self.core.network.security.default_role_ids_user.as_slice()
}
UserRoles::Admin => {
if tenant_id.is_none() {
self.core.network.security.default_role_ids_admin.as_slice()
} else {
self.core
.network
.security
.default_role_ids_tenant
.as_slice()
}
}
UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(),
},
tenant_id,
)
.await?;
let member_of = account
.member_group_ids
.iter()
.map(|m| m.id() as u32)
.collect::<TinyVec<[u32; 3]>>();
let mut access_to: Vec<AccessTo> = Vec::new();
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
for acl_item in self
.store()
.acl_query(AclQuery::HasAccess { grant_account_id })
.await
.caused_by(trc::location!())?
{
if acl_item.to_account_id != account_id
&& !member_of.contains(&acl_item.to_account_id)
{
let acl = Bitmap::<Acl>::from(acl_item.permissions);
let collection = acl_item.to_collection;
if !collection.is_valid() {
return Err(trc::StoreEvent::DataCorruption
.ctx(trc::Key::Reason, "Corrupted collection found in ACL key.")
.details(format!("{acl_item:?}"))
.account_id(grant_account_id)
.caused_by(trc::location!()));
}
let mut collections: Bitmap<Collection> = Bitmap::new();
if acl.contains(Acl::Read) {
collections.insert(collection);
}
if acl.contains(Acl::ReadItems)
&& let Some(child_col) = collection.child_collection()
{
collections.insert(child_col);
}
if !collections.is_empty() {
if let Some(idx) = access_to
.iter()
.position(|a| a.account_id == acl_item.to_account_id)
{
access_to[idx].collections.union(&collections);
} else {
access_to.push(AccessTo {
account_id: acl_item.to_account_id,
collections,
});
}
}
}
}
}
let now = now();
let mut credential_version = 0;
let mut credential_scopes = Vec::with_capacity(account.credentials.len());
credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX));
for credential in account.credentials {
match credential {
structs::Credential::Password(credential) => {
credential_version = xxh3::xxh3_64(credential.secret.as_bytes()).max(1);
if credential.expires_at.is_some() || !credential.allowed_ips.is_empty()
{
let credential_scope = &mut credential_scopes[0];
credential_scope.expires_at = credential
.expires_at
.map(|v| v.timestamp() as u64)
.unwrap_or(u64::MAX);
credential_scope.allowed_ips =
credential.allowed_ips.into_inner().into_boxed_slice();
}
}
structs::Credential::ApiKey(credential)
| structs::Credential::AppPassword(credential) => {
let credential_id = credential.credential_id.document_id();
let expires_at = credential
.expires_at
.map(|v| v.timestamp() as u64)
.unwrap_or(u64::MAX);
if expires_at > now {
let permissions = &credential_scopes[0].permissions;
let permissions = match credential.permissions {
structs::CredentialPermissions::Inherit => permissions.clone(),
structs::CredentialPermissions::Disable(list) => {
let mut permissions = permissions.clone();
permissions.clear_many(&Permissions::from_permission(
list.permissions.as_slice(),
));
permissions
}
structs::CredentialPermissions::Replace(list) => {
let mut replace_permissions = Permissions::from_permission(
list.permissions.as_slice(),
);
replace_permissions.intersection(permissions);
replace_permissions
}
};
credential_scopes.push(AccessScope {
credential_id,
permissions,
expires_at,
allowed_ips: credential
.allowed_ips
.into_inner()
.into_boxed_slice(),
})
}
}
}
}
Ok(AccessTokenInner {
concurrent_imap_requests: self
.core
.imap
.rate_concurrent
.map(ConcurrencyLimiter::new),
concurrent_http_requests: self
.core
.jmap
.request_max_concurrent
.map(ConcurrencyLimiter::new),
concurrent_uploads: self
.core
.jmap
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
revision,
revision_account,
credential_version,
account_id,
tenant_id,
member_of,
access_to: access_to.into_boxed_slice(),
scopes: []
.into_iter()
.chain(credential_scopes)
.collect::<Box<[AccessScope]>>(),
}
.update_size())
}
Account::Group(account) => {
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
let permissions = self
.effective_permissions(
&account.permissions,
account.roles.role_ids().unwrap_or(
self.core.network.security.default_role_ids_group.as_slice(),
),
tenant_id,
)
.await?;
Ok(AccessTokenInner {
concurrent_imap_requests: self
.core
.imap
.rate_concurrent
.map(ConcurrencyLimiter::new),
concurrent_http_requests: self
.core
.jmap
.request_max_concurrent
.map(ConcurrencyLimiter::new),
concurrent_uploads: self
.core
.jmap
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
revision,
revision_account,
credential_version: 0,
account_id,
tenant_id,
member_of: Default::default(),
access_to: Default::default(),
scopes: Box::new([AccessScope::new(permissions.finalize(), u32::MAX)]),
}
.update_size())
}
}
}
pub async fn access_token(&self, account_id: u32) -> trc::Result<Arc<AccessTokenInner>> {
match self
.inner
.cache
.access_tokens
.get_value_or_guard_async(&account_id)
.await
{
Ok(token) => {
trc::event!(
Store(StoreEvent::CacheHit),
Key = account_id,
Collection = "accessToken",
);
Ok(token)
}
Err(guard) => {
trc::event!(
Store(StoreEvent::CacheMiss),
Key = account_id,
Collection = "accessToken",
);
let token: Arc<AccessTokenInner> = if let Some(account) =
self.registry().object::<Account>(account_id.into()).await?
{
let revision = rand::random::<u64>();
let revision_account = hash_account(&account);
self.build_access_token(account, account_id, revision, revision_account)
.await?
.into()
} else if account_id == RECOVERY_ADMIN_ID {
AccessTokenInner::new_admin().into()
} else {
return Err(trc::SecurityEvent::Unauthorized
.into_err()
.details("Account not found")
.account_id(account_id)
.caused_by(trc::location!()));
};
let _ = guard.insert(token.clone());
Ok(token)
}
}
}
pub(crate) async fn access_token_from_account(
&self,
account_id: u32,
account: Account,
) -> trc::Result<Arc<AccessTokenInner>> {
let revision_account = hash_account(&account);
match self
.inner
.cache
.access_tokens
.get_value_or_guard_async(&account_id)
.await
{
Ok(token) => {
if token.revision_account == revision_account {
trc::event!(
Store(StoreEvent::CacheHit),
Key = account_id,
Collection = "accessToken",
);
Ok(token)
} else {
// Token is stale, rebuild it
trc::event!(
Store(StoreEvent::CacheStale),
Key = account_id,
Collection = "accessToken",
);
debug_assert!(
false,
"Token is stale, invalidation should have been triggered"
);
let revision = rand::random::<u64>();
let token: Arc<AccessTokenInner> = self
.build_access_token(account, account_id, revision, revision_account)
.await?
.into();
self.inner
.cache
.access_tokens
.update(account_id, token.clone());
Ok(token)
}
}
Err(guard) => {
trc::event!(
Store(StoreEvent::CacheMiss),
Key = account_id,
Collection = "accessToken",
);
let revision = rand::random::<u64>();
let token: Arc<AccessTokenInner> = self
.build_access_token(account, account_id, revision, revision_account)
.await?
.into();
let _ = guard.insert(token.clone());
Ok(token)
}
}
}
}
impl AccessToken {
pub fn new(inner: Arc<AccessTokenInner>, remote_ip: IpAddr) -> trc::Result<Self> {
AccessToken {
scope_idx: 0,
inner,
}
.assert_is_valid(remote_ip)
}
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
AccessToken {
scope_idx: 0,
inner,
}
}
pub fn new_scoped(
inner: Arc<AccessTokenInner>,
credential_id: u32,
remote_ip: IpAddr,
) -> trc::Result<Self> {
inner
.scopes
.iter()
.position(|scope| scope.credential_id == credential_id)
.ok_or_else(|| {
trc::SecurityEvent::Unauthorized
.into_err()
.ctx(trc::Key::AccountId, inner.account_id)
.ctx(trc::Key::Id, credential_id)
.reason("Credential expired or removed.")
})
.map(|scope_idx| AccessToken { scope_idx, inner })
.and_then(|token| token.assert_is_valid(remote_ip))
}
pub fn renew(
inner: Arc<AccessTokenInner>,
credential_id: Option<u32>,
remote_ip: IpAddr,
) -> trc::Result<Self> {
if let Some(credential_id) = credential_id {
Self::new_scoped(inner, credential_id, remote_ip)
} else {
AccessToken {
scope_idx: 0,
inner,
}
.assert_is_valid(remote_ip)
}
}
pub fn state(&self) -> u32 {
// Hash state
let mut s = AHasher::default();
self.inner.member_of.hash(&mut s);
self.inner.access_to.hash(&mut s);
s.finish() as u32
}
#[inline(always)]
pub fn account_id(&self) -> u32 {
self.inner.account_id
}
#[inline(always)]
pub fn tenant_id(&self) -> Option<u32> {
self.inner.tenant_id
}
pub fn secondary_ids(&self) -> impl Iterator<Item = &u32> {
self.inner
.member_of
.iter()
.chain(self.inner.access_to.iter().map(|a| &a.account_id))
}
pub fn member_ids(&self) -> impl Iterator<Item = u32> {
[self.inner.account_id]
.into_iter()
.chain(self.inner.member_of.iter().copied())
}
pub fn all_ids(&self) -> impl Iterator<Item = u32> {
[self.inner.account_id]
.into_iter()
.chain(self.inner.member_of.iter().copied())
.chain(self.inner.access_to.iter().map(|a| a.account_id))
}
pub fn all_ids_by_collection(&self, collection: Collection) -> impl Iterator<Item = u32> {
[self.inner.account_id]
.into_iter()
.chain(self.inner.member_of.iter().copied())
.chain(self.inner.access_to.iter().filter_map(move |a| {
if a.collections.contains(collection) {
Some(a.account_id)
} else {
None
}
}))
}
pub fn is_member(&self, account_id: u32) -> bool {
self.inner.account_id == account_id
|| self.inner.member_of.contains(&account_id)
|| self.has_permission(Permission::Impersonate)
}
pub fn is_account_id(&self, account_id: u32) -> bool {
self.inner.account_id == account_id
}
pub fn personal_id(&self, account_id: u32, collection: Collection) -> u32 {
let child_collection = collection.child_collection();
if self.is_account_id(account_id)
|| self.inner.member_of.contains(&account_id)
|| self.inner.access_to.iter().any(|a| {
a.account_id == account_id
&& (a.collections.contains(collection)
|| child_collection.is_some_and(|child| a.collections.contains(child)))
})
{
self.inner.account_id
} else {
account_id
}
}
#[inline(always)]
pub fn has_permission(&self, permission: Permission) -> bool {
self.inner
.scopes
.get(self.scope_idx)
.is_some_and(|scope| scope.permissions.get(permission as usize))
}
pub fn assert_is_valid(self, remote_ip: IpAddr) -> trc::Result<Self> {
if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
let has_expired = scope.expires_at <= now();
let is_valid_ip = scope.allowed_ips.is_empty()
|| scope
.allowed_ips
.iter()
.any(|ip_mask| ip_mask.matches(&remote_ip));
let mut access_token = self;
if has_expired {
if access_token.scope_idx > 0 {
return Err(trc::AuthEvent::CredentialExpired
.into_err()
.ctx(trc::Key::AccountId, access_token.inner.account_id)
.reason("Credential expired."));
} else {
trc::event!(
Auth(trc::AuthEvent::CredentialExpired),
AccountId = access_token.inner.account_id,
Reason = "Main credential expired, downgrading permissions.",
);
}
// Downgrade permissions to allow password change
let mut scopes = Vec::with_capacity(access_token.inner.scopes.len());
for (idx, scope) in access_token.inner.scopes.iter().enumerate() {
if idx == 0 {
let mut permissions = Permissions::new();
for permission in [
Permission::Authenticate,
Permission::AuthenticateWithAlias,
Permission::SysAccountPasswordGet,
Permission::SysAccountPasswordUpdate,
Permission::EmailReceive,
] {
if scope.permissions.get(permission as usize) {
permissions.set(permission as usize);
}
}
scopes.push(AccessScope {
permissions,
credential_id: scope.credential_id,
expires_at: u64::MAX,
allowed_ips: scope.allowed_ips.clone(),
});
} else {
scopes.push(scope.clone());
}
}
let old_inner = &access_token.inner;
let inner = AccessTokenInner {
scopes: scopes.into_boxed_slice(),
account_id: old_inner.account_id,
tenant_id: old_inner.tenant_id,
member_of: old_inner.member_of.clone(),
access_to: old_inner.access_to.clone(),
concurrent_http_requests: old_inner.concurrent_http_requests.clone(),
concurrent_imap_requests: old_inner.concurrent_imap_requests.clone(),
concurrent_uploads: old_inner.concurrent_uploads.clone(),
revision_account: old_inner.revision_account,
revision: old_inner.revision,
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
};
access_token = AccessToken {
scope_idx: access_token.scope_idx,
inner: Arc::new(inner),
};
}
if is_valid_ip {
Ok(access_token)
} else {
Err(trc::SecurityEvent::IpUnauthorized
.into_err()
.ctx(trc::Key::AccountId, access_token.inner.account_id)
.reason("IP address not allowed."))
}
} else {
Err(trc::SecurityEvent::Unauthorized
.into_err()
.ctx(trc::Key::AccountId, self.inner.account_id)
.reason("Credential not valid."))
}
}
#[inline(always)]
pub fn credential_id(&self) -> Option<u32> {
self.inner
.scopes
.get(self.scope_idx)
.map(|scope| scope.credential_id)
}
#[inline(always)]
pub fn revision(&self) -> u64 {
self.inner.revision
}
pub fn assert_has_permissions(self, permissions: &[Permission]) -> trc::Result<Self> {
for permission in permissions {
if !self.has_permission(*permission) {
return Err(trc::SecurityEvent::Unauthorized
.into_err()
.details(permission.as_str())
.account_id(self.account_id()));
}
}
Ok(self)
}
pub fn assert_has_permission(self, permission: Permission) -> trc::Result<Self> {
if self.has_permission(permission) {
Ok(self)
} else {
Err(trc::SecurityEvent::Unauthorized
.into_err()
.details(permission.as_str())
.account_id(self.account_id()))
}
}
pub fn enforce_permission(&self, permission: Permission) -> trc::Result<()> {
if self.has_permission(permission) {
Ok(())
} else {
Err(trc::SecurityEvent::Unauthorized
.into_err()
.details(permission.as_str())
.account_id(self.account_id()))
}
}
pub fn permissions(&self) -> Vec<Permission> {
if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
scope.permissions.build_permissions_list()
} else {
vec![]
}
}
#[inline(always)]
pub fn access_scope(&self) -> Option<&AccessScope> {
self.inner.scopes.get(self.scope_idx)
}
pub(crate) fn permissions_bits(&self) -> &Permissions {
&self
.inner
.scopes
.get(self.scope_idx)
.unwrap_or(&self.inner.scopes[0])
.permissions
}
pub fn account_permissions(&self) -> &Permissions {
&self.inner.scopes[0].permissions
}
pub fn is_shared(&self, account_id: u32) -> bool {
!self.is_member(account_id)
&& self
.inner
.access_to
.iter()
.any(|a| a.account_id == account_id)
}
pub fn shared_accounts(&self, collection: Collection) -> impl Iterator<Item = &u32> {
self.inner
.member_of
.iter()
.chain(self.inner.access_to.iter().filter_map(move |a| {
if a.collections.contains(collection) {
Some(&a.account_id)
} else {
None
}
}))
}
pub fn has_access(&self, to_account_id: u32, to_collection: impl Into<Collection>) -> bool {
let to_collection = to_collection.into();
self.is_member(to_account_id)
|| self
.inner
.access_to
.iter()
.any(|a| a.account_id == to_account_id && a.collections.contains(to_collection))
}
pub fn has_account_access(&self, to_account_id: u32) -> bool {
self.is_member(to_account_id)
|| self
.inner
.access_to
.iter()
.any(|a| a.account_id == to_account_id)
}
pub fn is_http_request_allowed(&self) -> LimiterResult {
self.inner
.concurrent_http_requests
.as_ref()
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
}
pub fn concurrent_http_requests(&self) -> u64 {
self.inner
.concurrent_http_requests
.as_ref()
.map(|limiter| limiter.max_concurrent())
.unwrap_or(0)
}
pub fn is_imap_request_allowed(&self) -> LimiterResult {
self.inner
.concurrent_imap_requests
.as_ref()
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
}
pub fn is_upload_allowed(&self) -> LimiterResult {
self.inner
.concurrent_uploads
.as_ref()
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
}
pub fn concurrent_uploads(&self) -> u64 {
self.inner
.concurrent_uploads
.as_ref()
.map(|limiter| limiter.max_concurrent())
.unwrap_or(0)
}
pub fn account_tenant_ids(&self) -> AccountTenantIds {
AccountTenantIds {
account_id: self.account_id(),
tenant_id: self.tenant_id(),
}
}
pub fn new_admin() -> AccessToken {
AccessToken {
scope_idx: 0,
inner: Arc::new(AccessTokenInner::new_admin()),
}
}
pub fn from_permissions(
account_id: u32,
set_permissions: impl IntoIterator<Item = Permission>,
) -> AccessToken {
let mut permissions = Permissions::new();
for permission in set_permissions {
permissions.set(permission as usize);
}
AccessToken {
scope_idx: 0,
inner: Arc::new(AccessTokenInner {
account_id,
tenant_id: Default::default(),
member_of: Default::default(),
access_to: Default::default(),
scopes: Box::new([AccessScope::new(permissions, u32::MAX)]),
concurrent_http_requests: Default::default(),
concurrent_imap_requests: Default::default(),
concurrent_uploads: Default::default(),
revision: Default::default(),
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
}),
}
}
pub fn from_id_maybe_invalid(account_id: u32) -> Self {
AccessToken::new_maybe_invalid(Arc::new(AccessTokenInner::from_id(account_id)))
}
}
impl AccessTokenInner {
pub fn from_id(account_id: u32) -> Self {
Self {
account_id,
..Default::default()
}
}
pub fn with_tenant_id(mut self, tenant_id: Option<u32>) -> Self {
self.tenant_id = tenant_id;
self
}
pub fn update_size(mut self) -> Self {
self.obj_size = (std::mem::size_of::<AccessToken>()
+ (self.member_of.len() * std::mem::size_of::<u32>())
+ (self.access_to.len() * (std::mem::size_of::<u32>() + std::mem::size_of::<u64>()))
+ (self.scopes.len() * std::mem::size_of::<AccessScope>()))
as u64;
self
}
pub fn new_admin() -> Self {
AccessTokenInner {
account_id: RECOVERY_ADMIN_ID,
tenant_id: Default::default(),
member_of: Default::default(),
access_to: Default::default(),
scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]),
concurrent_http_requests: Default::default(),
concurrent_imap_requests: Default::default(),
concurrent_uploads: Default::default(),
revision: Default::default(),
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
}
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn revision_account(&self) -> u64 {
self.revision_account
}
pub fn credential_version(&self) -> u64 {
self.credential_version
}
}
impl AccessScope {
pub fn new(permissions: Permissions, credential_id: u32) -> Self {
Self {
permissions,
credential_id,
expires_at: u64::MAX,
allowed_ips: Default::default(),
}
}
}
fn hash_account(account: &Account) -> u64 {
let mut s = AHasher::default();
match account {
Account::User(account) => {
account.member_tenant_id.hash(&mut s);
match &account.roles {
UserRoles::User => {
0u8.hash(&mut s);
}
UserRoles::Admin => {
1u8.hash(&mut s);
}
UserRoles::Custom(custom_roles) => {
2u8.hash(&mut s);
custom_roles.role_ids.as_slice().hash(&mut s);
}
}
hash_permissions(&mut s, &account.permissions);
for credential in account
.credentials
.iter()
.filter_map(|credential| credential.as_secondary_credential())
{
credential.credential_id.hash(&mut s);
credential.expires_at.hash(&mut s);
hash_credential_permissions(&mut s, &credential.permissions);
}
for group_id in account.member_group_ids.iter() {
group_id.hash(&mut s);
}
}
Account::Group(account) => {
account.member_tenant_id.hash(&mut s);
match &account.roles {
Roles::Default => {}
Roles::Custom(custom_roles) => {
custom_roles.role_ids.as_slice().hash(&mut s);
}
}
hash_permissions(&mut s, &account.permissions);
}
}
s.finish()
}
fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) {
match permissions {
structs::Permissions::Inherit => {
0u8.hash(hasher);
}
structs::Permissions::Merge(permissions) => {
2u8.hash(hasher);
permissions.enabled_permissions.as_slice().hash(hasher);
permissions.disabled_permissions.as_slice().hash(hasher);
}
structs::Permissions::Replace(permissions) => {
3u8.hash(hasher);
permissions.enabled_permissions.as_slice().hash(hasher);
permissions.disabled_permissions.as_slice().hash(hasher);
}
}
}
fn hash_credential_permissions(hasher: &mut AHasher, permissions: &structs::CredentialPermissions) {
match permissions {
structs::CredentialPermissions::Inherit => {
0u8.hash(hasher);
}
structs::CredentialPermissions::Disable(permissions) => {
2u8.hash(hasher);
permissions.permissions.as_slice().hash(hasher);
}
structs::CredentialPermissions::Replace(permissions) => {
3u8.hash(hasher);
permissions.permissions.as_slice().hash(hasher);
}
}
}
+644
View File
@@ -0,0 +1,644 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Server,
auth::{
AccessToken, AuthRequest, DomainCache,
credential::{ApiKey, AppPassword},
oauth::GrantType,
},
};
use base64::{Engine, engine::general_purpose};
use directory::{
Credentials, Directory, Recipient,
core::secret::{SecretVerificationResult, verify_mfa_secret_hash, verify_secret_hash},
};
use registry::schema::{
enums::Permission,
structs::{self, Credential},
};
use std::{net::IpAddr, sync::Arc};
use store::write::now;
use trc::AddContext;
pub struct UsernameParts {
pub account: Username,
pub master_user: Option<Username>,
}
#[derive(PartialEq, Eq)]
pub struct Username {
pub name: String,
pub domain_start: usize,
}
impl Server {
pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
match Box::pin(self.route_auth_request(req))
.await
.and_then(|token| token.assert_has_permission(Permission::Authenticate))
{
Ok(token) => Ok(token),
Err(err) => {
// Random delay to mitigate user enumeration attacks
#[cfg(not(feature = "test_mode"))]
{
use store::rand::{self, RngExt};
let delay = rand::rng().random_range(50..500);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
if matches!(
err.as_ref(),
trc::EventType::Auth(trc::AuthEvent::Failed)
| trc::EventType::Security(trc::SecurityEvent::IpUnauthorized)
) && self.has_auth_fail2ban()
&& self
.is_auth_fail2banned(req.remote_ip, req.username())
.await?
{
Err(trc::SecurityEvent::AuthenticationBan
.into_err()
.ctx(trc::Key::RemoteIp, req.remote_ip)
.ctx_opt(trc::Key::AccountName, req.username().map(|s| s.to_string())))
} else {
Err(err.ctx(trc::Key::RemoteIp, req.remote_ip))
}
}
}
}
async fn route_auth_request(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
match &req.credentials {
Credentials::Basic {
username,
secret,
mfa_token,
} => {
let mut username = UsernameParts::new(username);
// Try to authenticate as fallback admin if configured
if let Some((fallback_user, fallback_hash)) = &self.registry().recovery_admin()
&& username.auth_as().address() == fallback_user
{
return if verify_secret_hash(fallback_hash, secret.as_bytes()).await? {
if username.is_master() {
let address = username.account().address();
if let Some(account_id) =
self.impersonated_account_id(username.account()).await?
{
trc::event!(
Auth(trc::AuthEvent::Success),
AccountName = address.to_string(),
AccountId = account_id,
SpanId = req.session_id,
Details = fallback_user.to_string(),
);
self.access_token(account_id)
.await
.and_then(|token| AccessToken::new(token, req.remote_ip))
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, address.to_string())
.reason("Master user account not found for fallback admin authentication"))
}
} else {
trc::event!(
Auth(trc::AuthEvent::Success),
AccountName = fallback_user.to_string(),
SpanId = req.session_id,
);
Ok(AccessToken::new_admin())
}
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, fallback_user.to_string())
.ctx(trc::Key::SpanId, req.session_id)
.reason("Fallback admin authentication failed"))
};
}
// Add domain if missing, use the default domain
self.add_missing_domain(&mut username.account);
if let Some(master_user) = &mut username.master_user {
self.add_missing_domain(master_user);
}
// Obtain domain
let auth_as = username.auth_as();
let auth_as_address = auth_as.address();
let auth_as_local = auth_as.local();
let auth_as_domain = auth_as.domain().unwrap();
let domain = self.resolve_domain(auth_as_domain).await?;
// Authenticate app passwords
if let Some(app_pass) = AppPassword::parse(secret) {
if username.is_master() {
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::SpanId, req.session_id)
.reason("App passwords cannot be used for impersonation"));
}
return if let Some(account_id) =
self.account_id_from_parts(auth_as_local, domain.id).await?
{
self.validate_credential(
account_id,
app_pass.credential_id,
app_pass.secret.as_ref(),
req.remote_ip,
req.session_id,
)
.await
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.reason("App password authentication failed: account not found"))
};
}
// Obtain external directory, if any
let mut is_alias_login = false;
let token = if let Some(directory) = self.get_directory_for_cached_domain(&domain) {
let directory_account = if username.is_master() {
directory
.authenticate(&Credentials::Basic {
username: auth_as_address.to_string(),
secret: secret.clone(),
mfa_token: mfa_token.clone(),
})
.await?
} else {
directory.authenticate(&req.credentials).await?
};
is_alias_login = directory_account.email != auth_as_address;
self.build_directory_token(directory_account, req.remote_ip)
.await
} else if let Some(account_id) =
self.account_id_from_parts(auth_as_local, domain.id).await?
{
if let Some(account) = self
.registry()
.object::<structs::Account>(account_id.into())
.await?
.and_then(|account| account.into_user())
{
let Some(credential) = account.password_credential() else {
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::SpanId, req.session_id)
.reason("Password credential not found for account"));
};
match verify_mfa_secret_hash(
credential.otp_auth.as_deref(),
mfa_token.as_deref(),
credential.secret.as_str(),
secret,
)
.await?
{
SecretVerificationResult::Valid => {
is_alias_login = account.name != auth_as_local;
self.access_token(account_id)
.await
.and_then(|token| AccessToken::new(token, req.remote_ip))
}
SecretVerificationResult::Invalid => Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::SpanId, req.session_id)
.reason("Authentication failed")),
SecretVerificationResult::MissingMfaToken => {
Err(trc::AuthEvent::MfaRequired
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::SpanId, req.session_id)
.reason("MFA token required"))
}
}
} else {
Err(trc::AuthEvent::Error
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::AccountId, account_id)
.reason("Account not found in registry"))
}
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.reason("Account not found"))
}?;
// Enforce alias login restrictions
if is_alias_login && !token.has_permission(Permission::AuthenticateWithAlias) {
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, auth_as_address.to_string())
.ctx(trc::Key::AccountId, token.account_id())
.ctx(trc::Key::SpanId, req.session_id)
.reason("Authenticated using an email alias but account does not have AuthenticateAlias permission"));
}
// Validate master user access
if username.is_master() {
token.assert_has_permissions(&[
Permission::Impersonate,
Permission::Authenticate,
])?;
let address = username.account().address();
let master_address = auth_as_address;
if let Some(account_id) =
self.impersonated_account_id(username.account()).await?
{
trc::event!(
Auth(trc::AuthEvent::Success),
AccountName = address.to_string(),
AccountId = account_id,
SpanId = req.session_id,
Details = master_address.to_string(),
);
self.access_token(account_id)
.await
.map(AccessToken::new_maybe_invalid)
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, address.to_string())
.details(master_address.to_string())
.reason("Master user account not found"))
}
} else {
trc::event!(
Auth(trc::AuthEvent::Success),
AccountName = auth_as_address.to_string(),
AccountId = token.account_id(),
SpanId = req.session_id,
);
Ok(token)
}
}
Credentials::Bearer { username, token } => {
// Handle API key authentication
if let Some(key) = ApiKey::parse(token) {
return self
.validate_credential(
key.account_id,
key.credential_id,
key.secret.as_ref(),
req.remote_ip,
req.session_id,
)
.await;
}
#[cfg(feature = "dev_mode")]
if std::env::var("API_TOKEN_ADMIN").is_ok_and(|admin_token| &admin_token == token) {
return Ok(AccessToken::new_admin());
}
// Obtain external directory, if any. When no username is supplied
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
// user's domain so per-domain OIDC directories are reachable.
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new)
{
if let Some(domain_name) = username.auth_as().domain() {
self.get_directory_for_domain(domain_name).await?
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
}
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
};
// Try external directory authentication first if supported, then fallback to internal OAuth.
let mut external_error = None;
if let Some(directory) = directory
&& directory.has_bearer_token_support()
{
match directory.authenticate(&req.credentials).await {
Ok(result) => {
return self.build_directory_token(result, req.remote_ip).await;
}
Err(err) => {
external_error = Some(err);
}
}
}
// Internal OAuth
match self
.validate_access_token(GrantType::AccessToken.into(), token)
.await
{
Ok(token_info) => self
.access_token(token_info.account_id)
.await
.and_then(|token| AccessToken::new(token, req.remote_ip)),
Err(err) => {
if let Some(external_error) = external_error {
Err(external_error)
} else {
Err(err)
}
}
}
}
}
}
async fn impersonated_account_id(&self, username: &Username) -> trc::Result<Option<u32>> {
let address = username.address();
if let Some(account_id) = self.account_id_from_email(address, false).await? {
return Ok(Some(account_id));
}
if let Some(domain) = username.domain()
&& let Some(domain_cache) = self.domain(domain).await?
&& let Some(directory) = self.get_directory_for_cached_domain(&domain_cache)
&& let Recipient::Account(account) = directory.recipient(address).await?
{
return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id));
}
Ok(None)
}
async fn validate_credential(
&self,
account_id: u32,
credential_id: u32,
secret: &[u8],
remote_ip: IpAddr,
span_id: u64,
) -> trc::Result<AccessToken> {
if let Some(account) = self
.registry()
.object::<structs::Account>(account_id.into())
.await?
.and_then(|account| account.into_user())
{
// Find credential by credential_id
let mut authenticated = false;
for (credential, credential_type) in
account.credentials.iter().filter_map(|credential| {
credential
.as_secondary_credential()
.map(|secondary_credential| (secondary_credential, credential))
})
{
if credential.credential_id.document_id() == credential_id {
if !verify_secret_hash(&credential.secret, secret).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, account.name)
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::Id, credential_id)
.ctx(trc::Key::SpanId, span_id)
.reason("Invalid credential secret"));
}
if credential
.expires_at
.as_ref()
.is_some_and(|exp| exp.timestamp() < now() as i64)
{
return Err(trc::AuthEvent::CredentialExpired
.into_err()
.ctx(trc::Key::AccountName, account.name)
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::Id, credential_id)
.ctx(trc::Key::SpanId, span_id)
.reason("Credential has expired"));
}
trc::event!(
Auth(trc::AuthEvent::Success),
AccountName = account.name.clone(),
AccountId = account_id,
Id = credential_id,
SpanId = span_id,
Details = match credential_type {
Credential::AppPassword(_) => "Authenticated with app password",
Credential::ApiKey(_) => "Authenticated with API key",
_ => "Authenticated with credential",
}
);
authenticated = true;
break;
}
}
if authenticated {
let token = self
.access_token_from_account(account_id, structs::Account::User(account))
.await?;
AccessToken::new_scoped(token, credential_id, remote_ip)
.add_context(|ctx| ctx.span_id(span_id))
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::Id, credential_id)
.ctx(trc::Key::SpanId, span_id)
.reason("Credential not found for account"))
}
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountId, account_id)
.ctx(trc::Key::SpanId, span_id)
.reason("Account not found for credential"))
}
}
async fn resolve_domain(&self, domain_name: &str) -> trc::Result<Arc<DomainCache>> {
if let Some(domain) = self.domain(domain_name).await? {
Ok(domain)
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::Details, domain_name.to_string())
.reason("Domain not found"))
}
}
fn add_missing_domain(&self, address: &mut Username) {
if address.domain().is_none() {
trc::event!(
Auth(trc::AuthEvent::Warning),
AccountName = address.address().to_string(),
Reason = "No domain in username",
);
address.domain_start = address.name.len() + 1;
address.name = format!("{}@{}", address.name, self.core.email.default_domain_name);
}
}
async fn build_directory_token(
&self,
account: directory::Account,
remote_ip: IpAddr,
) -> trc::Result<AccessToken> {
let account = Box::pin(self.synchronize_account(account)).await?;
self.access_token_from_account(account.id, account.account)
.await
.and_then(|token| AccessToken::new(token, remote_ip))
}
pub async fn get_directory_for_domain(
&self,
domain_name: &str,
) -> trc::Result<Option<&Arc<Directory>>> {
Ok(self.get_default_directory())
}
pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> {
self.get_default_directory()
}
}
fn extract_jwt_domain(token: &str) -> Option<String> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
for claim in ["email", "preferred_username", "upn"] {
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
&& let Some((_, domain)) = val.rsplit_once('@')
&& !domain.is_empty()
{
return Some(domain.to_ascii_lowercase());
}
}
None
}
impl UsernameParts {
pub fn new(address: &str) -> Self {
let mut account = Username {
name: String::with_capacity(address.len()),
domain_start: usize::MAX,
};
let mut master_user = None;
for ch in address.chars() {
if ch == '%' {
master_user = Some(Username {
name: String::with_capacity(address.len()),
domain_start: usize::MAX,
});
} else {
let target = master_user.as_mut().unwrap_or(&mut account);
if ch != '@' {
for lower in ch.to_lowercase() {
target.name.push(lower);
}
} else {
target.name.push(ch);
target.domain_start = target.name.len();
}
}
}
UsernameParts {
master_user: master_user.filter(|u| u != &account),
account,
}
}
pub fn auth_as(&self) -> &Username {
self.master_user.as_ref().unwrap_or(&self.account)
}
pub fn account(&self) -> &Username {
&self.account
}
pub fn is_master(&self) -> bool {
self.master_user.is_some()
}
}
impl Username {
pub fn address(&self) -> &str {
self.name.as_str()
}
pub fn local(&self) -> &str {
self.name
.get(..self.domain_start.saturating_sub(1))
.unwrap_or_default()
}
pub fn domain(&self) -> Option<&str> {
self.name.get(self.domain_start..)
}
}
impl AuthRequest {
pub fn from_credentials(credentials: Credentials, session_id: u64, remote_ip: IpAddr) -> Self {
Self {
credentials,
session_id,
remote_ip,
}
}
pub fn from_plain(
user: impl Into<String>,
pass: impl Into<String>,
session_id: u64,
remote_ip: IpAddr,
) -> Self {
Self::from_credentials(
Credentials::Basic {
username: user.into(),
secret: pass.into(),
mfa_token: None,
},
session_id,
remote_ip,
)
}
pub fn username(&self) -> Option<&str> {
match &self.credentials {
Credentials::Basic { username, .. } => Some(username.as_str()),
Credentials::Bearer { username, .. } => username.as_deref(),
}
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use std::io::Write;
use store::{
U32_LEN,
rand::{self},
};
use utils::codec::base32_custom::{Base32Reader, Base32Writer};
pub struct ApiKey {
pub account_id: u32,
pub credential_id: u32,
pub secret: [u8; 20],
}
pub struct AppPassword {
pub credential_id: u32,
pub secret: [u8; 18],
}
impl ApiKey {
pub fn new(account_id: u32, credential_id: u32) -> Self {
ApiKey {
account_id,
credential_id,
secret: rand::random::<[u8; 20]>(),
}
}
pub fn parse(token: &str) -> Option<Self> {
let decoded = URL_SAFE_NO_PAD.decode(token.strip_prefix("API_")?).ok()?;
Some(ApiKey {
account_id: u32::from_be_bytes(decoded.get(0..U32_LEN)?.try_into().ok()?),
credential_id: u32::from_be_bytes(decoded.get(U32_LEN..U32_LEN * 2)?.try_into().ok()?),
secret: decoded.get(U32_LEN * 2..)?.try_into().ok()?,
})
}
pub fn build(&self) -> String {
let mut bytes = Vec::with_capacity(U32_LEN * 2 + self.secret.len());
bytes.extend_from_slice(&self.account_id.to_be_bytes());
bytes.extend_from_slice(&self.credential_id.to_be_bytes());
bytes.extend_from_slice(&self.secret);
format!("API_{}", URL_SAFE_NO_PAD.encode(bytes))
}
}
impl AppPassword {
pub fn new(credential_id: u32) -> Self {
AppPassword {
credential_id,
secret: rand::random::<[u8; 18]>(),
}
}
pub fn parse(token: &str) -> Option<Self> {
let token = token.strip_prefix("app")?;
let mut reader = Base32Reader::new(token.as_bytes().get(1..)?);
let mut credential_id = [0u8; 4];
let mut secret = [0u8; 18];
for byte in credential_id.iter_mut() {
*byte = reader.next()?;
}
for byte in secret.iter_mut() {
*byte = reader.next()?;
}
if reader.next().is_none() {
Some(AppPassword {
credential_id: u32::from_be_bytes(credential_id),
secret,
})
} else {
None
}
}
pub fn build(&self) -> String {
let mut writer = Base32Writer::with_capacity(std::mem::size_of::<Self>().div_ceil(5) * 8);
writer.push_string("app_");
let _ = writer.write(&self.credential_id.to_be_bytes());
let _ = writer.write_all(&self.secret);
writer.finalize()
}
}
+332
View File
@@ -0,0 +1,332 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
expr::if_block::IfBlock,
network::limiter::ConcurrencyLimiter,
storage::{ObjectQuota, TenantQuota},
};
use directory::Credentials;
use quick_cache::Equivalent;
use registry::{
schema::enums::{Locale, Permission},
types::{EnumImpl, ipmask::IpAddrOrMask},
};
use std::{
hash::{Hash, Hasher},
net::IpAddr,
sync::Arc,
};
use tinyvec::TinyVec;
use trc::ipc::bitset::Bitset;
use types::collection::Collection;
use utils::{cache::CacheItemWeight, map::bitmap::Bitmap};
pub mod access_token;
pub mod authentication;
pub mod credential;
pub mod oauth;
pub mod permissions;
pub mod rate_limit;
pub const RECOVERY_ADMIN_ID: u32 = u32::MAX;
const PERMISSIONS_BITSET_SIZE: usize = Permission::COUNT.div_ceil(std::mem::size_of::<usize>());
pub type Permissions = Bitset<PERMISSIONS_BITSET_SIZE>;
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EmailAddress {
pub local_part: Box<str>,
pub domain_id: u32,
}
#[derive(Debug, PartialEq, Eq)]
pub struct EmailAddressRef<'x> {
local_part: &'x str,
domain_id: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailCache {
Account(u32),
MailingList(u32),
DisabledAccountAddress(u32),
DisabledListAddress(u32),
}
#[derive(Debug, Clone)]
pub struct DomainCache {
pub names: Box<[Box<str>]>,
pub id: u32,
pub id_directory: Option<u32>,
pub id_tenant: Option<u32>,
pub catch_all: Option<Box<str>>,
pub sub_addressing_custom: Option<Box<IfBlock>>,
pub flags: u8,
}
pub const DOMAIN_FLAG_RELAY: u8 = 1;
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
#[derive(Debug, Clone, Default)]
pub struct AccountCache {
pub name: Box<str>,
pub id: u32,
pub addresses: Box<[EmailAddress]>,
pub id_tenant: Option<u32>,
pub id_member_of: TinyVec<[u32; 3]>,
pub quota_disk: u64,
pub quota_objects: Option<Box<ObjectQuota>>,
pub description: Option<Box<str>>,
pub encryption_key: Option<EncryptionKeys>,
pub locale: Locale,
pub flags: u64,
}
pub type EncryptionKeys = Box<[Box<[u8]>]>;
pub const ACCOUNT_IS_USER: u64 = 1;
pub const ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER: u64 = 1 << 1;
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME: u64 = 1 << 2;
pub const ACCOUNT_FLAG_ENCRYPT_METHOD_PGP: u64 = 1 << 3;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256: u64 = 1 << 4;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES128: u64 = 1 << 5;
pub const ACCOUNT_FLAG_ENCRYPT_APPEND: u64 = 1 << 6;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM: u64 = 1 << 7;
pub const ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305: u64 = 1 << 8;
#[derive(Debug, Clone)]
pub struct RoleCache {
pub id_roles: TinyVec<[u32; 3]>,
pub permissions: PermissionsGroup,
}
#[derive(Debug, Clone)]
pub struct MailingListCache {
pub addresses: Box<[EmailAddress]>,
pub recipients: Arc<[Box<str>]>,
}
#[derive(Debug, Clone)]
pub struct TenantCache {
pub id_roles: TinyVec<[u32; 3]>,
pub quota_disk: u64,
pub quota_objects: Option<Box<TenantQuota>>,
pub permissions: Option<Box<PermissionsGroup>>,
}
#[derive(Debug, Clone, Default)]
pub struct PermissionsGroup {
pub enabled: Permissions,
pub disabled: Permissions,
pub merge: bool,
}
#[derive(Debug, Default, Clone)]
pub struct AccessToken {
scope_idx: usize,
inner: Arc<AccessTokenInner>,
}
#[derive(Debug, Default, Clone)]
pub struct AccessTokenInner {
pub(crate) account_id: u32,
pub(crate) tenant_id: Option<u32>,
pub(crate) member_of: TinyVec<[u32; 3]>,
pub(crate) access_to: Box<[AccessTo]>,
pub(crate) scopes: Box<[AccessScope]>,
pub(crate) concurrent_http_requests: Option<ConcurrencyLimiter>,
pub(crate) concurrent_imap_requests: Option<ConcurrencyLimiter>,
pub(crate) concurrent_uploads: Option<ConcurrencyLimiter>,
pub(crate) revision_account: u64,
pub(crate) revision: u64,
pub(crate) credential_version: u64,
pub(crate) obj_size: u64,
}
#[derive(Debug, Default, Hash, Clone)]
pub struct AccessScope {
pub permissions: Permissions,
pub credential_id: u32,
pub expires_at: u64,
pub allowed_ips: Box<[IpAddrOrMask]>,
}
#[derive(Debug, Default, Hash, PartialEq, Eq, Clone)]
pub(crate) struct AccessTo {
pub account_id: u32,
pub collections: Bitmap<Collection>,
}
#[derive(Clone)]
pub struct AccountInfo {
pub account_id: u32,
pub account: Arc<AccountCache>,
pub addresses: Vec<String>,
}
#[derive(Clone, Copy)]
pub struct AccountTenantIds {
pub account_id: u32,
pub tenant_id: Option<u32>,
}
pub struct AuthRequest {
pub credentials: Credentials,
pub session_id: u64,
pub remote_ip: IpAddr,
}
impl CacheItemWeight for AccessTokenInner {
fn weight(&self) -> u64 {
self.obj_size
}
}
impl CacheItemWeight for EmailAddress {
fn weight(&self) -> u64 {
std::mem::size_of::<EmailAddress>() as u64 + self.local_part.len() as u64
}
}
impl CacheItemWeight for EmailCache {
fn weight(&self) -> u64 {
std::mem::size_of::<EmailCache>() as u64
}
}
impl CacheItemWeight for DomainCache {
fn weight(&self) -> u64 {
std::mem::size_of::<DomainCache>() as u64
+ self
.names
.iter()
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
.sum::<u64>()
+ self.catch_all.as_ref().map_or(0, |s| s.len() as u64)
+ self
.sub_addressing_custom
.as_ref()
.map_or(0, |s| s.weight())
}
}
impl Equivalent<EmailAddress> for EmailAddressRef<'_> {
fn equivalent(&self, key: &EmailAddress) -> bool {
self.local_part == &*key.local_part && self.domain_id == key.domain_id
}
}
impl Hash for EmailAddress {
fn hash<H: Hasher>(&self, state: &mut H) {
self.local_part.as_ref().hash(state);
self.domain_id.hash(state);
}
}
impl Hash for EmailAddressRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.local_part.hash(state);
self.domain_id.hash(state);
}
}
impl CacheItemWeight for AccountCache {
fn weight(&self) -> u64 {
std::mem::size_of::<AccountCache>() as u64
+ self.name.len() as u64
+ self
.addresses
.iter()
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
.sum::<u64>()
+ self.description.as_ref().map_or(0, |s| s.len() as u64)
+ self.encryption_key.as_ref().map_or(0, |keys| {
keys.iter()
.map(|k| k.len() as u64 + std::mem::size_of::<Box<[u8]>>() as u64)
.sum::<u64>()
})
}
}
impl CacheItemWeight for RoleCache {
fn weight(&self) -> u64 {
std::mem::size_of::<RoleCache>() as u64
}
}
impl CacheItemWeight for MailingListCache {
fn weight(&self) -> u64 {
std::mem::size_of::<MailingListCache>() as u64
+ self
.addresses
.iter()
.map(|s| s.local_part.len() as u64 + std::mem::size_of::<EmailAddress>() as u64)
.sum::<u64>()
+ self
.recipients
.iter()
.map(|s| s.len() as u64 + std::mem::size_of::<Box<str>>() as u64)
.sum::<u64>()
}
}
impl CacheItemWeight for TenantCache {
fn weight(&self) -> u64 {
std::mem::size_of::<TenantCache>() as u64
+ self.permissions.as_ref().map_or(0, |p| p.weight())
}
}
impl CacheItemWeight for PermissionsGroup {
fn weight(&self) -> u64 {
std::mem::size_of::<PermissionsGroup>() as u64
}
}
pub trait BuildAccessToken {
fn build(self) -> AccessToken;
}
impl BuildAccessToken for Arc<AccessTokenInner> {
fn build(self) -> AccessToken {
AccessToken {
scope_idx: 0,
inner: self,
}
}
}
impl EmailAddress {
pub fn new(local_part: impl Into<Box<str>>, domain_id: u32) -> Self {
Self {
local_part: local_part.into(),
domain_id,
}
}
}
impl<'x> EmailAddressRef<'x> {
pub fn new(local_part: &'x str, domain_id: u32) -> Self {
Self {
local_part,
domain_id,
}
}
}
impl AccountCache {
pub fn domain_id(&self) -> Option<u32> {
self.addresses.first().map(|address| address.domain_id)
}
}
impl DomainCache {
pub fn name(&self) -> &str {
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
}
}
+225
View File
@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{
SCOPE_CALENDARS, SCOPE_CONTACTS, SCOPE_MAIL, SCOPE_OFFLINE_ACCESS, SCOPE_OPENID,
crypto::SymmetricEncrypt,
};
use base64::{Engine, engine::general_purpose};
use store::blake3;
use utils::codec::leb128::{Leb128Iterator, Leb128Vec};
const CLIENT_ID_HEADER: &str = "swc1.";
const CLIENT_ID_KEY_CONTEXT: &str = "stalwart-oauth-client-id-sw1";
const CLIENT_ID_VERSION: u8 = 1;
const SCOPE_BITS: &[&str] = &[
SCOPE_OPENID,
SCOPE_OFFLINE_ACCESS,
SCOPE_MAIL,
SCOPE_CONTACTS,
SCOPE_CALENDARS,
];
pub fn scopes_to_mask(scope: &str) -> u64 {
let mut mask = 0u64;
for scope in scope.split_ascii_whitespace() {
if let Some(bit) = SCOPE_BITS.iter().position(|known| *known == scope) {
mask |= 1 << bit;
}
}
mask
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientMeta {
pub redirect_uris: Vec<String>,
pub scope_mask: u64,
pub client_name: Option<String>,
}
pub fn encode_client_id(key: &[u8], meta: &ClientMeta) -> Result<String, String> {
let client_name = meta.client_name.as_deref().unwrap_or_default();
let mut payload = Vec::with_capacity(
24 + meta
.redirect_uris
.iter()
.map(|u| u.len() + 2)
.sum::<usize>()
+ client_name.len(),
);
payload.push(CLIENT_ID_VERSION);
payload.push_leb128(meta.redirect_uris.len());
for uri in &meta.redirect_uris {
payload.push_leb128(uri.len());
payload.extend_from_slice(uri.as_bytes());
}
payload.push_leb128(meta.scope_mask);
payload.push_leb128(client_name.len());
payload.extend_from_slice(client_name.as_bytes());
let digest = blake3::hash(&payload);
let nonce = &digest.as_bytes()[..SymmetricEncrypt::NONCE_LEN];
let ciphertext =
SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT).encrypt_with_aad(&payload, nonce, &[])?;
let mut body = Vec::with_capacity(nonce.len() + ciphertext.len());
body.extend_from_slice(nonce);
body.extend_from_slice(&ciphertext);
let mut out = String::with_capacity(CLIENT_ID_HEADER.len() + body.len().div_ceil(3) * 4);
out.push_str(CLIENT_ID_HEADER);
general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out);
Ok(out)
}
pub fn decode_client_id(key: &[u8], client_id: &str) -> Option<ClientMeta> {
let body = general_purpose::URL_SAFE_NO_PAD
.decode(client_id.strip_prefix(CLIENT_ID_HEADER)?.as_bytes())
.ok()?;
if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN {
return None;
}
let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN);
let payload = SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT)
.decrypt_with_aad(ciphertext, nonce, &[])
.ok()?;
let mut bytes = payload.iter();
if bytes.next().copied()? != CLIENT_ID_VERSION {
return None;
}
let uri_count: usize = bytes.next_leb128()?;
if uri_count > u8::MAX as usize {
return None;
}
let mut redirect_uris = Vec::with_capacity(uri_count);
for _ in 0..uri_count {
redirect_uris.push(take_string(&mut bytes)?);
}
let scope_mask: u64 = bytes.next_leb128()?;
let client_name = take_string(&mut bytes)?;
Some(ClientMeta {
redirect_uris,
scope_mask,
client_name: (!client_name.is_empty()).then_some(client_name),
})
}
fn take_string(bytes: &mut std::slice::Iter<'_, u8>) -> Option<String> {
let len: usize = bytes.next_leb128()?;
let slice = bytes.as_slice();
if slice.len() < len {
return None;
}
let value = String::from_utf8(slice[..len].to_vec()).ok()?;
if len > 0 {
bytes.nth(len - 1)?;
}
Some(value)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8] = b"a-test-encryption-key-of-some-length";
fn sample() -> ClientMeta {
ClientMeta {
redirect_uris: vec![
"http://127.0.0.1/cb".to_string(),
"com.example.app:/oauth".to_string(),
],
scope_mask: scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")),
client_name: Some("Example Client".to_string()),
}
}
#[test]
fn round_trip_preserves_all_fields() {
for meta in [
sample(),
ClientMeta {
redirect_uris: vec!["http://[::1]/".to_string()],
scope_mask: 0,
client_name: None,
},
ClientMeta::default(),
] {
let client_id = encode_client_id(KEY, &meta).unwrap();
assert!(client_id.starts_with(CLIENT_ID_HEADER));
assert_eq!(decode_client_id(KEY, &client_id), Some(meta));
}
}
#[test]
fn scope_mask_is_order_independent_and_drops_unknown() {
assert_eq!(
scopes_to_mask(&format!("{SCOPE_MAIL} {SCOPE_OFFLINE_ACCESS}")),
scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}"))
);
assert_eq!(
scopes_to_mask(&format!("{SCOPE_MAIL} custom:unknown")),
scopes_to_mask(SCOPE_MAIL)
);
assert_eq!(scopes_to_mask("totally unknown"), 0);
}
#[test]
fn identical_input_is_deterministic() {
let meta = sample();
assert_eq!(
encode_client_id(KEY, &meta).unwrap(),
encode_client_id(KEY, &meta).unwrap()
);
}
#[test]
fn wrong_key_is_rejected() {
let client_id = encode_client_id(KEY, &sample()).unwrap();
assert_eq!(
decode_client_id(b"a-completely-different-key-value!", &client_id),
None
);
}
#[test]
fn tampering_is_rejected() {
let client_id = encode_client_id(KEY, &sample()).unwrap();
let (header, body_b64) = client_id.split_at(CLIENT_ID_HEADER.len());
let mut body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap();
for idx in 0..body.len() {
let mut tampered = body.clone();
tampered[idx] ^= 0x01;
let forged = format!(
"{header}{}",
general_purpose::URL_SAFE_NO_PAD.encode(&tampered)
);
assert_eq!(decode_client_id(KEY, &forged), None, "byte {idx}");
}
body[0] ^= 0x00;
assert!(decode_client_id(KEY, &client_id).is_some());
}
#[test]
fn malformed_input_never_panics() {
for case in [
"",
"swc1.",
"swc1.!!!",
"swc1.AAAA",
"wrong.AAAA",
"swc1.AAAAAAAAAAAAAAAAAAAAAAAAAAAA",
] {
assert_eq!(decode_client_id(KEY, case), None, "{case:?}");
}
}
}
+203
View File
@@ -0,0 +1,203 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
config::{EcKeyCurve, build_ecdsa_pem, build_rsa_keypair},
manager::application::Resource,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use jsonwebtoken::{
Algorithm, EncodingKey,
jwk::{
AlgorithmParameters, CommonParameters, EllipticCurve, EllipticCurveKeyParameters,
EllipticCurveKeyType, Jwk, JwkSet, KeyAlgorithm, OctetKeyParameters, OctetKeyType,
PublicKeyUse, RSAKeyParameters, RSAKeyType,
},
};
use registry::schema::{enums::JwtSignatureAlgorithm, prelude::ObjectType, structs::OidcProvider};
use std::borrow::Cow;
use store::{
rand::{RngExt, distr::Alphanumeric, rng},
registry::bootstrap::Bootstrap,
};
#[derive(Clone)]
pub struct OAuthConfig {
pub oauth_key: String,
pub oauth_expiry_user_code: u64,
pub oauth_expiry_auth_code: u64,
pub oauth_expiry_token: u64,
pub oauth_expiry_refresh_token: u64,
pub oauth_expiry_refresh_token_renew: u64,
pub oauth_max_auth_attempts: u32,
pub allow_anonymous_client_registration: bool,
pub require_client_authentication: bool,
pub oidc_expiry_id_token: u64,
pub oidc_signing_secret: EncodingKey,
pub oidc_signature_algorithm: Algorithm,
pub oidc_jwks: Resource<Vec<u8>>,
}
impl OAuthConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let auth = bp.setting_infallible::<OidcProvider>().await;
let oidc_signature_algorithm = match auth.signature_algorithm {
JwtSignatureAlgorithm::Es256 => Algorithm::ES256,
JwtSignatureAlgorithm::Es384 => Algorithm::ES384,
JwtSignatureAlgorithm::Ps256 => Algorithm::PS256,
JwtSignatureAlgorithm::Ps384 => Algorithm::PS384,
JwtSignatureAlgorithm::Ps512 => Algorithm::PS512,
JwtSignatureAlgorithm::Rs256 => Algorithm::RS256,
JwtSignatureAlgorithm::Rs384 => Algorithm::RS384,
JwtSignatureAlgorithm::Rs512 => Algorithm::RS512,
JwtSignatureAlgorithm::Hs256 => Algorithm::HS256,
JwtSignatureAlgorithm::Hs384 => Algorithm::HS384,
JwtSignatureAlgorithm::Hs512 => Algorithm::HS512,
};
let rand_key = rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect::<String>();
let signature_key = auth
.signature_key
.secret()
.await
.map_err(|err| {
bp.build_error(ObjectType::OidcProvider.singleton(), err);
})
.unwrap_or(Cow::Borrowed(rand_key.as_str()));
let fallback_key = || {
(
EncodingKey::from_secret(rand_key.as_bytes()),
AlgorithmParameters::OctetKey(OctetKeyParameters {
key_type: OctetKeyType::Octet,
value: URL_SAFE_NO_PAD.encode(&rand_key),
})
.into(),
)
};
let (oidc_signing_secret, algorithm) = match oidc_signature_algorithm {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
(EncodingKey::from_secret(signature_key.as_bytes()), None)
}
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512 => parse_rsa_key(&auth)
.await
.map_err(|err| {
bp.build_error(ObjectType::OidcProvider.singleton(), err);
})
.map(|(secret, alg)| (secret, Some(alg)))
.unwrap_or_else(|_| fallback_key()),
Algorithm::ES256 | Algorithm::ES384 => parse_ecdsa_key(&auth, oidc_signature_algorithm)
.await
.map_err(|err| {
bp.build_error(ObjectType::OidcProvider.singleton(), err);
})
.map(|(secret, alg)| (secret, Some(alg)))
.unwrap_or_else(|_| fallback_key()),
_ => {
bp.build_error(
ObjectType::OidcProvider.singleton(),
format!("Unsupported OIDC signature algorithm {oidc_signature_algorithm:?}"),
);
fallback_key()
}
};
let oidc_jwks = Resource {
content_type: "application/json".into(),
contents: serde_json::to_string(&JwkSet {
keys: algorithm
.into_iter()
.map(|algorithm| Jwk {
common: CommonParameters {
public_key_use: PublicKeyUse::Signature.into(),
key_algorithm: KeyAlgorithm::from(oidc_signature_algorithm).into(),
key_id: "default".to_string().into(),
..Default::default()
},
algorithm,
})
.collect(),
})
.unwrap_or_default()
.into_bytes(),
};
OAuthConfig {
oauth_key: auth
.encryption_key
.secret()
.await
.map_err(|err| bp.build_error(ObjectType::OidcProvider.singleton(), err))
.map_or_else(|_| rand_key.clone(), Cow::into_owned),
oauth_expiry_user_code: auth.user_code_expiry.as_secs(),
oauth_expiry_auth_code: auth.auth_code_expiry.as_secs(),
oauth_expiry_token: auth.access_token_expiry.as_secs(),
oauth_expiry_refresh_token: auth.refresh_token_expiry.as_secs(),
oauth_expiry_refresh_token_renew: auth.refresh_token_renewal.as_secs(),
oauth_max_auth_attempts: auth.auth_code_max_attempts as u32,
oidc_expiry_id_token: auth.id_token_expiry.as_secs(),
allow_anonymous_client_registration: auth.anonymous_client_registration,
require_client_authentication: auth.require_client_registration,
oidc_signing_secret,
oidc_signature_algorithm,
oidc_jwks,
}
}
}
async fn parse_rsa_key(auth: &OidcProvider) -> Result<(EncodingKey, AlgorithmParameters), String> {
let rsa_key = build_rsa_keypair(auth.signature_key.secret().await?.as_ref())?;
let rsa_key_params = RSAKeyParameters {
key_type: RSAKeyType::RSA,
n: URL_SAFE_NO_PAD.encode(&rsa_key.modulus),
e: URL_SAFE_NO_PAD.encode(&rsa_key.exponent),
};
Ok((
EncodingKey::from_rsa_der(&rsa_key.pkcs1_der),
AlgorithmParameters::RSA(rsa_key_params),
))
}
async fn parse_ecdsa_key(
auth: &OidcProvider,
oidc_signature_algorithm: Algorithm,
) -> Result<(EncodingKey, AlgorithmParameters), String> {
let (curve, ec_curve) = match oidc_signature_algorithm {
Algorithm::ES256 => (EllipticCurve::P256, EcKeyCurve::P256),
Algorithm::ES384 => (EllipticCurve::P384, EcKeyCurve::P384),
_ => unreachable!(),
};
let ecdsa_key = build_ecdsa_pem(ec_curve, auth.signature_key.secret().await?.as_ref())?;
let ecdsa_key_params = EllipticCurveKeyParameters {
key_type: EllipticCurveKeyType::EC,
curve,
x: URL_SAFE_NO_PAD.encode(&ecdsa_key.x),
y: URL_SAFE_NO_PAD.encode(&ecdsa_key.y),
};
Ok((
EncodingKey::from_ec_der(&ecdsa_key.pkcs8_der),
AlgorithmParameters::EllipticCurve(ecdsa_key_params),
))
}
+54
View File
@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use aes_gcm_siv::{
Aes256GcmSiv, Key, KeyInit, Nonce,
aead::{Aead, Payload},
};
use store::blake3;
pub struct SymmetricEncrypt {
aes: Aes256GcmSiv,
}
impl SymmetricEncrypt {
pub const ENCRYPT_TAG_LEN: usize = 16;
pub const NONCE_LEN: usize = 12;
pub fn new(key: &[u8], context: &str) -> Self {
SymmetricEncrypt {
aes: Aes256GcmSiv::new(&Key::<Aes256GcmSiv>::from(blake3::derive_key(context, key))),
}
}
pub fn encrypt_with_aad(
&self,
bytes: &[u8],
nonce: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, String> {
self.aes
.encrypt(
<&Nonce>::try_from(nonce).map_err(|e| e.to_string())?,
Payload { msg: bytes, aad },
)
.map_err(|e| e.to_string())
}
pub fn decrypt_with_aad(
&self,
bytes: &[u8],
nonce: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, String> {
self.aes
.decrypt(
<&Nonce>::try_from(nonce).map_err(|e| e.to_string())?,
Payload { msg: bytes, aad },
)
.map_err(|e| e.to_string())
}
}
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Server, auth::AccessToken};
use serde::{Deserialize, Serialize};
use trc::{AddContext, AuthEvent, EventType};
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct OAuthIntrospect {
#[serde(default)]
pub active: bool,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<i64>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<i64>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<i64>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
}
impl Server {
pub async fn introspect_access_token(
&self,
token: &str,
access_token: &AccessToken,
) -> trc::Result<OAuthIntrospect> {
match self.validate_access_token(None, token).await {
Ok(token_info) => Ok(OAuthIntrospect {
active: true,
username: self
.account(access_token.account_id())
.await
.caused_by(trc::location!())?
.name()
.to_string()
.into(),
token_type: Some("bearer".into()),
exp: Some(token_info.expiry as i64),
iat: Some(token_info.issued_at as i64),
..Default::default()
}),
Err(err)
if matches!(
err.event_type(),
EventType::Auth(AuthEvent::Error) | EventType::Auth(AuthEvent::TokenExpired)
) =>
{
Ok(OAuthIntrospect::default())
}
Err(err) => Err(err),
}
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod client_id;
pub mod config;
pub mod crypto;
pub mod introspect;
pub mod oidc;
pub mod registration;
pub mod token;
pub const DEVICE_CODE_LEN: usize = 40;
pub const USER_CODE_LEN: usize = 8;
pub const RANDOM_CODE_LEN: usize = 32;
pub const CLIENT_ID_MAX_LEN: usize = 2048;
pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1
pub const SCOPE_OPENID: &str = "openid";
pub const SCOPE_OFFLINE_ACCESS: &str = "offline_access";
pub const SCOPE_MAIL: &str = "urn:ietf:params:oauth:scope:mail";
pub const SCOPE_CONTACTS: &str = "urn:ietf:params:oauth:scope:contacts";
pub const SCOPE_CALENDARS: &str = "urn:ietf:params:oauth:scope:calendars";
pub const SUPPORTED_SCOPES: &[&str] = &[
SCOPE_OPENID,
SCOPE_OFFLINE_ACCESS,
SCOPE_MAIL,
SCOPE_CONTACTS,
SCOPE_CALENDARS,
];
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GrantType {
AccessToken,
RefreshToken,
LiveTracing,
LiveMetrics,
LiveDelivery,
Rsvp,
}
impl GrantType {
pub fn as_str(&self) -> &'static str {
match self {
GrantType::AccessToken => "access_token",
GrantType::RefreshToken => "refresh_token",
GrantType::LiveTracing => "live_tracing",
GrantType::LiveMetrics => "live_metrics",
GrantType::LiveDelivery => "live_delivery",
GrantType::Rsvp => "rsvp",
}
}
pub fn id(&self) -> u8 {
match self {
GrantType::AccessToken => 0,
GrantType::RefreshToken => 1,
GrantType::LiveTracing => 2,
GrantType::LiveMetrics => 3,
GrantType::LiveDelivery => 4,
GrantType::Rsvp => 5,
}
}
pub fn from_id(id: u8) -> Option<Self> {
match id {
0 => Some(GrantType::AccessToken),
1 => Some(GrantType::RefreshToken),
2 => Some(GrantType::LiveTracing),
3 => Some(GrantType::LiveMetrics),
4 => Some(GrantType::LiveDelivery),
5 => Some(GrantType::Rsvp),
_ => None,
}
}
}
+182
View File
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::fmt;
use jsonwebtoken::Header;
use serde::{
Deserialize, Deserializer, Serialize,
de::{self, Visitor},
};
use store::write::now;
use crate::Server;
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct Userinfo {
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub middle_name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_username: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, deserialize_with = "any_bool")]
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub email_verified: bool,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub zoneinfo: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
#[derive(Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
pub struct StandardClaims {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub nonce: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub preferred_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub description: Option<String>,
}
#[derive(Serialize)]
struct IdTokenClaims {
iss: String,
sub: String,
aud: String,
nbf: i64,
iat: i64,
exp: i64,
#[serde(flatten)]
private: StandardClaims,
}
impl Server {
pub fn issue_id_token(
&self,
subject: impl Into<String>,
issuer: impl Into<String>,
audience: impl Into<String>,
claims: StandardClaims,
) -> trc::Result<String> {
let now = now() as i64;
jsonwebtoken::encode(
&Header {
kid: Some("default".into()),
..Header::new(self.core.oauth.oidc_signature_algorithm)
},
&IdTokenClaims {
iss: issuer.into(),
sub: subject.into(),
aud: audience.into(),
nbf: now,
iat: now,
exp: now + self.core.oauth.oidc_expiry_id_token as i64,
private: claims,
},
&self.core.oauth.oidc_signing_secret,
)
.map_err(|err| {
trc::AuthEvent::Error
.into_err()
.reason(err)
.details("Failed to encode ID token")
})
}
}
fn any_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
struct AnyBoolVisitor;
impl Visitor<'_> for AnyBoolVisitor {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a boolean value")
}
fn visit_str<E>(self, value: &str) -> Result<bool, E>
where
E: de::Error,
{
match value {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(E::custom(format!("Unknown boolean: {value}"))),
}
}
fn visit_bool<E>(self, value: bool) -> Result<bool, E>
where
E: de::Error,
{
Ok(value)
}
}
deserializer.deserialize_any(AnyBoolVisitor)
}
@@ -0,0 +1,295 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "snake_case")]
pub struct ClientRegistrationRequest {
pub redirect_uris: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub response_types: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<ApplicationType>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contacts: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>, // Using serde_json::Value for flexibility
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub sector_identifier_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_type: Option<SubjectType>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_signed_response_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_enc: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_signed_response_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_enc: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_signing_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_enc: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<TokenEndpointAuthMethod>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_signing_alg: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub default_max_age: Option<u64>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub require_auth_time: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub default_acr_values: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub initiate_login_uri: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub request_uris: Vec<String>,
#[serde(flatten)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub additional_fields: HashMap<String, serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "snake_case")]
pub struct ClientRegistrationResponse {
// Required fields
pub client_id: String,
// Optional fields specific to the response
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id_issued_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret_expires_at: Option<u64>,
// Echo back the request
#[serde(flatten)]
pub request: ClientRegistrationRequest,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ApplicationType {
Web,
Native,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum SubjectType {
Pairwise,
Public,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TokenEndpointAuthMethod {
ClientSecretPost,
ClientSecretBasic,
ClientSecretJwt,
PrivateKeyJwt,
None,
}
#[derive(Serialize, Debug)]
pub struct ClientRegistrationError {
pub error: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<&'static str>,
}
impl ClientRegistrationError {
pub fn invalid_redirect_uri(description: &'static str) -> Self {
ClientRegistrationError {
error: "invalid_redirect_uri",
error_description: Some(description),
}
}
pub fn invalid_client_metadata(description: &'static str) -> Self {
ClientRegistrationError {
error: "invalid_client_metadata",
error_description: Some(description),
}
}
}
pub fn loopback_redirect_parts(uri: &str) -> Option<(&str, &str)> {
let uri = uri.strip_prefix("http://")?;
for host in ["127.0.0.1", "[::1]"] {
if let Some(rest) = uri.strip_prefix(host) {
if let Some(path) = rest.strip_prefix('/') {
return Some((host, path));
} else if let Some(after_colon) = rest.strip_prefix(':')
&& let Some((port, path)) = after_colon.split_once('/')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return Some((host, path));
}
}
}
None
}
pub fn redirect_uri_matches(registered: &str, presented: &str) -> bool {
registered == presented
|| matches!(
(
loopback_redirect_parts(registered),
loopback_redirect_parts(presented),
),
(Some(reg), Some(pres)) if reg == pres
)
}
pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> {
if uri.contains('#') {
return Err(ClientRegistrationError::invalid_redirect_uri(
"Redirect URI must not contain a fragment.",
));
} else if uri.contains("..") {
return Err(ClientRegistrationError::invalid_redirect_uri(
"Redirect URI must not contain consecutive dots.",
));
} else if uri.starts_with("https://") || loopback_redirect_parts(uri).is_some() {
return Ok(());
} else if let Some((scheme, _)) = uri.split_once(':')
&& scheme.contains('.')
&& scheme
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic)
&& scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+'))
{
return Ok(());
}
Err(ClientRegistrationError::invalid_redirect_uri(
"Redirect URI must be an https URL, a loopback (http://127.0.0.1/, http://[::1]/) or a private-use scheme URI.",
))
}
pub fn validate_grant_metadata(
request: &ClientRegistrationRequest,
) -> Result<(), ClientRegistrationError> {
if !request.response_types.is_empty() && !request.response_types.iter().any(|t| t == "code") {
return Err(ClientRegistrationError::invalid_client_metadata(
"response_types must include \"code\".",
));
}
if !request.grant_types.is_empty() {
if !request
.grant_types
.iter()
.any(|t| t == "authorization_code")
{
return Err(ClientRegistrationError::invalid_client_metadata(
"grant_types must include \"authorization_code\".",
));
}
if !request.grant_types.iter().any(|t| t == "refresh_token") {
return Err(ClientRegistrationError::invalid_client_metadata(
"grant_types must include \"refresh_token\".",
));
}
}
Ok(())
}
+418
View File
@@ -0,0 +1,418 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{GrantType, crypto::SymmetricEncrypt};
use crate::Server;
use base64::{Engine, engine::general_purpose};
use std::time::SystemTime;
use store::rand::{RngExt, rng};
use utils::codec::leb128::{Leb128Iterator, Leb128Vec};
pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
"Failed to decode token. If you are using an ",
"external OIDC provider, make sure it is configured as the default directory under ",
"the Authentication object."
);
const TOKEN_HEADER: &str = "sw1.";
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
pub struct TokenInfo {
pub grant_type: GrantType,
pub account_id: u32,
pub claims: Option<String>,
pub expiry: u64,
pub issued_at: u64,
pub expires_in: u64,
}
struct RawToken {
grant_type: GrantType,
account_id: u32,
claims: Option<String>,
issued_at: u64,
expiry: u64,
credential_version: u64,
}
impl Server {
pub async fn encode_access_token(
&self,
grant_type: GrantType,
account_id: u32,
account_name: &str,
expiry_in: u64,
claims: Option<&str>,
credential_version: Option<u64>,
) -> trc::Result<String> {
let issued_at = seconds_since_oauth_epoch();
let raw = RawToken {
grant_type,
account_id,
claims: claims.map(|claims| claims.to_string()),
issued_at,
expiry: issued_at + expiry_in,
credential_version: credential_version
.filter(|_| !matches!(grant_type, GrantType::Rsvp))
.unwrap_or_default(),
};
seal_token(
self.core.oauth.oauth_key.as_bytes(),
&raw,
account_name.as_bytes(),
)
.map_err(|err| {
trc::AuthEvent::Error
.into_err()
.ctx(trc::Key::Reason, "Failed to encrypt token")
.reason(err)
.caused_by(trc::location!())
})
}
pub async fn validate_access_token(
&self,
expected_grant_type: Option<GrantType>,
token_: &str,
) -> trc::Result<TokenInfo> {
let token = open_token(self.core.oauth.oauth_key.as_bytes(), token_).map_err(|_| {
trc::AuthEvent::Error
.into_err()
.ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN)
.caused_by(trc::location!())
.details(token_.to_string())
})?;
// Validate expiration
let now = seconds_since_oauth_epoch();
if token.expiry <= now || token.issued_at > now {
return Err(trc::AuthEvent::TokenExpired.into_err());
}
// Validate grant type
if expected_grant_type.is_some_and(|g| g != token.grant_type) {
return Err(trc::AuthEvent::Error
.into_err()
.details("Invalid grant type"));
}
// Enforce credential revocation for long lived tokens
if token.credential_version != 0 {
let current = self
.access_token(token.account_id)
.await
.map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))?
.credential_version();
if current != token.credential_version {
return Err(trc::AuthEvent::TokenExpired
.into_err()
.details("Token revoked"));
}
}
Ok(TokenInfo {
grant_type: token.grant_type,
account_id: token.account_id,
claims: token.claims,
expiry: token.expiry + OAUTH_EPOCH,
issued_at: token.issued_at + OAUTH_EPOCH,
expires_in: token.expiry - now,
})
}
}
fn seal_token(key: &[u8], token: &RawToken, footer: &[u8]) -> Result<String, String> {
let mut payload = Vec::with_capacity(32);
payload.push_leb128(token.account_id);
payload.push(token.grant_type.id());
payload.push_leb128(token.issued_at);
payload.push_leb128(token.expiry);
payload.push_leb128(token.credential_version);
if let Some(claims) = token.claims.as_deref().filter(|claims| !claims.is_empty()) {
payload.extend_from_slice(claims.as_bytes());
}
let nonce = rng().random::<[u8; SymmetricEncrypt::NONCE_LEN]>();
let ciphertext =
SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT).encrypt_with_aad(&payload, &nonce, footer)?;
let mut body = Vec::with_capacity(nonce.len() + ciphertext.len());
body.extend_from_slice(&nonce);
body.extend_from_slice(&ciphertext);
let mut out = String::with_capacity(TOKEN_HEADER.len() + (body.len() + footer.len()) * 2);
out.push_str(TOKEN_HEADER);
general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out);
if !footer.is_empty() {
out.push('.');
general_purpose::URL_SAFE_NO_PAD.encode_string(footer, &mut out);
}
Ok(out)
}
fn open_token(key: &[u8], token: &str) -> Result<RawToken, ()> {
let rest = token.strip_prefix(TOKEN_HEADER).ok_or(())?;
let (body, footer) = match rest.split_once('.') {
Some((body, footer)) => (
body,
general_purpose::URL_SAFE_NO_PAD
.decode(footer.as_bytes())
.map_err(|_| ())?,
),
None => (rest, Vec::new()),
};
let body = general_purpose::URL_SAFE_NO_PAD
.decode(body.as_bytes())
.map_err(|_| ())?;
if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN {
return Err(());
}
let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN);
let payload = SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT)
.decrypt_with_aad(ciphertext, nonce, &footer)
.map_err(|_| ())?;
let mut bytes = payload.iter();
let account_id: u32 = bytes.next_leb128().ok_or(())?;
let grant_type = GrantType::from_id(bytes.next().copied().ok_or(())?).ok_or(())?;
let issued_at: u64 = bytes.next_leb128().ok_or(())?;
let expiry: u64 = bytes.next_leb128().ok_or(())?;
let credential_version: u64 = bytes.next_leb128().ok_or(())?;
let bytes = bytes.as_slice();
let claims = if bytes.is_empty() {
None
} else {
Some(String::from_utf8(bytes.to_vec()).map_err(|_| ())?)
};
Ok(RawToken {
grant_type,
account_id,
claims,
issued_at,
expiry,
credential_version,
})
}
#[inline(always)]
fn seconds_since_oauth_epoch() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
.saturating_sub(OAUTH_EPOCH)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8] = b"a-test-encryption-key-of-some-length";
const NAME: &[u8] = b"[email protected]";
fn sample(grant_type: GrantType, claims: Option<&str>, cv: u64) -> RawToken {
RawToken {
grant_type,
account_id: 42,
claims: claims.map(|c| c.to_string()),
issued_at: 1_000,
expiry: 2_000,
credential_version: cv,
}
}
fn assert_eq_fields(a: &RawToken, b: &RawToken) {
assert_eq!(a.account_id, b.account_id);
assert_eq!(a.grant_type, b.grant_type);
assert_eq!(a.claims, b.claims);
assert_eq!(a.issued_at, b.issued_at);
assert_eq!(a.expiry, b.expiry);
assert_eq!(a.credential_version, b.credential_version);
}
#[test]
fn round_trip_preserves_all_fields() {
for (raw, footer) in [
(sample(GrantType::AccessToken, None, 0), NAME),
(
sample(GrantType::RefreshToken, None, 0xdead_beef_cafe),
NAME,
),
(
sample(GrantType::Rsvp, Some("[email protected];7"), 0),
b"[email protected]",
),
(sample(GrantType::AccessToken, None, 0), b""),
(
RawToken {
account_id: u32::MAX,
credential_version: u64::MAX,
..sample(GrantType::AccessToken, Some("名前;1"), 1)
},
"名字@example.org".as_bytes(),
),
] {
let token = seal_token(KEY, &raw, footer).unwrap();
assert!(token.starts_with(TOKEN_HEADER));
let opened = open_token(KEY, &token).unwrap();
assert_eq_fields(&raw, &opened);
// The footer (account name) round-trips in clear text for proxies
if footer.is_empty() {
assert!(!token[TOKEN_HEADER.len()..].contains('.'));
} else {
let segment = token.rsplit_once('.').unwrap().1;
assert_eq!(
general_purpose::URL_SAFE_NO_PAD.decode(segment).unwrap(),
footer
);
}
}
}
#[test]
fn account_name_is_readable_in_clear_text_footer() {
let token = seal_token(
KEY,
&sample(GrantType::AccessToken, None, 0),
b"[email protected]",
)
.unwrap();
let footer = token.rsplit_once('.').unwrap().1;
let decoded = general_purpose::URL_SAFE_NO_PAD.decode(footer).unwrap();
assert_eq!(decoded, b"[email protected]");
}
#[test]
fn wrong_key_is_rejected() {
let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap();
assert!(open_token(b"a-different-encryption-key-entirely!", &token).is_err());
}
#[test]
fn tampering_with_ciphertext_is_rejected() {
let raw = sample(GrantType::AccessToken, None, 0);
let token = seal_token(KEY, &raw, NAME).unwrap();
let (header, rest) = token.split_at(TOKEN_HEADER.len());
let (body_b64, footer) = match rest.split_once('.') {
Some((b, f)) => (b.to_string(), Some(f.to_string())),
None => (rest.to_string(), None),
};
let mut body = general_purpose::URL_SAFE_NO_PAD.decode(&body_b64).unwrap();
for idx in 0..body.len() {
let mut tampered = body.clone();
tampered[idx] ^= 0x01;
let mut rebuilt = String::from(header);
rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&tampered));
if let Some(footer) = &footer {
rebuilt.push('.');
rebuilt.push_str(footer);
}
assert!(
open_token(KEY, &rebuilt).is_err(),
"flipping byte {idx} of the body must invalidate the token"
);
}
// Sanity: the untampered token still opens
body[0] ^= 0x00;
assert!(open_token(KEY, &token).is_ok());
}
#[test]
fn tampering_with_clear_text_footer_is_rejected() {
let raw = sample(GrantType::AccessToken, None, 0);
let token = seal_token(KEY, &raw, b"[email protected]").unwrap();
let (body, _) = token.rsplit_once('.').unwrap();
// An attacker rewrites the clear-text account name to impersonate another account
let forged_footer = general_purpose::URL_SAFE_NO_PAD.encode(b"[email protected]");
let forged = format!("{body}.{forged_footer}");
assert!(
open_token(KEY, &forged).is_err(),
"the footer is bound through the associated data and must be authenticated"
);
}
#[test]
fn swapping_footers_between_tokens_is_rejected() {
let a = seal_token(
KEY,
&sample(GrantType::AccessToken, None, 0),
b"[email protected]",
)
.unwrap();
let b = seal_token(
KEY,
&sample(GrantType::AccessToken, None, 0),
b"[email protected]",
)
.unwrap();
let a_body = a.rsplit_once('.').unwrap().0;
let b_footer = b.rsplit_once('.').unwrap().1;
let frankentoken = format!("{a_body}.{b_footer}");
assert!(open_token(KEY, &frankentoken).is_err());
}
#[test]
fn malformed_input_never_panics_and_is_rejected() {
let valid = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap();
let cases = [
String::new(),
"sw1.".to_string(),
"sw1.!!!not-base64!!!".to_string(),
"sw1...".to_string(),
"wrong-prefix.".to_string(),
"sw1.AAAA".to_string(),
"sw1.AAAA.BBBB".to_string(),
valid.replace("sw1.", "sw2."),
valid[..valid.len() / 2].to_string(),
format!("sw1.{}", "A".repeat(10_000)),
"\u{0}\u{0}\u{0}".to_string(),
];
for case in cases {
assert!(open_token(KEY, &case).is_err(), "must reject {case:?}");
}
}
#[test]
fn truncating_the_body_is_rejected() {
let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap();
let (header, rest) = token.split_at(TOKEN_HEADER.len());
let body_b64 = rest.split_once('.').map(|(b, _)| b).unwrap_or(rest);
let body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap();
for len in 0..body.len() {
let mut rebuilt = String::from(header);
rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&body[..len]));
assert!(
open_token(KEY, &rebuilt).is_err(),
"truncation to {len} must be rejected"
);
}
}
#[test]
fn identical_input_produces_distinct_tokens() {
let raw = sample(GrantType::AccessToken, None, 7);
let a = seal_token(KEY, &raw, NAME).unwrap();
let b = seal_token(KEY, &raw, NAME).unwrap();
assert_ne!(a, b, "a random nonce must make each token unique");
assert_eq_fields(&open_token(KEY, &a).unwrap(), &open_token(KEY, &b).unwrap());
}
#[test]
fn claims_with_separators_round_trip_exactly() {
let raw = sample(GrantType::Rsvp, Some("a;b;c;[email protected];999"), 0);
let token = seal_token(KEY, &raw, b"[email protected]").unwrap();
let opened = open_token(KEY, &token).unwrap();
assert_eq!(opened.claims.as_deref(), Some("a;b;c;[email protected];999"));
}
}
+320
View File
@@ -0,0 +1,320 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Server,
auth::{AccessToken, Permissions, PermissionsGroup},
};
use ahash::AHashSet;
use registry::{
schema::{
enums::Permission,
structs::{self, Account, PermissionsList, UserRoles},
},
types::EnumImpl,
};
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
impl Server {
pub async fn add_role_permissions(
&self,
mut base_permissions: PermissionsGroup,
roles: impl IntoIterator<Item = u32>,
) -> trc::Result<PermissionsGroup> {
let mut role_ids = roles.into_iter().collect::<Vec<u32>>();
let mut fetched_role_ids = AHashSet::new();
while let Some(role_id) = role_ids.pop() {
if fetched_role_ids.insert(role_id) {
let role = self.role(role_id).await.caused_by(trc::location!())?;
base_permissions.union(&role.permissions);
role_ids.extend(role.id_roles.iter().copied());
}
}
Ok(base_permissions)
}
pub async fn effective_permissions(
&self,
permissions: &structs::Permissions,
role_ids: &[Id],
tenant_id: Option<u32>,
) -> trc::Result<PermissionsGroup> {
// Calculate effective permissions
let (mut permissions, roles) = match permissions {
structs::Permissions::Inherit => (PermissionsGroup::default(), role_ids),
structs::Permissions::Merge(permissions) => {
(PermissionsGroup::from(permissions), role_ids)
}
structs::Permissions::Replace(permissions) => {
(PermissionsGroup::from(permissions), &[][..])
}
};
if !roles.is_empty() {
permissions = self
.add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32))
.await
.caused_by(trc::location!())?
}
Ok(permissions)
}
pub async fn can_set_permissions(
&self,
access_token: &AccessToken,
account: &Account,
) -> trc::Result<Result<(), Vec<Permission>>> {
let (permissions, role_ids, tenant_id) = match account {
Account::User(account) => (
&account.permissions,
match &account.roles {
UserRoles::User => self.core.network.security.default_role_ids_user.as_slice(),
UserRoles::Admin => {
if access_token.tenant_id().is_none() {
self.core.network.security.default_role_ids_admin.as_slice()
} else {
self.core
.network
.security
.default_role_ids_tenant
.as_slice()
}
}
UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(),
},
account.member_tenant_id.map(|t| t.document_id()),
),
Account::Group(account) => (
&account.permissions,
account
.roles
.role_ids()
.unwrap_or(self.core.network.security.default_role_ids_group.as_slice()),
account.member_tenant_id.map(|t| t.document_id()),
),
};
self.effective_permissions(permissions, role_ids, tenant_id)
.await
.map(|permissions| access_token.can_grant_permissions(permissions.finalize()))
}
}
impl AccessToken {
pub fn can_grant_permissions(
&self,
mut requested_permissions: Permissions,
) -> Result<(), Vec<Permission>> {
requested_permissions.difference(self.permissions_bits());
if requested_permissions.is_empty() {
Ok(())
} else {
Err(requested_permissions.build_permissions_list())
}
}
}
pub trait PermissionsListBuilder {
fn build_permissions_list(&self) -> Vec<Permission>;
}
impl PermissionsListBuilder for Permissions {
fn build_permissions_list(&self) -> Vec<Permission> {
const USIZE_BITS: usize = std::mem::size_of::<usize>() * 8;
const USIZE_MASK: u32 = USIZE_BITS as u32 - 1;
let mut permissions = Vec::new();
for (block_num, bytes) in self.inner().iter().enumerate() {
let mut bytes = *bytes;
while bytes != 0 {
let item = USIZE_MASK - bytes.leading_zeros();
bytes ^= 1 << item;
if let Some(permission) =
Permission::from_id(((block_num * USIZE_BITS) + item as usize) as u16)
{
permissions.push(permission);
}
}
}
permissions
}
}
pub struct DefaultPermissions {
pub user: Vec<Permission>,
pub group: Vec<Permission>,
pub tenant: Vec<Permission>,
pub superuser: Vec<Permission>,
}
impl PermissionsGroup {
pub fn with_merge(mut self, merge: bool) -> Self {
self.merge = merge;
self
}
pub fn union(&mut self, other: &PermissionsGroup) {
self.enabled.union(&other.enabled);
self.disabled.union(&other.disabled);
}
pub fn restrict(&mut self, other: &PermissionsGroup) {
self.enabled.intersection(&other.enabled);
self.disabled.union(&other.disabled);
}
pub fn finalize(mut self) -> Permissions {
self.enabled.difference(&self.disabled);
self.enabled
}
pub fn finalize_as_ref(&self) -> Permissions {
let mut enabled = self.enabled.clone();
enabled.difference(&self.disabled);
enabled
}
pub fn user() -> Self {
let mut permissions = PermissionsGroup::default();
for permission in DefaultPermissions::default().user {
permissions.enabled.set(permission as usize);
}
permissions
}
}
impl Default for DefaultPermissions {
fn default() -> Self {
let mut default = Self {
user: Default::default(),
group: Default::default(),
tenant: Default::default(),
superuser: Default::default(),
};
for permission_id in 0..Permission::COUNT {
let permission = Permission::from_id(permission_id as u16).unwrap();
match permission {
Permission::Authenticate
| Permission::AuthenticateWithAlias
| Permission::InteractAi => {
default.user.push(permission);
default.superuser.push(permission);
default.tenant.push(permission);
}
Permission::Impersonate
| Permission::UnlimitedRequests
| Permission::UnlimitedUploads
| Permission::LiveMetrics
| Permission::LiveTracing => {
default.superuser.push(permission);
}
Permission::FetchAnyBlob | Permission::LiveDeliveryTest => {
default.superuser.push(permission);
default.tenant.push(permission);
}
permission => {
let name = permission.as_str();
if name.starts_with("jmap")
|| name.starts_with("imap")
|| name.starts_with("pop3")
|| name.starts_with("calendar")
|| name.starts_with("email")
|| name.starts_with("dav")
|| name.starts_with("sieve")
{
default.user.push(permission);
default.group.push(permission);
} else if name.starts_with("sysMaskedEmail")
|| name.starts_with("sysArchivedItem")
|| name.starts_with("sysAccountSettings")
|| name.starts_with("sysPublicKey")
|| (name.starts_with("sysSpamTrainingSample") && !name.contains("Create"))
{
default.user.push(permission);
default.group.push(permission);
default.superuser.push(permission);
} else if name.starts_with("sysAccountPassword")
|| name.starts_with("sysApiKey")
|| name.starts_with("sysAppPassword")
{
default.user.push(permission);
default.superuser.push(permission);
} else if name.starts_with("sysDomain")
|| name.starts_with("sysDkimSignature")
|| name.starts_with("sysAcmeProvider")
|| name.starts_with("sysAccount")
|| name.starts_with("sysRole")
|| name.starts_with("sysOAuthClient")
|| name.starts_with("sysMailingList")
|| name.starts_with("sysExternalReport")
|| name.starts_with("sysDnsServer")
|| name.starts_with("sysQueuedMessage")
{
default.tenant.push(permission);
default.superuser.push(permission);
} else {
default.superuser.push(permission);
}
}
}
}
default
}
}
impl From<PermissionsList> for PermissionsGroup {
fn from(value: PermissionsList) -> Self {
Self::from(&value)
}
}
impl From<&PermissionsList> for PermissionsGroup {
fn from(value: &PermissionsList) -> Self {
PermissionsGroup {
enabled: Permissions::from_permission(value.enabled_permissions.as_slice()),
disabled: Permissions::from_permission(value.disabled_permissions.as_slice()),
merge: false,
}
}
}
impl From<&VecMap<Permission, bool>> for PermissionsGroup {
fn from(value: &VecMap<Permission, bool>) -> Self {
let mut permissions = PermissionsGroup::default();
for (permission, is_set) in value {
if *is_set {
permissions.enabled.set(*permission as usize);
} else {
permissions.disabled.set(*permission as usize);
}
}
permissions
}
}
pub trait BuildPermissions {
fn from_permission(list: &[Permission]) -> Permissions;
}
impl BuildPermissions for Permissions {
fn from_permission(list: &[Permission]) -> Permissions {
let mut permission = Permissions::default();
for p in list {
permission.set(*p as usize);
}
permission
}
}
+107
View File
@@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::auth::AccessToken;
use crate::network::ip_to_bytes;
use crate::network::limiter::{InFlight, LimiterResult};
use crate::{KV_RATE_LIMIT_HTTP_ANONYMOUS, KV_RATE_LIMIT_HTTP_AUTHENTICATED, Server};
use registry::schema::enums::Permission;
use std::net::IpAddr;
use trc::AddContext;
impl Server {
pub async fn is_http_authenticated_request_allowed(
&self,
access_token: &AccessToken,
addr: IpAddr,
) -> trc::Result<Option<InFlight>> {
let rate_reset = if let Some(rate) = &self.core.network.http.rate_authenticated {
if self.is_ip_allowed(addr) {
None
} else {
self.core
.storage
.memory
.is_rate_allowed(
KV_RATE_LIMIT_HTTP_AUTHENTICATED,
&access_token.account_id().to_be_bytes(),
rate,
false,
)
.await
.caused_by(trc::location!())?
.map(|reset| (reset, rate.count))
}
} else {
None
};
if let Some((reset, count)) = rate_reset {
if access_token.has_permission(Permission::UnlimitedRequests) {
Ok(None)
} else {
Err(trc::LimitEvent::TooManyRequests
.into_err()
.ctx(trc::Key::Expires, reset)
.ctx(trc::Key::Limit, count))
}
} else {
match access_token.is_http_request_allowed() {
LimiterResult::Allowed(in_flight) => Ok(Some(in_flight)),
LimiterResult::Forbidden => {
if access_token.has_permission(Permission::UnlimitedRequests) {
Ok(None)
} else {
Err(trc::LimitEvent::ConcurrentRequest
.into_err()
.ctx(trc::Key::Limit, access_token.concurrent_http_requests()))
}
}
LimiterResult::Disabled => Ok(None),
}
}
}
pub async fn is_http_anonymous_request_allowed(&self, addr: IpAddr) -> trc::Result<()> {
if let Some(rate) = &self.core.network.http.rate_anonymous
&& !self.is_ip_allowed(addr)
&& let Some(reset) = self
.core
.storage
.memory
.is_rate_allowed(
KV_RATE_LIMIT_HTTP_ANONYMOUS,
&ip_to_bytes(&addr),
rate,
false,
)
.await
.caused_by(trc::location!())?
{
return Err(trc::LimitEvent::TooManyRequests
.into_err()
.ctx(trc::Key::Expires, reset)
.ctx(trc::Key::Limit, rate.count));
}
Ok(())
}
pub fn is_upload_allowed(&self, access_token: &AccessToken) -> trc::Result<Option<InFlight>> {
match access_token.is_upload_allowed() {
LimiterResult::Allowed(in_flight) => Ok(Some(in_flight)),
LimiterResult::Forbidden => {
if access_token.has_permission(Permission::UnlimitedRequests) {
Ok(None)
} else {
Err(trc::LimitEvent::ConcurrentUpload
.into_err()
.ctx(trc::Key::Limit, access_token.concurrent_uploads()))
}
}
LimiterResult::Disabled => Ok(None),
}
}
}