From f5694d32a4212394425466923788f43b4f08c778 Mon Sep 17 00:00:00 2001 From: Mauro D Date: Tue, 9 May 2023 17:39:52 +0000 Subject: [PATCH] Authentication untested. --- crates/jmap-proto/src/method/set.rs | 28 +- crates/jmap-proto/src/object/index.rs | 86 +++- crates/jmap-proto/src/object/mod.rs | 14 +- crates/jmap-proto/src/parser/impls.rs | 29 +- crates/jmap-proto/src/parser/json.rs | 16 - crates/jmap-proto/src/types/acl.rs | 42 +- crates/jmap-proto/src/types/blob.rs | 8 +- crates/jmap-proto/src/types/collection.rs | 68 ++- crates/jmap-proto/src/types/property.rs | 2 +- crates/jmap-proto/src/types/value.rs | 21 +- crates/jmap/Cargo.toml | 1 + crates/jmap/src/api/config.rs | 14 + crates/jmap/src/api/http.rs | 18 +- crates/jmap/src/api/request.rs | 190 ++++++--- crates/jmap/src/auth/account.rs | 66 +++ crates/jmap/src/auth/acl.rs | 492 ++++++++++++++++++++++ crates/jmap/src/auth/authenticate.rs | 117 +++++ crates/jmap/src/auth/mod.rs | 135 ++++++ crates/jmap/src/auth/oauth/device_auth.rs | 211 ++++++++++ crates/jmap/src/auth/oauth/mod.rs | 196 +++++++++ crates/jmap/src/auth/oauth/token.rs | 312 ++++++++++++++ crates/jmap/src/auth/oauth/user_code.rs | 151 +++++++ crates/jmap/src/auth/rate_limit.rs | 118 ++++++ crates/jmap/src/blob/download.rs | 48 ++- crates/jmap/src/changes/get.rs | 39 +- crates/jmap/src/changes/query.rs | 36 +- crates/jmap/src/changes/state.rs | 5 +- crates/jmap/src/email/copy.rs | 31 +- crates/jmap/src/email/get.rs | 21 +- crates/jmap/src/email/import.rs | 28 +- crates/jmap/src/email/parse.rs | 5 +- crates/jmap/src/email/query.rs | 16 +- crates/jmap/src/email/set.rs | 188 ++++++--- crates/jmap/src/email/snippet.rs | 11 +- crates/jmap/src/lib.rs | 57 ++- crates/jmap/src/mailbox/get.rs | 114 ++--- crates/jmap/src/mailbox/query.rs | 20 +- crates/jmap/src/mailbox/set.rs | 158 ++++++- crates/jmap/src/principal/mod.rs | 15 + crates/jmap/src/principal/set.rs | 266 ++++++++++++ crates/store/src/backend/sqlite/main.rs | 5 +- crates/store/src/backend/sqlite/read.rs | 4 +- crates/store/src/backend/sqlite/write.rs | 27 +- crates/store/src/lib.rs | 3 +- crates/store/src/query/get.rs | 6 +- crates/store/src/query/mod.rs | 4 + crates/store/src/write/batch.rs | 8 - crates/store/src/write/key.rs | 37 +- crates/store/src/write/mod.rs | 8 +- crates/utils/src/config/mod.rs | 8 +- crates/utils/src/config/utils.rs | 42 +- crates/utils/src/listener/limiter.rs | 1 + crates/utils/src/map/bitmap.rs | 24 +- resources/oauth/error.htx | 1 + resources/oauth/footer.htx | 1 + resources/oauth/header.htx | 1 + resources/oauth/login.htx | 1 + resources/oauth/login_code.htx | 1 + resources/oauth/login_code_hidden.htx | 1 + resources/oauth/login_hdr_client.htx | 1 + resources/oauth/login_hdr_device.htx | 1 + resources/oauth/login_hdr_failed.htx | 1 + resources/oauth/login_success.htx | 1 + resources/oauth/oauth.htx | 125 ++++++ 64 files changed, 3302 insertions(+), 403 deletions(-) create mode 100644 crates/jmap/src/auth/account.rs create mode 100644 crates/jmap/src/auth/acl.rs create mode 100644 crates/jmap/src/auth/authenticate.rs create mode 100644 crates/jmap/src/auth/mod.rs create mode 100644 crates/jmap/src/auth/oauth/device_auth.rs create mode 100644 crates/jmap/src/auth/oauth/mod.rs create mode 100644 crates/jmap/src/auth/oauth/token.rs create mode 100644 crates/jmap/src/auth/oauth/user_code.rs create mode 100644 crates/jmap/src/auth/rate_limit.rs create mode 100644 crates/jmap/src/principal/mod.rs create mode 100644 crates/jmap/src/principal/set.rs create mode 100644 resources/oauth/error.htx create mode 100644 resources/oauth/footer.htx create mode 100644 resources/oauth/header.htx create mode 100644 resources/oauth/login.htx create mode 100644 resources/oauth/login_code.htx create mode 100644 resources/oauth/login_code_hidden.htx create mode 100644 resources/oauth/login_hdr_client.htx create mode 100644 resources/oauth/login_hdr_device.htx create mode 100644 resources/oauth/login_hdr_failed.htx create mode 100644 resources/oauth/login_success.htx create mode 100644 resources/oauth/oauth.htx diff --git a/crates/jmap-proto/src/method/set.rs b/crates/jmap-proto/src/method/set.rs index 59cf7210..ce708a80 100644 --- a/crates/jmap-proto/src/method/set.rs +++ b/crates/jmap-proto/src/method/set.rs @@ -1,5 +1,5 @@ use ahash::AHashMap; -use utils::map::vec_map::VecMap; +use utils::map::{bitmap::Bitmap, vec_map::VecMap}; use crate::{ error::{ @@ -275,9 +275,29 @@ impl JsonObjectParser for Object { } } - Property::Acl => { - SetValue::Value(Value::parse::(parser.next_token()?, parser)?) - } + Property::Acl => match key.patch.len() { + 0 => { + parser + .next_token::()? + .assert_jmap(Token::DictStart)?; + let mut acls = Vec::new(); + while let Some(account) = parser.next_dict_key::()? { + acls.push(Value::Text(account)); + acls.push(Value::UnsignedInt(>::parse(parser)?.into())); + } + SetValue::Value(Value::List(acls)) + } + 1 => { + key.patch + .push(Value::UnsignedInt(>::parse(parser)?.into())); + SetValue::Patch(key.patch) + } + 2 => { + key.patch.push(Value::Bool(bool::parse(parser)?)); + SetValue::Patch(key.patch) + } + _ => unreachable!(), + }, Property::Aliases | Property::Attachments | Property::Bcc diff --git a/crates/jmap-proto/src/object/index.rs b/crates/jmap-proto/src/object/index.rs index 7243279f..cedf461d 100644 --- a/crates/jmap-proto/src/object/index.rs +++ b/crates/jmap-proto/src/object/index.rs @@ -34,6 +34,7 @@ pub enum IndexAs { IntegerList, LongInteger, HasProperty, + Acl, #[default] None, } @@ -70,21 +71,24 @@ impl ObjectIndexBuilder { self } + pub fn get(&self, property: &Property) -> &Value { + self.changes + .as_ref() + .and_then(|c| c.properties.get(property)) + .or_else(|| { + self.current + .as_ref() + .and_then(|c| c.properties.get(property)) + }) + .unwrap_or(&Value::Null) + } + pub fn validate(self) -> Result { for item in self.index { if item.required || item.max_size > 0 { - let value = self - .changes - .as_ref() - .and_then(|c| c.properties.get(&item.property)) - .or_else(|| { - self.current - .as_ref() - .and_then(|c| c.properties.get(&item.property)) - }); - let error: Cow = match value { - None if item.required => "Property cannot be empty.".into(), - Some(Value::Text(text)) => { + let error: Cow = match self.get(&item.property) { + Value::Null if item.required => "Property cannot be empty.".into(), + Value::Text(text) => { if item.required && text.trim().is_empty() { "Property cannot be empty.".into() } else if item.max_size > 0 && text.len() > item.max_size { @@ -327,6 +331,52 @@ fn merge_batch( }); } } + IndexAs::Acl => { + if let (Some(current_value), Some(value)) = + (current_value.as_list(), value.as_list()) + { + // Remove deleted ACLs + for item in current_value.chunks_exact(2) { + if let Some(Value::Id(id)) = item.first() { + if !value.contains(&item[1]) { + batch.ops.push(Operation::Acl { + grant_account_id: id.document_id(), + set: None, + }); + } + } + } + + // Update ACLs + for item in value.chunks_exact(2) { + if let (Some(Value::Id(id)), Some(Value::UnsignedInt(acl))) = + (item.first(), item.last()) + { + let mut add_item = true; + for current_item in current_value.chunks_exact(2) { + if let ( + Some(Value::Id(current_id)), + Some(Value::UnsignedInt(current_acl)), + ) = (current_item.first(), current_item.last()) + { + if id == current_id { + if acl != current_acl { + add_item = false; + } + break; + } + } + } + if add_item { + batch.ops.push(Operation::Acl { + grant_account_id: id.document_id(), + set: acl.serialize().into(), + }); + } + } + } + } + } IndexAs::None => (), } } @@ -434,6 +484,18 @@ fn build_batch( }); } } + (Value::List(values), IndexAs::Acl) => { + for item in values.chunks_exact(2) { + if let (Some(Value::Id(id)), Some(Value::UnsignedInt(acl))) = + (item.first(), item.last()) + { + batch.ops.push(Operation::Acl { + grant_account_id: id.document_id(), + set: acl.serialize().into(), + }); + } + } + } (value, IndexAs::HasProperty) if value != &Value::Null => { batch.ops.push(Operation::Bitmap { family: ().family(), diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 74957898..94db26ad 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -16,7 +16,7 @@ use utils::{ }; use crate::types::{ - acl::Acl, blob::BlobId, date::UTCDate, id::Id, keyword::Keyword, property::Property, + blob::BlobId, date::UTCDate, id::Id, keyword::Keyword, property::Property, type_state::TypeState, value::Value, }; @@ -97,10 +97,9 @@ const DATE: u8 = 5; const BLOB_ID: u8 = 6; const KEYWORD: u8 = 7; const TYPE_STATE: u8 = 8; -const ACL: u8 = 9; -const LIST: u8 = 10; -const OBJECT: u8 = 11; -const NULL: u8 = 12; +const LIST: u8 = 9; +const OBJECT: u8 = 10; +const NULL: u8 = 11; impl Serialize for Value { fn serialize(self) -> Vec { @@ -194,10 +193,6 @@ impl SerializeInto for Value { buf.push(TYPE_STATE); v.serialize_into(buf); } - Value::Acl(v) => { - buf.push(ACL); - v.serialize_into(buf); - } Value::List(v) => { buf.push(LIST); buf.push_leb128(v.len()); @@ -230,7 +225,6 @@ impl DeserializeFrom for Value { BLOB_ID => Some(Value::BlobId(BlobId::deserialize_from(bytes)?)), KEYWORD => Some(Value::Keyword(Keyword::deserialize_from(bytes)?)), TYPE_STATE => Some(Value::TypeState(TypeState::deserialize_from(bytes)?)), - ACL => Some(Value::Acl(Acl::deserialize_from(bytes)?)), LIST => { let len = bytes.next_leb128()?; let mut items = Vec::with_capacity(len); diff --git a/crates/jmap-proto/src/parser/impls.rs b/crates/jmap-proto/src/parser/impls.rs index 4a622d17..e9f25030 100644 --- a/crates/jmap-proto/src/parser/impls.rs +++ b/crates/jmap-proto/src/parser/impls.rs @@ -1,6 +1,9 @@ use std::fmt::Display; -use utils::map::vec_map::VecMap; +use utils::map::{ + bitmap::{Bitmap, BitmapItem}, + vec_map::VecMap, +}; use super::{json::Parser, Ignore, JsonObjectParser, Token}; @@ -211,6 +214,30 @@ impl JsonObjectParser for Option> { } } +impl JsonObjectParser for Bitmap { + fn parse(parser: &mut Parser<'_>) -> super::Result + where + Self: Sized, + { + let mut bm = Bitmap::new(); + match parser.next_token::()? { + Token::ArrayStart => { + loop { + match parser.next_token::()? { + Token::String(item) => bm.insert(item), + Token::Comma => (), + Token::ArrayEnd => break, + token => return Err(token.error("", "string")), + } + } + Ok(bm) + } + Token::Null => Ok(bm), + token => Err(token.error("", "array or null")), + } + } +} + impl JsonObjectParser for VecMap { fn parse(parser: &mut Parser<'_>) -> super::Result where diff --git a/crates/jmap-proto/src/parser/json.rs b/crates/jmap-proto/src/parser/json.rs index a6edfe33..138e9672 100644 --- a/crates/jmap-proto/src/parser/json.rs +++ b/crates/jmap-proto/src/parser/json.rs @@ -270,22 +270,6 @@ impl<'x> Parser<'x> { } } - /*pub fn is_dict_end(&mut self) -> super::Result { - match self.next_token::()? { - Token::Comma => Ok(false), - Token::DictEnd => Ok(true), - token => Err(self.error(&format!("Expected ',' or '}}', found {}", token))), - } - } - - pub fn is_array_end(&mut self) -> super::Result { - match self.next_token::()? { - Token::Comma => Ok(false), - Token::ArrayEnd => Ok(true), - token => Err(self.error(&format!("Expected ',' or ']', found {}", token))), - } - }*/ - pub fn skip_token( &mut self, start_depth_array: u32, diff --git a/crates/jmap-proto/src/types/acl.rs b/crates/jmap-proto/src/types/acl.rs index bc941c98..ec480a5c 100644 --- a/crates/jmap-proto/src/types/acl.rs +++ b/crates/jmap-proto/src/types/acl.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Display}; -use store::write::{DeserializeFrom, SerializeInto}; +use utils::map::bitmap::BitmapItem; use crate::parser::{json::Parser, JsonObjectParser}; @@ -17,6 +17,7 @@ pub enum Acl { CreateChild = 7, Administer = 8, Submit = 9, + None = 10, } impl JsonObjectParser for Acl { @@ -65,6 +66,7 @@ impl Acl { Acl::CreateChild => "createChild", Acl::Administer => "administer", Acl::Submit => "submit", + Acl::None => "", } } } @@ -84,7 +86,41 @@ impl serde::Serialize for Acl { } } -impl SerializeInto for Acl { +impl BitmapItem for Acl { + fn max() -> u64 { + Acl::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, Acl::None) + } +} + +impl From for u64 { + fn from(value: Acl) -> Self { + value as u64 + } +} + +impl From for Acl { + fn from(value: u64) -> Self { + match value { + 0 => Acl::Read, + 1 => Acl::Modify, + 2 => Acl::Delete, + 3 => Acl::ReadItems, + 4 => Acl::AddItems, + 5 => Acl::ModifyItems, + 6 => Acl::RemoveItems, + 7 => Acl::CreateChild, + 8 => Acl::Administer, + 9 => Acl::Submit, + _ => Acl::None, + } + } +} + +/*impl SerializeInto for Acl { fn serialize_into(&self, buf: &mut Vec) { buf.push(*self as u8); } @@ -106,4 +142,4 @@ impl DeserializeFrom for Acl { _ => None, } } -} +}*/ diff --git a/crates/jmap-proto/src/types/blob.rs b/crates/jmap-proto/src/types/blob.rs index c9c40ff0..b876900b 100644 --- a/crates/jmap-proto/src/types/blob.rs +++ b/crates/jmap-proto/src/types/blob.rs @@ -82,11 +82,11 @@ impl BlobId { } } - pub fn has_access(&self, account_id: u32) -> bool { + pub fn account_id(&self) -> u32 { match &self.kind { - BlobKind::Linked { account_id: a, .. } => *a == account_id, - BlobKind::LinkedMaildir { account_id: a, .. } => *a == account_id, - BlobKind::Temporary { account_id: a, .. } => *a == account_id, + BlobKind::Linked { account_id, .. } => *account_id, + BlobKind::LinkedMaildir { account_id, .. } => *account_id, + BlobKind::Temporary { account_id, .. } => *account_id, } } } diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 27f16cae..d9143282 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -1,30 +1,46 @@ use std::fmt::{self, Display, Formatter}; +use utils::map::bitmap::BitmapItem; + #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] #[repr(u8)] pub enum Collection { - Principal = 0, - PushSubscription = 1, - Email = 2, - Mailbox = 3, - Thread = 4, - Identity = 5, - EmailSubmission = 6, - SieveScript = 7, + Email = 0, + Mailbox = 1, + Thread = 2, + Identity = 3, + EmailSubmission = 4, + SieveScript = 5, + PushSubscription = 6, + None = 8, } impl From for Collection { fn from(v: u8) -> Self { match v { - 0 => Collection::Principal, - 1 => Collection::PushSubscription, - 2 => Collection::Email, - 3 => Collection::Mailbox, - 4 => Collection::Thread, - 5 => Collection::Identity, - 6 => Collection::EmailSubmission, - 7 => Collection::SieveScript, - _ => panic!("Invalid collection"), + 0 => Collection::Email, + 1 => Collection::Mailbox, + 2 => Collection::Thread, + 3 => Collection::Identity, + 4 => Collection::EmailSubmission, + 5 => Collection::SieveScript, + 6 => Collection::PushSubscription, + _ => Collection::None, + } + } +} + +impl From for Collection { + fn from(v: u64) -> Self { + match v { + 0 => Collection::Email, + 1 => Collection::Mailbox, + 2 => Collection::Thread, + 3 => Collection::Identity, + 4 => Collection::EmailSubmission, + 5 => Collection::SieveScript, + 6 => Collection::PushSubscription, + _ => Collection::None, } } } @@ -35,10 +51,15 @@ impl From for u8 { } } +impl From for u64 { + fn from(collection: Collection) -> u64 { + collection as u64 + } +} + impl Display for Collection { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { - Collection::Principal => write!(f, "principal"), Collection::PushSubscription => write!(f, "pushSubscription"), Collection::Email => write!(f, "email"), Collection::Mailbox => write!(f, "mailbox"), @@ -46,6 +67,17 @@ impl Display for Collection { Collection::Identity => write!(f, "identity"), Collection::EmailSubmission => write!(f, "emailSubmission"), Collection::SieveScript => write!(f, "sieveScript"), + Collection::None => write!(f, ""), } } } + +impl BitmapItem for Collection { + fn max() -> u64 { + Collection::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, Collection::None) + } +} diff --git a/crates/jmap-proto/src/types/property.rs b/crates/jmap-proto/src/types/property.rs index fc39da23..cf7e7c18 100644 --- a/crates/jmap-proto/src/types/property.rs +++ b/crates/jmap-proto/src/types/property.rs @@ -256,7 +256,7 @@ impl JsonObjectParser for SetProperty { if has_acl { match Acl::parse(parser) { Ok(acl) => { - patch.push(Value::Acl(acl)); + patch.push(Value::UnsignedInt(acl as u64)); } Err(Error::Method(_)) => { property = parser.invalid_property()?; diff --git a/crates/jmap-proto/src/types/value.rs b/crates/jmap-proto/src/types/value.rs index 780950d9..ed81912d 100644 --- a/crates/jmap-proto/src/types/value.rs +++ b/crates/jmap-proto/src/types/value.rs @@ -11,7 +11,6 @@ use crate::{ }; use super::{ - acl::Acl, blob::BlobId, date::UTCDate, id::Id, @@ -31,7 +30,6 @@ pub enum Value { BlobId(BlobId), Keyword(Keyword), TypeState(TypeState), - Acl(Acl), List(Vec), Object(Object), #[default] @@ -269,6 +267,13 @@ impl Value { } } + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + pub fn try_cast_uint(&self) -> Option { match self { Value::UnsignedInt(u) => Some(*u), @@ -317,12 +322,6 @@ impl IntoValue for UTCDate { } } -impl IntoValue for Acl { - fn into_value(self) -> Value { - Value::Acl(self) - } -} - impl IntoValue for TypeState { fn into_value(self) -> Value { Value::TypeState(self) @@ -395,12 +394,6 @@ impl From for Value { } } -impl From for Value { - fn from(value: Acl) -> Self { - Value::Acl(value) - } -} - impl From for Value { fn from(date: DateTime) -> Self { Value::Date(UTCDate { diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index a4b194ea..0bd79a8c 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -19,6 +19,7 @@ http-body-util = "0.1.0-rc.2" form_urlencoded = "1.1.0" tracing = "0.1" tokio = { version = "1.23", features = ["rt"] } +aes-gcm-siv = "0.11.1" [features] test_mode = [] diff --git a/crates/jmap/src/api/config.rs b/crates/jmap/src/api/config.rs index f6a4ff06..1943939b 100644 --- a/crates/jmap/src/api/config.rs +++ b/crates/jmap/src/api/config.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use store::fts::Language; use super::session::BaseCapabilities; @@ -56,6 +58,18 @@ impl crate::Config { .property("jmap.protocol.max-scripts")? .unwrap_or(256), capabilities: BaseCapabilities::default(), + session_cache_ttl: settings + .property("jmap.session.cache.ttl")? + .unwrap_or(Duration::from_secs(3600)), + rate_authenticated: settings + .property_or_static("jmap.rate-limit.authenticated.rate", "1000/1s")?, + rate_authenticate_req: settings + .property_or_static("jmap.rate-limit.authenticate.rate", "10/1s")?, + rate_anonymous: settings + .property_or_static("jmap.rate-limit.anonymous.rate", "100/1s")?, + rate_use_forwarded: settings + .property("jmap.rate-limit.use-forwarded")? + .unwrap_or(false), }; config.add_capabilites(settings); Ok(config) diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index d8a159c3..8979d087 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{net::IpAddr, sync::Arc}; use http_body_util::{combinators::BoxBody, BodyExt, Full}; use hyper::{ @@ -20,6 +20,7 @@ use tokio::{ use utils::listener::{ServerInstance, SessionData, SessionManager}; use crate::{ + auth::AclToken, blob::{DownloadResponse, UploadResponse}, JMAP, }; @@ -30,10 +31,17 @@ impl JMAP { pub async fn parse_request( &self, req: &mut hyper::Request, + remote_ip: IpAddr, instance: &ServerInstance, ) -> hyper::Response> { let mut path = req.uri().path().split('/'); path.next(); + let acl_token = AclToken { + primary_id: todo!(), + member_of: todo!(), + access_to: todo!(), + }; + match path.next().unwrap_or("") { "jmap" => match (path.next().unwrap_or(""), req.method()) { ("", &Method::POST) => { @@ -42,7 +50,7 @@ impl JMAP { //let delete = "fd"; //println!("<- {}", String::from_utf8_lossy(&bytes)); - match self.handle_request(&bytes).await { + match self.handle_request(&bytes, acl_token).await { Ok(response) => response.into_http_response(), Err(err) => err.into_http_response(), } @@ -56,7 +64,7 @@ impl JMAP { path.next().and_then(BlobId::from_base32), path.next(), ) { - return match self.blob_download(&blob_id, account_id.document_id()).await { + return match self.blob_download(&blob_id, &acl_token).await { Ok(Some(blob)) => DownloadResponse { filename: name.to_string(), content_type: req @@ -215,7 +223,9 @@ async fn handle_request( let instance = session.instance.clone(); async move { - let response = jmap.parse_request(&mut req, &instance).await; + let response = jmap + .parse_request(&mut req, session.remote_ip, &instance) + .await; tracing::debug!( parent: &span, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 83990991..ab90c5ff 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -1,14 +1,19 @@ use jmap_proto::{ - error::request::RequestError, + error::{method::MethodError, request::RequestError}, method::{get, query, set}, - request::{method::MethodName, Request, RequestMethod}, + request::{method::MethodName, Call, Request, RequestMethod}, response::{Response, ResponseMethod}, + types::collection::Collection, }; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; impl JMAP { - pub async fn handle_request(&self, bytes: &[u8]) -> Result { + pub async fn handle_request( + &self, + bytes: &[u8], + acl_token: AclToken, + ) -> Result { let request = Request::parse( bytes, self.config.request_max_calls, @@ -28,68 +33,23 @@ impl JMAP { loop { let mut next_call = None; - let method_response: ResponseMethod = match call.method { - RequestMethod::Get(mut req) => match req.take_arguments() { - get::RequestArguments::Email(arguments) => { - self.email_get(req.with_arguments(arguments)).await.into() - } - get::RequestArguments::Mailbox => self.mailbox_get(req).await.into(), - get::RequestArguments::Thread => self.thread_get(req).await.into(), - get::RequestArguments::Identity => todo!(), - get::RequestArguments::EmailSubmission => todo!(), - get::RequestArguments::PushSubscription => todo!(), - get::RequestArguments::SieveScript => todo!(), - get::RequestArguments::VacationResponse => todo!(), - get::RequestArguments::Principal => todo!(), - }, - RequestMethod::Query(mut req) => match req.take_arguments() { - query::RequestArguments::Email(arguments) => { - self.email_query(req.with_arguments(arguments)).await.into() - } - query::RequestArguments::Mailbox(arguments) => self - .mailbox_query(req.with_arguments(arguments)) - .await - .into(), - query::RequestArguments::EmailSubmission => todo!(), - query::RequestArguments::SieveScript => todo!(), - query::RequestArguments::Principal => todo!(), - }, - RequestMethod::Set(mut req) => match req.take_arguments() { - set::RequestArguments::Email => self.email_set(req).await.into(), - set::RequestArguments::Mailbox(arguments) => { - self.mailbox_set(req.with_arguments(arguments)).await.into() - } - set::RequestArguments::Identity => todo!(), - set::RequestArguments::EmailSubmission(_) => todo!(), - set::RequestArguments::PushSubscription => todo!(), - set::RequestArguments::SieveScript(_) => todo!(), - set::RequestArguments::VacationResponse => todo!(), - set::RequestArguments::Principal => todo!(), - }, - RequestMethod::Changes(req) => self.changes(req).await.into(), - RequestMethod::Copy(req) => self.email_copy(req, &mut next_call).await.into(), - RequestMethod::CopyBlob(_) => todo!(), - RequestMethod::ImportEmail(req) => self.email_import(req).await.into(), - RequestMethod::ParseEmail(req) => self.email_parse(req).await.into(), - RequestMethod::QueryChanges(req) => self.query_changes(req).await.into(), - RequestMethod::SearchSnippet(req) => { - self.email_search_snippet(req).await.into() - } - RequestMethod::ValidateScript(_) => todo!(), - RequestMethod::Echo(req) => req.into(), - RequestMethod::Error(error) => error.into(), - }; // Add response - response.push_response( - call.id, - if !matches!(method_response, ResponseMethod::Error(_)) { - call.name - } else { - MethodName::error() - }, - method_response, - ); + match self + .handle_method_call(call.method, &acl_token, &mut next_call) + .await + { + Ok(method_response) => { + response.push_response(call.id, call.name, method_response); + } + Err(err) => { + response.push_response( + call.id, + MethodName::error(), + ResponseMethod::Error(err), + ); + } + } // Process next call if let Some(next_call) = next_call { @@ -103,4 +63,106 @@ impl JMAP { Ok(response) } + + async fn handle_method_call( + &self, + method: RequestMethod, + acl_token: &AclToken, + next_call: &mut Option>, + ) -> Result { + Ok(match method { + RequestMethod::Get(mut req) => match req.take_arguments() { + get::RequestArguments::Email(arguments) => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_get(req.with_arguments(arguments), acl_token) + .await? + .into() + } + get::RequestArguments::Mailbox => { + acl_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_get(req, acl_token).await?.into() + } + get::RequestArguments::Thread => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.thread_get(req).await?.into() + } + get::RequestArguments::Identity => todo!(), + get::RequestArguments::EmailSubmission => todo!(), + get::RequestArguments::PushSubscription => todo!(), + get::RequestArguments::SieveScript => todo!(), + get::RequestArguments::VacationResponse => todo!(), + get::RequestArguments::Principal => todo!(), + }, + RequestMethod::Query(mut req) => match req.take_arguments() { + query::RequestArguments::Email(arguments) => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_query(req.with_arguments(arguments), acl_token) + .await? + .into() + } + query::RequestArguments::Mailbox(arguments) => { + acl_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_query(req.with_arguments(arguments), acl_token) + .await? + .into() + } + query::RequestArguments::EmailSubmission => todo!(), + query::RequestArguments::SieveScript => todo!(), + query::RequestArguments::Principal => todo!(), + }, + RequestMethod::Set(mut req) => match req.take_arguments() { + set::RequestArguments::Email => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_set(req, acl_token).await?.into() + } + set::RequestArguments::Mailbox(arguments) => { + acl_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_set(req.with_arguments(arguments), acl_token) + .await? + .into() + } + set::RequestArguments::Identity => todo!(), + set::RequestArguments::EmailSubmission(_) => todo!(), + set::RequestArguments::PushSubscription => todo!(), + set::RequestArguments::SieveScript(_) => todo!(), + set::RequestArguments::VacationResponse => todo!(), + set::RequestArguments::Principal => todo!(), + }, + RequestMethod::Changes(req) => self.changes(req, acl_token).await?.into(), + RequestMethod::Copy(req) => { + acl_token + .assert_has_access(req.account_id, Collection::Email)? + .assert_has_access(req.from_account_id, Collection::Email)?; + + self.email_copy(req, acl_token, next_call).await?.into() + } + RequestMethod::CopyBlob(_) => todo!(), + RequestMethod::ImportEmail(req) => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_import(req, acl_token).await?.into() + } + RequestMethod::ParseEmail(req) => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_parse(req, acl_token).await?.into() + } + RequestMethod::QueryChanges(req) => self.query_changes(req, acl_token).await?.into(), + RequestMethod::SearchSnippet(req) => { + acl_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_search_snippet(req, acl_token).await?.into() + } + RequestMethod::ValidateScript(_) => todo!(), + RequestMethod::Echo(req) => req.into(), + RequestMethod::Error(error) => return Err(error), + }) + } } diff --git a/crates/jmap/src/auth/account.rs b/crates/jmap/src/auth/account.rs new file mode 100644 index 00000000..e551e881 --- /dev/null +++ b/crates/jmap/src/auth/account.rs @@ -0,0 +1,66 @@ +use jmap_proto::types::collection::Collection; + +use crate::{JMAP, SUPERUSER_ID}; + +use super::{AccountDetails, AccountKey, AclToken}; + +impl JMAP { + pub async fn authenticate(&self, account: &str, secret: &str) -> Option { + todo!() + } + + pub async fn get_acl_token(&self, account_id: u32) -> Option { + todo!() + } + + pub async fn get_account_details(&self, account: &str) -> Option { + None + } + + pub async fn map_account_id(&self, account: &str) -> Option { + match self + .store + .get_value::(AccountKey::new(account.to_lowercase())) + .await + { + Ok(Some(id)) => Some(id), + Ok(None) => { + match self + .assign_document_id(SUPERUSER_ID, Collection::Identity) + .await + { + Ok(account_id) => { + match self + .store + .set_value(AccountKey::new(account.to_lowercase()), account_id) + .await + { + Ok(_) => Some(account_id), + Err(err) => { + tracing::error!( + event = "error", + context = "get_account_id", + error = ?err, + "Failed to write account id."); + None + } + } + } + Err(_) => None, + } + } + Err(err) => { + tracing::error!( + event = "error", + context = "get_account_id", + error = ?err, + "Failed to obtain account id."); + None + } + } + } + + pub async fn map_account_name(&self, account_id: u32) -> Option { + None + } +} diff --git a/crates/jmap/src/auth/acl.rs b/crates/jmap/src/auth/acl.rs new file mode 100644 index 00000000..8bb00500 --- /dev/null +++ b/crates/jmap/src/auth/acl.rs @@ -0,0 +1,492 @@ +use jmap_proto::{ + error::{method::MethodError, set::SetError}, + object::Object, + types::{ + acl::Acl, + collection::Collection, + id::Id, + property::Property, + value::{MaybePatchValue, Value}, + }, +}; +use store::{roaring::RoaringBitmap, write::key::DeserializeBigEndian, AclKey, Deserialize, Error}; +use utils::map::bitmap::{Bitmap, BitmapItem}; + +use crate::{JMAP, SUPERUSER_ID}; + +use super::AclToken; + +impl JMAP { + pub async fn shared_accounts(&self, mut acl_token: AclToken) -> Option { + for &grant_account_id in [acl_token.primary_id] + .iter() + .chain(acl_token.member_of.clone().iter()) + { + let from_key = AclKey { + grant_account_id, + to_account_id: 0, + to_collection: 0, + to_document_id: 0, + }; + let to_key = AclKey { + grant_account_id, + to_account_id: u32::MAX, + to_collection: u8::MAX, + to_document_id: u32::MAX, + }; + match self + .store + .iterate( + acl_token, + from_key, + to_key, + false, + true, + |acl_token, key, value| { + let acl_key = AclKey::deserialize(key)?; + if acl_token.is_member(acl_key.to_account_id) { + return Ok(true); + } + + let acl = Bitmap::::from(u64::deserialize(value)?); + let collection = Collection::from(acl_key.to_collection); + if !collection.is_valid() { + return Err(Error::InternalError(format!( + "Found corrupted collection in key {key:?}" + ))); + } + + let mut collections: Bitmap = Bitmap::new(); + if acl.contains(Acl::Read) || acl.contains(Acl::Administer) { + collections.insert(collection); + } + if collection == Collection::Mailbox + && (acl.contains(Acl::ReadItems) || acl.contains(Acl::Administer)) + { + collections.insert(Collection::Email); + } + + if !collections.is_empty() { + if let Some((_, sharing)) = acl_token + .access_to + .iter_mut() + .find(|(account_id, _)| *account_id == acl_key.to_account_id) + { + sharing.union(&collections); + } else { + acl_token + .access_to + .push((acl_key.to_account_id, collections)); + } + } + + Ok(true) + }, + ) + .await + { + Ok(acl_token_) => { + acl_token = acl_token_; + } + Err(err) => { + tracing::error!( + event = "error", + context = "shared_accounts", + error = ?err, + "Failed to iterate ACLs."); + return None; + } + } + } + acl_token.into() + } + + pub async fn shared_documents( + &self, + acl_token: &AclToken, + to_account_id: u32, + to_collection: Collection, + check_acls: impl Into>, + ) -> Result { + let check_acls = check_acls.into(); + let mut document_ids = RoaringBitmap::new(); + let to_collection = u8::from(to_collection); + for &grant_account_id in [acl_token.primary_id] + .iter() + .chain(acl_token.member_of.clone().iter()) + { + let from_key = AclKey { + grant_account_id, + to_account_id, + to_collection, + to_document_id: 0, + }; + let mut to_key = from_key; + to_key.to_document_id = u32::MAX; + + match self + .store + .iterate( + document_ids, + from_key, + to_key, + false, + true, + move |document_ids, key, value| { + let mut acls = Bitmap::::from(u64::deserialize(value)?); + + acls.intersection(&check_acls); + if !acls.is_empty() { + document_ids.insert( + key.deserialize_be_u32(key.len() - std::mem::size_of::())?, + ); + } + + Ok(true) + }, + ) + .await + { + Ok(document_ids_) => { + document_ids = document_ids_; + } + Err(err) => { + tracing::error!( + event = "error", + context = "shared_accounts", + error = ?err, + "Failed to iterate ACLs."); + return Err(MethodError::ServerPartialFail); + } + } + } + + Ok(document_ids) + } + + pub async fn shared_messages( + &self, + acl_token: &AclToken, + to_account_id: u32, + check_acls: impl Into>, + ) -> Result { + let check_acls = check_acls.into(); + let shared_mailboxes = self + .shared_documents(acl_token, to_account_id, Collection::Mailbox, check_acls) + .await?; + if shared_mailboxes.is_empty() { + return Ok(shared_mailboxes); + } + let mut shared_messages = RoaringBitmap::new(); + for mailbox_id in shared_mailboxes { + if let Some(messages_in_mailbox) = self + .get_tag( + to_account_id, + Collection::Email, + Property::MailboxIds, + mailbox_id, + ) + .await? + { + shared_messages |= messages_in_mailbox; + } + } + + Ok(shared_messages) + } + + pub async fn owned_or_shared_documents( + &self, + acl_token: &AclToken, + account_id: u32, + collection: Collection, + check_acls: impl Into>, + ) -> Result { + let check_acls = check_acls.into(); + let mut document_ids = self + .get_document_ids(account_id, collection) + .await? + .unwrap_or_default(); + if !document_ids.is_empty() && !acl_token.is_member(account_id) { + document_ids &= self + .shared_documents(acl_token, account_id, collection, check_acls) + .await?; + } + Ok(document_ids) + } + + pub async fn owned_or_shared_messages( + &self, + acl_token: &AclToken, + account_id: u32, + check_acls: impl Into>, + ) -> Result { + let check_acls = check_acls.into(); + let mut document_ids = self + .get_document_ids(account_id, Collection::Email) + .await? + .unwrap_or_default(); + if !document_ids.is_empty() && !acl_token.is_member(account_id) { + document_ids &= self + .shared_messages(acl_token, account_id, check_acls) + .await?; + } + Ok(document_ids) + } + + pub async fn has_access_to_document( + &self, + acl_token: &AclToken, + to_account_id: u32, + to_collection: impl Into, + to_document_id: u32, + check_acls: Bitmap, + ) -> Result { + let to_collection = to_collection.into(); + for &grant_account_id in [acl_token.primary_id] + .iter() + .chain(acl_token.member_of.clone().iter()) + { + match self + .store + .get_value::(AclKey { + grant_account_id, + to_account_id, + to_collection, + to_document_id, + }) + .await + { + Ok(Some(acls)) => { + let mut acls = Bitmap::::from(acls); + + acls.intersection(&check_acls); + if !acls.is_empty() { + return Ok(true); + } + } + Ok(None) => (), + Err(err) => { + tracing::error!( + event = "error", + context = "has_access_to_document", + error = ?err, + "Failed to verify ACL."); + return Err(MethodError::ServerPartialFail); + } + } + } + Ok(false) + } + + pub async fn acl_set( + &self, + changes: &mut Object, + current: Option<&Object>, + acl_changes: MaybePatchValue, + ) -> Result<(), SetError> { + match acl_changes { + MaybePatchValue::Value(Value::List(values)) => { + changes.properties.set( + Property::Acl, + Value::List(self.map_acl_accounts(values).await?), + ); + } + MaybePatchValue::Patch(patch) => { + let acl = if let Value::List(acl) = + changes + .properties + .get_mut_or_insert_with(Property::Acl, || { + current + .and_then(|current| current.properties.get(&Property::Acl).cloned()) + .unwrap_or_else(|| Value::List(Vec::new())) + }) { + acl + } else { + return Err(SetError::invalid_properties() + .with_property(Property::Acl) + .with_description("Invalid ACL value found.")); + }; + let account_id = patch.first().unwrap().as_id().unwrap(); + match patch.len() { + 2 => { + let acl_update = patch.last().unwrap().as_uint().unwrap(); + if let Some(idx) = + acl.iter().position(|item| item.as_id() == Some(account_id)) + { + if acl_update != 0 { + acl[idx + 1] = Value::UnsignedInt(acl_update); + } else if acl.len() > 2 { + acl.remove(idx); + acl.remove(idx); + } else { + acl.clear(); + } + } else if acl_update != 0 { + acl.push(Value::Id(*account_id)); + acl.push(Value::UnsignedInt(acl_update)); + } + } + 3 => { + let acl_item = Acl::from(patch[1].as_uint().unwrap()); + let set = patch[2].as_bool().unwrap_or(false); + if let Some(idx) = + acl.iter().position(|item| item.as_id() == Some(account_id)) + { + if let Some(Value::UnsignedInt(current)) = acl.get_mut(idx + 1) { + let mut bitmap = Bitmap::from(*current); + if set { + bitmap.insert(acl_item); + } else { + bitmap.remove(acl_item); + } + if !bitmap.is_empty() { + *current = bitmap.into(); + } else { + acl.remove(idx); + acl.remove(idx); + } + } else { + return Err(SetError::invalid_properties() + .with_property(Property::Acl) + .with_description("Invalid ACL value found.")); + } + } else if set { + acl.push(Value::Id(*account_id)); + acl.push(Value::UnsignedInt(Bitmap::new().with_item(acl_item).into())); + } + } + _ => unreachable!(), + } + } + _ => { + return Err(SetError::invalid_properties() + .with_property(Property::Acl) + .with_description("Invalid ACL property.")) + } + } + Ok(()) + } + + pub async fn acl_get(&self, value: &[Value], acl_token: &AclToken, account_id: u32) -> Value { + if acl_token.is_member(account_id) + || value.chunks_exact(2).any(|item| { + acl_token.is_member( + item.first() + .and_then(|v| v.as_id().map(|id| id.document_id())) + .unwrap_or(u32::MAX), + ) && Bitmap::from(item.last().and_then(|a| a.as_uint()).unwrap_or_default()) + .contains(Acl::Administer) + }) + { + let mut acl_obj = Object::with_capacity(value.len() / 2); + for item in value.chunks_exact(2) { + if let (Some(Value::Id(id)), Some(Value::UnsignedInt(acl_bits))) = + (item.first(), item.last()) + { + if let Some(account_name) = self.map_account_name(id.document_id()).await { + acl_obj.append( + Property::_T(account_name), + Bitmap::::from(*acl_bits) + .map(|acl_item| Value::Text(acl_item.to_string())) + .collect::>(), + ); + } + } + } + + Value::Object(acl_obj) + } else { + Value::Null + } + } + + async fn map_acl_accounts(&self, mut acl_set: Vec) -> Result, SetError> { + for item in &mut acl_set { + if let Value::Text(account_name) = item { + if let Some(account_id) = self.map_account_id(account_name).await { + *item = Value::Id(account_id.into()); + } else { + return Err(SetError::invalid_properties() + .with_property(Property::Acl) + .with_description(format!("Account {account_name} does not exist."))); + } + } + } + + Ok(acl_set) + } +} + +impl AclToken { + pub fn primary_id(&self) -> u32 { + self.primary_id + } + + pub fn is_member(&self, account_id: u32) -> bool { + self.primary_id == account_id + || self.member_of.contains(&account_id) + || self.primary_id == SUPERUSER_ID + || self.member_of.contains(&SUPERUSER_ID) + } + + pub fn is_shared(&self, account_id: u32) -> bool { + !self.is_member(account_id) && self.access_to.iter().any(|(id, _)| *id == account_id) + } + + pub fn has_access(&self, to_account_id: u32, to_collection: Collection) -> bool { + self.is_member(to_account_id) + || self.access_to.iter().any(|(id, collections)| { + *id == to_account_id && collections.contains(to_collection) + }) + } + + pub fn assert_has_access( + &self, + to_account_id: Id, + to_collection: Collection, + ) -> Result<&Self, MethodError> { + if self.has_access(to_account_id.document_id(), to_collection) { + Ok(self) + } else { + Err(MethodError::Forbidden(format!( + "You do not have access to account {}", + to_account_id + ))) + } + } + + pub fn assert_is_member(&self, account_id: Id) -> Result<&Self, MethodError> { + if self.is_member(account_id.document_id()) { + Ok(self) + } else { + Err(MethodError::Forbidden(format!( + "You are not an owner of account {}", + account_id + ))) + } + } +} + +pub trait EffectiveAcl { + fn effective_acl(&self, acl_token: &AclToken) -> Bitmap; +} + +impl EffectiveAcl for Object { + fn effective_acl(&self, acl_token: &AclToken) -> Bitmap { + let mut acl = Bitmap::::new(); + if let Some(Value::List(permissions)) = self.properties.get(&Property::Acl) { + for item in permissions.chunks(2) { + if let (Some(Value::Id(account_id)), Some(Value::UnsignedInt(acl_bits))) = + (item.first(), item.last()) + { + if acl_token.is_member(account_id.document_id()) { + acl.union(&Bitmap::from(*acl_bits)); + } + } + } + } + + acl + } +} diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs new file mode 100644 index 00000000..8c013a1e --- /dev/null +++ b/crates/jmap/src/auth/authenticate.rs @@ -0,0 +1,117 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::Arc, + time::Instant, +}; + +use hyper::header; +use jmap_proto::error::request::RequestError; +use mail_parser::decoders::base64::base64_decode; +use mail_send::mail_auth::common::lru::DnsCache; +use utils::listener::limiter::InFlight; + +use crate::JMAP; + +use super::{rate_limit::RemoteAddress, AclToken}; + +impl JMAP { + pub async fn authenticate_headers( + &self, + req: &mut hyper::Request, + remote_ip: IpAddr, + ) -> Result)>, RequestError> { + if let Some((mechanism, token)) = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.split_once(' ').map(|(l, t)| (l, t.trim().to_string()))) + { + let session = if let Some(session) = self.sessions.get(&token) { + session.into() + } else { + let addr = self.build_remote_addr(req, remote_ip); + if mechanism.eq_ignore_ascii_case("basic") { + // Enforce rate limit for authentication requests + self.is_auth_allowed(addr)?; + + // Decode the base64 encoded credentials + if let Some((account, secret)) = base64_decode(token.as_bytes()) + .and_then(|token| String::from_utf8(token).ok()) + .and_then(|token| { + token.split_once(':').map(|(login, secret)| { + (login.trim().to_lowercase(), secret.to_string()) + }) + }) + { + self.authenticate(&account, &secret).await + } else { + tracing::debug!( + context = "authenticate_headers", + token = token, + "Failed to decode Basic auth request.", + ); + None + } + } else if mechanism.eq_ignore_ascii_case("bearer") { + // Enforce anonymous rate limit for bearer auth requests + self.is_anonymous_allowed(addr)?; + + if let Some((account_id, _, _)) = + self.validate_access_token("access_token", &token) + { + self.get_acl_token(account_id).await + } else { + None + } + } else { + // Enforce anonymous rate limit + self.is_anonymous_allowed(addr)?; + None + } + .map(|session| { + let session = Arc::new(session); + self.sessions.insert( + token, + session.clone(), + Instant::now() + self.config.session_cache_ttl, + ); + session + }) + }; + + if let Some(session) = session { + // Enforce authenticated rate limit + Ok(Some(( + self.is_account_allowed(session.primary_id())?, + session, + ))) + } else { + Ok(None) + } + } else { + // Enforce anonymous rate limit + self.is_anonymous_allowed(self.build_remote_addr(req, remote_ip))?; + + Ok(None) + } + } + + pub fn build_remote_addr( + &self, + req: &hyper::Request, + remote_ip: IpAddr, + ) -> RemoteAddress { + if !self.config.rate_use_forwarded { + RemoteAddress::IpAddress(remote_ip) + } else if let Some(forwarded_for) = req + .headers() + .get(header::FORWARDED) + .and_then(|h| h.to_str().ok()) + { + RemoteAddress::IpAddressFwd(forwarded_for.trim().to_string()) + } else { + tracing::debug!("Warning: No remote address found in request, using loopback."); + RemoteAddress::IpAddress(Ipv4Addr::new(127, 0, 0, 1).into()) + } + } +} diff --git a/crates/jmap/src/auth/mod.rs b/crates/jmap/src/auth/mod.rs new file mode 100644 index 00000000..0cdd709a --- /dev/null +++ b/crates/jmap/src/auth/mod.rs @@ -0,0 +1,135 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; + +use aes_gcm_siv::{ + aead::{generic_array::GenericArray, Aead}, + AeadInPlace, Aes256GcmSiv, KeyInit, Nonce, +}; + +use jmap_proto::types::collection::Collection; +use store::{blake3, write::key::KeySerializer, Key, Serialize, SUBSPACE_VALUES}; +use utils::map::bitmap::Bitmap; + +pub mod account; +pub mod acl; +pub mod authenticate; +pub mod oauth; +pub mod rate_limit; + +#[derive(Debug, Clone)] +pub struct AclToken { + pub primary_id: u32, + pub member_of: Vec, + pub access_to: Vec<(u32, Bitmap)>, +} + +#[derive(Debug, Clone)] +pub enum AuthenticationResults { + Success(AccountDetails), + Failure, +} + +#[derive(Debug, Clone)] +pub struct AccountDetails { + pub id: String, + pub member_of: Vec, +} + +pub struct AccountKey { + pub name: String, +} + +impl AclToken { + pub fn new(primary_id: u32) -> Self { + Self { + primary_id, + member_of: Vec::new(), + access_to: Vec::new(), + } + } + + pub fn with_member_of(self, member_of: Vec) -> Self { + Self { member_of, ..self } + } + + pub fn with_access_to(self, access_to: Vec<(u32, Bitmap)>) -> Self { + Self { access_to, ..self } + } + + pub fn state(&self) -> u32 { + // Hash state + let mut s = DefaultHasher::new(); + self.member_of.hash(&mut s); + self.access_to.hash(&mut s); + s.finish() as u32 + } +} + +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(&GenericArray::clone_from_slice( + &blake3::derive_key(context, key)[..], + )), + } + } + + #[allow(clippy::ptr_arg)] + pub fn encrypt_in_place(&self, bytes: &mut Vec, nonce: &[u8]) -> Result<(), String> { + self.aes + .encrypt_in_place(Nonce::from_slice(nonce), b"", bytes) + .map_err(|e| e.to_string()) + } + + pub fn encrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { + self.aes + .encrypt(Nonce::from_slice(nonce), bytes) + .map_err(|e| e.to_string()) + } + + pub fn decrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { + self.aes + .decrypt(Nonce::from_slice(nonce), bytes) + .map_err(|e| e.to_string()) + } +} + +impl AccountKey { + pub fn new(name: String) -> Self { + Self { name } + } +} + +impl Serialize for AccountKey { + fn serialize(self) -> Vec { + { + #[cfg(feature = "key_subspace")] + { + KeySerializer::new(std::mem::size_of::() + self.name.len() + 1) + .write(SUBSPACE_VALUES) + } + #[cfg(not(feature = "key_subspace"))] + { + KeySerializer::new(std::mem::size_of::() + self.name.len()) + } + } + .write(0u32) + .write(self.name.as_bytes()) + .finalize() + } +} + +impl Key for AccountKey { + fn subspace(&self) -> u8 { + SUBSPACE_VALUES + } +} diff --git a/crates/jmap/src/auth/oauth/device_auth.rs b/crates/jmap/src/auth/oauth/device_auth.rs new file mode 100644 index 00000000..0bf68ac2 --- /dev/null +++ b/crates/jmap/src/auth/oauth/device_auth.rs @@ -0,0 +1,211 @@ +use std::{ + sync::{atomic, Arc}, + time::Instant, +}; + +use hyper::StatusCode; +use store::rand::{ + distributions::{Alphanumeric, Standard}, + thread_rng, +}; + +use crate::auth::oauth::{ + OAUTH_HTML_ERROR, OAUTH_HTML_LOGIN_HEADER_FAILED, OAUTH_HTML_LOGIN_SUCCESS, STATUS_AUTHORIZED, +}; + +use super::{ + DeviceAuthGet, DeviceAuthResponse, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, + OAUTH_HTML_FOOTER, OAUTH_HTML_HEADER, OAUTH_HTML_LOGIN_CODE, OAUTH_HTML_LOGIN_FORM, + OAUTH_HTML_LOGIN_HEADER_DEVICE, STATUS_PENDING, USER_CODE_ALPHABET, USER_CODE_LEN, +}; + +// Device authorization endpoint +pub async fn handle_device_auth( + core: web::Data>, + params: web::Form, +) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + // Validate clientId + if params.client_id.len() > CLIENT_ID_MAX_LEN { + return HttpResponse::BadRequest().body("Client ID is too long"); + } + + // Generate device code + let device_code = thread_rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Generate user code + let mut user_code = String::with_capacity(USER_CODE_LEN + 1); + for (pos, ch) in thread_rng() + .sample_iter::(Standard) + .take(USER_CODE_LEN) + .map(|v| char::from(USER_CODE_ALPHABET[v % USER_CODE_ALPHABET.len()])) + .enumerate() + { + if pos == USER_CODE_LEN / 2 { + user_code.push('-'); + } + user_code.push(ch); + } + + // Add OAuth status + let oauth_code = Arc::new(OAuthCode { + status: STATUS_PENDING.into(), + account_id: u32::MAX.into(), + expiry: Instant::now(), + client_id: params.into_inner().client_id, + redirect_uri: None, + }); + core.oauth_codes + .insert(device_code.clone(), oauth_code.clone()) + .await; + core.oauth_codes.insert(user_code.clone(), oauth_code).await; + + // Build response + let response = DeviceAuthResponse { + verification_uri: format!("{}/auth", core.base_session.base_url()), + verification_uri_complete: format!( + "{}/auth/code?={}", + core.base_session.base_url(), + user_code + ), + device_code, + user_code, + expires_in: core.oauth.expiry_user_code, + interval: 5, + }; + + HttpResponse::build(StatusCode::OK) + .content_type("application/json") + .body(serde_json::to_string(&response).unwrap_or_default()) +} + +// Device authorization flow, renders the authorization page +pub async fn handle_user_device_auth(params: web::Query) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + let code = params.code.as_deref().unwrap_or(""); + let mut response = String::with_capacity( + OAUTH_HTML_HEADER.len() + + OAUTH_HTML_LOGIN_HEADER_DEVICE.len() + + OAUTH_HTML_LOGIN_CODE.len() + + OAUTH_HTML_LOGIN_FORM.len() + + OAUTH_HTML_FOOTER.len() + + code.len() + + 16, + ); + + response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth")); + response.push_str(OAUTH_HTML_LOGIN_HEADER_DEVICE); + response.push_str(&OAUTH_HTML_LOGIN_CODE.replace("@@@", code)); + response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", "about:blank")); + response.push_str(OAUTH_HTML_FOOTER); + + HttpResponse::build(StatusCode::OK) + .content_type("text/html; charset=utf-8") + .body(response) +} + +// Handles POST request from the device authorization form +pub async fn handle_user_device_auth_post( + core: web::Data>, + params: web::Form, +) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + enum Response { + Success, + Failed, + InvalidCode, + Error, + } + + let params = params.into_inner(); + let code = if let Some(oauth) = params + .code + .as_ref() + .and_then(|code| core.oauth_codes.get(code)) + { + if (STATUS_PENDING..STATUS_PENDING + core.oauth.max_auth_attempts) + .contains(&oauth.status.load(atomic::Ordering::Relaxed)) + && oauth.expiry.elapsed().as_secs() < core.oauth.expiry_user_code + { + if let (Some(email), Some(password)) = (params.email, params.password) { + let store = core.store.clone(); + match core + .spawn_worker(move || store.authenticate(&email, &password)) + .await + { + Ok(Some(account_id)) => { + oauth + .account_id + .store(account_id, atomic::Ordering::Relaxed); + oauth + .status + .store(STATUS_AUTHORIZED, atomic::Ordering::Relaxed); + Response::Success + } + Ok(None) => { + oauth.status.fetch_add(1, atomic::Ordering::Relaxed); + Response::Failed + } + Err(_) => Response::Error, + } + } else { + Response::Failed + } + } else { + Response::InvalidCode + } + } else { + Response::InvalidCode + }; + + let mut response = String::with_capacity( + OAUTH_HTML_HEADER.len() + + OAUTH_HTML_LOGIN_HEADER_DEVICE.len() + + OAUTH_HTML_LOGIN_CODE.len() + + OAUTH_HTML_LOGIN_FORM.len() + + OAUTH_HTML_FOOTER.len() + + USER_CODE_LEN + + 17, + ); + response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth")); + + match code { + Response::Success => { + response.push_str(OAUTH_HTML_LOGIN_SUCCESS); + } + Response::Failed => { + response.push_str(OAUTH_HTML_LOGIN_HEADER_FAILED); + response.push_str( + &OAUTH_HTML_LOGIN_CODE.replace("@@@", params.code.as_deref().unwrap_or("")), + ); + response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", "about:blank")); + } + Response::InvalidCode => { + response.push_str( + &OAUTH_HTML_ERROR.replace("@@@", "Invalid or expired authentication code."), + ); + } + Response::Error => { + response.push_str(&OAUTH_HTML_ERROR.replace( + "@@@", + "There was a problem processing your request, please try again later.", + )); + } + } + + response.push_str(OAUTH_HTML_FOOTER); + + HttpResponse::build(StatusCode::OK) + .content_type("text/html; charset=utf-8") + .body(response) +} diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs new file mode 100644 index 00000000..4ff9c4b8 --- /dev/null +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -0,0 +1,196 @@ +use std::{sync::atomic::AtomicU32, time::Instant}; + +use serde::{Deserialize, Serialize}; + +pub mod device_auth; +pub mod token; +pub mod user_code; + +const OAUTH_HTML_HEADER: &str = include_str!("../../../../../resources/oauth/header.htx"); +const OAUTH_HTML_FOOTER: &str = include_str!("../../../../../resources/oauth/footer.htx"); +const OAUTH_HTML_LOGIN_HEADER_CLIENT: &str = + include_str!("../../../../../resources/oauth/login_hdr_client.htx"); +const OAUTH_HTML_LOGIN_HEADER_DEVICE: &str = + include_str!("../../../../../resources/oauth/login_hdr_device.htx"); +const OAUTH_HTML_LOGIN_HEADER_FAILED: &str = + include_str!("../../../../../resources/oauth/login_hdr_failed.htx"); +const OAUTH_HTML_LOGIN_FORM: &str = include_str!("../../../../../resources/oauth/login.htx"); +const OAUTH_HTML_LOGIN_CODE: &str = include_str!("../../../../../resources/oauth/login_code.htx"); +const OAUTH_HTML_LOGIN_CODE_HIDDEN: &str = + include_str!("../../../../../resources/oauth/login_code_hidden.htx"); +const OAUTH_HTML_LOGIN_SUCCESS: &str = + include_str!("../../../../../resources/oauth/login_success.htx"); +const OAUTH_HTML_ERROR: &str = include_str!("../../../../../resources/oauth/error.htx"); + +const STATUS_AUTHORIZED: u32 = 0; +const STATUS_TOKEN_ISSUED: u32 = 1; +const STATUS_PENDING: u32 = 2; + +const DEVICE_CODE_LEN: usize = 40; +const USER_CODE_LEN: usize = 8; +const RANDOM_CODE_LEN: usize = 32; +const CLIENT_ID_MAX_LEN: usize = 20; + +const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 + +pub struct OAuth { + pub key: String, + pub expiry_user_code: u64, + pub expiry_auth_code: u64, + pub expiry_token: u64, + pub expiry_refresh_token: u64, + pub expiry_refresh_token_renew: u64, + pub max_auth_attempts: u32, + pub metadata: String, +} + +pub struct OAuthCode { + pub status: AtomicU32, + pub account_id: AtomicU32, + pub expiry: Instant, + pub client_id: String, + pub redirect_uri: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthGet { + code: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthPost { + code: Option, + email: Option, + password: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthRequest { + client_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodeAuthRequest { + response_type: String, + client_id: String, + redirect_uri: String, + scope: Option, + state: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodeAuthForm { + code: String, + email: Option, + password: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: Option, + pub device_code: Option, + pub client_id: Option, + pub refresh_token: Option, + pub redirect_uri: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum TokenResponse { + Granted { + access_token: String, + token_type: String, + expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + }, + Error { + error: ErrorType, + }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ErrorType { + #[serde(rename = "invalid_grant")] + InvalidGrant, + #[serde(rename = "invalid_client")] + InvalidClient, + #[serde(rename = "invalid_scope")] + InvalidScope, + #[serde(rename = "invalid_request")] + InvalidRequest, + #[serde(rename = "unauthorized_client")] + UnauthorizedClient, + #[serde(rename = "unsupported_grant_type")] + UnsupportedGrantType, + #[serde(rename = "authorization_pending")] + AuthorizationPending, + #[serde(rename = "slow_down")] + SlowDown, + #[serde(rename = "access_denied")] + AccessDenied, + #[serde(rename = "expired_token")] + ExpiredToken, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthMetadata { + pub issuer: String, + pub token_endpoint: String, + pub grant_types_supported: Vec, + pub device_authorization_endpoint: String, + pub response_types_supported: Vec, + pub scopes_supported: Vec, + pub authorization_endpoint: String, +} + +// /.well-known/oauth-authorization-server endpoint +pub async fn handle_oauth_metadata(core: web::Data>) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + HttpResponse::build(StatusCode::OK) + .content_type("application/json") + .body(core.oauth.metadata.clone()) +} + +impl OAuthMetadata { + pub fn new(base_url: &str) -> Self { + OAuthMetadata { + issuer: base_url.to_string(), + authorization_endpoint: format!("{}/auth/code", base_url), + token_endpoint: format!("{}/auth/token", base_url), + grant_types_supported: vec![ + "authorization_code".to_string(), + "implicit".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ], + device_authorization_endpoint: format!("{}/auth/device", base_url), + response_types_supported: vec!["code".to_string(), "code token".to_string()], + scopes_supported: vec!["offline_access".to_string()], + } + } +} + +impl TokenResponse { + pub fn error(error: ErrorType) -> Self { + TokenResponse::Error { error } + } + + pub fn is_error(&self) -> bool { + matches!(self, TokenResponse::Error { .. }) + } +} diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs new file mode 100644 index 00000000..3407b204 --- /dev/null +++ b/crates/jmap/src/auth/oauth/token.rs @@ -0,0 +1,312 @@ +use std::{sync::atomic, time::SystemTime}; + +use hyper::StatusCode; +use mail_builder::encoders::base64::base64_encode; +use mail_parser::decoders::base64::base64_decode; +use store::{blake3, rand::thread_rng}; + +use crate::{auth::SymmetricEncrypt, JMAP}; + +use super::{ + ErrorType, TokenResponse, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN, STATUS_AUTHORIZED, + STATUS_PENDING, STATUS_TOKEN_ISSUED, +}; + +// Token endpoint +pub async fn handle_token_request( + core: web::Data>, + params: web::Form, +) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + let mut response = TokenResponse::error(ErrorType::InvalidGrant); + + if params.grant_type.eq_ignore_ascii_case("authorization_code") { + response = if let (Some(code), Some(client_id), Some(redirect_uri)) = + (¶ms.code, ¶ms.client_id, ¶ms.redirect_uri) + { + if let Some(oauth) = core.oauth_codes.get(code) { + if client_id != &oauth.client_id + || redirect_uri != oauth.redirect_uri.as_deref().unwrap_or("") + { + TokenResponse::error(ErrorType::InvalidClient) + } else if oauth.status.load(atomic::Ordering::Relaxed) == STATUS_AUTHORIZED + && oauth.expiry.elapsed().as_secs() < core.oauth.expiry_auth_code + { + // Mark this token as issued + oauth + .status + .store(STATUS_TOKEN_ISSUED, atomic::Ordering::Relaxed); + + // Issue token + core.issue_token( + oauth.account_id.load(atomic::Ordering::Relaxed), + &oauth.client_id, + true, + ) + .await + .unwrap_or_else(|err| { + tracing::error!("Failed to generate OAuth token: {}", err); + TokenResponse::error(ErrorType::InvalidRequest) + }) + } else { + TokenResponse::error(ErrorType::InvalidGrant) + } + } else { + TokenResponse::error(ErrorType::AccessDenied) + } + } else { + TokenResponse::error(ErrorType::InvalidClient) + }; + } else if params + .grant_type + .eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") + { + response = TokenResponse::error(ErrorType::ExpiredToken); + + if let (Some(oauth), Some(client_id)) = ( + params + .device_code + .as_ref() + .and_then(|dc| core.oauth_codes.get(dc)), + ¶ms.client_id, + ) { + if &oauth.client_id != client_id { + response = TokenResponse::error(ErrorType::InvalidClient); + } else if oauth.expiry.elapsed().as_secs() < core.oauth.expiry_user_code { + response = match oauth.status.load(atomic::Ordering::Relaxed) { + STATUS_AUTHORIZED => { + // Mark this token as issued + oauth + .status + .store(STATUS_TOKEN_ISSUED, atomic::Ordering::Relaxed); + + // Issue token + core.issue_token( + oauth.account_id.load(atomic::Ordering::Relaxed), + &oauth.client_id, + true, + ) + .await + .unwrap_or_else(|err| { + tracing::error!("Failed to generate OAuth token: {}", err); + TokenResponse::error(ErrorType::InvalidRequest) + }) + } + status + if (STATUS_PENDING..STATUS_PENDING + core.oauth.max_auth_attempts) + .contains(&status) => + { + TokenResponse::error(ErrorType::AuthorizationPending) + } + STATUS_TOKEN_ISSUED => TokenResponse::error(ErrorType::ExpiredToken), + _ => TokenResponse::error(ErrorType::AccessDenied), + }; + } + } + } else if params.grant_type.eq_ignore_ascii_case("refresh_token") { + if let Some(refresh_token) = ¶ms.refresh_token { + match core + .validate_access_token("refresh_token", refresh_token) + .await + { + Ok((account_id, client_id, time_left)) => { + // TODO: implement revoking client ids + response = core + .issue_token( + account_id, + &client_id, + time_left <= core.oauth.expiry_refresh_token_renew, + ) + .await + .unwrap_or_else(|err| { + tracing::debug!("Failed to refresh OAuth token: {}", err); + TokenResponse::error(ErrorType::InvalidGrant) + }); + } + Err(err) => { + tracing::debug!("Refresh token failed validation: {}", err); + } + } + } else { + response = TokenResponse::error(ErrorType::InvalidRequest); + } + } + + HttpResponse::build(if response.is_error() { + StatusCode::BAD_REQUEST + } else { + StatusCode::OK + }) + .content_type("application/json") + .body(serde_json::to_string(&response).unwrap_or_default()) +} + +impl JMAP { + async fn issue_token( + &self, + account_id: u32, + client_id: &str, + with_refresh_token: bool, + ) -> store::Result { + let store = self.store.clone(); + let password_hash = self + .spawn_worker(move || { + // Make sure account still exits + if let Some(secret_hash) = store.get_account_secret_hash(account_id)? { + Ok(secret_hash) + } else { + Err(StoreError::DeserializeError( + "Account no longer exists".into(), + )) + } + }) + .await?; + + Ok(TokenResponse::Granted { + access_token: self.encode_access_token( + "access_token", + account_id, + &password_hash, + client_id, + self.oauth.expiry_token, + )?, + token_type: "bearer".to_string(), + expires_in: self.oauth.expiry_token, + refresh_token: if with_refresh_token { + self.encode_access_token( + "refresh_token", + account_id, + &password_hash, + client_id, + self.oauth.expiry_refresh_token, + )? + .into() + } else { + None + }, + scope: None, + }) + } + + fn encode_access_token( + &self, + grant_type: &str, + account_id: u32, + password_hash: &str, + client_id: &str, + expiry_in: u64, + ) -> store::Result { + // Build context + if client_id.len() > CLIENT_ID_MAX_LEN { + return Err(StoreError::DeserializeError("ClientId is too long".into())); + } + let key = self.oauth.key.clone(); + let context = format!( + "{} {} {} {}", + grant_type, client_id, account_id, password_hash + ); + let context_nonce = format!("{} nonce {}", grant_type, password_hash); + + // Set expiration time + let expiry = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + .saturating_sub(946684800) // Jan 1, 2000 + + expiry_in; + + // Calculate nonce + let mut hasher = blake3::Hasher::new(); + hasher.update(context_nonce.as_bytes()); + hasher.update(expiry.to_be_bytes().as_slice()); + let nonce = hasher + .finalize() + .as_bytes() + .iter() + .take(SymmetricEncrypt::NONCE_LEN) + .copied() + .collect::>(); + + // Encrypt random bytes + let mut token = SymmetricEncrypt::new(key.as_bytes(), &context) + .encrypt(&thread_rng().gen::<[u8; RANDOM_CODE_LEN]>(), &nonce) + .map_err(StoreError::DeserializeError)?; + token.push_leb128(account_id); + token.push_leb128(expiry); + token.extend_from_slice(client_id.as_bytes()); + + Ok(String::from_utf8(base64_encode(&token).unwrap_or_default()).unwrap()) + } + + pub fn validate_access_token( + &self, + grant_type: &str, + token: &str, + ) -> Option<(u32, String, u64)> { + // Base64 decode token + let token = base64_decode(token.as_bytes()) + .ok_or_else(|| StoreError::DeserializeError("Failed to decode.".to_string()))?; + let (account_id, expiry, client_id) = token + .get((RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN)..) + .and_then(|bytes| { + let mut bytes = bytes.iter(); + ( + bytes.next_leb128()?, + bytes.next_leb128::()?, + bytes.copied().map(char::from).collect::(), + ) + .into() + }) + .ok_or_else(|| StoreError::DeserializeError("Failed to decode token.".into()))?; + + // Validate expiration + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + .saturating_sub(946684800); // Jan 1, 2000 + if expiry <= now { + return Err(StoreError::DeserializeError("Token expired.".into())); + } + + // Optain password hash + let store = self.store.clone(); + let password_hash = self + .spawn_worker(move || store.get_account_secret_hash(account_id)) + .await? + .ok_or_else(|| StoreError::DeserializeError("Account no longer exists".into()))?; + + // Build context + let key = self.oauth.key.clone(); + let context = format!( + "{} {} {} {}", + grant_type, client_id, account_id, password_hash + ); + let context_nonce = format!("{} nonce {}", grant_type, password_hash); + + // Calculate nonce + let mut hasher = blake3::Hasher::new(); + hasher.update(context_nonce.as_bytes()); + hasher.update(expiry.to_be_bytes().as_slice()); + let nonce = hasher + .finalize() + .as_bytes() + .iter() + .take(SymmetricEncrypt::NONCE_LEN) + .copied() + .collect::>(); + + // Decrypt + SymmetricEncrypt::new(key.as_bytes(), &context) + .decrypt( + &token[..RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN], + &nonce, + ) + .map_err(|e| StoreError::DeserializeError(format!("Failed to decrypt: {}", e)))?; + + // Success + Ok((account_id, client_id, expiry - now)) + } +} diff --git a/crates/jmap/src/auth/oauth/user_code.rs b/crates/jmap/src/auth/oauth/user_code.rs new file mode 100644 index 00000000..0f60124d --- /dev/null +++ b/crates/jmap/src/auth/oauth/user_code.rs @@ -0,0 +1,151 @@ +use std::{sync::Arc, time::Instant}; + +use hyper::{header, StatusCode}; +use mail_builder::encoders::base64::base64_encode; +use mail_parser::decoders::base64::base64_decode; +use store::rand::{distributions::Alphanumeric, thread_rng}; + +use super::{ + CodeAuthRequest, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, OAUTH_HTML_FOOTER, + OAUTH_HTML_HEADER, OAUTH_HTML_LOGIN_CODE_HIDDEN, OAUTH_HTML_LOGIN_FORM, + OAUTH_HTML_LOGIN_HEADER_CLIENT, OAUTH_HTML_LOGIN_HEADER_FAILED, STATUS_AUTHORIZED, +}; + +// Code authorization flow, handles an authorization request +pub async fn handle_user_code_auth(params: web::Query) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + // Validate clientId + if params.client_id.len() > CLIENT_ID_MAX_LEN { + return HttpResponse::BadRequest().body("Client ID is too long"); + } else if !params.redirect_uri.starts_with("https://") { + return HttpResponse::BadRequest().body("Redirect URI must be HTTPS"); + } + + let params = params.into_inner(); + let mut cancel_link = format!("{}?error=access_denied", params.redirect_uri); + if let Some(state) = ¶ms.state { + let _ = write!(cancel_link, "&state={}", state); + } + let code = String::from_utf8( + base64_encode(&bincode::serialize(&(1u32, params)).unwrap_or_default()).unwrap_or_default(), + ) + .unwrap(); + + let mut response = String::with_capacity( + OAUTH_HTML_HEADER.len() + + OAUTH_HTML_LOGIN_HEADER_CLIENT.len() + + OAUTH_HTML_LOGIN_CODE_HIDDEN.len() + + OAUTH_HTML_LOGIN_FORM.len() + + OAUTH_HTML_FOOTER.len() + + code.len() + + cancel_link.len() + + 10, + ); + + response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth/code")); + response.push_str(OAUTH_HTML_LOGIN_HEADER_CLIENT); + response.push_str(&OAUTH_HTML_LOGIN_CODE_HIDDEN.replace("@@@", &code)); + response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", &cancel_link)); + response.push_str(OAUTH_HTML_FOOTER); + + HttpResponse::build(StatusCode::OK) + .content_type("text/html; charset=utf-8") + .body(response) +} + +// Handles POST request from the code authorization form +pub async fn handle_user_code_auth_post( + core: web::Data>, + params: web::Form, +) -> HttpResponse +where + T: for<'x> Store<'x> + 'static, +{ + let mut auth_code = None; + let params = params.into_inner(); + let (auth_attempts, code_req) = match base64_decode(params.code.as_bytes()) + .and_then(|bytes| bincode::deserialize::<(u32, CodeAuthRequest)>(&bytes).ok()) + { + Some(code) => code, + None => { + return HttpResponse::BadRequest().body("Failed to deserialize code."); + } + }; + + // Authenticate user + if let (Some(email), Some(password)) = (params.email, params.password) { + let store = core.store.clone(); + + if let Ok(Some(account_id)) = core + .spawn_worker(move || store.authenticate(&email, &password)) + .await + { + // Generate client code + let client_code = thread_rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Add client code + core.oauth_codes + .insert( + client_code.clone(), + Arc::new(OAuthCode { + status: STATUS_AUTHORIZED.into(), + account_id: account_id.into(), + expiry: Instant::now(), + client_id: code_req.client_id.clone(), + redirect_uri: code_req.redirect_uri.clone().into(), + }), + ) + .await; + + auth_code = client_code.into(); + } + } + + // Build redirect link + let mut redirect_link = if let Some(auth_code) = &auth_code { + format!("{}?code={}", code_req.redirect_uri, auth_code) + } else { + format!("{}?error=access_denied", code_req.redirect_uri) + }; + if let Some(state) = &code_req.state { + let _ = write!(redirect_link, "&state={}", state); + } + + if auth_code.is_none() && (auth_attempts < core.oauth.max_auth_attempts) { + let code = String::from_utf8( + base64_encode(&bincode::serialize(&(auth_attempts + 1, code_req)).unwrap_or_default()) + .unwrap_or_default(), + ) + .unwrap(); + + let mut response = String::with_capacity( + OAUTH_HTML_HEADER.len() + + OAUTH_HTML_LOGIN_HEADER_CLIENT.len() + + OAUTH_HTML_LOGIN_CODE_HIDDEN.len() + + OAUTH_HTML_LOGIN_FORM.len() + + OAUTH_HTML_FOOTER.len() + + code.len() + + redirect_link.len() + + 10, + ); + response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth/code")); + response.push_str(OAUTH_HTML_LOGIN_HEADER_FAILED); + response.push_str(&OAUTH_HTML_LOGIN_CODE_HIDDEN.replace("@@@", &code)); + response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", &redirect_link)); + response.push_str(OAUTH_HTML_FOOTER); + + HttpResponse::build(StatusCode::OK) + .content_type("text/html; charset=utf-8") + .body(response) + } else { + HttpResponse::build(StatusCode::TEMPORARY_REDIRECT) + .insert_header((header::LOCATION, redirect_link)) + .finish() + } +} diff --git a/crates/jmap/src/auth/rate_limit.rs b/crates/jmap/src/auth/rate_limit.rs new file mode 100644 index 00000000..8f83c2a0 --- /dev/null +++ b/crates/jmap/src/auth/rate_limit.rs @@ -0,0 +1,118 @@ +use std::{ + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; + +use jmap_proto::error::request::{RequestError, RequestLimitError}; +use mail_send::mail_auth::common::lru::DnsCache; +use store::parking_lot::Mutex; +use utils::listener::limiter::{ConcurrencyLimiter, InFlight, RateLimiter}; + +use crate::{JMAP, SUPERUSER_ID}; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum RemoteAddress { + IpAddress(IpAddr), + IpAddressFwd(String), +} + +pub struct AuthenticatedLimiter { + request_limiter: RateLimiter, + concurrent_requests: ConcurrencyLimiter, + concurrent_uploads: ConcurrencyLimiter, +} + +pub struct AnonymousLimiter { + request_limiter: RateLimiter, + auth_limiter: RateLimiter, +} + +impl JMAP { + pub fn get_authenticated_limiter(&self, account_id: u32) -> Arc> { + self.rate_limit_auth.get(&account_id).unwrap_or_else(|| { + let limiter = Arc::new(Mutex::new(AuthenticatedLimiter { + request_limiter: RateLimiter::new( + self.config.rate_authenticated.requests, + self.config.rate_authenticated.period.as_secs(), + ), + concurrent_requests: ConcurrencyLimiter::new(self.config.request_max_concurrent), + concurrent_uploads: ConcurrencyLimiter::new( + self.config.upload_max_concurrent as u64, + ), + })); + self.rate_limit_auth.insert( + account_id, + limiter.clone(), + Instant::now() + self.config.session_cache_ttl, + ); + limiter + }) + } + + pub fn get_anonymous_limiter(&self, addr: RemoteAddress) -> Arc> { + self.rate_limit_unauth.get(&addr).unwrap_or_else(|| { + let limiter = Arc::new(Mutex::new(AnonymousLimiter { + request_limiter: RateLimiter::new( + self.config.rate_anonymous.requests, + self.config.rate_anonymous.period.as_secs(), + ), + auth_limiter: RateLimiter::new( + self.config.rate_authenticate_req.requests, + self.config.rate_authenticate_req.period.as_secs(), + ), + })); + self.rate_limit_unauth.insert( + addr, + limiter.clone(), + Instant::now() + Duration::from_secs(86400), + ); + limiter + }) + } + + pub fn is_account_allowed(&self, account_id: u32) -> Result { + if account_id != SUPERUSER_ID { + let limiter_ = self.get_authenticated_limiter(account_id); + let mut limiter = limiter_.lock(); + + if limiter.request_limiter.is_allowed() { + if let Some(in_flight_request) = limiter.concurrent_requests.is_allowed() { + Ok(in_flight_request) + } else { + Err(RequestError::limit(RequestLimitError::Concurrent)) + } + } else { + Err(RequestError::too_many_requests()) + } + } else { + Ok(InFlight::default()) + } + } + + pub fn is_anonymous_allowed(&self, addr: RemoteAddress) -> Result<(), RequestError> { + if self + .get_anonymous_limiter(addr) + .lock() + .request_limiter + .is_allowed() + { + Ok(()) + } else { + Err(RequestError::too_many_requests()) + } + } + + pub fn is_auth_allowed(&self, addr: RemoteAddress) -> Result<(), RequestError> { + if self + .get_anonymous_limiter(addr) + .lock() + .auth_limiter + .is_allowed() + { + Ok(()) + } else { + Err(RequestError::too_many_auth_attempts()) + } + } +} diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index c0f636b1..0f8ef5cb 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -1,24 +1,58 @@ use std::ops::Range; -use jmap_proto::{error::method::MethodError, types::blob::BlobId}; +use jmap_proto::{ + error::method::MethodError, + types::{acl::Acl, blob::BlobId}, +}; use mail_parser::{ decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode}, Encoding, }; use store::BlobKind; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; impl JMAP { pub async fn blob_download( &self, blob_id: &BlobId, - account_id: u32, + acl_token: &AclToken, ) -> store::Result>> { - if !blob_id.has_access(account_id) { - // TODO: validate ACL - let acl = "true"; - return Ok(None); + if !acl_token.is_member(blob_id.account_id()) { + match &blob_id.kind { + BlobKind::Linked { + account_id, + collection, + document_id, + } => { + match self + .has_access_to_document( + acl_token, + *account_id, + *collection, + *document_id, + Acl::Read.into(), + ) + .await + { + Ok(has_access) if has_access => (), + _ => return Ok(None), + } + } + BlobKind::LinkedMaildir { + account_id, + document_id, + } => { + match self + .shared_messages(acl_token, *account_id, Acl::ReadItems) + .await + { + Ok(shared_messages) if shared_messages.contains(*document_id) => (), + _ => return Ok(None), + } + } + BlobKind::Temporary { .. } => return Ok(None), + } } if let Some(section) = &blob_id.section { diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index a44af4b3..1885f154 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -5,17 +5,42 @@ use jmap_proto::{ }; use store::query::log::{Change, Changes, Query}; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; impl JMAP { - pub async fn changes(&self, request: ChangesRequest) -> Result { + pub async fn changes( + &self, + request: ChangesRequest, + acl_token: &AclToken, + ) -> Result { + // Map collection and validate ACLs let collection = match request.arguments { - RequestArguments::Email => Collection::Email, - RequestArguments::Mailbox => Collection::Mailbox, - RequestArguments::Thread => Collection::Thread, - RequestArguments::Identity => Collection::Identity, - RequestArguments::EmailSubmission => Collection::EmailSubmission, + RequestArguments::Email => { + acl_token.assert_has_access(request.account_id, Collection::Email)?; + Collection::Email + } + RequestArguments::Mailbox => { + acl_token.assert_has_access(request.account_id, Collection::Mailbox)?; + + Collection::Mailbox + } + RequestArguments::Thread => { + acl_token.assert_has_access(request.account_id, Collection::Email)?; + + Collection::Thread + } + RequestArguments::Identity => { + acl_token.assert_is_member(request.account_id)?; + + Collection::Identity + } + RequestArguments::EmailSubmission => { + acl_token.assert_is_member(request.account_id)?; + + Collection::EmailSubmission + } }; + let max_changes = if self.config.changes_max_results > 0 && self.config.changes_max_results < request.max_changes.unwrap_or(0) { diff --git a/crates/jmap/src/changes/query.rs b/crates/jmap/src/changes/query.rs index c28eb70f..32c6f914 100644 --- a/crates/jmap/src/changes/query.rs +++ b/crates/jmap/src/changes/query.rs @@ -7,28 +7,32 @@ use jmap_proto::{ }, }; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; impl JMAP { pub async fn query_changes( &self, request: QueryChangesRequest, + acl_token: &AclToken, ) -> Result { // Query changes let changes = self - .changes(ChangesRequest { - account_id: request.account_id, - since_state: request.since_query_state.clone(), - max_changes: request.max_changes, - arguments: match &request.arguments { - query::RequestArguments::Email(_) => changes::RequestArguments::Email, - query::RequestArguments::Mailbox(_) => changes::RequestArguments::Mailbox, - query::RequestArguments::EmailSubmission => { - changes::RequestArguments::EmailSubmission - } - _ => return Err(MethodError::UnknownMethod("Unknown method".to_string())), + .changes( + ChangesRequest { + account_id: request.account_id, + since_state: request.since_query_state.clone(), + max_changes: request.max_changes, + arguments: match &request.arguments { + query::RequestArguments::Email(_) => changes::RequestArguments::Email, + query::RequestArguments::Mailbox(_) => changes::RequestArguments::Mailbox, + query::RequestArguments::EmailSubmission => { + changes::RequestArguments::EmailSubmission + } + _ => return Err(MethodError::UnknownMethod("Unknown method".to_string())), + }, }, - }) + acl_token, + ) .await?; let calculate_total = request.calculate_total.unwrap_or(false); let has_changes = changes.has_changes(); @@ -60,10 +64,12 @@ impl JMAP { .map_or(false, |sort| sort.iter().any(|s| !s.is_immutable())); let results = match request.arguments { query::RequestArguments::Email(arguments) => { - self.email_query(query.with_arguments(arguments)).await? + self.email_query(query.with_arguments(arguments), acl_token) + .await? } query::RequestArguments::Mailbox(arguments) => { - self.mailbox_query(query.with_arguments(arguments)).await? + self.mailbox_query(query.with_arguments(arguments), acl_token) + .await? } query::RequestArguments::EmailSubmission => { let implement = "true"; diff --git a/crates/jmap/src/changes/state.rs b/crates/jmap/src/changes/state.rs index 16de3093..34b2966a 100644 --- a/crates/jmap/src/changes/state.rs +++ b/crates/jmap/src/changes/state.rs @@ -9,15 +9,16 @@ impl JMAP { pub async fn get_state( &self, account_id: u32, - collection: Collection, + collection: impl Into, ) -> Result { + let collection = collection.into(); match self.store.get_last_change_id(account_id, collection).await { Ok(id) => Ok(id.into()), Err(err) => { tracing::error!(event = "error", context = "store", account_id = account_id, - collection = ?collection, + collection = ?Collection::from(collection), error = ?err, "Failed to obtain state"); Err(MethodError::ServerPartialFail) diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index 1dbaca58..980ace44 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -11,6 +11,7 @@ use jmap_proto::{ Call, RequestMethod, }, types::{ + acl::Acl, blob::BlobId, collection::Collection, id::Id, @@ -27,7 +28,7 @@ use store::{ }; use utils::map::vec_map::VecMap; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; use super::{ index::{EmailIndexBuilder, TrimTextValue, MAX_SORT_FIELD_LENGTH}, @@ -38,6 +39,7 @@ impl JMAP { pub async fn email_copy( &self, request: CopyRequest, + acl_token: &AclToken, next_call: &mut Option>, ) -> Result { let account_id = request.account_id.document_id(); @@ -61,13 +63,16 @@ impl JMAP { }; let from_message_ids = self - .get_document_ids(from_account_id, Collection::Email) - .await? - .unwrap_or_default(); - let mailbox_ids = self - .get_document_ids(account_id, Collection::Mailbox) - .await? - .unwrap_or_default(); + .owned_or_shared_messages(acl_token, from_account_id, Acl::ReadItems) + .await?; + let mailbox_ids = self.mailbox_get_or_create(account_id).await?; + let can_add_mailbox_ids = if acl_token.is_shared(account_id) { + self.shared_documents(acl_token, account_id, Collection::Mailbox, Acl::AddItems) + .await? + .into() + } else { + None + }; let on_success_delete = request.on_success_destroy_original.unwrap_or(false); let mut destroy_ids = Vec::new(); @@ -164,11 +169,17 @@ impl JMAP { .with_description(format!("mailboxId {mailbox_id} does not exist.")), ); continue 'create; + } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {mailbox_id}." + )), + ); + continue 'create; } } - let validate_acl = "true"; - // Obtain term index and metadata let (mut metadata, token_index) = if let (Some(metadata), Some(token_index)) = ( self.get_property::>( diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index dacc9201..e03e2b4e 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -3,13 +3,13 @@ use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::{email::GetArguments, Object}, types::{ - blob::BlobId, collection::Collection, id::Id, keyword::Keyword, property::Property, - value::Value, + acl::Acl, blob::BlobId, collection::Collection, id::Id, keyword::Keyword, + property::Property, value::Value, }, }; use mail_parser::Message; -use crate::{email::headers::HeaderToValue, JMAP}; +use crate::{auth::AclToken, email::headers::HeaderToValue, JMAP}; use super::body::{ToBodyPart, TruncateBody}; @@ -17,6 +17,7 @@ impl JMAP { pub async fn email_get( &self, mut request: GetRequest, + acl_token: &AclToken, ) -> Result { let ids = request.unwrap_ids(self.config.get_max_objects)?; let properties = request.unwrap_properties(&[ @@ -65,14 +66,14 @@ impl JMAP { let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0); let account_id = request.account_id.document_id(); + let message_ids = self + .owned_or_shared_messages(acl_token, account_id, Acl::ReadItems) + .await?; let ids = if let Some(ids) = ids { ids } else { - let document_ids = self - .get_document_ids(account_id, Collection::Email) - .await? - .unwrap_or_default() - .into_iter() + let document_ids = message_ids + .iter() .take(self.config.get_max_objects) .collect::>(); self.get_properties::( @@ -119,6 +120,10 @@ impl JMAP { for id in ids { // Obtain the email object + if !message_ids.contains(id.document_id()) { + response.not_found.push(id); + continue; + } let mut values = match self .get_property::>( account_id, diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index ecc009ce..a29e3a6d 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -4,16 +4,17 @@ use jmap_proto::{ set::{SetError, SetErrorType}, }, method::import::{ImportEmailRequest, ImportEmailResponse}, - types::{collection::Collection, property::Property, state::State}, + types::{acl::Acl, collection::Collection, property::Property, state::State}, }; use utils::map::vec_map::VecMap; -use crate::{MaybeError, JMAP}; +use crate::{auth::AclToken, MaybeError, JMAP}; impl JMAP { pub async fn email_import( &self, request: ImportEmailRequest, + acl_token: &AclToken, ) -> Result { // Validate state let account_id = request.account_id.document_id(); @@ -21,11 +22,14 @@ impl JMAP { .assert_state(account_id, Collection::Email, &request.if_in_state) .await?; - let cococ = "implement ACLS"; - let valid_mailbox_ids = self - .get_document_ids(account_id, Collection::Mailbox) - .await? - .unwrap_or_default(); + let valid_mailbox_ids = self.mailbox_get_or_create(account_id).await?; + let can_add_mailbox_ids = if acl_token.is_shared(account_id) { + self.shared_documents(acl_token, account_id, Collection::Mailbox, Acl::AddItems) + .await? + .into() + } else { + None + }; let mut created = VecMap::with_capacity(request.emails.len()); let mut not_created = VecMap::with_capacity(request.emails.len()); @@ -56,11 +60,19 @@ impl JMAP { .with_description(format!("Mailbox {} does not exist.", mailbox_id)), ); continue 'outer; + } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {mailbox_id}." + )), + ); + continue 'outer; } } // Fetch raw message to import - let raw_message = match self.blob_download(&email.blob_id, account_id).await { + let raw_message = match self.blob_download(&email.blob_id, acl_token).await { Ok(Some(raw_message)) => raw_message, Ok(None) => { not_created.append( diff --git a/crates/jmap/src/email/parse.rs b/crates/jmap/src/email/parse.rs index d3d24255..a120d8a8 100644 --- a/crates/jmap/src/email/parse.rs +++ b/crates/jmap/src/email/parse.rs @@ -9,7 +9,7 @@ use mail_parser::{ }; use utils::map::vec_map::VecMap; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; use super::{ body::{ToBodyPart, TruncateBody}, @@ -21,6 +21,7 @@ impl JMAP { pub async fn email_parse( &self, request: ParseEmailRequest, + acl_token: &AclToken, ) -> Result { if request.blob_ids.len() > self.config.mail_parse_max_items { return Err(MethodError::RequestTooLarge); @@ -78,7 +79,7 @@ impl JMAP { for blob_id in request.blob_ids { // Fetch raw message to parse - let raw_message = match self.blob_download(&blob_id, account_id).await { + let raw_message = match self.blob_download(&blob_id, acl_token).await { Ok(Some(raw_message)) => raw_message, Ok(None) => { response.not_found.push(blob_id); diff --git a/crates/jmap/src/email/query.rs b/crates/jmap/src/email/query.rs index e35925b5..8e86062d 100644 --- a/crates/jmap/src/email/query.rs +++ b/crates/jmap/src/email/query.rs @@ -2,7 +2,7 @@ use jmap_proto::{ error::method::MethodError, method::query::{Comparator, Filter, QueryRequest, QueryResponse, SortProperty}, object::email::QueryArguments, - types::{collection::Collection, keyword::Keyword, property::Property}, + types::{acl::Acl, collection::Collection, keyword::Keyword, property::Property}, }; use mail_parser::{HeaderName, RfcHeader}; use store::{ @@ -12,12 +12,13 @@ use store::{ ValueKey, }; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; impl JMAP { pub async fn email_query( &self, mut request: QueryRequest, + acl_token: &AclToken, ) -> Result { let account_id = request.account_id.document_id(); let mut filters = Vec::with_capacity(request.filter.len()); @@ -215,9 +216,14 @@ impl JMAP { } } - let (response, result_set, paginate) = self - .query(account_id, Collection::Email, filters, &request) - .await?; + let mut result_set = self.filter(account_id, Collection::Email, filters).await?; + if acl_token.is_shared(account_id) { + result_set.apply_mask( + self.shared_messages(acl_token, account_id, Acl::ReadItems) + .await?, + ); + } + let (response, paginate) = self.build_query_response(&result_set, &request).await?; if let Some(paginate) = paginate { // Parse sort criteria diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index a1b9b627..48b09da2 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -8,6 +8,7 @@ use jmap_proto::{ method::set::{RequestArguments, SetRequest, SetResponse}, object::Object, types::{ + acl::Acl, collection::Collection, id::Id, keyword::Keyword, @@ -33,7 +34,7 @@ use store::{ BlobKind, Serialize, ValueKey, }; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; use super::{ headers::{BuildHeader, ValueToHeader}, @@ -44,18 +45,33 @@ impl JMAP { pub async fn email_set( &self, mut request: SetRequest, + acl_token: &AclToken, ) -> Result { // Prepare response let account_id = request.account_id.document_id(); - let mut set_response = self + let mut response = self .prepare_set_response(&request, Collection::Email) .await?; // Obtain mailboxIds - let mailbox_ids = self - .get_document_ids(account_id, Collection::Mailbox) - .await? - .unwrap_or_default(); + let mailbox_ids = self.mailbox_get_or_create(account_id).await?; + let (can_add_mailbox_ids, can_delete_mailbox_ids, can_modify_message_ids) = if acl_token + .is_shared(account_id) + { + ( + self.shared_documents(acl_token, account_id, Collection::Mailbox, Acl::AddItems) + .await? + .into(), + self.shared_documents(acl_token, account_id, Collection::Mailbox, Acl::RemoveItems) + .await? + .into(), + self.shared_messages(acl_token, account_id, Acl::ModifyItems) + .await? + .into(), + ) + } else { + (None, None, None) + }; let will_destroy = request.unwrap_destroy(); @@ -98,10 +114,10 @@ impl JMAP { // Parse properties for (property, value) in object.properties { - let value = match set_response.eval_object_references(value) { + let value = match response.eval_object_references(value) { Ok(value) => value, Err(err) => { - set_response.not_created.append(id, err); + response.not_created.append(id, err); continue 'create; } }; @@ -173,7 +189,7 @@ impl JMAP { builder = builder.header(header.as_rfc_header(), Address::List(addresses)); } else { - set_response.invalid_property_create(id, header); + response.invalid_property_create(id, header); continue 'create; } } @@ -211,7 +227,7 @@ impl JMAP { }), ) } else { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property(property) @@ -224,7 +240,7 @@ impl JMAP { (value.try_unwrap_list().unwrap_or_default(), None) } _ => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_properties([property, Property::BodyStructure]) @@ -331,7 +347,7 @@ impl JMAP { } } (Property::Headers, _) => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property(( @@ -351,7 +367,7 @@ impl JMAP { subparts = values.into(); } (body_property, value) if value != Value::Null => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, body_property)) @@ -370,7 +386,7 @@ impl JMAP { let is_multipart = content_type.starts_with("multipart/"); if is_multipart { if !matches!(property, Property::BodyStructure) { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, Property::Type)) @@ -382,7 +398,7 @@ impl JMAP { .as_ref() .map_or(false, |v| v != &content_type) { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, Property::Type)) @@ -397,7 +413,7 @@ impl JMAP { // Validate partId/blobId match (blob_id.is_some(), part_id.is_some()) { (true, true) if !is_multipart => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_properties([(property.clone(), Property::BlobId), (property, Property::PartId)]) @@ -408,7 +424,7 @@ impl JMAP { continue 'create; } (false, false) if !is_multipart => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_description("Expected a \"partId\" or \"blobId\" field in body part."), @@ -416,7 +432,7 @@ impl JMAP { continue 'create; } (false, true) if !is_multipart && has_size => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, Property::Size)) @@ -427,7 +443,7 @@ impl JMAP { continue 'create; } (true, _) | (_, true) if is_multipart => { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_properties([(property.clone(), Property::BlobId), (property, Property::PartId)]) @@ -449,7 +465,7 @@ impl JMAP { .attributes .push(("charset".into(), charset.into())); } else { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, Property::Charset)) @@ -502,12 +518,12 @@ impl JMAP { headers, contents: if !is_multipart { if let Some(blob_id) = blob_id { - match self.blob_download(&blob_id, account_id).await { + match self.blob_download(&blob_id, acl_token).await { Ok(Some(contents)) => { BodyPart::Binary(contents.into()) } Ok(None) => { - set_response.not_created.append( + response.not_created.append( id, SetError::new(SetErrorType::BlobNotFound).with_description( format!("blobId {blob_id} does not exist on this server.") @@ -531,7 +547,7 @@ impl JMAP { { BodyPart::Text(contents.as_str().into()) } else { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property((property, Property::PartId)) @@ -555,7 +571,7 @@ impl JMAP { if self.config.mail_attachments_max_size > 0 && size_attachments > self.config.mail_attachments_max_size { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property(property) @@ -606,7 +622,7 @@ impl JMAP { builder = builder_; } Err(header) => { - set_response.invalid_property_create(id, Property::Header(header)); + response.invalid_property_create(id, Property::Header(header)); continue 'create; } } @@ -615,7 +631,7 @@ impl JMAP { (_, MaybePatchValue::Value(Value::Null)) => (), (property, _) => { - set_response.invalid_property_create(id, property); + response.invalid_property_create(id, property); continue 'create; } } @@ -623,7 +639,7 @@ impl JMAP { // Make sure message belongs to at least one mailbox if mailboxes.is_empty() { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property(Property::MailboxIds) @@ -635,13 +651,21 @@ impl JMAP { // Verify that the mailboxIds are valid for mailbox_id in &mailboxes { if !mailbox_ids.contains(*mailbox_id) { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_property(Property::MailboxIds) .with_description(format!("mailboxId {mailbox_id} does not exist.")), ); continue 'create; + } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {mailbox_id}." + )), + ); + continue 'create; } } @@ -652,7 +676,7 @@ impl JMAP { && builder.text_body.is_none() && builder.attachments.is_none() { - set_response.not_created.append( + response.not_created.append( id, SetError::invalid_properties() .with_description("Message has to have at least one header or body part."), @@ -676,7 +700,7 @@ impl JMAP { builder.write_to(&mut raw_message).unwrap_or_default(); // Ingest message - set_response.created.insert( + response.created.insert( id, self.email_ingest(&raw_message, account_id, mailboxes, keywords, received_at) .await @@ -690,9 +714,7 @@ impl JMAP { 'update: for (id, object) in request.unwrap_update() { // Make sure id won't be destroyed if will_destroy.contains(&id) { - set_response - .not_updated - .append(id, SetError::will_destroy()); + response.not_updated.append(id, SetError::will_destroy()); continue 'update; } @@ -716,7 +738,7 @@ impl JMAP { ) { (TagManager::new(mailboxes), TagManager::new(keywords)) } else { - set_response.not_updated.append(id, SetError::not_found()); + response.not_updated.append(id, SetError::not_found()); continue 'update; }; @@ -727,10 +749,10 @@ impl JMAP { .with_collection(Collection::Email); for (property, value) in object.properties { - let value = match set_response.eval_object_references(value) { + let value = match response.eval_object_references(value) { Ok(value) => value, Err(err) => { - set_response.not_updated.append(id, err); + response.not_updated.append(id, err); continue 'update; } }; @@ -765,14 +787,14 @@ impl JMAP { ); } (property, _) => { - set_response.invalid_property_update(id, property); + response.invalid_property_update(id, property); continue 'update; } } } if !mailboxes.has_changes() && !keywords.has_changes() { - set_response.not_updated.append( + response.not_updated.append( id, SetError::invalid_properties() .with_description("No changes found in request.".to_string()), @@ -787,6 +809,16 @@ impl JMAP { // Process keywords if keywords.has_changes() { + // Verify permissions on shared accounts + if matches!(&can_modify_message_ids, Some(ids) if !ids.contains(document_id)) { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify keywords."), + ); + continue 'update; + } + // Set all current mailboxes as changed if the Seen tag changed if keywords .changed_tags() @@ -805,7 +837,7 @@ impl JMAP { if mailboxes.has_changes() { // Make sure the message is at least in one mailbox if !mailboxes.has_tags() { - set_response.not_updated.append( + response.not_updated.append( id, SetError::invalid_properties() .with_property(Property::MailboxIds) @@ -817,9 +849,21 @@ impl JMAP { // Make sure all new mailboxIds are valid for mailbox_id in mailboxes.added() { if mailbox_ids.contains(*mailbox_id) { - changed_mailboxes.insert(*mailbox_id); + // Verify permissions on shared accounts + if !matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) + { + changed_mailboxes.insert(*mailbox_id); + } else { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {mailbox_id}." + )), + ); + continue 'update; + } } else { - set_response.not_updated.append( + response.not_updated.append( id, SetError::invalid_properties() .with_property(Property::MailboxIds) @@ -833,7 +877,18 @@ impl JMAP { // Add all removed mailboxes to change list for mailbox_id in mailboxes.removed() { - changed_mailboxes.insert(*mailbox_id); + // Verify permissions on shared accounts + if !matches!(&can_delete_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + changed_mailboxes.insert(*mailbox_id); + } else { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to delete messages from mailbox {mailbox_id}." + )), + ); + continue 'update; + } } // Update mailboxIds property @@ -850,10 +905,10 @@ impl JMAP { match self.store.write(batch.build()).await { Ok(_) => { // Add to updated list - set_response.updated.append(id, None); + response.updated.append(id, None); } Err(store::Error::AssertValueFailed) => { - set_response.not_updated.append( + response.not_updated.append( id, SetError::forbidden().with_description( "Another process modified this message, please try again.", @@ -878,22 +933,37 @@ impl JMAP { .get_document_ids(account_id, Collection::Email) .await? .unwrap_or_default(); + let can_destroy_message_ids = if acl_token.is_shared(account_id) { + self.shared_messages(acl_token, account_id, Acl::RemoveItems) + .await? + .into() + } else { + None + }; for destroy_id in will_destroy { - if email_ids.contains(destroy_id.document_id()) { - match self - .email_delete(account_id, destroy_id.document_id()) - .await? + let document_id = destroy_id.document_id(); + + if email_ids.contains(document_id) { + if !matches!(&can_destroy_message_ids, Some(ids) if !ids.contains(document_id)) { - Ok(change) => { - changes.merge(change); - set_response.destroyed.push(destroy_id); - } - Err(err) => { - set_response.not_destroyed.append(destroy_id, err); + match self.email_delete(account_id, document_id).await? { + Ok(change) => { + changes.merge(change); + response.destroyed.push(destroy_id); + } + Err(err) => { + response.not_destroyed.append(destroy_id, err); + } } + } else { + response.not_destroyed.append( + destroy_id, + SetError::forbidden() + .with_description("You are not allowed to delete this message."), + ); } } else { - set_response + response .not_destroyed .append(destroy_id, SetError::not_found()); } @@ -901,12 +971,12 @@ impl JMAP { } if !changes.is_empty() { - set_response.new_state = self.commit_changes(account_id, changes).await?.into(); - } else if !set_response.created.is_empty() { - set_response.new_state = self.get_state(account_id, Collection::Email).await?.into(); + response.new_state = self.commit_changes(account_id, changes).await?.into(); + } else if !response.created.is_empty() { + response.new_state = self.get_state(account_id, Collection::Email).await?.into(); } - Ok(set_response) + Ok(response) } pub async fn email_delete( diff --git a/crates/jmap/src/email/snippet.rs b/crates/jmap/src/email/snippet.rs index 03b15ec9..0c380924 100644 --- a/crates/jmap/src/email/snippet.rs +++ b/crates/jmap/src/email/snippet.rs @@ -4,7 +4,7 @@ use jmap_proto::{ query::Filter, search_snippet::{GetSearchSnippetRequest, GetSearchSnippetResponse, SearchSnippet}, }, - types::collection::Collection, + types::{acl::Acl, collection::Collection}, }; use mail_parser::{decoders::html::html_to_text, Message, PartType}; use store::{ @@ -19,7 +19,7 @@ use store::{ BlobKind, }; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; use super::index::MAX_MESSAGE_PARTS; @@ -27,6 +27,7 @@ impl JMAP { pub async fn email_search_snippet( &self, request: GetSearchSnippetRequest, + acl_token: &AclToken, ) -> Result { let mut filter_stack = vec![]; let mut include_term = true; @@ -77,11 +78,9 @@ impl JMAP { } } let account_id = request.account_id.document_id(); - let todo = "acls"; let document_ids = self - .get_document_ids(account_id, Collection::Email) - .await? - .unwrap_or_default(); + .owned_or_shared_messages(acl_token, account_id, Acl::ReadItems) + .await?; let email_ids = request.email_ids.unwrap(); let mut response = GetSearchSnippetResponse { account_id: request.account_id, diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index b21eea64..d63660f2 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -1,4 +1,10 @@ +use std::{sync::Arc, time::Duration}; + use api::session::BaseCapabilities; +use auth::{ + rate_limit::{AnonymousLimiter, AuthenticatedLimiter, RemoteAddress}, + AclToken, +}; use jmap_proto::{ error::method::MethodError, method::{ @@ -8,26 +14,33 @@ use jmap_proto::{ request::reference::MaybeReference, types::{collection::Collection, property::Property}, }; +use mail_send::mail_auth::common::lru::{DnsCache, LruCache}; use store::{ ahash::AHashMap, - fts::{term_index::TermIndex, Language}, + fts::Language, + parking_lot::Mutex, query::{sort::Pagination, Comparator, Filter, ResultSet, SortedResultSet}, roaring::RoaringBitmap, write::BitmapFamily, BitmapKey, Deserialize, Serialize, Store, ValueKey, }; -use utils::{map::vec_map::VecMap, UnwrapFailure}; +use utils::{config::Rate, map::vec_map::VecMap, UnwrapFailure}; pub mod api; pub mod blob; pub mod changes; pub mod email; pub mod mailbox; +//pub mod principal; +pub mod auth; pub mod thread; pub struct JMAP { pub store: Store, pub config: Config, + pub sessions: LruCache>, + pub rate_limit_auth: LruCache>>, + pub rate_limit_unauth: LruCache>>, } pub struct Config { @@ -54,9 +67,17 @@ pub struct Config { pub sieve_max_script_name: usize, pub sieve_max_scripts: usize, + pub session_cache_ttl: Duration, + pub rate_authenticated: Rate, + pub rate_authenticate_req: Rate, + pub rate_anonymous: Rate, + pub rate_use_forwarded: bool, + pub capabilities: BaseCapabilities, } +pub const SUPERUSER_ID: u32 = 0; + pub enum MaybeError { Temporary, Permanent(String), @@ -67,6 +88,24 @@ impl JMAP { JMAP { store: Store::open(config).await.failed("Unable to open database"), config: Config::new(config).failed("Invalid configuration file"), + sessions: LruCache::with_capacity( + config + .property("jmap.session.cache.size") + .failed("Invalid property") + .unwrap_or(100), + ), + rate_limit_auth: LruCache::with_capacity( + config + .property("jmap.rate-limit.authenticated.size") + .failed("Invalid property") + .unwrap_or(1024), + ), + rate_limit_unauth: LruCache::with_capacity( + config + .property("jmap.rate-limit.anonymous.size") + .failed("Invalid property") + .unwrap_or(2048), + ), } } @@ -293,14 +332,11 @@ impl JMAP { }) } - pub async fn query( + pub async fn build_query_response( &self, - account_id: u32, - collection: Collection, - filters: Vec, + result_set: &ResultSet, request: &QueryRequest, - ) -> Result<(QueryResponse, ResultSet, Option), MethodError> { - let result_set = self.filter(account_id, collection, filters).await?; + ) -> Result<(QueryResponse, Option), MethodError> { let total = result_set.results.len() as usize; let (limit_total, limit) = if let Some(limit) = request.limit { if limit > 0 { @@ -318,7 +354,9 @@ impl JMAP { Ok(( QueryResponse { account_id: request.account_id, - query_state: self.get_state(account_id, collection).await?, + query_state: self + .get_state(result_set.account_id, result_set.collection) + .await?, can_calculate_changes: true, position: 0, ids: vec![], @@ -329,7 +367,6 @@ impl JMAP { }, limit: if total > limit { Some(limit) } else { None }, }, - result_set, if limit_total > 0 { Pagination::new( limit_total, diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index f992ad78..6836b625 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -2,16 +2,20 @@ use jmap_proto::{ error::method::MethodError, method::get::{GetRequest, GetResponse, RequestArguments}, object::Object, - types::{collection::Collection, keyword::Keyword, property::Property, value::Value}, + types::{acl::Acl, collection::Collection, keyword::Keyword, property::Property, value::Value}, }; use store::{ahash::AHashSet, roaring::RoaringBitmap}; -use crate::JMAP; +use crate::{ + auth::{acl::EffectiveAcl, AclToken}, + JMAP, +}; impl JMAP { pub async fn mailbox_get( &self, mut request: GetRequest, + acl_token: &AclToken, ) -> Result { let ids = request.unwrap_ids(self.config.get_max_objects)?; let properties = request.unwrap_properties(&[ @@ -28,10 +32,12 @@ impl JMAP { Property::MyRights, ]); let account_id = request.account_id.document_id(); - let mailbox_ids = self - .get_document_ids(account_id, Collection::Mailbox) - .await? - .unwrap_or_default(); + let mut mailbox_ids = self.mailbox_get_or_create(account_id).await?; + if acl_token.is_shared(account_id) { + mailbox_ids &= self + .shared_documents(acl_token, account_id, Collection::Mailbox, Acl::Read) + .await?; + } let message_ids = self.get_document_ids(account_id, Collection::Email).await?; let ids = if let Some(ids) = ids { ids @@ -146,36 +152,68 @@ impl JMAP { .await? as u64, ), Property::MyRights => { - let todo = "add shared"; - mailbox_rights_owner() + if acl_token.is_shared(account_id) { + let acl = values.effective_acl(acl_token); + Object::with_capacity(9) + .with_property(Property::MayReadItems, acl.contains(Acl::ReadItems)) + .with_property(Property::MayAddItems, acl.contains(Acl::AddItems)) + .with_property( + Property::MayRemoveItems, + acl.contains(Acl::RemoveItems), + ) + .with_property(Property::MaySetSeen, acl.contains(Acl::ModifyItems)) + .with_property( + Property::MaySetKeywords, + acl.contains(Acl::ModifyItems), + ) + .with_property( + Property::MayCreateChild, + acl.contains(Acl::CreateChild), + ) + .with_property(Property::MayRename, acl.contains(Acl::Modify)) + .with_property(Property::MayDelete, acl.contains(Acl::Delete)) + .with_property(Property::MaySubmit, acl.contains(Acl::Submit)) + .into() + } else { + Object::with_capacity(9) + .with_property(Property::MayReadItems, true) + .with_property(Property::MayAddItems, true) + .with_property(Property::MayRemoveItems, true) + .with_property(Property::MaySetSeen, true) + .with_property(Property::MaySetKeywords, true) + .with_property(Property::MayCreateChild, true) + .with_property(Property::MayRename, true) + .with_property(Property::MayDelete, true) + .with_property(Property::MaySubmit, true) + .into() + } } Property::IsSubscribed => values .properties .remove(property) .map(|parent_id| match parent_id { Value::List(values) - if values.contains(&Value::Id(account_id.into())) => + if values.contains(&Value::Id(acl_token.primary_id().into())) => { - let todo = "use acl id"; Value::Bool(true) } _ => Value::Bool(false), }) .unwrap_or(Value::Bool(false)), - /*Property::ACL - if acl.is_member(account_id) - || self - .mail_shared_folders(account_id, &acl.member_of, Acl::Administer)? - .has_access(document_id) => - { - let mut acl_get = VecMap::new(); - for (account_id, acls) in fields.as_ref().unwrap().get_acls() { - if let Some(email) = self.principal_to_email(account_id)? { - acl_get.append(email, acls); - } - } - Value::ACLGet(acl_get) - }*/ + Property::Acl => { + self.acl_get( + values + .properties + .get(&Property::Acl) + .and_then(|v| v.as_list()) + .map(|v| &v[..]) + .unwrap_or_else(|| &[]), + acl_token, + account_id, + ) + .await + } + _ => Value::Null, }; @@ -253,31 +291,3 @@ impl JMAP { } } } - -fn mailbox_rights_owner() -> Value { - Object::with_capacity(9) - .with_property(Property::MayReadItems, true) - .with_property(Property::MayAddItems, true) - .with_property(Property::MayRemoveItems, true) - .with_property(Property::MaySetSeen, true) - .with_property(Property::MaySetKeywords, true) - .with_property(Property::MayCreateChild, true) - .with_property(Property::MayRename, true) - .with_property(Property::MayDelete, true) - .with_property(Property::MaySubmit, true) - .into() -} - -/*fn mailbox_rights_shared(acl: Bitmap) -> Value { - Object::with_capacity(9) - .with_property(Property::MayReadItems, acl.contains(Acl::ReadItems)) - .with_property(Property::MayAddItems, acl.contains(Acl::AddItems)) - .with_property(Property::MayRemoveItems, acl.contains(Acl::RemoveItems)) - .with_property(Property::MaySetSeen, acl.contains(Acl::ModifyItems)) - .with_property(Property::MaySetKeywords, acl.contains(Acl::ModifyItems)) - .with_property(Property::MayCreateChild, acl.contains(Acl::CreateChild)) - .with_property(Property::MayRename, acl.contains(Acl::Modify)) - .with_property(Property::MayDelete, acl.contains(Acl::Delete)) - .with_property(Property::MaySubmit, acl.contains(Acl::Submit)) - .into() -}*/ diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs index f9069a4e..5d6594d7 100644 --- a/crates/jmap/src/mailbox/query.rs +++ b/crates/jmap/src/mailbox/query.rs @@ -2,7 +2,7 @@ use jmap_proto::{ error::method::MethodError, method::query::{Comparator, Filter, QueryRequest, QueryResponse, SortProperty}, object::{mailbox::QueryArguments, Object}, - types::{collection::Collection, property::Property, value::Value}, + types::{acl::Acl, collection::Collection, property::Property, value::Value}, }; use store::{ ahash::{AHashMap, AHashSet}, @@ -11,15 +11,14 @@ use store::{ roaring::RoaringBitmap, }; -use crate::{UpdateResults, JMAP}; +use crate::{auth::AclToken, UpdateResults, JMAP}; impl JMAP { pub async fn mailbox_query( &self, mut request: QueryRequest, + acl_token: &AclToken, ) -> Result { - let todo = "fix primary"; - let primary_account_id = request.account_id.document_id(); let account_id = request.account_id.document_id(); let sort_as_tree = request.arguments.sort_as_tree.unwrap_or(false); let filter_as_tree = request.arguments.filter_as_tree.unwrap_or(false); @@ -69,7 +68,7 @@ impl JMAP { } filters.push(query::Filter::eq( Property::IsSubscribed, - primary_account_id, + acl_token.primary_id, )); if !is_subscribed { filters.push(query::Filter::End); @@ -83,9 +82,16 @@ impl JMAP { } } - let (mut response, mut result_set, mut paginate) = self - .query(account_id, Collection::Mailbox, filters, &request) + let mut result_set = self + .filter(account_id, Collection::Mailbox, filters) .await?; + if acl_token.is_shared(account_id) { + result_set.apply_mask( + self.shared_documents(acl_token, account_id, Collection::Mailbox, Acl::Read) + .await?, + ); + } + let (mut response, mut paginate) = self.build_query_response(&result_set, &request).await?; // Build mailbox tree let mut hierarchy = AHashMap::default(); diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index a3dc3ea3..eb0919a8 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -10,6 +10,7 @@ use jmap_proto::{ Object, }, types::{ + acl::Acl, collection::Collection, id::Id, property::Property, @@ -22,19 +23,23 @@ use store::{ write::{assert::HashedValue, log::ChangeLogBuilder, BatchBuilder, F_BITMAP, F_CLEAR, F_VALUE}, }; -use crate::JMAP; +use crate::{ + auth::{acl::EffectiveAcl, AclToken}, + JMAP, SUPERUSER_ID, +}; use super::{INBOX_ID, TRASH_ID}; -struct SetContext { +struct SetContext<'x> { account_id: u32, - primary_id: u32, + acl_token: &'x AclToken, + is_shared: bool, set_response: SetResponse, mailbox_ids: RoaringBitmap, will_destroy: Vec, } -static SCHEMA: &[IndexProperty] = &[ +pub static SCHEMA: &[IndexProperty] = &[ IndexProperty::new(Property::Name) .index_as(IndexAs::Text { tokenize: true, @@ -49,6 +54,7 @@ static SCHEMA: &[IndexProperty] = &[ IndexProperty::new(Property::ParentId).index_as(IndexAs::Integer), IndexProperty::new(Property::SortOrder).index_as(IndexAs::Integer), IndexProperty::new(Property::IsSubscribed).index_as(IndexAs::IntegerList), + IndexProperty::new(Property::Acl).index_as(IndexAs::Acl), ]; impl JMAP { @@ -56,20 +62,19 @@ impl JMAP { pub async fn mailbox_set( &self, mut request: SetRequest, + acl_token: &AclToken, ) -> Result { // Prepare response let account_id = request.account_id.document_id(); let on_destroy_remove_emails = request.arguments.on_destroy_remove_emails.unwrap_or(false); let mut ctx = SetContext { account_id, - primary_id: account_id, + is_shared: acl_token.is_shared(account_id), + acl_token, set_response: self .prepare_set_response(&request, Collection::Mailbox) .await?, - mailbox_ids: self - .get_document_ids(account_id, Collection::Mailbox) - .await? - .unwrap_or_default(), + mailbox_ids: self.mailbox_get_or_create(account_id).await?, will_destroy: request.unwrap_destroy(), }; @@ -121,6 +126,29 @@ impl JMAP { ) .await? { + // Validate ACL + if ctx.is_shared { + let acl = mailbox.inner.effective_acl(acl_token); + if !acl.contains(Acl::Modify) { + ctx.set_response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this mailbox."), + ); + continue 'update; + } else if object.properties.contains_key(&Property::Acl) + && !acl.contains(Acl::Administer) + { + ctx.set_response.not_updated.append( + id, + SetError::forbidden().with_description( + "You are not allowed to change the permissions of this mailbox.", + ), + ); + continue 'update; + } + } + match self .mailbox_set_item(object, (document_id, mailbox.take()).into(), &ctx) .await? @@ -172,8 +200,9 @@ impl JMAP { 'destroy: for id in ctx.will_destroy { let document_id = id.document_id(); // Internal folders cannot be deleted - #[cfg(not(feature = "test_mode"))] - if document_id == INBOX_ID || document_id == TRASH_ID { + if (document_id == INBOX_ID || document_id == TRASH_ID) + && !acl_token.is_member(SUPERUSER_ID) + { ctx.set_response.not_destroyed.append( id, SetError::forbidden() @@ -331,6 +360,28 @@ impl JMAP { ) .await? { + // Validate ACLs + if ctx.is_shared { + let acl = mailbox.inner.effective_acl(acl_token); + if !acl.contains(Acl::Administer) { + if !acl.contains(Acl::Delete) { + ctx.set_response.not_destroyed.append( + id, + SetError::forbidden().with_description( + "You are not allowed to delete this mailbox.", + ), + ); + } else if on_destroy_remove_emails && !acl.contains(Acl::RemoveItems) { + ctx.set_response.not_destroyed.append( + id, + SetError::forbidden().with_description( + "You are not allowed to delete emails from this mailbox.", + ), + ); + } + } + } + let mut batch = BatchBuilder::new(); batch .with_account_id(account_id) @@ -384,7 +435,7 @@ impl JMAP { &self, changes_: Object, update: Option<(u32, Object)>, - ctx: &SetContext, + ctx: &SetContext<'_>, ) -> Result, MethodError> { // Parse properties let mut changes = Object::with_capacity(changes_.properties.len()); @@ -427,8 +478,7 @@ impl JMAP { } (Property::ParentId, MaybePatchValue::Value(Value::Null)) => Value::Id(0u64.into()), (Property::IsSubscribed, MaybePatchValue::Value(Value::Bool(subscribe))) => { - let fixme = "true"; - let account_id = Value::Id(ctx.primary_id.into()); + let account_id = Value::Id(ctx.acl_token.primary_id().into()); let mut new_value = None; if let Some((_, current_fields)) = update.as_ref() { if let Value::List(subscriptions) = @@ -487,9 +537,18 @@ impl JMAP { (Property::SortOrder, MaybePatchValue::Value(Value::UnsignedInt(value))) => { Value::UnsignedInt(value) } - (Property::Acl, _) => { - todo!() + (Property::Acl, value) => { + match self + .acl_set(&mut changes, update.as_ref().map(|(_, obj)| obj), value) + .await + { + Ok(_) => continue, + Err(err) => { + return Ok(Err(err)); + } + } } + _ => { return Ok(Err(SetError::invalid_properties() .with_property(property) @@ -507,12 +566,16 @@ impl JMAP { .map_or(u32::MAX, |(mailbox_id, _)| *mailbox_id + 1); let mut mailbox_parent_id = mailbox_parent_id.document_id(); let mut success = false; - for _ in 0..self.config.mailbox_max_depth { + for depth in 0..self.config.mailbox_max_depth { if mailbox_parent_id == current_mailbox_id { return Ok(Err(SetError::invalid_properties() .with_property(Property::ParentId) .with_description("Mailbox cannot be a parent of itself."))); } else if mailbox_parent_id == 0 { + if depth == 0 && ctx.is_shared { + return Ok(Err(SetError::forbidden() + .with_description("You are not allowed to create root folders."))); + } success = true; break; } @@ -527,6 +590,17 @@ impl JMAP { ) .await? { + if depth == 0 + && ctx.is_shared + && !fields + .effective_acl(ctx.acl_token) + .contains_any([Acl::CreateChild, Acl::Administer].into_iter()) + { + return Ok(Err(SetError::forbidden().with_description( + "You are not allowed to create sub mailboxes under this mailbox.", + ))); + } + mailbox_parent_id = fields .properties .remove(&Property::ParentId) @@ -652,4 +726,54 @@ impl JMAP { .with_current_opt(update.map(|(_, current)| current)) .validate()) } + + pub async fn mailbox_get_or_create( + &self, + account_id: u32, + ) -> Result { + let mut mailbox_ids = self + .get_document_ids(account_id, Collection::Mailbox) + .await? + .unwrap_or_default(); + if !mailbox_ids.is_empty() { + return Ok(mailbox_ids); + } + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox); + + // Create mailboxes + for (name, role) in [ + ("Inbox", "inbox"), + ("Deleted Items", "trash"), + ("Drafts", "drafts"), + ("Sent Items", "sent"), + ("Junk Mail", "junk"), + ] { + let mailbox_id = self + .assign_document_id(account_id, Collection::Mailbox) + .await?; + batch.create_document(mailbox_id).custom( + ObjectIndexBuilder::new(SCHEMA).with_changes( + Object::with_capacity(3) + .with_property(Property::Name, name) + .with_property(Property::Role, role) + .with_property(Property::ParentId, 0u32), + ), + ); + mailbox_ids.insert(mailbox_id); + } + self.store.write(batch.build()).await.map_err(|err| { + tracing::error!( + event = "error", + context = "mailbox_get_or_create", + error = ?err, + "Failed to create mailboxes."); + MethodError::ServerPartialFail + })?; + + Ok(mailbox_ids) + } } diff --git a/crates/jmap/src/principal/mod.rs b/crates/jmap/src/principal/mod.rs new file mode 100644 index 00000000..e3dcac25 --- /dev/null +++ b/crates/jmap/src/principal/mod.rs @@ -0,0 +1,15 @@ +pub mod set; + +/* + +user +uid +gid +secret +addresses +name +quota + + + + */ diff --git a/crates/jmap/src/principal/set.rs b/crates/jmap/src/principal/set.rs new file mode 100644 index 00000000..15fe91f0 --- /dev/null +++ b/crates/jmap/src/principal/set.rs @@ -0,0 +1,266 @@ +use jmap_proto::{ + error::{ + method::MethodError, + set::{SetError, SetErrorType}, + }, + method::set::{RequestArguments, SetRequest, SetResponse}, + object::{ + index::{IndexAs, IndexProperty, ObjectIndexBuilder}, + Object, + }, + types::{ + collection::Collection, + id::Id, + keyword::Keyword, + property::Property, + value::{MaybePatchValue, SetValue, Value}, + }, +}; +use store::{ + roaring::RoaringBitmap, + write::{ + assert::HashedValue, log::ChangeLogBuilder, BatchBuilder, DeserializeFrom, SerializeInto, + ToBitmaps, F_BITMAP, F_CLEAR, F_VALUE, + }, + BlobKind, Serialize, ValueKey, +}; + +use crate::{mailbox, JMAP, SUPERUSER_ID}; + +struct SetContext { + set_response: SetResponse, + principal_ids: RoaringBitmap, + will_destroy: Vec, +} + +pub static SCHEMA: &[IndexProperty] = &[ + IndexProperty::new(Property::Name) + .index_as(IndexAs::Text { + tokenize: true, + index: true, + }) + .required(), + IndexProperty::new(Property::Role).index_as(IndexAs::Text { + tokenize: false, + index: true, + }), + IndexProperty::new(Property::Role).index_as(IndexAs::HasProperty), + IndexProperty::new(Property::ParentId).index_as(IndexAs::Integer), + IndexProperty::new(Property::SortOrder).index_as(IndexAs::Integer), + IndexProperty::new(Property::IsSubscribed).index_as(IndexAs::IntegerList), +]; + +impl JMAP { + pub async fn principal_set( + &self, + mut request: SetRequest, + ) -> Result { + // Prepare response + let mut ctx = SetContext { + set_response: self + .prepare_set_response(&request, Collection::Principal) + .await?, + principal_ids: self + .get_document_ids(SUPERUSER_ID, Collection::Principal) + .await? + .unwrap_or_default(), + will_destroy: request.unwrap_destroy(), + }; + + // Process creates + let mut changes = ChangeLogBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + match self.principal_set_item(object, None, &ctx).await? { + Ok(builder) => { + let mut batch = BatchBuilder::new(); + let principal_id = self + .assign_document_id(SUPERUSER_ID, Collection::Principal) + .await?; + let create_mailboxes = matches!( + builder.get(&Property::Type), + Value::Text(t) if ["individual", "group"].contains(&t.as_str()) + ); + batch + .with_account_id(SUPERUSER_ID) + .with_collection(Collection::Principal) + .create_document(principal_id) + .custom(builder); + + // Create mailboxes + if create_mailboxes { + batch + .with_account_id(principal_id) + .with_collection(Collection::Mailbox); + for (name, role) in [ + ("Inbox", "inbox"), + ("Deleted Items", "trash"), + ("Drafts", "drafts"), + ("Sent Items", "sent"), + ("Junk Mail", "junk"), + ] { + batch + .create_document( + self.assign_document_id(principal_id, Collection::Mailbox) + .await?, + ) + .custom( + ObjectIndexBuilder::new(mailbox::set::SCHEMA).with_changes( + Object::with_capacity(3) + .with_property(Property::Name, name) + .with_property(Property::Role, role) + .with_property(Property::ParentId, 0u32), + ), + ); + } + } + + changes.log_insert(Collection::Principal, principal_id); + ctx.principal_ids.insert(principal_id); + self.store.write(batch.build()).await.map_err(|err| { + tracing::error!( + event = "error", + context = "principal_set", + error = ?err, + "Failed to create mailbox(es)."); + MethodError::ServerPartialFail + })?; + + ctx.set_response.created(id, principal_id); + } + Err(err) => { + ctx.set_response.not_created.append(id, err); + continue 'create; + } + } + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + // Obtain mailbox + let principal_id = id.document_id(); + if let Some(mut principal) = self + .get_property::>>( + SUPERUSER_ID, + Collection::Principal, + principal_id, + Property::Value, + ) + .await? + { + match self + .principal_set_item(object, (principal_id, principal.take()).into(), &ctx) + .await? + { + Ok(builder) => { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(SUPERUSER_ID) + .with_collection(Collection::Principal) + .create_document(principal_id) + .assert_value(Property::Value, &principal) + .custom(builder); + if !batch.is_empty() { + changes.log_update(Collection::Principal, principal_id); + match self.store.write(batch.build()).await { + Ok(_) => (), + Err(store::Error::AssertValueFailed) => { + ctx.set_response.not_updated.append(id, SetError::forbidden().with_description( + "Another process modified this principal, please try again.", + )); + continue 'update; + } + Err(err) => { + tracing::error!( + event = "error", + context = "principal_set", + error = ?err, + "Failed to update principal(s)."); + return Err(MethodError::ServerPartialFail); + } + } + } + ctx.set_response.updated.append(id, None); + } + Err(err) => { + ctx.set_response.not_updated.append(id, err); + continue 'update; + } + } + } else { + ctx.set_response + .not_updated + .append(id, SetError::not_found()); + } + } + + // Process deletions + 'destroy: for id in ctx.will_destroy { + let principal_id = id.document_id(); + // Obtain mailbox + if let Some(mailbox) = self + .get_property::>>( + SUPERUSER_ID, + Collection::Principal, + principal_id, + Property::Value, + ) + .await? + { + let delete_account_data = "todo"; + let mut batch = BatchBuilder::new(); + batch + .with_account_id(SUPERUSER_ID) + .with_collection(Collection::Principal) + .delete_document(principal_id) + .assert_value(Property::Value, &mailbox) + .custom(ObjectIndexBuilder::new(SCHEMA).with_current(mailbox.inner)); + + match self.store.write(batch.build()).await { + Ok(_) => { + changes.log_delete(Collection::Principal, principal_id); + ctx.set_response.destroyed.push(id); + } + Err(store::Error::AssertValueFailed) => { + ctx.set_response.not_destroyed.append( + id, + SetError::forbidden().with_description(concat!( + "Another process modified this mailbox ", + "while deleting it, please try again." + )), + ); + } + Err(err) => { + tracing::error!( + event = "error", + context = "mailbox_set", + document_id = principal_id, + error = ?err, + "Failed to delete principal."); + return Err(MethodError::ServerPartialFail); + } + } + } else { + ctx.set_response + .not_destroyed + .append(id, SetError::not_found()); + } + } + + // Write changes + if !changes.is_empty() { + ctx.set_response.new_state = self.commit_changes(SUPERUSER_ID, changes).await?.into(); + } + + Ok(ctx.set_response) + } + + #[allow(clippy::blocks_in_if_conditions)] + async fn principal_set_item( + &self, + changes_: Object, + update: Option<(u32, Object)>, + ctx: &SetContext, + ) -> Result, MethodError> { + todo!() + } +} diff --git a/crates/store/src/backend/sqlite/main.rs b/crates/store/src/backend/sqlite/main.rs index 45f6602c..40161c52 100644 --- a/crates/store/src/backend/sqlite/main.rs +++ b/crates/store/src/backend/sqlite/main.rs @@ -7,8 +7,7 @@ use tokio::sync::oneshot; use utils::{config::Config, UnwrapFailure}; use crate::{ - blob::BlobStore, Store, SUBSPACE_ACLS, SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, - SUBSPACE_VALUES, + blob::BlobStore, Store, SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES, }; use super::pool::SqliteConnectionManager; @@ -46,7 +45,7 @@ impl Store { pub(super) fn create_tables(&self) -> crate::Result<()> { let conn = self.conn_pool.get()?; - for table in [SUBSPACE_VALUES, SUBSPACE_LOGS, SUBSPACE_ACLS] { + for table in [SUBSPACE_VALUES, SUBSPACE_LOGS] { let table = char::from(table); conn.execute( &format!( diff --git a/crates/store/src/backend/sqlite/read.rs b/crates/store/src/backend/sqlite/read.rs index 5713f692..818e99b1 100644 --- a/crates/store/src/backend/sqlite/read.rs +++ b/crates/store/src/backend/sqlite/read.rs @@ -7,7 +7,7 @@ use crate::{ query::Operator, write::key::{DeserializeBigEndian, KeySerializer}, BitmapKey, Deserialize, IndexKey, IndexKeyPrefix, Key, LogKey, ReadTransaction, Serialize, - Store, ValueKey, + Store, }; use super::{BITS_PER_BLOCK, WORDS_PER_BLOCK, WORD_SIZE_BITS}; @@ -15,7 +15,7 @@ use super::{BITS_PER_BLOCK, WORDS_PER_BLOCK, WORD_SIZE_BITS}; impl ReadTransaction<'_> { #[inline(always)] #[maybe_async::maybe_async] - pub async fn get_value(&self, key: ValueKey) -> crate::Result> + pub async fn get_value(&self, key: impl Key) -> crate::Result> where U: Deserialize, { diff --git a/crates/store/src/backend/sqlite/write.rs b/crates/store/src/backend/sqlite/write.rs index 71dee1c4..243f45b9 100644 --- a/crates/store/src/backend/sqlite/write.rs +++ b/crates/store/src/backend/sqlite/write.rs @@ -2,7 +2,7 @@ use rusqlite::{params, OptionalExtension}; use crate::{ write::{Batch, Operation}, - AclKey, BitmapKey, IndexKey, LogKey, Serialize, Store, ValueKey, + AclKey, BitmapKey, IndexKey, Key, LogKey, Serialize, Store, ValueKey, }; use super::{BITS_MASK, BITS_PER_BLOCK}; @@ -176,10 +176,10 @@ impl Store { .serialize(); if let Some(value) = set { - trx.prepare_cached("INSERT OR REPLACE INTO a (k, v) VALUES (?, ?)")? + trx.prepare_cached("INSERT OR REPLACE INTO v (k, v) VALUES (?, ?)")? .execute([&key, value])?; } else { - trx.prepare_cached("DELETE FROM a WHERE k = ?")? + trx.prepare_cached("DELETE FROM v WHERE k = ?")? .execute([&key])?; } } @@ -230,17 +230,30 @@ impl Store { .await } + #[inline(always)] + pub async fn set_value(&self, key: impl Key, value: impl Serialize) -> crate::Result<()> { + let key = key.serialize(); + let value = value.serialize(); + + let conn = self.conn_pool.get()?; + self.spawn_worker(move || { + conn.prepare_cached("INSERT OR REPLACE INTO l (k, v) VALUES (?, ?)")? + .execute([&key, &value]) + .map_err(Into::into) + }) + .await?; + + Ok(()) + } + #[cfg(feature = "test_mode")] pub async fn destroy(&self) { - use crate::{ - SUBSPACE_ACLS, SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES, - }; + use crate::{SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES}; let conn = self.conn_pool.get().unwrap(); for table in [ SUBSPACE_VALUES, SUBSPACE_LOGS, - SUBSPACE_ACLS, SUBSPACE_BITMAPS, SUBSPACE_INDEXES, ] { diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 91ac350f..7a990b62 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -9,6 +9,8 @@ pub mod query; pub mod write; pub use ahash; +pub use blake3; +pub use parking_lot; pub use rand; pub use roaring; @@ -178,4 +180,3 @@ pub const SUBSPACE_BITMAPS: u8 = b'b'; pub const SUBSPACE_VALUES: u8 = b'v'; pub const SUBSPACE_LOGS: u8 = b'l'; pub const SUBSPACE_INDEXES: u8 = b'i'; -pub const SUBSPACE_ACLS: u8 = b'c'; diff --git a/crates/store/src/query/get.rs b/crates/store/src/query/get.rs index 6ccc296b..52c2b55a 100644 --- a/crates/store/src/query/get.rs +++ b/crates/store/src/query/get.rs @@ -1,9 +1,9 @@ use roaring::RoaringBitmap; -use crate::{BitmapKey, Deserialize, Key, Store, ValueKey}; +use crate::{BitmapKey, Deserialize, Key, Store}; impl Store { - pub async fn get_value(&self, key: ValueKey) -> crate::Result> + pub async fn get_value(&self, key: impl Key) -> crate::Result> where U: Deserialize + 'static, { @@ -19,7 +19,7 @@ impl Store { } } - pub async fn get_values(&self, key: Vec) -> crate::Result>> + pub async fn get_values(&self, key: Vec) -> crate::Result>> where U: Deserialize + 'static, { diff --git a/crates/store/src/query/mod.rs b/crates/store/src/query/mod.rs index 2d6b31d0..b96ad480 100644 --- a/crates/store/src/query/mod.rs +++ b/crates/store/src/query/mod.rs @@ -77,6 +77,10 @@ impl ResultSet { results, } } + + pub fn apply_mask(&mut self, mask: RoaringBitmap) { + self.results &= mask; + } } impl Filter { diff --git a/crates/store/src/write/batch.rs b/crates/store/src/write/batch.rs index a7ebc8bb..1512af52 100644 --- a/crates/store/src/write/batch.rs +++ b/crates/store/src/write/batch.rs @@ -116,14 +116,6 @@ impl BatchBuilder { self } - pub fn acl(&mut self, grant_account_id: u32, acl: Option) -> &mut Self { - self.ops.push(Operation::Acl { - grant_account_id, - set: acl.map(|acl| acl.serialize()), - }); - self - } - pub fn custom(&mut self, value: impl IntoOperations) -> &mut Self { value.build(self); self diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs index cdce6ded..3139505d 100644 --- a/crates/store/src/write/key.rs +++ b/crates/store/src/write/key.rs @@ -2,8 +2,8 @@ use std::convert::TryInto; use utils::codec::leb128::Leb128_; use crate::{ - AclKey, BitmapKey, IndexKey, IndexKeyPrefix, Key, LogKey, Serialize, ValueKey, - SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES, + AclKey, BitmapKey, Deserialize, Error, IndexKey, IndexKeyPrefix, Key, LogKey, Serialize, + ValueKey, SUBSPACE_BITMAPS, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES, }; pub struct KeySerializer { @@ -209,7 +209,7 @@ impl Serialize for &ValueKey { KeySerializer::new(std::mem::size_of::() + 1) } } - .write_leb128(self.account_id) + .write(self.account_id) .write(self.collection) .write_leb128(self.document_id); @@ -260,15 +260,28 @@ impl Serialize for &AclKey { KeySerializer::new(std::mem::size_of::()) } } - .write_leb128(self.grant_account_id) + .write(self.grant_account_id) .write(u8::MAX) - .write_leb128(self.to_account_id) + .write(self.to_account_id) .write(self.to_collection) - .write_leb128(self.to_document_id) + .write(self.to_document_id) .finalize() } } +impl Deserialize for AclKey { + fn deserialize(bytes: &[u8]) -> crate::Result { + Ok(AclKey { + grant_account_id: bytes.deserialize_be_u32(0)?, + to_account_id: bytes.deserialize_be_u32(std::mem::size_of::() + 1)?, + to_collection: *bytes + .get((std::mem::size_of::() * 2) + 1) + .ok_or_else(|| Error::InternalError(format!("Corrupted acl key {bytes:?}")))?, + to_document_id: bytes.deserialize_be_u32((std::mem::size_of::() * 2) + 2)?, + }) + } +} + impl Serialize for &LogKey { fn serialize(self) -> Vec { { @@ -306,6 +319,12 @@ impl Key for ValueKey { } } +impl Key for AclKey { + fn subspace(&self) -> u8 { + SUBSPACE_VALUES + } +} + impl + Sync + Send + 'static> Key for IndexKey { fn subspace(&self) -> u8 { SUBSPACE_INDEXES @@ -324,6 +343,12 @@ impl Serialize for ValueKey { } } +impl Serialize for AclKey { + fn serialize(self) -> Vec { + (&self).serialize() + } +} + impl> Serialize for IndexKey { fn serialize(self) -> Vec { (&self).serialize() diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index 916e9333..07df7a1b 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -49,6 +49,10 @@ pub enum Operation { family: u8, set: Option>, }, + Acl { + grant_account_id: u32, + set: Option>, + }, Index { field: u8, key: Vec, @@ -60,10 +64,6 @@ pub enum Operation { key: Vec, set: bool, }, - Acl { - grant_account_id: u32, - set: Option>, - }, Log { change_id: u64, collection: u8, diff --git a/crates/utils/src/config/mod.rs b/crates/utils/src/config/mod.rs index f9a5e591..cf96ac29 100644 --- a/crates/utils/src/config/mod.rs +++ b/crates/utils/src/config/mod.rs @@ -26,7 +26,7 @@ pub mod listener; pub mod parser; pub mod utils; -use std::{collections::BTreeMap, fmt::Display, net::SocketAddr}; +use std::{collections::BTreeMap, fmt::Display, net::SocketAddr, time::Duration}; use rustls::ServerConfig; use tokio::net::TcpSocket; @@ -70,6 +70,12 @@ pub enum ServerProtocol { Http, } +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct Rate { + pub requests: u64, + pub period: Duration, +} + impl Display for ServerProtocol { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/crates/utils/src/config/utils.rs b/crates/utils/src/config/utils.rs index 4f662804..be5349e9 100644 --- a/crates/utils/src/config/utils.rs +++ b/crates/utils/src/config/utils.rs @@ -23,7 +23,7 @@ use std::{net::IpAddr, time::Duration}; -use super::Config; +use super::{Config, Rate}; impl Config { pub fn property(&self, key: impl AsKey) -> super::Result> { @@ -35,6 +35,16 @@ impl Config { } } + pub fn property_or_static( + &self, + key: impl AsKey, + default: &str, + ) -> super::Result { + let key = key.as_key(); + let value = self.keys.get(&key).map_or(default, |v| v.as_str()); + T::parse_value(key, value) + } + pub fn property_or_default( &self, key: impl AsKey, @@ -368,6 +378,36 @@ impl ParseValue for Duration { } } +impl ParseValue for Rate { + fn parse_value(key: impl AsKey, value: &str) -> super::Result { + if let Some((requests, period)) = value.split_once('/') { + Ok(Rate { + requests: requests + .trim() + .parse::() + .ok() + .and_then(|r| if r > 0 { Some(r) } else { None }) + .ok_or_else(|| { + format!( + "Invalid rate value {:?} for property {:?}.", + value, + key.as_key() + ) + })?, + period: period.parse_key(key)?, + }) + } else if ["false", "none", "unlimited"].contains(&value) { + Ok(Rate::default()) + } else { + Err(format!( + "Invalid rate value {:?} for property {:?}.", + value, + key.as_key() + )) + } + } +} + pub trait AsKey: Clone { fn as_key(&self) -> String; fn as_prefix(&self) -> String; diff --git a/crates/utils/src/listener/limiter.rs b/crates/utils/src/listener/limiter.rs index 175c478f..e144e68e 100644 --- a/crates/utils/src/listener/limiter.rs +++ b/crates/utils/src/listener/limiter.rs @@ -19,6 +19,7 @@ pub struct ConcurrencyLimiter { pub concurrent: Arc, } +#[derive(Default)] pub struct InFlight { concurrent: Arc, } diff --git a/crates/utils/src/map/bitmap.rs b/crates/utils/src/map/bitmap.rs index 83a52ca7..068eb6ce 100644 --- a/crates/utils/src/map/bitmap.rs +++ b/crates/utils/src/map/bitmap.rs @@ -24,7 +24,7 @@ use std::ops::Deref; #[derive( - Debug, serde::Serialize, serde::Deserialize, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, + Debug, serde::Serialize, serde::Deserialize, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash, )] pub struct Bitmap { pub bitmap: u64, @@ -66,6 +66,12 @@ impl Bitmap { self.bitmap |= 1 << item.into(); } + #[inline(always)] + pub fn with_item(mut self, item: T) -> Self { + self.insert(item); + self + } + #[inline(always)] pub fn remove(&mut self, item: T) { debug_assert!(item.is_valid()); @@ -88,6 +94,16 @@ impl Bitmap { self.bitmap & (1 << item.into()) != 0 } + #[inline(always)] + pub fn contains_any(&self, items: impl Iterator) -> bool { + for item in items { + if self.bitmap & (1 << item.into()) != 0 { + return true; + } + } + false + } + #[inline(always)] pub fn is_empty(&self) -> bool { self.bitmap == 0 @@ -127,6 +143,12 @@ impl Deref for Bitmap { } } +impl From> for u64 { + fn from(value: Bitmap) -> Self { + value.bitmap + } +} + impl Iterator for Bitmap { type Item = T; diff --git a/resources/oauth/error.htx b/resources/oauth/error.htx new file mode 100644 index 00000000..4fded2e0 --- /dev/null +++ b/resources/oauth/error.htx @@ -0,0 +1 @@ +

@@@

diff --git a/resources/oauth/footer.htx b/resources/oauth/footer.htx new file mode 100644 index 00000000..4182b881 --- /dev/null +++ b/resources/oauth/footer.htx @@ -0,0 +1 @@ + diff --git a/resources/oauth/header.htx b/resources/oauth/header.htx new file mode 100644 index 00000000..23b8ce6e --- /dev/null +++ b/resources/oauth/header.htx @@ -0,0 +1 @@ +Stalwart JMAP - Authorization + + + + + \ No newline at end of file