Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user