Merge pull request #216 from mprasil/superuser

Implement poor man's admin panel
This commit is contained in:
Daniel García 2018-10-13 17:16:06 +02:00 committed by GitHub
commit e0614620ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 209 additions and 71 deletions

View File

@ -24,6 +24,7 @@ _*Note, that this project is not associated with the [Bitwarden](https://bitward
- [Configuring bitwarden service](#configuring-bitwarden-service) - [Configuring bitwarden service](#configuring-bitwarden-service)
- [Disable registration of new users](#disable-registration-of-new-users) - [Disable registration of new users](#disable-registration-of-new-users)
- [Disable invitations](#disable-invitations) - [Disable invitations](#disable-invitations)
- [Configure server administrator](#configure-server-administrator)
- [Enabling HTTPS](#enabling-https) - [Enabling HTTPS](#enabling-https)
- [Enabling WebSocket notifications](#enabling-websocket-notifications) - [Enabling WebSocket notifications](#enabling-websocket-notifications)
- [Enabling U2F authentication](#enabling-u2f-authentication) - [Enabling U2F authentication](#enabling-u2f-authentication)
@ -154,6 +155,21 @@ docker run -d --name bitwarden \
-p 80:80 \ -p 80:80 \
mprasil/bitwarden:latest 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 ### Enabling HTTPS
To enable HTTPS, you need to configure the `ROCKET_TLS`. To enable HTTPS, you need to configure the `ROCKET_TLS`.

View File

@ -288,28 +288,11 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
if !user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password") err!("Invalid password")
} }
// Delete ciphers and their attachments match user.delete(&conn) {
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) { Ok(()) => Ok(()),
if cipher.delete(&conn).is_err() { Err(_) => err!("Failed deleting user account, are you the only owner of some organization?")
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")
}
}
// 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")] #[get("/accounts/revision-date")]

View File

@ -151,9 +151,10 @@ fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: D
err!("Device not owned by user") err!("Device not owned by user")
} }
device.delete(&conn); match device.delete(&conn) {
Ok(()) => Ok(()),
Ok(()) Err(_) => err!("Failed deleting device")
}
} }
#[put("/devices/identifier/<uuid>/token", data = "<data>")] #[put("/devices/identifier/<uuid>/token", data = "<data>")]

View File

@ -326,12 +326,7 @@ fn get_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResul
} }
#[get("/organizations/<org_id>/users")] #[get("/organizations/<org_id>/users")]
fn get_org_users(org_id: String, headers: AdminHeaders, conn: DbConn) -> JsonResult { 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")
}
let users = UserOrganization::find_by_org(&org_id, &conn); 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(); 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()); // Don't create UserOrganization in virtual organization
let access_all = data.AccessAll.unwrap_or(false); if org_id != Organization::VIRTUAL_ID {
new_user.access_all = access_all; let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
new_user.type_ = new_type; let access_all = data.AccessAll.unwrap_or(false);
new_user.status = user_org_status; 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 no accessAll, add the collections received
if !access_all { if !access_all {
for col in &data.Collections { for col in &data.Collections {
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {
if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() { if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
err!("Failed saving collection access for user") err!("Failed saving collection access for user")
}
} }
} }
} }
} }
}
new_user.save(&conn); new_user.save(&conn);
}
} }
Ok(()) 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>")] #[delete("/organizations/<org_id>/users/<org_user_id>")]
fn delete_user(org_id: String, org_user_id: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { 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) { let user_to_delete = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn) {
Some(user) => user, Some(user) => user,
None => err!("User to delete isn't member of the organization") None => err!("User to delete isn't member of the organization")

View File

@ -107,11 +107,13 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, re
Some(device) => { Some(device) => {
// Check if valid device // Check if valid device
if device.user_uuid != user.uuid { if device.user_uuid != user.uuid {
device.delete(&conn); match device.delete(&conn) {
err!("Device is not owned by user") 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 => { None => {
// Create new device // Create new device

View File

@ -95,7 +95,7 @@ use rocket::Outcome;
use rocket::request::{self, Request, FromRequest}; use rocket::request::{self, Request, FromRequest};
use db::DbConn; use db::DbConn;
use db::models::{User, UserOrganization, UserOrgType, UserOrgStatus, Device}; use db::models::{User, Organization, UserOrganization, UserOrgType, UserOrgStatus, Device};
pub struct Headers { pub struct Headers {
pub host: String, 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") 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{ Outcome::Success(Self{

View File

@ -182,6 +182,13 @@ impl Cipher {
Ok(()) 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> { 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) { match self.get_folder_uuid(&user_uuid, &conn) {
None => { None => {

View File

@ -123,13 +123,17 @@ impl Device {
} }
} }
pub fn delete(self, conn: &DbConn) -> bool { pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
match diesel::delete(devices::table.filter( diesel::delete(devices::table.filter(
devices::uuid.eq(self.uuid))) devices::uuid.eq(self.uuid)
.execute(&**conn) { )).execute(&**conn).and(Ok(()))
Ok(1) => true, // One row deleted }
_ => false,
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> { pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {

View File

@ -93,6 +93,13 @@ impl Folder {
).execute(&**conn).and(Ok(())) ).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> { pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
folders::table folders::table
.filter(folders::uuid.eq(uuid)) .filter(folders::uuid.eq(uuid))

View File

@ -1,7 +1,7 @@
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use uuid::Uuid; use uuid::Uuid;
use super::{User, CollectionUser}; use super::{User, CollectionUser, Invitation};
#[derive(Debug, Identifiable, Queryable, Insertable)] #[derive(Debug, Identifiable, Queryable, Insertable)]
#[table_name = "organizations"] #[table_name = "organizations"]
@ -51,6 +51,8 @@ impl UserOrgType {
/// Local methods /// Local methods
impl Organization { impl Organization {
pub const VIRTUAL_ID: &'static str = "00000000-0000-0000-0000-000000000000";
pub fn new(name: String, billing_email: String) -> Self { pub fn new(name: String, billing_email: String) -> Self {
Self { Self {
uuid: Uuid::new_v4().to_string(), 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 { pub fn to_json(&self) -> JsonValue {
json!({ json!({
"Id": self.uuid, "Id": self.uuid,
@ -103,6 +113,20 @@ impl UserOrganization {
type_: UserOrgType::User as i32, 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 /// Database methods
impl Organization { impl Organization {
pub fn save(&mut self, conn: &DbConn) -> bool { pub fn save(&mut self, conn: &DbConn) -> bool {
if self.uuid == Organization::VIRTUAL_ID {
return false
}
UserOrganization::find_by_org(&self.uuid, conn) UserOrganization::find_by_org(&self.uuid, conn)
.iter() .iter()
.for_each(|user_org| { .for_each(|user_org| {
@ -131,6 +159,10 @@ impl Organization {
pub fn delete(self, conn: &DbConn) -> QueryResult<()> { pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
use super::{Cipher, Collection}; use super::{Cipher, Collection};
if self.uuid == Organization::VIRTUAL_ID {
return Err(diesel::result::Error::NotFound)
}
Cipher::delete_all_by_organization(&self.uuid, &conn)?; Cipher::delete_all_by_organization(&self.uuid, &conn)?;
Collection::delete_all_by_organization(&self.uuid, &conn)?; Collection::delete_all_by_organization(&self.uuid, &conn)?;
UserOrganization::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> { pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
if uuid == Organization::VIRTUAL_ID {
return Some(Self::new_virtual())
};
organizations::table organizations::table
.filter(organizations::uuid.eq(uuid)) .filter(organizations::uuid.eq(uuid))
.first::<Self>(&**conn).ok() .first::<Self>(&**conn).ok()
@ -232,6 +267,9 @@ impl UserOrganization {
} }
pub fn save(&mut self, conn: &DbConn) -> bool { 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); User::update_uuid_revision(&self.user_uuid, conn);
match diesel::replace_into(users_organizations::table) match diesel::replace_into(users_organizations::table)
@ -243,6 +281,9 @@ impl UserOrganization {
} }
pub fn delete(self, conn: &DbConn) -> QueryResult<()> { 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); User::update_uuid_revision(&self.user_uuid, conn);
CollectionUser::delete_all_by_user(&self.user_uuid, &conn)?; CollectionUser::delete_all_by_user(&self.user_uuid, &conn)?;
@ -261,6 +302,13 @@ impl UserOrganization {
Ok(()) 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 { pub fn has_full_access(self) -> bool {
self.access_all || self.type_ < UserOrgType::User as i32 self.access_all || self.type_ < UserOrgType::User as i32
} }
@ -292,10 +340,29 @@ impl UserOrganization {
.load::<Self>(&**conn).unwrap_or_default() .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 users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.load::<Self>(&**conn).expect("Error loading user organizations") .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> { pub fn find_by_org_and_type(org_uuid: &str, type_: i32, conn: &DbConn) -> Vec<Self> {

View File

@ -103,22 +103,31 @@ impl User {
pub fn reset_security_stamp(&mut self) { pub fn reset_security_stamp(&mut self) {
self.security_stamp = Uuid::new_v4().to_string(); 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;
use diesel::prelude::*; use diesel::prelude::*;
use db::DbConn; use db::DbConn;
use db::schema::{users, invitations}; use db::schema::{users, invitations};
use super::{Cipher, Folder, Device, UserOrganization, UserOrgType};
/// Database methods /// Database methods
impl User { impl User {
pub fn to_json(&self, conn: &DbConn) -> JsonValue { pub fn to_json(&self, conn: &DbConn) -> JsonValue {
use super::UserOrganization; use super::{UserOrganization, UserOrgType, UserOrgStatus, TwoFactor};
use super::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 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(); let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty();
json!({ json!({
@ -150,13 +159,27 @@ impl User {
} }
} }
pub fn delete(self, conn: &DbConn) -> bool { pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
match diesel::delete(users::table.filter( for user_org in UserOrganization::find_by_user(&self.uuid, &*conn) {
users::uuid.eq(self.uuid))) if user_org.type_ == UserOrgType::Owner as i32 {
.execute(&**conn) { if UserOrganization::find_by_org_and_type(
Ok(1) => true, // One row deleted &user_org.org_uuid,
_ => false, 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) { pub fn update_uuid_revision(uuid: &str, conn: &DbConn) {
@ -190,6 +213,11 @@ impl User {
.filter(users::uuid.eq(uuid)) .filter(users::uuid.eq(uuid))
.first::<Self>(&**conn).ok() .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)] #[derive(Debug, Identifiable, Queryable, Insertable)]

View File

@ -237,6 +237,7 @@ pub struct Config {
local_icon_extractor: bool, local_icon_extractor: bool,
signups_allowed: bool, signups_allowed: bool,
invitations_allowed: bool, invitations_allowed: bool,
server_admin_email: Option<String>,
password_iterations: i32, password_iterations: i32,
show_password_hint: bool, show_password_hint: bool,
@ -272,6 +273,7 @@ impl Config {
local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false), local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false),
signups_allowed: get_env_or("SIGNUPS_ALLOWED", true), signups_allowed: get_env_or("SIGNUPS_ALLOWED", true),
server_admin_email: get_env("SERVER_ADMIN_EMAIL"),
invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true), invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true),
password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000), password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000),
show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true), show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true),