diff --git a/crates/cloud-common/src/lib.rs b/crates/cloud-common/src/lib.rs index 4ce223d7..eb3297c2 100644 --- a/crates/cloud-common/src/lib.rs +++ b/crates/cloud-common/src/lib.rs @@ -79,13 +79,13 @@ impl From for User { }; User { - username: user_data.username.to_lowercase(), + username: user_data.username, hash, salt, email: user_data.email, group_id: user_data.group_id, created_at: DateTime::from_system_time(SystemTime::now()), - linked_accounts: std::vec::Vec::new(), + linked_accounts: Vec::new(), role: user_data.role.unwrap_or(UserRole::User), services_hosts: None, service_settings: HashMap::new(), diff --git a/crates/cloud/src/app_data/mod.rs b/crates/cloud/src/app_data/mod.rs index fcca6ed1..7dab5ec5 100644 --- a/crates/cloud/src/app_data/mod.rs +++ b/crates/cloud/src/app_data/mod.rs @@ -12,7 +12,10 @@ use lettre::{Address, Message, SmtpTransport, Transport}; use log::{info, warn}; use lru::LruCache; use mongodb::bson::{doc, DateTime, Document}; -use mongodb::options::{FindOneAndUpdateOptions, IndexOptions, ReturnDocument}; +use mongodb::options::{ + Collation, CollationStrength, FindOneAndDeleteOptions, FindOneAndUpdateOptions, FindOneOptions, + IndexOptions, ReturnDocument, +}; use rusoto_core::credential::StaticProvider; use rusoto_core::Region; use serde::{Deserialize, Serialize}; @@ -33,7 +36,7 @@ use crate::libraries; use crate::network::topology::{self, SetStorage, TopologyActor}; use actix::{Actor, Addr}; use futures::TryStreamExt; -use mongodb::{Client, Collection, IndexModel}; +use mongodb::{Client, Collection, Cursor, IndexModel}; use rusoto_s3::{ CreateBucketRequest, DeleteObjectRequest, GetObjectRequest, PutObjectOutput, PutObjectRequest, S3Client, S3, @@ -52,7 +55,7 @@ pub struct AppData { pub(crate) settings: Settings, pub(crate) network: Addr, pub(crate) groups: Collection, - pub(crate) users: Collection, + users: Collection, pub(crate) banned_accounts: Collection, pub(crate) friends: Collection, pub(crate) project_metadata: Collection, @@ -211,6 +214,23 @@ impl AppData { .await .map_err(InternalError::DatabaseConnectionError)?; + let case_insensitive_col = Collation::builder() + .locale("en_US") + .strength(CollationStrength::Primary) + .build(); + let user_index = IndexModel::builder() + .keys(doc! {"username": 1}) + .options( + IndexOptions::builder() + .collation(case_insensitive_col) + .build(), + ) + .build(); + self.users + .create_index(user_index, None) + .await + .map_err(InternalError::DatabaseConnectionError)?; + self.project_metadata .create_indexes( vec![ @@ -294,6 +314,151 @@ impl AppData { }); } + pub async fn create_user(&self, user: User) -> Result<(), UserError> { + let query = doc! {"email": &user.email}; + if let Some(_account) = self + .banned_accounts + .find_one(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)? + { + return Err(UserError::InvalidEmailAddress); + } + + let query = doc! {"username": &user.username}; + let update = doc! {"$setOnInsert": &user}; + let options = mongodb::options::FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::Before) + .upsert(true) + .collation( + Collation::builder() + .locale("en_US") + .strength(CollationStrength::Primary) + .build(), + ) + .build(); + let existing_user = self + .users + .find_one_and_update(query, update, options) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + if existing_user.is_some() { + Err(UserError::UserExistsError) + } else { + Ok(()) + } + } + + pub async fn update_user( + &self, + username: &str, + update: Document, + ) -> Result, InternalError> { + let query = doc! {"username": username}; + let collation = Collation::builder() + .locale("en_US") + .strength(CollationStrength::Primary) + .build(); + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::Before) + .upsert(true) + .collation(collation) + .build(); + + let updated_user = self + .users + .find_one_and_update(query, update, options) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + Ok(updated_user) + } + + pub async fn find_user(&self, username: &str) -> Result, InternalError> { + let query = doc! {"username": &username}; + let collation = Collation::builder() + .locale("en_US") + .strength(CollationStrength::Primary) + .build(); + let options = FindOneOptions::builder().collation(collation).build(); + self.users + .find_one(query, Some(options)) + .await + .map_err(InternalError::DatabaseConnectionError) + } + + pub async fn delete_user(&self, username: &str) -> Result, UserError> { + let query = doc! {"username": &username}; + let collation = Collation::builder() + .locale("en_US") + .strength(CollationStrength::Primary) + .build(); + let options = FindOneAndDeleteOptions::builder() + .collation(collation) + .build(); + let deleted_user = self + .users + .find_one_and_delete(query, Some(options)) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + Ok(deleted_user) + } + + /// Find users using an arbitrary query. Do not use this for username-based lookups! + pub async fn find_users_where(&self, query: Document) -> Result, InternalError> { + let cursor = self + .users + .find(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + Ok(cursor) + } + + pub async fn all_users(&self) -> Result, InternalError> { + let query = doc! {}; + self.find_users_where(query).await + } + + /// Look up a user using a custom query (such as by a linked account). + /// + /// Do not use this to look up a user by username! Usernames are case-insensitive + /// and require specific collation options (handled by `find_user` and `update_user`). + pub async fn find_user_where(&self, query: Document) -> Result, InternalError> { + self.users + .find_one(query, None) + .await + .map_err(InternalError::DatabaseConnectionError) + } + + pub async fn usernames_with_prefix( + &self, + prefix: &str, + ) -> Result, InternalError> { + let prefix_query = mongodb::bson::Regex { + pattern: format!("^{}", &prefix), + options: String::new(), + }; + let query = doc! {"username": {"$regex": prefix_query}}; + // TODO: this could be optimized to map on the stream... + let names = self + .users + .find(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)? + .try_collect::>() + .await + .map_err(InternalError::DatabaseConnectionError)? + .into_iter() + .map(|user| user.username) + .collect::>(); + + Ok(names) + } + pub async fn get_project_metadatum( &self, id: &ProjectId, diff --git a/crates/cloud/src/friends.rs b/crates/cloud/src/friends.rs index 7380e137..574e810e 100644 --- a/crates/cloud/src/friends.rs +++ b/crates/cloud/src/friends.rs @@ -24,10 +24,8 @@ async fn list_friends( let is_universal_friend = matches!(get_user_role(&app, &owner).await?, UserRole::Admin); let friend_names = if is_universal_friend { - app.users - .find(doc! {}, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + app.all_users() + .await? .try_collect::>() .await .map_err(InternalError::DatabaseConnectionError)? diff --git a/crates/cloud/src/groups.rs b/crates/cloud/src/groups.rs index ee474de9..b1c0a023 100644 --- a/crates/cloud/src/groups.rs +++ b/crates/cloud/src/groups.rs @@ -75,11 +75,7 @@ async fn list_members( ensure_can_edit_group(&app, &session, &id).await?; let query = doc! {"groupId": id}; - let cursor = app - .users - .find(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + let cursor = app.find_users_where(query).await?; let members: Vec = cursor .try_collect::>() .await diff --git a/crates/cloud/src/services/hosts.rs b/crates/cloud/src/services/hosts.rs index 3675bb08..0d27fa05 100644 --- a/crates/cloud/src/services/hosts.rs +++ b/crates/cloud/src/services/hosts.rs @@ -86,12 +86,9 @@ async fn list_user_hosts( ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": username}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; Ok(HttpResponse::Ok().json(user.services_hosts.unwrap_or_default())) @@ -109,18 +106,11 @@ async fn set_user_hosts( let service_hosts: Vec = hosts.into_inner(); let update = doc! {"$set": {"servicesHosts": &service_hosts }}; - let filter = doc! {"username": username}; - let result = app - .users - .update_one(filter, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + app.update_user(&username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; - if result.modified_count == 1 { - Ok(HttpResponse::Ok().finish()) - } else { - Ok(HttpResponse::NotFound().finish()) - } + Ok(HttpResponse::Ok().finish()) } #[get("/all/{username}")] @@ -132,12 +122,9 @@ async fn list_all_hosts( let (username,) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": &username}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let mut groups = app diff --git a/crates/cloud/src/services/settings.rs b/crates/cloud/src/services/settings.rs index 170828e9..4f6fcd62 100644 --- a/crates/cloud/src/services/settings.rs +++ b/crates/cloud/src/services/settings.rs @@ -21,12 +21,9 @@ async fn list_user_hosts_with_settings( let (username,) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": &username}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let hosts: Vec<_> = user.service_settings.keys().collect(); @@ -42,12 +39,9 @@ async fn get_user_settings( let (username, host) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": &username}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let default_settings = String::from(""); @@ -71,12 +65,9 @@ async fn get_all_settings( ensure_can_edit_user(&app, &session, &username).await?; } - let query = doc! {"username": &username}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let query = match user.group_id { @@ -126,21 +117,13 @@ async fn set_user_settings( ensure_can_edit_user(&app, &session, &username).await?; let settings = std::str::from_utf8(&body).map_err(|_err| UserError::InternalError)?; - - let query = doc! {"username": &username}; let update = doc! {"$set": {format!("serviceSettings.{}", &host): settings}}; - let result = app - .users - .update_one(query, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + app.update_user(&username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; - if result.matched_count == 0 { - Err(UserError::UserNotFoundError) - } else { - Ok(HttpResponse::Ok().finish()) - } + Ok(HttpResponse::Ok().finish()) } #[delete("/user/{username}/{host}")] @@ -152,20 +135,13 @@ async fn delete_user_settings( let (username, host) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": &username}; let update = doc! {"$unset": {format!("serviceSettings.{}", &host): true}}; - let result = app - .users - .update_one(query, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + app.update_user(&username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; - if result.matched_count == 0 { - Err(UserError::UserNotFoundError) - } else { - Ok(HttpResponse::Ok().finish()) - } + Ok(HttpResponse::Ok().finish()) } #[get("/group/{group_id}/")] diff --git a/crates/cloud/src/users/mod.rs b/crates/cloud/src/users/mod.rs index 0de1d595..ab3b8a4a 100644 --- a/crates/cloud/src/users/mod.rs +++ b/crates/cloud/src/users/mod.rs @@ -39,12 +39,9 @@ async fn get_session_role(app: &AppData, session: &Session) -> Result Result { - let query = doc! {"username": username}; Ok(app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(username) + .await? .map(|user| user.role) .unwrap_or(UserRole::User)) } @@ -100,13 +97,7 @@ pub async fn can_edit_user( } async fn has_group_containing(app: &AppData, owner: &str, member: &str) -> Result { - let query = doc! {"username": member}; - match app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? - { + match app.find_user(member).await? { Some(user) => match user.group_id { Some(group_id) => { let query = doc! {"owner": owner}; @@ -134,12 +125,7 @@ async fn has_group_containing(app: &AppData, owner: &str, member: &str) -> Resul #[get("/")] async fn list_users(app: web::Data, session: Session) -> Result { ensure_is_super_user(&app, &session).await?; - let query = doc! {}; - let cursor = app - .users - .find(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + let cursor = app.all_users().await?; let users: Vec = cursor .try_collect::>() .await @@ -174,34 +160,9 @@ async fn create_user( let user: User = user_data.into_inner().into(); ensure_valid_username(&user.username)?; + app.create_user(user).await?; - let query = doc! {"email": &user.email}; - if let Some(_account) = app - .banned_accounts - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? - { - return Err(UserError::InvalidEmailAddress); - } - - let query = doc! {"username": &user.username}; - let update = doc! {"$setOnInsert": &user}; - let options = mongodb::options::FindOneAndUpdateOptions::builder() - .return_document(ReturnDocument::Before) - .upsert(true) - .build(); - let existing_user = app - .users - .find_one_and_update(query, update, options) - .await - .map_err(InternalError::DatabaseConnectionError)?; - - if existing_user.is_some() { - Err(UserError::UserExistsError) - } else { - Ok(HttpResponse::Ok().body("User created")) - } + Ok(HttpResponse::Ok().body("User created")) } fn ensure_valid_email(email: &str) -> Result<(), UserError> { @@ -225,7 +186,7 @@ fn is_valid_username(name: &str) -> bool { let min_len = 3; let char_count = name.chars().count(); lazy_static! { - static ref USERNAME_REGEX: Regex = Regex::new(r"^[a-z][a-z0-9_\-]+$").unwrap(); + static ref USERNAME_REGEX: Regex = Regex::new(r"^[a-zA-Z][A-Za-z0-9_\-]+$").unwrap(); } char_count > min_len @@ -328,13 +289,7 @@ async fn ban_user( let (username,) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": username}; - match app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? - { + match app.find_user(&username).await? { Some(user) => { let account = BannedAccount::new(user.username, user.email); app.banned_accounts @@ -356,17 +311,11 @@ async fn delete_user( let (username,) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": username}; - let result = app - .users - .delete_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; - if result.deleted_count > 0 { - Ok(HttpResponse::Ok().finish()) - } else { - Ok(HttpResponse::NotFound().finish()) - } + app.delete_user(&username) + .await? + .ok_or(UserError::UserNotFoundError)?; + + Ok(HttpResponse::Ok().finish()) } #[post("/{username}/password")] @@ -378,10 +327,8 @@ async fn reset_password( app.ensure_not_tor_ip(req).await?; let (username,) = path.into_inner(); let user = app - .users - .find_one(doc! {"username": &username}, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let token = SetPasswordToken::new(username.clone()); @@ -448,12 +395,9 @@ async fn change_password( } async fn set_password(app: &AppData, username: &str, password: String) -> Result<(), UserError> { - let query = doc! {"username": username}; let user = app - .users - .find_one(query.clone(), None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(username) + .await? .ok_or(UserError::UserNotFoundError)?; let update = doc! { @@ -461,17 +405,11 @@ async fn set_password(app: &AppData, username: &str, password: String) -> Result "hash": sha512(&(password + &user.salt)) } }; - let result = app - .users - .update_one(query, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + app.update_user(username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; - if result.matched_count == 0 { - Err(UserError::UserNotFoundError) - } else { - Ok(()) - } + Ok(()) } pub(crate) fn sha512(text: &str) -> String { @@ -494,12 +432,9 @@ async fn view_user( ensure_can_edit_user(&app, &session, &username).await? }; - let query = doc! {"username": username}; let user: api::User = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)? .into(); @@ -525,29 +460,18 @@ async fn link_account( let account: api::LinkedAccount = creds.into(); let query = doc! {"linkedAccounts": {"$elemMatch": &account}}; - let existing = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + let existing = app.find_user_where(query).await?; if existing.is_some() { return Err(UserError::AccountAlreadyLinkedError); } - let query = doc! {"username": &username}; let update = doc! {"$push": {"linkedAccounts": &account}}; - let result = app - .users - .update_one(query, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; + app.update_user(&username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; - if result.matched_count == 0 { - Ok(HttpResponse::NotFound().finish()) - } else { - Ok(HttpResponse::Ok().finish()) - } + Ok(HttpResponse::Ok().finish()) } #[post("/{username}/unlink")] @@ -559,18 +483,12 @@ async fn unlink_account( ) -> Result { let (username,) = path.into_inner(); ensure_can_edit_user(&app, &session, &username).await?; - let query = doc! {"username": username}; let update = doc! {"$pull": {"linkedAccounts": &account.into_inner()}}; - let result = app - .users - .update_one(query, update, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; - if result.matched_count == 0 { - Ok(HttpResponse::NotFound().finish()) - } else { - Ok(HttpResponse::Ok().finish()) - } + app.update_user(&username, update) + .await? + .ok_or(UserError::UserNotFoundError)?; + + Ok(HttpResponse::Ok().finish()) } pub fn config(cfg: &mut web::ServiceConfig) { diff --git a/crates/cloud/src/users/strategies.rs b/crates/cloud/src/users/strategies.rs index 0bdb4229..3bc7ccb8 100644 --- a/crates/cloud/src/users/strategies.rs +++ b/crates/cloud/src/users/strategies.rs @@ -1,23 +1,12 @@ -use std::{ - collections::{HashMap, HashSet}, - time::SystemTime, -}; +use std::{collections::HashMap, time::SystemTime}; pub(crate) use crate::common::api::Credentials; use crate::common::api::{self, UserRole}; -use futures::TryStreamExt; -use mongodb::{ - bson::{doc, DateTime}, - options::UpdateOptions, -}; +use mongodb::bson::{doc, DateTime}; use reqwest::{Method, Response}; use serde::Deserialize; -use crate::{ - app_data::AppData, - common::User, - errors::{InternalError, UserError}, -}; +use crate::{app_data::AppData, common::User, errors::UserError}; use super::sha512; @@ -68,11 +57,7 @@ pub async fn login(app: &AppData, credentials: Credentials) -> Result Result { - let query = doc! {"username": &username.to_lowercase()}; let user = app - .users - .find_one(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? + .find_user(&username) + .await? .ok_or(UserError::UserNotFoundError)?; let hash = sha512(&(password + &user.salt)); @@ -125,7 +105,6 @@ async fn create_account( account: &api::LinkedAccount, ) -> Result { let username = username_from(app, account).await?; - let query = doc! {"username": &username}; let salt = passwords::PasswordGenerator::new() .length(8) .exclude_similar_characters(true) @@ -149,13 +128,7 @@ async fn create_account( service_settings: HashMap::new(), }; - let update = doc!("$setOnInsert": &user); - let options = UpdateOptions::builder().upsert(true).build(); - app.users - .update_one(query, update, options) - .await - .map_err(InternalError::DatabaseConnectionError)?; - + app.create_user(user.clone()).await?; Ok(user) } @@ -163,26 +136,9 @@ async fn username_from( app: &AppData, credentials: &api::LinkedAccount, ) -> Result { - let basename = credentials.username.to_owned(); - let starts_with_name = mongodb::bson::Regex { - pattern: format!("^{}", &basename), - options: String::new(), - }; - let query = doc! {"username": {"$regex": starts_with_name}}; - // TODO: this could be optimized to map on the stream... - let existing_names = app - .users - .find(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)? - .try_collect::>() - .await - .map_err(InternalError::DatabaseConnectionError)? - .into_iter() - .map(|user| user.username) - .collect::>(); - - if existing_names.contains(&basename) { + let basename = &credentials.username; + let existing_names = app.usernames_with_prefix(basename).await?; + if existing_names.contains(basename) { let strategy: String = credentials .strategy .to_ascii_lowercase() @@ -198,6 +154,6 @@ async fn username_from( } Ok(username) } else { - Ok(basename) + Ok(basename.to_owned()) } }