diff --git a/README.md b/README.md index 8a9b739a3343d0c52c0adab6980459f27ce69159..353e053695bef74f5bc3ccdf06c284baca26dc06 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 8d6b7f15f2ca14677fc2c1892022b748b5f434c3..7d45492b657f3a734520a242609e05185b90c1b1 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -288,28 +288,11 @@ fn delete_account(data: JsonUpcase<PasswordData>, 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") - } - } - - // Delete folders - for f in Folder::find_by_user(&user.uuid, &conn) { - if f.delete(&conn).is_err() { - err!("Failed deleting folder") - } + + match user.delete(&conn) { + Ok(()) => Ok(()), + Err(_) => err!("Failed deleting user account, are you the only owner of some organization?") } - - // 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 b3218a1f6bda813936e5ee5d1bdcbabb3e1dc98e..d5c235bc474baf271e17703079aed0db3f20f3fd 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<Value>, 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/<uuid>/token", data = "<data>")] diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 2deab3fa4a9aba14366c7d06e3e10c2298d9c28f..1a74e6c886be5cf4c0b229fe3e376908a20a99d9 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/<org_id>/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<Value> = users.iter().map(|c| c.to_json_user_details(&conn)).collect(); @@ -410,27 +405,30 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, 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; - - // 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") + // 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") + } } } } } - } - 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<EditUserData> #[delete("/organizations/<org_id>/users/<org_user_id>")] 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 db62398bd3105ece3ad1fabf0bebe106d1c692b0..f4df90c4def72d27359a81211586da979968f5e8 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 2f7faf25948f77d9c90fa668d1a315dfb9e9cb1c..6b541a6bad6ad80da9f3f740760f2acea02c2c5d 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 e67ea0fd7c3d9ee56abf3247ffe62bf240c7d8be..db0db473afd9e9cdabfbd8ee99b5c30807eca19d 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<String>, 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 8063e9ee2a12005686febca6a24d9469b7794b44..0e51c9e5623efcb89c3a8ea707a0f768975ef40f 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<Self> { diff --git a/src/db/models/folder.rs b/src/db/models/folder.rs index 95b1bc8828d2afc1c7a7ab5c8f2dcc8f33b96bba..d50912edbebdfbee4e34f8b98125332f44e6bd05 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<Self> { folders::table .filter(folders::uuid.eq(uuid)) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 9dd9cc99f6298f76e16146e345c1f2a650149d86..3ce29d291466f2eed60767375dc6fa98529b49aa 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<Self> { + if uuid == Organization::VIRTUAL_ID { + return Some(Self::new_virtual()) + }; organizations::table .filter(organizations::uuid.eq(uuid)) .first::<Self>(&**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::<Self>(&**conn).unwrap_or_default() } - pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> { + pub fn find_any_state_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> { users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .load::<Self>(&**conn).expect("Error loading user organizations") + .filter(users_organizations::user_uuid.eq(user_uuid)) + .load::<Self>(&**conn).unwrap_or_default() + } + + pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> { + 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::<Self>(&**conn).expect("Error loading user organizations") + } } pub fn find_by_org_and_type(org_uuid: &str, type_: i32, conn: &DbConn) -> Vec<Self> { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 67c0f493f7475403f2a73181ba0abdea5b00b3e7..02439e92775b66dec5a6020ada70702789ddfaf1 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<JsonValue> = 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::<Self>(&**conn).ok() } + + pub fn get_all(conn: &DbConn) -> Vec<Self> { + users::table + .load::<Self>(&**conn).expect("Error loading users") + } } #[derive(Debug, Identifiable, Queryable, Insertable)] diff --git a/src/main.rs b/src/main.rs index a53f5a4318507470273e488dd8ca56049aaabf48..eabca342cffc8897ea444e8f4942f684c4c8702e 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<String>, 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),