diff --git a/README.md b/README.md index 8a9b739a..353e0536 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ _*Note, that this project is not associated with the [Bitwarden](https://bitward - [Configuring bitwarden service](#configuring-bitwarden-service) - [Disable registration of new users](#disable-registration-of-new-users) - [Disable invitations](#disable-invitations) + - [Configure server administrator](#configure-server-administrator) - [Enabling HTTPS](#enabling-https) - [Enabling WebSocket notifications](#enabling-websocket-notifications) - [Enabling U2F authentication](#enabling-u2f-authentication) @@ -154,6 +155,21 @@ docker run -d --name bitwarden \ -p 80:80 \ mprasil/bitwarden:latest ``` +### Configure server administrator + +You can configure one email account to be server administrator via the `SERVER_ADMIN_EMAIL` environment variable: + +```sh +docker run -d --name bitwarden \ + -e SERVER_ADMIN_EMAIL=admin@example.com \ + -v /bw-data/:/data/ \ + -p 80:80 \ + mprasil/bitwarden:latest +``` + +This will give the user extra functionality and privileges to manage users on the server. In the Vault, the user will see a special (virtual) organization called `bitwarden_rs`. This organization doesn't actually exist and can't be used for most things. (can't have collections or ciphers) Instead it just contains all the users registered on the server. Deleting users from this organization will actually completely delete the user from the server. Inviting users into this organization will just invite the user so they are able to register, but will not grant any organization membership. (unlike inviting user to regular organization) + +You can think of the `bitwarden_rs` organization as sort of Admin interface to manage users on the server. Due to the virtual nature of this organization, it is missing some internal data structures and most of the functionality. It is thus strongly recommended to use dedicated account for `SERVER_ADMIN_EMAIL` and this account shouldn't be used for actually storing passwords. Also keep in mind that deleting user this way removes the user permanently without any way to restore the deleted data just as if user deleted their own account. ### Enabling HTTPS To enable HTTPS, you need to configure the `ROCKET_TLS`. diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8d6b7f15..7d45492b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -288,28 +288,11 @@ fn delete_account(data: JsonUpcase, headers: Headers, conn: DbConn if !user.check_valid_password(&data.MasterPasswordHash) { err!("Invalid password") } - - // Delete ciphers and their attachments - for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) { - if cipher.delete(&conn).is_err() { - err!("Failed deleting cipher") - } + + match user.delete(&conn) { + Ok(()) => Ok(()), + Err(_) => err!("Failed deleting user account, are you the only owner of some organization?") } - - // Delete folders - for f in Folder::find_by_user(&user.uuid, &conn) { - if f.delete(&conn).is_err() { - err!("Failed deleting folder") - } - } - - // Delete devices - for d in Device::find_by_user(&user.uuid, &conn) { d.delete(&conn); } - - // Delete user - user.delete(&conn); - - Ok(()) } #[get("/accounts/revision-date")] diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b3218a1f..d5c235bc 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -151,9 +151,10 @@ fn clear_device_token(uuid: String, data: Json, headers: Headers, conn: D err!("Device not owned by user") } - device.delete(&conn); - - Ok(()) + match device.delete(&conn) { + Ok(()) => Ok(()), + Err(_) => err!("Failed deleting device") + } } #[put("/devices/identifier//token", data = "")] diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 2deab3fa..1a74e6c8 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -326,12 +326,7 @@ fn get_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResul } #[get("/organizations//users")] -fn get_org_users(org_id: String, headers: AdminHeaders, conn: DbConn) -> JsonResult { - match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) { - Some(_) => (), - None => err!("User isn't member of organization") - } - +fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { let users = UserOrganization::find_by_org(&org_id, &conn); let users_json: Vec = users.iter().map(|c| c.to_json_user_details(&conn)).collect(); @@ -410,27 +405,30 @@ fn send_invite(org_id: String, data: JsonUpcase, headers: AdminHeade }; - let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone()); - let access_all = data.AccessAll.unwrap_or(false); - new_user.access_all = access_all; - new_user.type_ = new_type; - new_user.status = user_org_status; + // Don't create UserOrganization in virtual organization + if org_id != Organization::VIRTUAL_ID { + let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone()); + let access_all = data.AccessAll.unwrap_or(false); + new_user.access_all = access_all; + new_user.type_ = new_type; + new_user.status = user_org_status; - // If no accessAll, add the collections received - if !access_all { - for col in &data.Collections { - match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { - None => err!("Collection not found in Organization"), - Some(collection) => { - if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() { - err!("Failed saving collection access for user") + // If no accessAll, add the collections received + if !access_all { + for col in &data.Collections { + match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { + None => err!("Collection not found in Organization"), + Some(collection) => { + if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() { + err!("Failed saving collection access for user") + } } } } } - } - new_user.save(&conn); + new_user.save(&conn); + } } Ok(()) @@ -560,6 +558,23 @@ fn edit_user(org_id: String, org_user_id: String, data: JsonUpcase #[delete("/organizations//users/")] fn delete_user(org_id: String, org_user_id: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + // We're deleting user in virtual Organization. Delete User, not UserOrganization + if org_id == Organization::VIRTUAL_ID { + match User::find_by_uuid(&org_user_id, &conn) { + Some(user_to_delete) => { + if user_to_delete.uuid == headers.user.uuid { + err!("Delete your account in the account settings") + } else { + match user_to_delete.delete(&conn) { + Ok(()) => return Ok(()), + Err(_) => err!("Failed to delete user - likely because it's the only owner of organization") + } + } + }, + None => err!("User not found") + } + } + let user_to_delete = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn) { Some(user) => user, None => err!("User to delete isn't member of the organization") diff --git a/src/api/identity.rs b/src/api/identity.rs index db62398b..f4df90c4 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -107,11 +107,13 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, re Some(device) => { // Check if valid device if device.user_uuid != user.uuid { - device.delete(&conn); - err!("Device is not owned by user") + match device.delete(&conn) { + Ok(()) => Device::new(device_id, user.uuid.clone(), device_name, device_type_num), + Err(_) => err!("Tried to delete device not owned by user, but failed") + } + } else { + device } - - device } None => { // Create new device diff --git a/src/auth.rs b/src/auth.rs index 2f7faf25..6b541a6b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -95,7 +95,7 @@ use rocket::Outcome; use rocket::request::{self, Request, FromRequest}; use db::DbConn; -use db::models::{User, UserOrganization, UserOrgType, UserOrgStatus, Device}; +use db::models::{User, Organization, UserOrganization, UserOrgType, UserOrgStatus, Device}; pub struct Headers { pub host: String, @@ -212,7 +212,13 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders { err_handler!("The current user isn't confirmed member of the organization") } } - None => err_handler!("The current user isn't member of the organization") + None => { + if headers.user.is_server_admin() && org_id == Organization::VIRTUAL_ID { + UserOrganization::new_virtual(headers.user.uuid.clone(), UserOrgType::Owner, UserOrgStatus::Confirmed) + } else { + err_handler!("The current user isn't member of the organization") + } + } }; Outcome::Success(Self{ diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index e67ea0fd..db0db473 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -182,6 +182,13 @@ impl Cipher { Ok(()) } + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> { + for cipher in Self::find_owned_by_user(user_uuid, &conn) { + cipher.delete(&conn)?; + } + Ok(()) + } + pub fn move_to_folder(&self, folder_uuid: Option, user_uuid: &str, conn: &DbConn) -> Result<(), &str> { match self.get_folder_uuid(&user_uuid, &conn) { None => { diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 8063e9ee..0e51c9e5 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -123,13 +123,17 @@ impl Device { } } - pub fn delete(self, conn: &DbConn) -> bool { - match diesel::delete(devices::table.filter( - devices::uuid.eq(self.uuid))) - .execute(&**conn) { - Ok(1) => true, // One row deleted - _ => false, + pub fn delete(self, conn: &DbConn) -> QueryResult<()> { + diesel::delete(devices::table.filter( + devices::uuid.eq(self.uuid) + )).execute(&**conn).and(Ok(())) + } + + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> { + for device in Self::find_by_user(user_uuid, &conn) { + device.delete(&conn)?; } + Ok(()) } pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { diff --git a/src/db/models/folder.rs b/src/db/models/folder.rs index 95b1bc88..d50912ed 100644 --- a/src/db/models/folder.rs +++ b/src/db/models/folder.rs @@ -93,6 +93,13 @@ impl Folder { ).execute(&**conn).and(Ok(())) } + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> { + for folder in Self::find_by_user(user_uuid, &conn) { + folder.delete(&conn)?; + } + Ok(()) + } + pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { folders::table .filter(folders::uuid.eq(uuid)) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 9dd9cc99..3ce29d29 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,7 +1,7 @@ use serde_json::Value as JsonValue; use uuid::Uuid; -use super::{User, CollectionUser}; +use super::{User, CollectionUser, Invitation}; #[derive(Debug, Identifiable, Queryable, Insertable)] #[table_name = "organizations"] @@ -51,6 +51,8 @@ impl UserOrgType { /// Local methods impl Organization { + pub const VIRTUAL_ID: &'static str = "00000000-0000-0000-0000-000000000000"; + pub fn new(name: String, billing_email: String) -> Self { Self { uuid: Uuid::new_v4().to_string(), @@ -60,6 +62,14 @@ impl Organization { } } + pub fn new_virtual() -> Self { + Self { + uuid: String::from(Organization::VIRTUAL_ID), + name: String::from("bitwarden_rs"), + billing_email: String::from("none@none.none") + } + } + pub fn to_json(&self) -> JsonValue { json!({ "Id": self.uuid, @@ -103,6 +113,20 @@ impl UserOrganization { type_: UserOrgType::User as i32, } } + + pub fn new_virtual(user_uuid: String, type_: UserOrgType, status: UserOrgStatus) -> Self { + Self { + uuid: user_uuid.clone(), + + user_uuid, + org_uuid: String::from(Organization::VIRTUAL_ID), + + access_all: true, + key: String::new(), + status: status as i32, + type_: type_ as i32, + } + } } @@ -114,6 +138,10 @@ use db::schema::{organizations, users_organizations, users_collections, ciphers_ /// Database methods impl Organization { pub fn save(&mut self, conn: &DbConn) -> bool { + if self.uuid == Organization::VIRTUAL_ID { + return false + } + UserOrganization::find_by_org(&self.uuid, conn) .iter() .for_each(|user_org| { @@ -131,6 +159,10 @@ impl Organization { pub fn delete(self, conn: &DbConn) -> QueryResult<()> { use super::{Cipher, Collection}; + if self.uuid == Organization::VIRTUAL_ID { + return Err(diesel::result::Error::NotFound) + } + Cipher::delete_all_by_organization(&self.uuid, &conn)?; Collection::delete_all_by_organization(&self.uuid, &conn)?; UserOrganization::delete_all_by_organization(&self.uuid, &conn)?; @@ -143,6 +175,9 @@ impl Organization { } pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { + if uuid == Organization::VIRTUAL_ID { + return Some(Self::new_virtual()) + }; organizations::table .filter(organizations::uuid.eq(uuid)) .first::(&**conn).ok() @@ -232,6 +267,9 @@ impl UserOrganization { } pub fn save(&mut self, conn: &DbConn) -> bool { + if self.org_uuid == Organization::VIRTUAL_ID { + return false + } User::update_uuid_revision(&self.user_uuid, conn); match diesel::replace_into(users_organizations::table) @@ -243,6 +281,9 @@ impl UserOrganization { } pub fn delete(self, conn: &DbConn) -> QueryResult<()> { + if self.org_uuid == Organization::VIRTUAL_ID { + return Err(diesel::result::Error::NotFound) + } User::update_uuid_revision(&self.user_uuid, conn); CollectionUser::delete_all_by_user(&self.user_uuid, &conn)?; @@ -261,6 +302,13 @@ impl UserOrganization { Ok(()) } + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> { + for user_org in Self::find_any_state_by_user(&user_uuid, &conn) { + user_org.delete(&conn)?; + } + Ok(()) + } + pub fn has_full_access(self) -> bool { self.access_all || self.type_ < UserOrgType::User as i32 } @@ -292,10 +340,29 @@ impl UserOrganization { .load::(&**conn).unwrap_or_default() } - pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec { + pub fn find_any_state_by_user(user_uuid: &str, conn: &DbConn) -> Vec { users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .load::(&**conn).expect("Error loading user organizations") + .filter(users_organizations::user_uuid.eq(user_uuid)) + .load::(&**conn).unwrap_or_default() + } + + pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec { + if org_uuid == Organization::VIRTUAL_ID { + User::get_all(&*conn).iter().map(|user| { + Self::new_virtual( + user.uuid.clone(), + UserOrgType::User, + if Invitation::find_by_mail(&user.email, &conn).is_some() { + UserOrgStatus::Invited + } else { + UserOrgStatus::Confirmed + }) + }).collect() + } else { + users_organizations::table + .filter(users_organizations::org_uuid.eq(org_uuid)) + .load::(&**conn).expect("Error loading user organizations") + } } pub fn find_by_org_and_type(org_uuid: &str, type_: i32, conn: &DbConn) -> Vec { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 67c0f493..02439e92 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -103,22 +103,31 @@ impl User { pub fn reset_security_stamp(&mut self) { self.security_stamp = Uuid::new_v4().to_string(); } + + pub fn is_server_admin(&self) -> bool { + match CONFIG.server_admin_email { + Some(ref server_admin_email) => &self.email == server_admin_email, + None => false + } + } } use diesel; use diesel::prelude::*; use db::DbConn; use db::schema::{users, invitations}; +use super::{Cipher, Folder, Device, UserOrganization, UserOrgType}; /// Database methods impl User { pub fn to_json(&self, conn: &DbConn) -> JsonValue { - use super::UserOrganization; - use super::TwoFactor; + use super::{UserOrganization, UserOrgType, UserOrgStatus, TwoFactor}; - let orgs = UserOrganization::find_by_user(&self.uuid, conn); + let mut orgs = UserOrganization::find_by_user(&self.uuid, conn); + if self.is_server_admin() { + orgs.push(UserOrganization::new_virtual(self.uuid.clone(), UserOrgType::Owner, UserOrgStatus::Confirmed)); + } let orgs_json: Vec = orgs.iter().map(|c| c.to_json(&conn)).collect(); - let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty(); json!({ @@ -150,13 +159,27 @@ impl User { } } - pub fn delete(self, conn: &DbConn) -> bool { - match diesel::delete(users::table.filter( - users::uuid.eq(self.uuid))) - .execute(&**conn) { - Ok(1) => true, // One row deleted - _ => false, + pub fn delete(self, conn: &DbConn) -> QueryResult<()> { + for user_org in UserOrganization::find_by_user(&self.uuid, &*conn) { + if user_org.type_ == UserOrgType::Owner as i32 { + if UserOrganization::find_by_org_and_type( + &user_org.org_uuid, + UserOrgType::Owner as i32, &conn + ).len() <= 1 { + return Err(diesel::result::Error::NotFound); + } + } } + + UserOrganization::delete_all_by_user(&self.uuid, &*conn)?; + Cipher::delete_all_by_user(&self.uuid, &*conn)?; + Folder::delete_all_by_user(&self.uuid, &*conn)?; + Device::delete_all_by_user(&self.uuid, &*conn)?; + Invitation::take(&self.email, &*conn); // Delete invitation if any + + diesel::delete(users::table.filter( + users::uuid.eq(self.uuid))) + .execute(&**conn).and(Ok(())) } pub fn update_uuid_revision(uuid: &str, conn: &DbConn) { @@ -190,6 +213,11 @@ impl User { .filter(users::uuid.eq(uuid)) .first::(&**conn).ok() } + + pub fn get_all(conn: &DbConn) -> Vec { + users::table + .load::(&**conn).expect("Error loading users") + } } #[derive(Debug, Identifiable, Queryable, Insertable)] diff --git a/src/main.rs b/src/main.rs index a53f5a43..eabca342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,6 +237,7 @@ pub struct Config { local_icon_extractor: bool, signups_allowed: bool, invitations_allowed: bool, + server_admin_email: Option, password_iterations: i32, show_password_hint: bool, @@ -272,6 +273,7 @@ impl Config { local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false), signups_allowed: get_env_or("SIGNUPS_ALLOWED", true), + server_admin_email: get_env("SERVER_ADMIN_EMAIL"), invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true), password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000), show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true),