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)
- [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`.

View File

@ -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")
}
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")]

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")
}
device.delete(&conn);
Ok(())
match device.delete(&conn) {
Ok(()) => Ok(()),
Err(_) => err!("Failed deleting device")
}
}
#[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")]
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;
// 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<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")

View File

@ -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

View File

@ -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{

View File

@ -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 => {

View File

@ -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> {

View File

@ -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))

View File

@ -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> {

View File

@ -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)]

View File

@ -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),