diff --git a/crates/jmap-proto/src/method/copy.rs b/crates/jmap-proto/src/method/copy.rs index 50f7d860..e3dd0657 100644 --- a/crates/jmap-proto/src/method/copy.rs +++ b/crates/jmap-proto/src/method/copy.rs @@ -65,12 +65,12 @@ pub struct CopyBlobResponse { pub account_id: Id, #[serde(rename = "copied")] - #[serde(skip_serializing_if = "Option::is_none")] - pub copied: Option>, + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub copied: VecMap, #[serde(rename = "notCopied")] - #[serde(skip_serializing_if = "Option::is_none")] - pub not_copied: Option>, + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_copied: VecMap, } #[derive(Debug, Clone)] @@ -174,9 +174,7 @@ impl JsonObjectParser for CopyBlobRequest { parser.next_token::()?.unwrap_string("fromAccountId")?; } 0x0073_6449_626f_6c62 => { - request.blob_ids = parser - .next_token::>()? - .unwrap_string("blobIds")?; + request.blob_ids = >::parse(parser)?; } _ => { parser.skip_token(parser.depth_array, parser.depth_dict)?; diff --git a/crates/jmap-proto/src/method/import.rs b/crates/jmap-proto/src/method/import.rs index ea4ae214..03661d4c 100644 --- a/crates/jmap-proto/src/method/import.rs +++ b/crates/jmap-proto/src/method/import.rs @@ -46,12 +46,12 @@ pub struct ImportEmailResponse { pub new_state: State, #[serde(rename = "created")] - #[serde(skip_serializing_if = "Option::is_none")] - pub created: Option>>, + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub created: VecMap>, #[serde(rename = "notCreated")] - #[serde(skip_serializing_if = "Option::is_none")] - pub not_created: Option>, + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap, } impl JsonObjectParser for ImportEmailRequest { diff --git a/crates/jmap-proto/src/object/index.rs b/crates/jmap-proto/src/object/index.rs index cedf461d..01d219db 100644 --- a/crates/jmap-proto/src/object/index.rs +++ b/crates/jmap-proto/src/object/index.rs @@ -332,42 +332,55 @@ 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, - }); + match (current_value, &value) { + (Value::List(current_value), Value::List(value)) => { + // Remove deleted ACLs + for item in current_value.chunks_exact(2) { + if let Some(Value::Id(id)) = item.first() { + if !value.contains(&Value::Id(*id)) { + 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(), + }); + } } } } - - // 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 { + (Value::Null, Value::List(values)) => { + // Add all ACLs + 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(), @@ -375,6 +388,18 @@ fn merge_batch( } } } + (Value::List(current_values), Value::Null) => { + // Remove all ACLs + for item in current_values.chunks_exact(2) { + if let Some(Value::Id(id)) = item.first() { + batch.ops.push(Operation::Acl { + grant_account_id: id.document_id(), + set: None, + }); + } + } + } + _ => {} } } IndexAs::None => (), @@ -491,7 +516,7 @@ fn build_batch( { batch.ops.push(Operation::Acl { grant_account_id: id.document_id(), - set: acl.serialize().into(), + set: if set { acl.serialize().into() } else { None }, }); } } diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 74e90675..acbcac19 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -18,7 +18,7 @@ use crate::{ validate::ValidateSieveScriptResponse, }, request::{echo::Echo, method::MethodName, Call}, - types::id::Id, + types::{id::Id, property::Property}, }; use self::serialize::serialize_hex; @@ -69,10 +69,36 @@ impl Response { name: MethodName, method: impl Into, ) { + // Add created ids + let method = method.into(); + if !self.created_ids.is_empty() { + match &method { + ResponseMethod::Set(SetResponse { created, .. }) => { + for (user_id, obj) in created { + if let Some(id) = obj.get(&Property::Id).as_id() { + self.created_ids.insert(user_id.clone(), *id); + } + } + } + ResponseMethod::ImportEmail(ImportEmailResponse { created, .. }) => { + for (user_id, obj) in created { + if let Some(id) = obj.get(&Property::Id).as_id() { + self.created_ids.insert(user_id.clone(), *id); + } + } + } + _ => {} + } + } + + self.method_responses.push(Call { id, method, name }); + } + + pub fn push_error(&mut self, id: String, err: MethodError) { self.method_responses.push(Call { id, - method: method.into(), - name, + method: ResponseMethod::Error(err), + name: MethodName::error(), }); } diff --git a/crates/jmap-proto/src/response/references.rs b/crates/jmap-proto/src/response/references.rs index 590c973d..7d3308f5 100644 --- a/crates/jmap-proto/src/response/references.rs +++ b/crates/jmap-proto/src/response/references.rs @@ -4,7 +4,7 @@ use utils::map::vec_map::VecMap; use crate::{ error::{method::MethodError, set::SetError}, - method::set::SetResponse, + method::{copy::CopyResponse, set::SetResponse}, object::Object, request::{ reference::{MaybeReference, ResultReference}, @@ -306,17 +306,15 @@ impl Response { } } -impl SetResponse { - pub fn eval_object_references(&self, set_value: SetValue) -> Result { +pub trait EvalObjectReferences { + fn get_id(&self, id_ref: &str) -> Option<&Id>; + + fn eval_object_references(&self, set_value: SetValue) -> Result { match set_value { SetValue::Value(value) => Ok(MaybePatchValue::Value(value)), SetValue::Patch(patch) => Ok(MaybePatchValue::Patch(patch)), SetValue::IdReference(MaybeReference::Reference(id_ref)) => { - if let Some(Value::Id(id)) = self - .created - .get(&id_ref) - .and_then(|obj| obj.properties.get(&Property::Id)) - { + if let Some(id) = self.get_id(&id_ref) { Ok(MaybePatchValue::Value(Value::Id(*id))) } else { Err(SetError::not_found() @@ -334,11 +332,7 @@ impl SetResponse { ids.push(Value::Id(id)); } MaybeReference::Reference(id_ref) => { - if let Some(Value::Id(id)) = self - .created - .get(&id_ref) - .and_then(|obj| obj.properties.get(&Property::Id)) - { + if let Some(id) = self.get_id(&id_ref) { ids.push(Value::Id(*id)); } else { return Err(SetError::not_found().with_description(format!( @@ -355,6 +349,21 @@ impl SetResponse { } } +impl EvalObjectReferences for SetResponse { + fn get_id(&self, id_ref: &str) -> Option<&Id> { + self.created + .get(id_ref) + .and_then(|obj| obj.properties.get(&Property::Id)) + .and_then(|v| v.as_id()) + } +} + +impl EvalObjectReferences for CopyResponse { + fn get_id(&self, _id_ref: &str) -> Option<&Id> { + None + } +} + impl EvalResult { pub fn unwrap_ids(self, rr: &ResultReference) -> Result, MethodError> { if let EvalResult::Values(values) = self { diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 0bd79a8c..03d7f8a3 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -20,6 +20,10 @@ form_urlencoded = "1.1.0" tracing = "0.1" tokio = { version = "1.23", features = ["rt"] } aes-gcm-siv = "0.11.1" +bincode = "1.3.3" +form-data = { version = "0.4.2", features = ["sync"], default-features = false } +mime = "0.3.17" +sqlx = { git = "https://github.com/mdecimus/sqlx", features = [ "runtime-tokio-rustls", "postgres", "mysql", "sqlite" ] } [features] test_mode = [] diff --git a/crates/jmap/src/api/config.rs b/crates/jmap/src/api/config.rs index 1943939b..c95b4868 100644 --- a/crates/jmap/src/api/config.rs +++ b/crates/jmap/src/api/config.rs @@ -1,6 +1,9 @@ use std::time::Duration; -use store::fts::Language; +use store::{ + fts::Language, + rand::{distributions::Alphanumeric, thread_rng, Rng}, +}; use super::session::BaseCapabilities; @@ -70,6 +73,32 @@ impl crate::Config { rate_use_forwarded: settings .property("jmap.rate-limit.use-forwarded")? .unwrap_or(false), + oauth_key: settings + .value("oauth.key") + .map(|k| k.into()) + .unwrap_or_else(|| { + thread_rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::() + }), + oauth_expiry_user_code: settings + .property_or_static::("oauth.expiry.user-code", "30m")? + .as_secs(), + oauth_expiry_auth_code: settings + .property_or_static::("oauth.expiry.auth-code", "10m")? + .as_secs(), + oauth_expiry_token: settings + .property_or_static::("oauth.expiry.token", "1h")? + .as_secs(), + oauth_expiry_refresh_token: settings + .property_or_static::("oauth.expiry.refresh-token", "30d")? + .as_secs(), + oauth_expiry_refresh_token_renew: settings + .property_or_static::("oauth.expiry.refresh-token-renew", "4d")? + .as_secs(), + oauth_max_auth_attempts: settings.property_or_static("oauth.max-auth-attempts", "3")?, }; config.add_capabilites(settings); Ok(config) diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 8979d087..b131a8ca 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -1,6 +1,6 @@ use std::{net::IpAddr, sync::Arc}; -use http_body_util::{combinators::BoxBody, BodyExt, Full}; +use http_body_util::{BodyExt, Full}; use hyper::{ body::{self, Bytes}, header::{self, CONTENT_TYPE}, @@ -20,12 +20,12 @@ use tokio::{ use utils::listener::{ServerInstance, SessionData, SessionManager}; use crate::{ - auth::AclToken, + auth::oauth::OAuthMetadata, blob::{DownloadResponse, UploadResponse}, JMAP, }; -use super::session::Session; +use super::{session::Session, HtmlResponse, HttpResponse, JsonResponse}; impl JMAP { pub async fn parse_request( @@ -33,81 +33,28 @@ impl JMAP { req: &mut hyper::Request, remote_ip: IpAddr, instance: &ServerInstance, - ) -> hyper::Response> { + ) -> HttpResponse { 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) => { - return match fetch_body(req, self.config.request_max_size).await { - Ok(bytes) => { - //let delete = "fd"; - //println!("<- {}", String::from_utf8_lossy(&bytes)); + "jmap" => { + // Authenticate request + let (_in_flight, acl_token) = match self.authenticate_headers(req, remote_ip).await + { + Ok(Some(session)) => session, + Ok(None) => return RequestError::unauthorized().into_http_response(), + Err(err) => return err.into_http_response(), + }; - match self.handle_request(&bytes, acl_token).await { - Ok(response) => response.into_http_response(), - Err(err) => err.into_http_response(), - } - } - Err(err) => err.into_http_response(), - }; - } - ("download", &Method::GET) => { - if let (Some(account_id), Some(blob_id), Some(name)) = ( - path.next().and_then(|p| Id::from_bytes(p.as_bytes())), - path.next().and_then(BlobId::from_base32), - path.next(), - ) { - return match self.blob_download(&blob_id, &acl_token).await { - Ok(Some(blob)) => DownloadResponse { - filename: name.to_string(), - content_type: req - .uri() - .query() - .and_then(|q| { - form_urlencoded::parse(q.as_bytes()) - .find(|(k, _)| k == "accept") - .map(|(_, v)| v.into_owned()) - }) - .unwrap_or("application/octet-stream".to_string()), - blob, - } - .into_http_response(), - Ok(None) => RequestError::not_found().into_http_response(), - Err(err) => { - tracing::error!(event = "error", - context = "blob_store", - account_id = account_id.document_id(), - blob_id = ?blob_id, - error = ?err, - "Failed to download blob"); - RequestError::internal_server_error().into_http_response() - } - }; - } - } - ("upload", &Method::POST) => { - if let Some(account_id) = path.next().and_then(|p| Id::from_bytes(p.as_bytes())) - { - return match fetch_body(req, self.config.upload_max_size).await { + match (path.next().unwrap_or(""), req.method()) { + ("", &Method::POST) => { + return match fetch_body(req, self.config.request_max_size).await { Ok(bytes) => { - match self - .blob_upload( - account_id, - req.headers() - .get(CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream"), - &bytes, - ) - .await - { + //let delete = "fd"; + //println!("<- {}", String::from_utf8_lossy(&bytes)); + + match self.handle_request(&bytes, acl_token).await { Ok(response) => response.into_http_response(), Err(err) => err.into_http_response(), } @@ -115,48 +62,151 @@ impl JMAP { Err(err) => err.into_http_response(), }; } + ("download", &Method::GET) => { + if let (Some(account_id), Some(blob_id), Some(name)) = ( + path.next().and_then(|p| Id::from_bytes(p.as_bytes())), + path.next().and_then(BlobId::from_base32), + path.next(), + ) { + return match self.blob_download(&blob_id, &acl_token).await { + Ok(Some(blob)) => DownloadResponse { + filename: name.to_string(), + content_type: req + .uri() + .query() + .and_then(|q| { + form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == "accept") + .map(|(_, v)| v.into_owned()) + }) + .unwrap_or("application/octet-stream".to_string()), + blob, + } + .into_http_response(), + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => { + tracing::error!(event = "error", + context = "blob_store", + account_id = account_id.document_id(), + blob_id = ?blob_id, + error = ?err, + "Failed to download blob"); + RequestError::internal_server_error().into_http_response() + } + }; + } + } + ("upload", &Method::POST) => { + if let Some(account_id) = + path.next().and_then(|p| Id::from_bytes(p.as_bytes())) + { + return match fetch_body(req, self.config.upload_max_size).await { + Ok(bytes) => { + match self + .blob_upload( + account_id, + req.headers() + .get(CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or("application/octet-stream"), + &bytes, + ) + .await + { + Ok(response) => response.into_http_response(), + Err(err) => err.into_http_response(), + } + } + Err(err) => err.into_http_response(), + }; + } + } + ("eventsource", &Method::GET) => { + todo!() + } + ("ws", &Method::GET) => { + todo!() + } + _ => (), } - ("eventsource", &Method::GET) => { - todo!() - } - ("ws", &Method::GET) => { - todo!() - } - _ => (), - }, + } ".well-known" => match (path.next().unwrap_or(""), req.method()) { ("jmap", &Method::GET) => { - return match self.handle_session_resource(instance).await { + // Authenticate request + let (_in_flight, acl_token) = + match self.authenticate_headers(req, remote_ip).await { + Ok(Some(session)) => session, + Ok(None) => return RequestError::unauthorized().into_http_response(), + Err(err) => return err.into_http_response(), + }; + + return match self.handle_session_resource(instance, acl_token).await { Ok(session) => session.into_http_response(), Err(err) => err.into_http_response(), }; } ("oauth-authorization-server", &Method::GET) => { - todo!() + let remote_addr = self.build_remote_addr(req, remote_ip); + // Limit anonymous requests + return match self.is_anonymous_allowed(remote_addr) { + Ok(_) => JsonResponse::new(OAuthMetadata::new(&instance.data)) + .into_http_response(), + Err(err) => err.into_http_response(), + }; } _ => (), }, - "auth" => match (path.next().unwrap_or(""), req.method()) { - ("", &Method::GET) => { - todo!() + "auth" => { + let remote_addr = self.build_remote_addr(req, remote_ip); + + match (path.next().unwrap_or(""), req.method()) { + ("", &Method::GET) => { + // Limit anonymous requests + if let Err(err) = self.is_anonymous_allowed(remote_addr) { + return err.into_http_response(); + } + todo!() + } + ("", &Method::POST) => { + // Limit authentication requests + if let Err(err) = self.is_auth_allowed(remote_addr) { + return err.into_http_response(); + } + + todo!() + } + ("code", &Method::GET) => { + // Limit anonymous requests + if let Err(err) = self.is_anonymous_allowed(remote_addr) { + return err.into_http_response(); + } + todo!() + } + ("code", &Method::POST) => { + // Limit authentication requests + if let Err(err) = self.is_auth_allowed(remote_addr) { + return err.into_http_response(); + } + + todo!() + } + ("device", &Method::POST) => { + // Limit anonymous requests + if let Err(err) = self.is_anonymous_allowed(remote_addr) { + return err.into_http_response(); + } + todo!() + } + ("token", &Method::POST) => { + // Limit anonymous requests + if let Err(err) = self.is_anonymous_allowed(remote_addr) { + return err.into_http_response(); + } + todo!() + } + _ => (), } - ("", &Method::POST) => { - todo!() - } - ("code", &Method::GET) => { - todo!() - } - ("code", &Method::POST) => { - todo!() - } - ("device", &Method::POST) => { - todo!() - } - ("token", &Method::POST) => { - todo!() - } - _ => (), - }, + } _ => (), } RequestError::not_found().into_http_response() @@ -249,7 +299,7 @@ async fn handle_request( } } -async fn fetch_body( +pub async fn fetch_body( req: &mut hyper::Request, max_size: usize, ) -> Result, RequestError> { @@ -266,19 +316,17 @@ async fn fetch_body( Ok(bytes) } -trait ToHttpResponse { - fn into_http_response(self) -> hyper::Response>; +pub trait ToHttpResponse { + fn into_http_response(self) -> HttpResponse; } -impl ToHttpResponse for Response { - fn into_http_response(self) -> hyper::Response> { - //let delete = ""; - //println!("-> {}", serde_json::to_string_pretty(&self).unwrap()); +impl ToHttpResponse for JsonResponse { + fn into_http_response(self) -> HttpResponse { hyper::Response::builder() - .status(StatusCode::OK) + .status(self.status) .header(header::CONTENT_TYPE, "application/json; charset=utf-8") .body( - Full::new(Bytes::from(serde_json::to_string(&self).unwrap())) + Full::new(Bytes::from(serde_json::to_string(&self.inner).unwrap())) .map_err(|never| match never {}) .boxed(), ) @@ -286,22 +334,50 @@ impl ToHttpResponse for Response { } } +impl JsonResponse { + pub fn new(inner: T) -> Self { + JsonResponse { + inner, + status: StatusCode::OK, + } + } + + pub fn with_status(status: StatusCode, inner: T) -> Self { + JsonResponse { inner, status } + } +} + +impl HtmlResponse { + pub fn new(body: String) -> Self { + HtmlResponse { + body, + status: StatusCode::OK, + } + } + + pub fn with_status(status: StatusCode, body: String) -> Self { + HtmlResponse { body, status } + } +} + +impl ToHttpResponse for Response { + fn into_http_response(self) -> HttpResponse { + //let delete = ""; + //println!("-> {}", serde_json::to_string_pretty(&self).unwrap()); + JsonResponse::new(self).into_http_response() + } +} + impl ToHttpResponse for Session { - fn into_http_response(self) -> hyper::Response> { - hyper::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json; charset=utf-8") - .body( - Full::new(Bytes::from(serde_json::to_string(&self).unwrap())) - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap() + fn into_http_response(self) -> HttpResponse { + //let delete = ""; + //println!("-> {}", serde_json::to_string_pretty(&self).unwrap()); + JsonResponse::new(self).into_http_response() } } impl ToHttpResponse for DownloadResponse { - fn into_http_response(self) -> hyper::Response> { + fn into_http_response(self) -> HttpResponse { hyper::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, self.content_type) @@ -326,26 +402,25 @@ impl ToHttpResponse for DownloadResponse { } impl ToHttpResponse for UploadResponse { - fn into_http_response(self) -> hyper::Response> { - hyper::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json; charset=utf-8") - .body( - Full::new(Bytes::from(serde_json::to_string(&self).unwrap())) - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap() + fn into_http_response(self) -> HttpResponse { + JsonResponse::new(self).into_http_response() } } impl ToHttpResponse for RequestError { - fn into_http_response(self) -> hyper::Response> { + fn into_http_response(self) -> HttpResponse { + JsonResponse::with_status(StatusCode::from_u16(self.status).unwrap(), self) + .into_http_response() + } +} + +impl ToHttpResponse for HtmlResponse { + fn into_http_response(self) -> HttpResponse { hyper::Response::builder() .status(self.status) - .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .body( - Full::new(Bytes::from(serde_json::to_string(&self).unwrap())) + Full::new(Bytes::from(self.body)) .map_err(|never| match never {}) .boxed(), ) diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index ab10613f..6899ea48 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -1,5 +1,8 @@ use std::sync::Arc; +use hyper::StatusCode; +use serde::Serialize; + use crate::JMAP; pub mod config; @@ -19,3 +22,17 @@ impl From for SessionManager { } } } + +pub struct JsonResponse { + status: StatusCode, + inner: T, +} + +pub struct HtmlResponse { + status: StatusCode, + body: String, +} + +pub type HttpRequest = hyper::Request; +pub type HttpResponse = + hyper::Response>; diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index ab90c5ff..9151e93e 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use jmap_proto::{ error::{method::MethodError, request::RequestError}, method::{get, query, set}, @@ -12,7 +14,7 @@ impl JMAP { pub async fn handle_request( &self, bytes: &[u8], - acl_token: AclToken, + acl_token: Arc, ) -> Result { let request = Request::parse( bytes, @@ -20,10 +22,11 @@ impl JMAP { self.config.request_max_size, )?; let mut response = Response::new( - 0, + acl_token.state(), request.created_ids.unwrap_or_default(), request.method_calls.len(), ); + for mut call in request.method_calls { // Resolve result and id references if let Err(method_error) = response.resolve_references(&mut call.method) { @@ -43,11 +46,7 @@ impl JMAP { response.push_response(call.id, call.name, method_response); } Err(err) => { - response.push_response( - call.id, - MethodName::error(), - ResponseMethod::Error(err), - ); + response.push_error(call.id, err); } } @@ -143,7 +142,7 @@ impl JMAP { self.email_copy(req, acl_token, next_call).await?.into() } - RequestMethod::CopyBlob(_) => todo!(), + RequestMethod::CopyBlob(req) => self.blob_copy(req, acl_token).await?.into(), RequestMethod::ImportEmail(req) => { acl_token.assert_has_access(req.account_id, Collection::Email)?; diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 217758e7..ccffd34e 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -1,11 +1,15 @@ +use std::sync::Arc; + use jmap_proto::{ - error::request::RequestError, request::capability::Capability, - response::serialize::serialize_hex, types::id::Id, + error::request::RequestError, + request::capability::Capability, + response::serialize::serialize_hex, + types::{acl::Acl, collection::Collection, id::Id}, }; use store::ahash::AHashSet; use utils::{listener::ServerInstance, map::vec_map::VecMap, UnwrapFailure}; -use crate::JMAP; +use crate::{auth::AclToken, JMAP}; #[derive(Debug, Clone, serde::Serialize)] pub struct Session { @@ -138,15 +142,41 @@ impl JMAP { pub async fn handle_session_resource( &self, instance: &ServerInstance, + acl_token: Arc, ) -> Result { let mut session = Session::new(&instance.data, &self.config.capabilities); - session.set_state(0); + session.set_state(acl_token.state()); + let account_name = self + .get_account_login(acl_token.primary_id()) + .await + .unwrap_or_else(|| Id::from(acl_token.primary_id()).to_string()); session.set_primary_account( - 1u64.into(), - "jdoe@example.org".to_string(), - "John Doe".to_string(), + acl_token.primary_id().into(), + account_name.to_string(), + account_name, None, ); + + // Add secondary accounts + for id in acl_token.secondary_ids() { + let is_personal = !acl_token.is_member(*id); + let is_readonly = is_personal + && self + .shared_documents(&acl_token, *id, Collection::Mailbox, Acl::AddItems) + .await + .map_or(true, |ids| ids.is_empty()); + + session.add_account( + (*id).into(), + self.get_account_login(*id) + .await + .unwrap_or_else(|| Id::from(*id).to_string()), + is_personal, + is_readonly, + Some(&[Capability::Core, Capability::Mail, Capability::WebSocket]), + ); + } + Ok(session) } } diff --git a/crates/jmap/src/auth/account.rs b/crates/jmap/src/auth/account.rs index e551e881..136eae60 100644 --- a/crates/jmap/src/auth/account.rs +++ b/crates/jmap/src/auth/account.rs @@ -1,66 +1,281 @@ -use jmap_proto::types::collection::Collection; +use crate::JMAP; -use crate::{JMAP, SUPERUSER_ID}; - -use super::{AccountDetails, AccountKey, AclToken}; +use super::{AclToken, AuthDatabase, SqlDatabase}; impl JMAP { pub async fn authenticate(&self, account: &str, secret: &str) -> Option { - todo!() + let account_id = self.get_account_id(account).await?; + let account_secret = self.get_account_secret(account_id).await?; + if secret == account_secret { + self.get_acl_token(account_id).await + } else { + tracing::debug!(context = "auth", event = "failed", account = account); + None + } } pub async fn get_acl_token(&self, account_id: u32) -> Option { - todo!() + self.update_acl_token(AclToken { + primary_id: account_id, + member_of: self.get_account_gids(account_id).await, + access_to: Vec::new(), + }) + .await } - 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) + pub async fn get_account_secret(&self, account_id: u32) -> Option { + match &self.auth_db { + AuthDatabase::Sql { + db, + query_secret_by_uid, + .. + } => { + db.fetch_string(query_secret_by_uid, account_id as i64) .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, - } } + AuthDatabase::Ldap => None, + } + } + + pub async fn get_account_id(&self, account: &str) -> Option { + match &self.auth_db { + AuthDatabase::Sql { + db, + query_uid_by_login, + .. + } => db + .fetch_id(query_uid_by_login, account) + .await + .map(|id| id as u32), + AuthDatabase::Ldap => None, + } + } + + pub async fn get_account_gids(&self, account_id: u32) -> Vec { + match &self.auth_db { + AuthDatabase::Sql { + db, + query_gids_by_uid, + .. + } => db + .fetch_ids(query_gids_by_uid, account_id as i64) + .await + .into_iter() + .map(|id| id as u32) + .collect(), + AuthDatabase::Ldap => vec![], + } + } + + pub async fn get_account_login(&self, account_id: u32) -> Option { + match &self.auth_db { + AuthDatabase::Sql { + db, + query_login_by_uid, + .. + } => db.fetch_string(query_login_by_uid, account_id as i64).await, + AuthDatabase::Ldap => None, + } + } +} + +impl SqlDatabase { + pub async fn fetch_string(&self, query: &str, uid: i64) -> Option { + let result = match &self { + SqlDatabase::Postgres(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_optional(pool) + .await + } + SqlDatabase::MySql(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_optional(pool) + .await + } + /*SqlDatabase::MsSql(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_optional(pool) + .await + }*/ + SqlDatabase::SqlLite(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_optional(pool) + .await + } + }; + + match result { + Ok(result) => result, Err(err) => { - tracing::error!( - event = "error", - context = "get_account_id", - error = ?err, - "Failed to obtain account id."); + tracing::warn!(context = "sql", event = "error", query = query, reason = ?err); None } } } - pub async fn map_account_name(&self, account_id: u32) -> Option { - None + pub async fn fetch_id(&self, query: &str, param: &str) -> Option { + let result = match &self { + SqlDatabase::Postgres(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(param) + .fetch_optional(pool) + .await + } + SqlDatabase::MySql(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(param) + .fetch_optional(pool) + .await + } + /*SqlDatabase::MsSql(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(param) + .fetch_optional(pool) + .await + }*/ + SqlDatabase::SqlLite(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(param) + .fetch_optional(pool) + .await + } + }; + + match result { + Ok(result) => result, + Err(err) => { + tracing::warn!(context = "sql", event = "error", query = query, reason = ?err); + None + } + } + } + + pub async fn fetch_strings(&self, query: &str, uid: i64) -> Vec { + let result = match &self { + SqlDatabase::Postgres(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_all(pool) + .await + } + SqlDatabase::MySql(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_all(pool) + .await + } + /*SqlDatabase::MsSql(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_all(pool) + .await + }*/ + SqlDatabase::SqlLite(pool) => { + sqlx::query_scalar::<_, String>(query) + .bind(uid) + .fetch_all(pool) + .await + } + }; + + match result { + Ok(result) => result, + Err(err) => { + tracing::warn!(context = "sql", event = "error", query = query, reason = ?err); + vec![] + } + } + } + + pub async fn fetch_ids(&self, query: &str, uid: i64) -> Vec { + let result = match &self { + SqlDatabase::Postgres(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(uid) + .fetch_all(pool) + .await + } + SqlDatabase::MySql(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(uid) + .fetch_all(pool) + .await + } + /*SqlDatabase::MsSql(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(uid) + .fetch_all(pool) + .await + }*/ + SqlDatabase::SqlLite(pool) => { + sqlx::query_scalar::<_, i64>(query) + .bind(uid) + .fetch_all(pool) + .await + } + }; + + match result { + Ok(result) => result, + Err(err) => { + tracing::warn!(context = "sql", event = "error", query = query, reason = ?err); + vec![] + } + } + } + + pub async fn execute(&self, query: &str, params: impl Iterator) -> bool { + let result = match self { + SqlDatabase::Postgres(pool) => { + let mut q = sqlx::query(query); + for param in params { + q = q.bind(param); + } + q.execute(pool).await.map(|_| ()) + } + SqlDatabase::MySql(pool) => { + let mut q = sqlx::query(query); + for param in params { + q = q.bind(param); + } + q.execute(pool).await.map(|_| ()) + } + /*SqlDatabase::MsSql(pool) => { + let mut q = sqlx::query(query); + for param in params { + q = q.bind(param); + } + q.execute(pool).await.map(|_| ()) + }*/ + SqlDatabase::SqlLite(pool) => { + let mut q = sqlx::query(query); + for param in params { + q = q.bind(param); + } + q.execute(pool).await.map(|_| ()) + } + }; + + match result { + Ok(_) => true, + Err(err) => { + tracing::warn!(context = "sql", event = "error", query = query, reason = ?err); + false + } + } + } +} + +#[cfg(feature = "test_mode")] +impl AuthDatabase { + pub async fn execute(&self, query: &str, params: impl Iterator) -> bool { + match self { + AuthDatabase::Sql { db, .. } => db.execute(query, params).await, + AuthDatabase::Ldap => unimplemented!(), + } } } diff --git a/crates/jmap/src/auth/acl.rs b/crates/jmap/src/auth/acl.rs index 8bb00500..5680b454 100644 --- a/crates/jmap/src/auth/acl.rs +++ b/crates/jmap/src/auth/acl.rs @@ -17,7 +17,7 @@ use crate::{JMAP, SUPERUSER_ID}; use super::AclToken; impl JMAP { - pub async fn shared_accounts(&self, mut acl_token: AclToken) -> Option { + pub async fn update_acl_token(&self, mut acl_token: AclToken) -> Option { for &grant_account_id in [acl_token.primary_id] .iter() .chain(acl_token.member_of.clone().iter()) @@ -240,9 +240,10 @@ impl JMAP { to_account_id: u32, to_collection: impl Into, to_document_id: u32, - check_acls: Bitmap, + check_acls: impl Into>, ) -> Result { let to_collection = to_collection.into(); + let check_acls = check_acls.into(); for &grant_account_id in [acl_token.primary_id] .iter() .chain(acl_token.member_of.clone().iter()) @@ -293,6 +294,7 @@ impl JMAP { ); } MaybePatchValue::Patch(patch) => { + let patch = self.map_acl_accounts(patch).await?; let acl = if let Value::List(acl) = changes .properties @@ -384,7 +386,7 @@ impl JMAP { 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 { + if let Some(account_name) = self.get_account_login(id.document_id()).await { acl_obj.append( Property::_T(account_name), Bitmap::::from(*acl_bits) @@ -401,10 +403,56 @@ impl JMAP { } } + pub fn refresh_acls(&self, changes: &Object, current: &Option>) { + if let Value::List(acl_changes) = changes.get(&Property::Acl) { + let mut acl_tokens = self.acl_tokens.lock(); + if let Some(Value::List(acl_current)) = current + .as_ref() + .and_then(|current| current.properties.get(&Property::Acl)) + { + for current_item in acl_current.chunks_exact(2) { + let mut invalidate = true; + for change_item in acl_changes.chunks_exact(2) { + if change_item.first() == current_item.first() { + invalidate = change_item.last() != current_item.last(); + break; + } + } + if invalidate { + if let Some(Value::Id(id)) = current_item.first() { + acl_tokens.remove(&id.document_id()); + } + } + } + + for change_item in acl_changes.chunks_exact(2) { + let mut invalidate = true; + for current_item in acl_current.chunks_exact(2) { + if change_item.first() == current_item.first() { + invalidate = change_item.last() != current_item.last(); + break; + } + } + if invalidate { + if let Some(Value::Id(id)) = change_item.first() { + acl_tokens.remove(&id.document_id()); + } + } + } + } else { + for value in acl_changes { + if let Value::Id(id) = value { + acl_tokens.remove(&id.document_id()); + } + } + } + } + } + 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 { + if let Some(account_id) = self.get_account_id(account_name).await { *item = Value::Id(account_id.into()); } else { return Err(SetError::invalid_properties() @@ -423,6 +471,12 @@ impl AclToken { self.primary_id } + pub fn secondary_ids(&self) -> impl Iterator { + self.member_of + .iter() + .chain(self.access_to.iter().map(|(id, _)| id)) + } + pub fn is_member(&self, account_id: u32) -> bool { self.primary_id == account_id || self.member_of.contains(&account_id) @@ -434,7 +488,8 @@ impl AclToken { !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 { + pub fn has_access(&self, to_account_id: u32, to_collection: impl Into) -> bool { + let to_collection = to_collection.into(); self.is_member(to_account_id) || self.access_to.iter().any(|(id, collections)| { *id == to_account_id && collections.contains(to_collection) diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index 8c013a1e..fead7cfd 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -17,7 +17,7 @@ use super::{rate_limit::RemoteAddress, AclToken}; impl JMAP { pub async fn authenticate_headers( &self, - req: &mut hyper::Request, + req: &hyper::Request, remote_ip: IpAddr, ) -> Result)>, RequestError> { if let Some((mechanism, token)) = req @@ -26,8 +26,21 @@ impl JMAP { .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() + let session = if let Some(account_id) = self.sessions.get(&token) { + if let Some(acl_token) = self.acl_tokens.get(&account_id) { + acl_token.into() + } else { + // Refresh ACL token + self.get_acl_token(account_id).await.map(|acl_token| { + let acl_token = Arc::new(acl_token); + self.acl_tokens.insert( + account_id, + acl_token.clone(), + Instant::now() + self.config.session_cache_ttl, + ); + acl_token + }) + } } else { let addr = self.build_remote_addr(req, remote_ip); if mechanism.eq_ignore_ascii_case("basic") { @@ -56,12 +69,16 @@ impl JMAP { // 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 + match self.validate_access_token("access_token", &token).await { + Ok((account_id, _, _)) => self.get_acl_token(account_id).await, + Err(err) => { + tracing::debug!( + context = "authenticate_headers", + err = err, + "Failed to validate access token." + ); + None + } } } else { // Enforce anonymous rate limit @@ -72,6 +89,11 @@ impl JMAP { let session = Arc::new(session); self.sessions.insert( token, + session.primary_id(), + Instant::now() + self.config.session_cache_ttl, + ); + self.acl_tokens.insert( + session.primary_id(), session.clone(), Instant::now() + self.config.session_cache_ttl, ); diff --git a/crates/jmap/src/auth/mod.rs b/crates/jmap/src/auth/mod.rs index 0cdd709a..4a0f2537 100644 --- a/crates/jmap/src/auth/mod.rs +++ b/crates/jmap/src/auth/mod.rs @@ -9,7 +9,7 @@ use aes_gcm_siv::{ }; use jmap_proto::types::collection::Collection; -use store::{blake3, write::key::KeySerializer, Key, Serialize, SUBSPACE_VALUES}; +use store::blake3; use utils::map::bitmap::Bitmap; pub mod account; @@ -18,6 +18,24 @@ pub mod authenticate; pub mod oauth; pub mod rate_limit; +pub enum AuthDatabase { + Sql { + db: SqlDatabase, + query_uid_by_login: String, + query_login_by_uid: String, + query_secret_by_uid: String, + query_gids_by_uid: String, + }, + Ldap, +} + +pub enum SqlDatabase { + Postgres(sqlx::Pool), + MySql(sqlx::Pool), + //MsSql(sqlx::Pool), + SqlLite(sqlx::Pool), +} + #[derive(Debug, Clone)] pub struct AclToken { pub primary_id: u32, @@ -25,22 +43,6 @@ pub struct AclToken { 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 { @@ -102,34 +104,3 @@ impl SymmetricEncrypt { .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 index 0bf68ac2..cce5ca27 100644 --- a/crates/jmap/src/auth/oauth/device_auth.rs +++ b/crates/jmap/src/auth/oauth/device_auth.rs @@ -1,211 +1,212 @@ use std::{ sync::{atomic, Arc}, - time::Instant, + time::{Duration, Instant}, }; use hyper::StatusCode; +use mail_send::mail_auth::common::lru::DnsCache; use store::rand::{ distributions::{Alphanumeric, Standard}, - thread_rng, + thread_rng, Rng, }; +use utils::listener::ServerInstance; -use crate::auth::oauth::{ - OAUTH_HTML_ERROR, OAUTH_HTML_LOGIN_HEADER_FAILED, OAUTH_HTML_LOGIN_SUCCESS, STATUS_AUTHORIZED, +use crate::{ + api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse, JsonResponse}, + auth::oauth::{ + OAUTH_HTML_ERROR, OAUTH_HTML_LOGIN_HEADER_FAILED, OAUTH_HTML_LOGIN_SUCCESS, + STATUS_AUTHORIZED, + }, + JMAP, }; use super::{ - DeviceAuthGet, DeviceAuthResponse, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, + parse_form_data, 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 +impl JMAP { + pub async fn handle_device_auth( + &self, + req: &mut HttpRequest, + instance: &ServerInstance, + ) -> HttpResponse { + // Parse form + let client_id = match parse_form_data(req) + .await + .map(|mut p| p.remove("client_id")) { - 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(client_id)) if client_id.len() < CLIENT_ID_MAX_LEN => client_id, + Err(err) => return err, + _ => { + return HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Client ID is invalid.".to_string(), + ) + .into_http_response(); + } + }; + + // 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(), + client_id, + redirect_uri: None, + }); + let expiry = Instant::now() + Duration::from_secs(self.config.oauth_expiry_user_code); + self.oauth_codes + .insert(device_code.clone(), oauth_code.clone(), expiry); + self.oauth_codes + .insert(user_code.clone(), oauth_code, expiry); + + // Build response + JsonResponse::new(DeviceAuthResponse { + verification_uri: format!("{}/auth", instance.data), + verification_uri_complete: format!("{}/auth/code?={}", instance.data, user_code), + device_code, + user_code, + expires_in: self.config.oauth_expiry_user_code, + interval: 5, + }) + .into_http_response() + } + + // Device authorization flow, renders the authorization page + pub async fn handle_user_device_auth(&self, req: &mut HttpRequest) -> HttpResponse { + let code = req + .uri() + .query() + .and_then(|q| { + form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == "code") + .map(|(_, v)| v.into_owned()) + }) + .unwrap_or_default(); + 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); + + HtmlResponse::new(response).into_http_response() + } + + // Handles POST request from the device authorization form + pub async fn handle_user_device_auth_post(&self, req: &mut HttpRequest) -> HttpResponse { + // Parse form + let fields = match parse_form_data(req).await { + Ok(fields) => fields, + Err(err) => return err, + }; + + enum Response { + Success, + Failed, + InvalidCode, + } + + let code = if let Some(oauth) = fields + .get("code") + .and_then(|code| self.oauth_codes.get(code)) + { + if (STATUS_PENDING..STATUS_PENDING + self.config.oauth_max_auth_attempts) + .contains(&oauth.status.load(atomic::Ordering::Relaxed)) + { + if let (Some(email), Some(password)) = (fields.get("email"), fields.get("password")) { - Ok(Some(account_id)) => { + if let Some(id) = self.authenticate(email, password).await { oauth .account_id - .store(account_id, atomic::Ordering::Relaxed); + .store(id.primary_id(), atomic::Ordering::Relaxed); oauth .status .store(STATUS_AUTHORIZED, atomic::Ordering::Relaxed); Response::Success - } - Ok(None) => { + } else { oauth.status.fetch_add(1, atomic::Ordering::Relaxed); Response::Failed } - Err(_) => Response::Error, + } else { + Response::Failed } } else { - Response::Failed + Response::InvalidCode } } 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")); + 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.", - )); + 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( + "@@@", + fields.get("code").map(|s| s.as_str()).unwrap_or_default(), + )); + 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); + + HtmlResponse::new(response).into_http_response() } - - 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 index 4ff9c4b8..69d96d8f 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -1,7 +1,13 @@ -use std::{sync::atomic::AtomicU32, time::Instant}; +use std::{collections::HashMap, sync::atomic::AtomicU32}; +use hyper::{header::CONTENT_TYPE, StatusCode}; use serde::{Deserialize, Serialize}; +use crate::api::{ + http::{fetch_body, ToHttpResponse}, + HtmlResponse, HttpRequest, HttpResponse, +}; + pub mod device_auth; pub mod token; pub mod user_code; @@ -47,7 +53,6 @@ pub struct OAuth { pub struct OAuthCode { pub status: AtomicU32, pub account_id: AtomicU32, - pub expiry: Instant, pub client_id: String, pub redirect_uri: Option, } @@ -157,16 +162,6 @@ pub struct OAuthMetadata { 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 { @@ -194,3 +189,36 @@ impl TokenResponse { matches!(self, TokenResponse::Error { .. }) } } + +pub async fn parse_form_data( + req: &mut HttpRequest, +) -> Result, HttpResponse> { + match ( + req.headers() + .get(CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .and_then(|val| val.parse::().ok()), + fetch_body(req, 2048).await, + ) { + (Some(content_type), Ok(body)) => { + let mut fields = HashMap::new(); + if let Some(boundary) = content_type.get_param(mime::BOUNDARY) { + for mut field in form_data::FormData::new(&body[..], boundary.as_str()).flatten() { + let value = String::from_utf8(field.bytes().unwrap_or_default().to_vec()) + .unwrap_or_default(); + fields.insert(field.name, value); + } + } else { + for (key, value) in form_urlencoded::parse(&body) { + fields.insert(key.into_owned(), value.into_owned()); + } + } + Ok(fields) + } + _ => Err(HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Invalid post request".to_string(), + ) + .into_http_response()), + } +} diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 3407b204..c46e1c7d 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -3,87 +3,58 @@ 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 mail_send::mail_auth::common::lru::DnsCache; +use store::{ + blake3, + rand::{thread_rng, Rng}, +}; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; -use crate::{auth::SymmetricEncrypt, JMAP}; - -use super::{ - ErrorType, TokenResponse, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN, STATUS_AUTHORIZED, - STATUS_PENDING, STATUS_TOKEN_ISSUED, +use crate::{ + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + auth::SymmetricEncrypt, + JMAP, }; -// 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); +use super::{ + parse_form_data, ErrorType, TokenResponse, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN, + STATUS_AUTHORIZED, STATUS_PENDING, STATUS_TOKEN_ISSUED, +}; - 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) +impl JMAP { + // Token endpoint + pub async fn handle_token_request(&self, req: &mut HttpRequest) -> HttpResponse { + // Parse form + let params = match parse_form_data(req).await { + Ok(params) => params, + Err(err) => return err, }; - } else if params - .grant_type - .eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") - { - response = TokenResponse::error(ErrorType::ExpiredToken); + let grant_type = params + .get("grant_type") + .map(|s| s.as_str()) + .unwrap_or_default(); - 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 => { + let mut response = TokenResponse::error(ErrorType::InvalidGrant); + + if grant_type.eq_ignore_ascii_case("authorization_code") { + response = if let (Some(code), Some(client_id), Some(redirect_uri)) = ( + params.get("code"), + params.get("client_id"), + params.get("redirect_uri"), + ) { + if let Some(oauth) = self.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 { // Mark this token as issued oauth .status .store(STATUS_TOKEN_ISSUED, atomic::Ordering::Relaxed); // Issue token - core.issue_token( + self.issue_token( oauth.account_id.load(atomic::Ordering::Relaxed), &oauth.client_id, true, @@ -93,31 +64,70 @@ where tracing::error!("Failed to generate OAuth token: {}", err); TokenResponse::error(ErrorType::InvalidRequest) }) + } else { + TokenResponse::error(ErrorType::InvalidGrant) } - status - if (STATUS_PENDING..STATUS_PENDING + core.oauth.max_auth_attempts) - .contains(&status) => - { - TokenResponse::error(ErrorType::AuthorizationPending) + } else { + TokenResponse::error(ErrorType::AccessDenied) + } + } else { + TokenResponse::error(ErrorType::InvalidClient) + }; + } else if 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 + .get("device_code") + .and_then(|dc| self.oauth_codes.get(dc)), + params.get("client_id"), + ) { + response = if &oauth.client_id != client_id { + TokenResponse::error(ErrorType::InvalidClient) + } else { + 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 + self.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 + self.config.oauth_max_auth_attempts) + .contains(&status) => + { + TokenResponse::error(ErrorType::AuthorizationPending) + } + STATUS_TOKEN_ISSUED => TokenResponse::error(ErrorType::ExpiredToken), + _ => TokenResponse::error(ErrorType::AccessDenied), } - 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)) => { + } else if grant_type.eq_ignore_ascii_case("refresh_token") { + if let Some(refresh_token) = params.get("refresh_token") { + if let Ok((account_id, client_id, time_left)) = self + .validate_access_token("refresh_token", refresh_token) + .await + { // TODO: implement revoking client ids - response = core + response = self .issue_token( account_id, &client_id, - time_left <= core.oauth.expiry_refresh_token_renew, + time_left <= self.config.oauth_expiry_refresh_token_renew, ) .await .unwrap_or_else(|err| { @@ -125,44 +135,32 @@ where TokenResponse::error(ErrorType::InvalidGrant) }); } - Err(err) => { - tracing::debug!("Refresh token failed validation: {}", err); - } + } else { + response = TokenResponse::error(ErrorType::InvalidRequest); } - } else { - response = TokenResponse::error(ErrorType::InvalidRequest); } + + JsonResponse::with_status( + if response.is_error() { + StatusCode::BAD_REQUEST + } else { + StatusCode::OK + }, + response, + ) + .into_http_response() } - 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(); + ) -> Result { 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?; + .get_account_secret(account_id) + .await + .ok_or("Account no longer exists")?; Ok(TokenResponse::Granted { access_token: self.encode_access_token( @@ -170,17 +168,17 @@ impl JMAP { account_id, &password_hash, client_id, - self.oauth.expiry_token, + self.config.oauth_expiry_token, )?, token_type: "bearer".to_string(), - expires_in: self.oauth.expiry_token, + expires_in: self.config.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, + self.config.oauth_expiry_refresh_token, )? .into() } else { @@ -197,12 +195,12 @@ impl JMAP { password_hash: &str, client_id: &str, expiry_in: u64, - ) -> store::Result { + ) -> Result { // Build context if client_id.len() > CLIENT_ID_MAX_LEN { - return Err(StoreError::DeserializeError("ClientId is too long".into())); + return Err("ClientId is too long"); } - let key = self.oauth.key.clone(); + let key = self.config.oauth_key.clone(); let context = format!( "{} {} {} {}", grant_type, client_id, account_id, password_hash @@ -232,7 +230,7 @@ impl JMAP { // 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)?; + .map_err(|_| "Failed to encrypt token.")?; token.push_leb128(account_id); token.push_leb128(expiry); token.extend_from_slice(client_id.as_bytes()); @@ -240,14 +238,13 @@ impl JMAP { Ok(String::from_utf8(base64_encode(&token).unwrap_or_default()).unwrap()) } - pub fn validate_access_token( + pub async fn validate_access_token( &self, grant_type: &str, token: &str, - ) -> Option<(u32, String, u64)> { + ) -> Result<(u32, String, u64), &'static str> { // Base64 decode token - let token = base64_decode(token.as_bytes()) - .ok_or_else(|| StoreError::DeserializeError("Failed to decode.".to_string()))?; + let token = base64_decode(token.as_bytes()).ok_or("Failed to decode.")?; let (account_id, expiry, client_id) = token .get((RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN)..) .and_then(|bytes| { @@ -259,7 +256,7 @@ impl JMAP { ) .into() }) - .ok_or_else(|| StoreError::DeserializeError("Failed to decode token.".into()))?; + .ok_or("Failed to decode token.")?; // Validate expiration let now = SystemTime::now() @@ -268,18 +265,17 @@ impl JMAP { .unwrap_or(0) .saturating_sub(946684800); // Jan 1, 2000 if expiry <= now { - return Err(StoreError::DeserializeError("Token expired.".into())); + return Err("Token expired."); } // 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()))?; + .get_account_secret(account_id) + .await + .ok_or("Account no longer exists")?; // Build context - let key = self.oauth.key.clone(); + let key = self.config.oauth_key.clone(); let context = format!( "{} {} {} {}", grant_type, client_id, account_id, password_hash @@ -304,7 +300,7 @@ impl JMAP { &token[..RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN], &nonce, ) - .map_err(|e| StoreError::DeserializeError(format!("Failed to decrypt: {}", e)))?; + .map_err(|_| "Failed to decrypt token.")?; // 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 index 0f60124d..8de962b8 100644 --- a/crates/jmap/src/auth/oauth/user_code.rs +++ b/crates/jmap/src/auth/oauth/user_code.rs @@ -1,125 +1,64 @@ -use std::{sync::Arc, time::Instant}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; -use hyper::{header, StatusCode}; +use http_body_util::{BodyExt, Full}; +use hyper::{body::Bytes, header, StatusCode}; use mail_builder::encoders::base64::base64_encode; use mail_parser::decoders::base64::base64_decode; -use store::rand::{distributions::Alphanumeric, thread_rng}; +use mail_send::mail_auth::common::lru::DnsCache; +use std::fmt::Write; +use store::rand::{distributions::Alphanumeric, thread_rng, Rng}; + +use crate::{ + api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}, + JMAP, +}; use super::{ - CodeAuthRequest, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, OAUTH_HTML_FOOTER, + parse_form_data, 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"); - } +impl JMAP { + // Code authorization flow, handles an authorization request + pub async fn handle_user_code_auth(req: &mut HttpRequest) -> HttpResponse { + let params = form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()) + .into_owned() + .collect::>(); + let client_id = params + .get("client_id") + .map(|s| s.as_str()) + .unwrap_or_default(); + let redirect_uri = params + .get("redirect_uri") + .map(|s| s.as_str()) + .unwrap_or_default(); - 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."); + // Validate clientId + if client_id.len() > CLIENT_ID_MAX_LEN { + return HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Client ID is invalid.".to_string(), + ) + .into_http_response(); + } else if !redirect_uri.starts_with("https://") { + return HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Redirect URI must be HTTPS".to_string(), + ) + .into_http_response(); } - }; - // 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(); + let mut cancel_link = format!("{}?error=access_denied", redirect_uri); + if let Some(state) = params.get("state") { + let _ = write!(cancel_link, "&state={}", state); } - } - - // 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()) + base64_encode(&bincode::serialize(&(1u32, params)).unwrap_or_default()) .unwrap_or_default(), ) .unwrap(); @@ -131,21 +70,132 @@ where + OAUTH_HTML_LOGIN_FORM.len() + OAUTH_HTML_FOOTER.len() + code.len() - + redirect_link.len() + + cancel_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_HEADER_CLIENT); 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_LOGIN_FORM.replace("@@@", &cancel_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() + HtmlResponse::new(response).into_http_response() + } + + // Handles POST request from the code authorization form + pub async fn handle_user_code_auth_post(&self, req: &mut HttpRequest) -> HttpResponse { + // Parse form + let params = match parse_form_data(req).await { + Ok(params) => params, + Err(err) => return err, + }; + + let mut auth_code = None; + let (auth_attempts, code_req) = match params + .get("code") + .and_then(|code| base64_decode(code.as_bytes())) + .and_then(|bytes| bincode::deserialize::<(u32, HashMap)>(&bytes).ok()) + { + Some(code) => code, + None => { + return HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Failed to deserialize code.".to_string(), + ) + .into_http_response(); + } + }; + + // Authenticate user + if let (Some(email), Some(password)) = (params.get("email"), params.get("password")) { + if let Some(acl_token) = self.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 + self.oauth_codes.insert( + client_code.clone(), + Arc::new(OAuthCode { + status: STATUS_AUTHORIZED.into(), + account_id: acl_token.primary_id().into(), + client_id: code_req + .get("client_id") + .map(|s| s.as_str()) + .unwrap_or_default() + .to_string(), + redirect_uri: code_req.get("redirect_uri").cloned(), + }), + Instant::now() + Duration::from_secs(self.config.oauth_expiry_auth_code), + ); + + auth_code = client_code.into(); + } + } + + // Build redirect link + let mut redirect_link = if let Some(auth_code) = &auth_code { + format!( + "{}?code={}", + code_req + .get("redirect_uri") + .map(|s| s.as_str()) + .unwrap_or_default(), + auth_code + ) + } else { + format!( + "{}?error=access_denied", + code_req + .get("redirect_uri") + .map(|s| s.as_str()) + .unwrap_or_default() + ) + }; + if let Some(state) = &code_req.get("state") { + let _ = write!(redirect_link, "&state={}", state); + } + + if auth_code.is_none() && (auth_attempts < self.config.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); + + HtmlResponse::new(response).into_http_response() + } else { + hyper::Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header(header::LOCATION, redirect_link) + .body( + Full::new(Bytes::from(Vec::::new())) + .map_err(|never| match never {}) + .boxed(), + ) + .unwrap() + } } } diff --git a/crates/jmap/src/auth/rate_limit.rs b/crates/jmap/src/auth/rate_limit.rs index 8f83c2a0..7cfd4501 100644 --- a/crates/jmap/src/auth/rate_limit.rs +++ b/crates/jmap/src/auth/rate_limit.rs @@ -103,6 +103,23 @@ impl JMAP { } } + pub fn is_upload_allowed(&self, account_id: u32) -> Result { + if account_id != SUPERUSER_ID { + if let Some(in_flight_request) = self + .get_authenticated_limiter(account_id) + .lock() + .concurrent_uploads + .is_allowed() + { + Ok(in_flight_request) + } else { + Err(RequestError::limit(RequestLimitError::Concurrent)) + } + } else { + Ok(InFlight::default()) + } + } + pub fn is_auth_allowed(&self, addr: RemoteAddress) -> Result<(), RequestError> { if self .get_anonymous_limiter(addr) diff --git a/crates/jmap/src/blob/copy.rs b/crates/jmap/src/blob/copy.rs new file mode 100644 index 00000000..a8c7382a --- /dev/null +++ b/crates/jmap/src/blob/copy.rs @@ -0,0 +1,98 @@ +use jmap_proto::{ + error::{ + method::MethodError, + set::{SetError, SetErrorType}, + }, + method::copy::{CopyBlobRequest, CopyBlobResponse}, + types::{acl::Acl, blob::BlobId}, +}; +use store::BlobKind; +use utils::map::vec_map::VecMap; + +use crate::{auth::AclToken, JMAP}; + +impl JMAP { + pub async fn blob_copy( + &self, + request: CopyBlobRequest, + acl_token: &AclToken, + ) -> Result { + let mut response = CopyBlobResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + copied: VecMap::with_capacity(request.blob_ids.len()), + not_copied: VecMap::new(), + }; + let account_id = request.account_id.document_id(); + + for blob_id in request.blob_ids { + let has_access = match &blob_id.kind { + BlobKind::Linked { + account_id, + collection, + document_id, + } => { + acl_token.is_member(*account_id) + || (acl_token.has_access(*account_id, *collection) + && self + .has_access_to_document( + acl_token, + *account_id, + *collection, + *document_id, + Acl::Read, + ) + .await?) + } + BlobKind::LinkedMaildir { + account_id, + document_id, + } => { + acl_token.is_member(*account_id) + || self + .shared_messages(acl_token, *account_id, Acl::ReadItems) + .await? + .contains(*document_id) + } + BlobKind::Temporary { account_id, .. } => acl_token.is_member(*account_id), + }; + + if has_access { + let dest_blob_id = BlobId::temporary(account_id); + match self + .store + .copy_blob(&blob_id.kind, &dest_blob_id.kind) + .await + { + Ok(success) => { + if success { + response.copied.append(blob_id, dest_blob_id); + } else { + response.not_copied.append( + blob_id, + SetError::new(SetErrorType::BlobNotFound) + .with_description("blobId does not exist."), + ); + } + } + Err(err) => { + tracing::error!( + context = "copy_blob", + event = "error", + reason = %err, + "Failed to copy blob"); + return Err(MethodError::ServerPartialFail); + } + } + } else { + response.not_copied.append( + blob_id, + SetError::forbidden() + .with_description("You do not have access to this blobId."), + ); + } + } + + Ok(response) + } +} diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index 0f8ef5cb..548d4678 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -31,7 +31,7 @@ impl JMAP { *account_id, *collection, *document_id, - Acl::Read.into(), + Acl::Read, ) .await { diff --git a/crates/jmap/src/blob/mod.rs b/crates/jmap/src/blob/mod.rs index 5d315a28..cc448cf5 100644 --- a/crates/jmap/src/blob/mod.rs +++ b/crates/jmap/src/blob/mod.rs @@ -1,5 +1,6 @@ use jmap_proto::types::{blob::BlobId, id::Id}; +pub mod copy; pub mod download; pub mod upload; diff --git a/crates/jmap/src/blob/upload.rs b/crates/jmap/src/blob/upload.rs index ca5bccc0..9ef74172 100644 --- a/crates/jmap/src/blob/upload.rs +++ b/crates/jmap/src/blob/upload.rs @@ -14,6 +14,17 @@ impl JMAP { content_type: &str, data: &[u8], ) -> Result { + // Limit concurrent uploads + let _in_flight = self.is_upload_allowed(account_id.document_id())?; + + #[cfg(feature = "test_mode")] + { + // Used for concurrent upload tests + if data == b"sleep" { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + let blob_id = BlobId::temporary(account_id.document_id()); match self.store.put_blob(&blob_id.kind, data).await { diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index 980ace44..136462a8 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -10,13 +10,14 @@ use jmap_proto::{ reference::MaybeReference, Call, RequestMethod, }, + response::references::EvalObjectReferences, types::{ acl::Acl, blob::BlobId, collection::Collection, id::Id, property::Property, - value::{SetValue, Value}, + value::{MaybePatchValue, Value}, }, }; use mail_parser::parsers::fields::thread::thread_name; @@ -95,15 +96,23 @@ impl JMAP { let mut received_at = None; for (property, value) in create.properties { + let value = match response.eval_object_references(value) { + Ok(value) => value, + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + }; + match (property, value) { - (Property::MailboxIds, SetValue::Value(Value::List(ids))) => { + (Property::MailboxIds, MaybePatchValue::Value(Value::List(ids))) => { mailboxes = ids .into_iter() .map(|id| id.unwrap_id().document_id()) .collect(); } - (Property::MailboxIds, SetValue::Patch(patch)) => { + (Property::MailboxIds, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); let document_id = patch.next().unwrap().unwrap_id().document_id(); if patch.next().unwrap().unwrap_bool() { @@ -115,14 +124,14 @@ impl JMAP { } } - (Property::Keywords, SetValue::Value(Value::List(keywords_))) => { + (Property::Keywords, MaybePatchValue::Value(Value::List(keywords_))) => { keywords = keywords_ .into_iter() .map(|keyword| keyword.unwrap_keyword()) .collect(); } - (Property::Keywords, SetValue::Patch(patch)) => { + (Property::Keywords, MaybePatchValue::Patch(patch)) => { let mut patch = patch.into_iter(); let keyword = patch.next().unwrap().unwrap_keyword(); if patch.next().unwrap().unwrap_bool() { @@ -133,7 +142,7 @@ impl JMAP { keywords.retain(|k| k != &keyword); } } - (Property::ReceivedAt, SetValue::Value(Value::Date(value))) => { + (Property::ReceivedAt, MaybePatchValue::Value(Value::Date(value))) => { received_at = value.into(); } (property, _) => { diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index a29e3a6d..146f637f 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -127,16 +127,8 @@ impl JMAP { old_state.clone() }, old_state: old_state.into(), - created: if !created.is_empty() { - created.into() - } else { - None - }, - not_created: if !not_created.is_empty() { - not_created.into() - } else { - None - }, + created, + not_created, }) } } diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 48b09da2..98dd1c01 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -7,6 +7,7 @@ use jmap_proto::{ }, method::set::{RequestArguments, SetRequest, SetResponse}, object::Object, + response::references::EvalObjectReferences, types::{ acl::Acl, collection::Collection, diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index d63660f2..9dba8192 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -2,8 +2,9 @@ use std::{sync::Arc, time::Duration}; use api::session::BaseCapabilities; use auth::{ + oauth::OAuthCode, rate_limit::{AnonymousLimiter, AuthenticatedLimiter, RemoteAddress}, - AclToken, + AclToken, AuthDatabase, SqlDatabase, }; use jmap_proto::{ error::method::MethodError, @@ -15,6 +16,7 @@ use jmap_proto::{ types::{collection::Collection, property::Property}, }; use mail_send::mail_auth::common::lru::{DnsCache, LruCache}; +use sqlx::{mysql::MySqlPoolOptions, postgres::PgPoolOptions, sqlite::SqlitePoolOptions}; use store::{ ahash::AHashMap, fts::Language, @@ -24,23 +26,26 @@ use store::{ write::BitmapFamily, BitmapKey, Deserialize, Serialize, Store, ValueKey, }; -use utils::{config::Rate, map::vec_map::VecMap, UnwrapFailure}; +use utils::{config::Rate, failed, map::vec_map::VecMap, UnwrapFailure}; pub mod api; +pub mod auth; 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 sessions: LruCache, + pub acl_tokens: LruCache>, pub rate_limit_auth: LruCache>>, pub rate_limit_unauth: LruCache>>, + pub oauth_codes: LruCache>, + pub auth_db: AuthDatabase, } pub struct Config { @@ -73,6 +78,14 @@ pub struct Config { pub rate_anonymous: Rate, pub rate_use_forwarded: bool, + pub oauth_key: String, + pub oauth_expiry_user_code: u64, + pub oauth_expiry_auth_code: u64, + pub oauth_expiry_token: u64, + pub oauth_expiry_refresh_token: u64, + pub oauth_expiry_refresh_token_renew: u64, + pub oauth_max_auth_attempts: u32, + pub capabilities: BaseCapabilities, } @@ -85,6 +98,90 @@ pub enum MaybeError { impl JMAP { pub async fn new(config: &utils::config::Config) -> Self { + let auth_db = match config + .value_require("jmap.auth.database.type") + .failed("Invalid property") + { + "ldap" => AuthDatabase::Ldap, + "sql" => { + let address = config + .value_require("jmap.auth.database.address") + .failed("Invalid property"); + let max_connections = config + .property("jmap.auth.database.max-connections") + .failed("Invalid property") + .unwrap_or(10); + let min_connections = config + .property("jmap.auth.database.min-connections") + .failed("Invalid property") + .unwrap_or(0); + let idle_timeout = config + .property("jmap.auth.database.idle-timeout") + .failed("Invalid property"); + + let db = if address.starts_with("postgres:") { + SqlDatabase::Postgres( + PgPoolOptions::new() + .max_connections(max_connections) + .min_connections(min_connections) + .idle_timeout(idle_timeout) + .connect_lazy(address) + .failed(&format!("Failed to create connection pool for {address:?}")), + ) + } else if address.starts_with("mysql:") { + SqlDatabase::MySql( + MySqlPoolOptions::new() + .max_connections(max_connections) + .min_connections(min_connections) + .idle_timeout(idle_timeout) + .connect_lazy(address) + .failed(&format!("Failed to create connection pool for {address:?}")), + ) + } else if address.starts_with("mssql:") { + todo!() + /*SqlDatabase::MsSql( + MssqlPoolOptions::new() + .max_connections(max_connections) + .min_connections(min_connections) + .idle_timeout(idle_timeout) + .connect_lazy(address) + .failed(&format!("Failed to create connection pool for {address:?}")), + )*/ + } else if address.starts_with("sqlite:") { + SqlDatabase::SqlLite( + SqlitePoolOptions::new() + .max_connections(max_connections) + .min_connections(min_connections) + .idle_timeout(idle_timeout) + .connect_lazy(address) + .failed(&format!("Failed to create connection pool for {address:?}")), + ) + } else { + failed(&format!("Invalid database address {address:?}")); + }; + AuthDatabase::Sql { + db, + query_uid_by_login: config + .value_require("jmap.auth.database.query.uid-by-login") + .failed("Invalid property") + .to_string(), + query_login_by_uid: config + .value_require("jmap.auth.database.query.login-by-uid") + .failed("Invalid property") + .to_string(), + query_secret_by_uid: config + .value_require("jmap.auth.database.query.secret-by-uid") + .failed("Invalid property") + .to_string(), + query_gids_by_uid: config + .value_require("jmap.auth.database.query.gids-by-uid") + .failed("Invalid property") + .to_string(), + } + } + _ => failed("Invalid auth database type"), + }; + JMAP { store: Store::open(config).await.failed("Unable to open database"), config: Config::new(config).failed("Invalid configuration file"), @@ -94,6 +191,12 @@ impl JMAP { .failed("Invalid property") .unwrap_or(100), ), + acl_tokens: 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") @@ -106,6 +209,13 @@ impl JMAP { .failed("Invalid property") .unwrap_or(2048), ), + oauth_codes: LruCache::with_capacity( + config + .property("oauth.code.cache-size") + .failed("Invalid property") + .unwrap_or(128), + ), + auth_db, } } diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index 6836b625..b52e4910 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -56,6 +56,7 @@ impl JMAP { | Property::Role | Property::SortOrder | Property::Acl + | Property::MyRights ) }); let mut response = GetResponse { diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index eb0919a8..1dcee8b4 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -9,6 +9,7 @@ use jmap_proto::{ mailbox::SetArguments, Object, }, + response::references::EvalObjectReferences, types::{ acl::Acl, collection::Collection, @@ -371,6 +372,7 @@ impl JMAP { "You are not allowed to delete this mailbox.", ), ); + continue 'destroy; } else if on_destroy_remove_emails && !acl.contains(Acl::RemoveItems) { ctx.set_response.not_destroyed.append( id, @@ -378,6 +380,7 @@ impl JMAP { "You are not allowed to delete emails from this mailbox.", ), ); + continue 'destroy; } } } @@ -720,10 +723,16 @@ impl JMAP { } } + // Refresh ACLs + let current = update.map(|(_, current)| current); + if changes.properties.contains_key(&Property::Acl) { + self.refresh_acls(&changes, ¤t); + } + // Validate Ok(ObjectIndexBuilder::new(SCHEMA) .with_changes(changes) - .with_current_opt(update.map(|(_, current)| current)) + .with_current_opt(current) .validate()) } diff --git a/tests/src/jmap/acl.rs b/tests/src/jmap/acl.rs new file mode 100644 index 00000000..ae28f3d4 --- /dev/null +++ b/tests/src/jmap/acl.rs @@ -0,0 +1,800 @@ +use std::{sync::Arc, time::Duration}; + +use jmap::{ + mailbox::{INBOX_ID, TRASH_ID}, + JMAP, +}; +use jmap_client::{ + client::{Client, Credentials}, + core::{ + error::{MethodError, MethodErrorType}, + set::{SetError, SetErrorType}, + }, + email::{ + self, + import::EmailImportResponse, + query::{Comparator, Filter}, + Property, + }, + mailbox::{self, Role}, + principal::ACL, +}; +use jmap_proto::types::id::Id; +use store::ahash::AHashMap; + +pub async fn test(server: Arc, admin_client: &mut Client) { + println!("Running ACL tests..."); + + // Create a group and three test accounts + let inbox_id = Id::new(INBOX_ID as u64).to_string(); + let trash_id = Id::new(TRASH_ID as u64).to_string(); + const JOHN_ID: u64 = 1; + const JANE_ID: u64 = 2; + const BILL_ID: u64 = 3; + const SALES_ID: u64 = 4; + let john_id = Id::from(JOHN_ID).to_string(); + let jane_id = Id::from(JANE_ID).to_string(); + let bill_id = Id::from(BILL_ID).to_string(); + let sales_id = Id::from(SALES_ID).to_string(); + + for (login, secret, name) in [ + ("jdoe@example.com", "12345", "John Doe"), + ("jane.smith@example.com", "abcde", "Jane Smith"), + ("bill@example.com", "098765", "Bill Foobar"), + ("sales@example.com", "Sales Group", ""), + ] { + assert!( + server + .auth_db + .execute( + "INSERT INTO users (login, secret, name) VALUES (?, ?, ?)", + vec![login.to_string(), secret.to_string(), name.to_string()].into_iter() + ) + .await + ); + } + + // Authenticate all accounts + let mut john_client = Client::new() + .credentials(Credentials::basic("jdoe@example.com", "12345")) + .timeout(Duration::from_secs(60)) + .accept_invalid_certs(true) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + + let mut jane_client = Client::new() + .credentials(Credentials::basic("jane.smith@example.com", "abcde")) + .timeout(Duration::from_secs(60)) + .accept_invalid_certs(true) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + + let mut bill_client = Client::new() + .credentials(Credentials::basic("bill@example.com", "098765")) + .timeout(Duration::from_secs(60)) + .accept_invalid_certs(true) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + + // Insert two emails in each account + let mut email_ids = AHashMap::default(); + for (client, account_id, name) in [ + (&mut john_client, &john_id, "john"), + (&mut jane_client, &jane_id, "jane"), + (&mut bill_client, &bill_id, "bill"), + (admin_client, &sales_id, "sales"), + ] { + let user_name = client.session().username().to_string(); + let mut ids = Vec::with_capacity(2); + for (mailbox_id, mailbox_name) in [(&inbox_id, "inbox"), (&trash_id, "trash")] { + ids.push( + client + .set_default_account_id(account_id) + .email_import( + format!( + concat!( + "From: acl_test@example.com\r\n", + "To: {}\r\n", + "Subject: Owned by {} in {}\r\n", + "\r\n", + "This message is owned by {}.", + ), + user_name, name, mailbox_name, name + ) + .into_bytes(), + [mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + email_ids.insert(name, ids); + } + + // John should have access to his emails only + assert_eq!( + john_client + .email_get( + email_ids.get("john").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by john in inbox" + ); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_get(&inbox_id, None::>) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(&sales_id) + .email_get( + email_ids.get("sales").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(&sales_id) + .mailbox_get(&inbox_id, None::>) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_query(None::, None::>) + .await, + ); + + // Jane grants Inbox ReadItems access to John + jane_client + .mailbox_update_acl(&inbox_id, "jdoe@example.com", [ACL::ReadItems]) + .await + .unwrap(); + + // John shoud have ReadItems access to Inbox + assert_eq!( + john_client + .set_default_account_id(&jane_id) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by jane in inbox" + ); + assert_eq!( + john_client + .set_default_account_id(&jane_id) + .email_query(None::, None::>) + .await + .unwrap() + .ids(), + [email_ids.get("jane").unwrap().first().unwrap().as_str()] + ); + + // John's session resource should contain Jane's account details + john_client.refresh_session().await.unwrap(); + assert_eq!( + john_client.session().account(&jane_id).unwrap().name(), + "jane.smith@example.com" + ); + + // John should not have access to emails in Jane's Trash folder + assert!(john_client + .set_default_account_id(&jane_id) + .email_get( + email_ids.get("jane").unwrap().last().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .is_none()); + + // John should only be able to copy blobs he has access to + let blob_id = jane_client + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::BlobId].into(), + ) + .await + .unwrap() + .unwrap() + .take_blob_id(); + john_client + .set_default_account_id(&john_id) + .blob_copy(&jane_id, &blob_id) + .await + .unwrap(); + let blob_id = jane_client + .email_get( + email_ids.get("jane").unwrap().last().unwrap(), + [Property::BlobId].into(), + ) + .await + .unwrap() + .unwrap() + .take_blob_id(); + assert_forbidden( + john_client + .set_default_account_id(&john_id) + .blob_copy(&jane_id, &blob_id) + .await, + ); + + // John only has ReadItems access to Inbox but no Read access + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await, + ); + jane_client + .mailbox_update_acl(&inbox_id, "jdoe@example.com", [ACL::Read, ACL::ReadItems]) + .await + .unwrap(); + assert_eq!( + john_client + .set_default_account_id(&jane_id) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list(), + vec![ACL::ReadItems] + ); + + // Try to add items using import and copy + let blob_id = john_client + .set_default_account_id(&john_id) + .upload( + Some(&john_id), + concat!( + "From: acl_test@example.com\r\n", + "To: jane.smith@example.com\r\n", + "Subject: Created by john in jane's inbox\r\n", + "\r\n", + "This message is owned by jane.", + ) + .as_bytes() + .to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let mut request = john_client.set_default_account_id(&jane_id).build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + assert_forbidden( + request + .send_single::() + .await + .unwrap() + .created(&email_id), + ); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_copy( + &john_id, + email_ids.get("john").unwrap().last().unwrap(), + [&inbox_id], + None::>, + None, + ) + .await, + ); + + // Grant access and try again + jane_client + .mailbox_update_acl( + &inbox_id, + "jdoe@example.com", + [ACL::Read, ACL::ReadItems, ACL::AddItems], + ) + .await + .unwrap(); + + let mut request = john_client.set_default_account_id(&jane_id).build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + let email_id = request + .send_single::() + .await + .unwrap() + .created(&email_id) + .unwrap() + .take_id(); + let email_id_2 = john_client + .set_default_account_id(&jane_id) + .email_copy( + &john_id, + email_ids.get("john").unwrap().last().unwrap(), + [&inbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + + assert_eq!( + jane_client + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in jane's inbox" + ); + assert_eq!( + jane_client + .email_get(&email_id_2, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by john in trash" + ); + + // Try removing items + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_destroy(&email_id) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + "jdoe@example.com", + [ACL::Read, ACL::ReadItems, ACL::AddItems, ACL::RemoveItems], + ) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .email_destroy(&email_id) + .await + .unwrap(); + + // Try to set keywords + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_set_keyword(&email_id_2, "$seen", true) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + "jdoe@example.com", + [ + ACL::Read, + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::ModifyItems, + ], + ) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .email_set_keyword(&email_id_2, "$seen", true) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .email_set_keyword(&email_id_2, "my-keyword", true) + .await + .unwrap(); + + // Try to create a child + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_create("John's mailbox", None::<&str>, Role::None) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + "jdoe@example.com", + [ + ACL::Read, + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::ModifyItems, + ACL::CreateChild, + ], + ) + .await + .unwrap(); + let mailbox_id = john_client + .set_default_account_id(&jane_id) + .mailbox_create("John's mailbox", Some(&inbox_id), Role::None) + .await + .unwrap() + .take_id(); + + // Try renaming a mailbox + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_rename(&mailbox_id, "John's private mailbox") + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + "jdoe@example.com", + [ACL::Read, ACL::ReadItems, ACL::Modify], + ) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .mailbox_rename(&mailbox_id, "John's private mailbox") + .await + .unwrap(); + + // Try moving a message + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_set_mailbox(&email_id_2, &mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + "jdoe@example.com", + [ACL::Read, ACL::ReadItems, ACL::Modify, ACL::AddItems], + ) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .email_set_mailbox(&email_id_2, &mailbox_id, true) + .await + .unwrap(); + + // Try deleting a mailbox + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_destroy(&mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + "jdoe@example.com", + [ + ACL::Read, + ACL::ReadItems, + ACL::Modify, + ACL::AddItems, + ACL::Delete, + ], + ) + .await + .unwrap(); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_destroy(&mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + "jdoe@example.com", + [ + ACL::Read, + ACL::ReadItems, + ACL::Modify, + ACL::AddItems, + ACL::Delete, + ACL::RemoveItems, + ], + ) + .await + .unwrap(); + john_client + .set_default_account_id(&jane_id) + .mailbox_destroy(&mailbox_id, true) + .await + .unwrap(); + + // Try changing ACL + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .mailbox_update_acl(&inbox_id, "bill@example.com", [ACL::Read, ACL::ReadItems]) + .await, + ); + assert_forbidden( + bill_client + .set_default_account_id(&jane_id) + .email_query(None::, None::>) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + "jdoe@example.com", + [ + ACL::Read, + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::ModifyItems, + ACL::CreateChild, + ACL::Modify, + ACL::Administer, + ], + ) + .await + .unwrap(); + assert_eq!( + john_client + .set_default_account_id(&jane_id) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list(), + vec![ + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::ModifyItems, + ACL::CreateChild, + ACL::Modify + ] + ); + john_client + .set_default_account_id(&jane_id) + .mailbox_update_acl(&inbox_id, "bill@example.com", [ACL::Read, ACL::ReadItems]) + .await + .unwrap(); + assert_eq!( + bill_client + .set_default_account_id(&jane_id) + .email_query( + None::, + vec![email::query::Comparator::subject()].into() + ) + .await + .unwrap() + .ids(), + [ + email_ids.get("jane").unwrap().first().unwrap().as_str(), + &email_id_2 + ] + ); + + // Revoke all access to John + jane_client + .mailbox_update_acl(&inbox_id, "jdoe@example.com", []) + .await + .unwrap(); + assert_forbidden( + john_client + .set_default_account_id(&jane_id) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + john_client.refresh_session().await.unwrap(); + assert!(john_client.session().account(&jane_id).is_none()); + assert_eq!( + bill_client + .set_default_account_id(&jane_id) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by jane in inbox" + ); + + // Add John and Jane to the Sales group + for id in [JANE_ID, JOHN_ID] { + assert!( + server + .auth_db + .execute( + &format!( + "INSERT INTO groups (uid, gid) VALUES ({}, {})", + id, SALES_ID + ), + Vec::::new().into_iter(), + ) + .await + ); + } + server.acl_tokens.lock().clear(); + john_client.refresh_session().await.unwrap(); + jane_client.refresh_session().await.unwrap(); + bill_client.refresh_session().await.unwrap(); + assert_eq!( + john_client.session().account(&sales_id).unwrap().name(), + "sales@example.com" + ); + assert!(!john_client + .session() + .account(&sales_id) + .unwrap() + .is_personal()); + assert_eq!( + jane_client.session().account(&sales_id).unwrap().name(), + "sales@example.com" + ); + assert!(bill_client.session().account(&sales_id).is_none()); + + // Insert a message in Sales's inbox + let blob_id = john_client + .set_default_account_id(&sales_id) + .upload( + Some(&sales_id), + concat!( + "From: acl_test@example.com\r\n", + "To: sales@example.com\r\n", + "Subject: Created by john in sales\r\n", + "\r\n", + "This message is owned by sales.", + ) + .as_bytes() + .to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let mut request = john_client.build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + let email_id = request + .send_single::() + .await + .unwrap() + .created(&email_id) + .unwrap() + .take_id(); + + // Both Jane and John should be able to see this message, but not Bill + assert_eq!( + john_client + .set_default_account_id(&sales_id) + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in sales" + ); + assert_eq!( + jane_client + .set_default_account_id(&sales_id) + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in sales" + ); + assert_forbidden( + bill_client + .set_default_account_id(&sales_id) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + + // Remove John from the sales group + assert!( + server + .auth_db + .execute( + &format!( + "DELETE FROM groups WHERE uid = {} AND gid ={}", + JOHN_ID, SALES_ID + ), + Vec::::new().into_iter(), + ) + .await + ); + server.sessions.lock().clear(); + assert_forbidden( + john_client + .set_default_account_id(&sales_id) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + + let coco = "fd"; + // Check that Jane's id is not assigned to new accounts before the + // purge has taken place. + /*server.store.id_assigner.invalidate_all(); + let tom_id = admin_client + .individual_create("tom@example.com", "098765", "Tom Foobar") + .await + .unwrap() + .take_id(); + assert_ne!(tom_id, jane_id); + + // Destroy test accounts + for principal_id in [tom_id, john_id, bill_id, sales_id, domain_id] { + admin_client.principal_destroy(&principal_id).await.unwrap(); + } + server.store.principal_purge().unwrap(); + server.store.assert_is_empty();*/ +} + +use std::fmt::Debug; +pub fn assert_forbidden(result: Result) { + if !matches!( + result, + Err(jmap_client::Error::Method(MethodError { + p_type: MethodErrorType::Forbidden + })) | Err(jmap_client::Error::Set(SetError { + type_: SetErrorType::Forbidden, + .. + })) + ) { + panic!("Expected forbidden, got {:?}", result); + } +} diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 5a3ccff5..519345ca 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -7,6 +7,7 @@ use tokio::sync::watch; use crate::{add_test_certs, store::TempDir}; +pub mod acl; pub mod email_changes; pub mod email_copy; pub mod email_get; @@ -47,6 +48,16 @@ private-key = 'file://{PK}' [jmap.protocol] set.max-objects = 100000 +[jmap.auth.database] +type = 'sql' +address = 'sqlite::memory:' + +[jmap.auth.database.query] +uid-by-login = 'SELECT ROWID - 1 FROM users WHERE login = ?' +login-by-uid = 'SELECT login FROM users WHERE ROWID - 1 = ?' +secret-by-uid = 'SELECT secret FROM users WHERE ROWID - 1 = ?' +gids-by-uid = 'SELECT gid FROM groups WHERE uid = ?' + "; #[tokio::test] @@ -67,10 +78,12 @@ pub async fn jmap_tests() { //email_search_snippet::test(params.server.clone(), &mut params.client).await; //email_changes::test(params.server.clone(), &mut params.client).await; //email_query_changes::test(params.server.clone(), &mut params.client).await; - email_copy::test(params.server.clone(), &mut params.client).await; + //email_copy::test(params.server.clone(), &mut params.client).await; //thread_get::test(params.server.clone(), &mut params.client).await; //thread_merge::test(params.server.clone(), &mut params.client).await; //mailbox::test(params.server.clone(), &mut params.client).await; + acl::test(params.server.clone(), &mut params.client).await; + if delete { params.temp_dir.delete(); } @@ -99,9 +112,26 @@ async fn init_jmap_tests(delete_if_exists: bool) -> JMAPTest { server.spawn(manager.clone(), shutdown_rx); }); + // Create tables + for query in [ + "CREATE TABLE users (login TEXT PRIMARY KEY, secret TEXT, name TEXT)", + "CREATE TABLE groups (uid INTEGER, gid INTEGER, PRIMARY KEY (uid, gid))", + "CREATE TABLE emails (uid INTEGER NOT NULL, email TEXT NOT NULL, PRIMARY KEY (uid, email))", + "INSERT INTO users (login, secret) VALUES ('admin', 'secret')", // RowID 0 is admin + ] { + assert!( + manager + .inner + .auth_db + .execute(query, Vec::::new().into_iter()) + .await, + "failed for {query}" + ); + } + // Create client let mut client = Client::new() - .credentials(Credentials::bearer("DO_NOT_ATTEMPT_THIS_AT_HOME")) + .credentials(Credentials::basic("admin", "secret")) .timeout(Duration::from_secs(60)) .accept_invalid_certs(true) .connect("https://127.0.0.1:8899")