diff --git a/README.md b/README.md index 358941a9012ba0175e249c7b47fe1dcab620752d..4b27f35a0fc8cd4d166b0c63a36aeeedff9e7823 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ _*Note, that this project is not associated with the [Bitwarden](https://bitward - [Updating the bitwarden image](#updating-the-bitwarden-image) - [Configuring bitwarden service](#configuring-bitwarden-service) - [Disable registration of new users](#disable-registration-of-new-users) + - [Disable invitations](#disable-invitations) - [Enabling HTTPS](#enabling-https) - [Enabling U2F authentication](#enabling-u2f-authentication) - [Changing persistent data location](#changing-persistent-data-location) @@ -136,6 +137,20 @@ docker run -d --name bitwarden \ -p 80:80 \ mprasil/bitwarden:latest ``` +Note: While users can't register on their own, they can still be invited by already registered users. Read bellow if you also want to disable that. + +### Disable invitations + +Even when registration is disabled, organization administrators or owners can invite users to join organization. This won't send email invitation to the users, but after they are invited, they can register with the invited email even if `SIGNUPS_ALLOWED` is actually set to `false`. You can disable this functionality completely by setting `INVITATIONS_ALLOWED` env variable to `false`: + +```sh +docker run -d --name bitwarden \ + -e SIGNUPS_ALLOWED=false \ + -e INVITATIONS_ALLOWED=false \ + -v /bw-data/:/data/ \ + -p 80:80 \ + mprasil/bitwarden:latest +``` ### Enabling HTTPS To enable HTTPS, you need to configure the `ROCKET_TLS`. @@ -365,7 +380,7 @@ We use upstream Vault interface directly without any (significant) changes, this ### Inviting users into organization -The users must already be registered on your server to invite them, because we can't send the invitation via email. The invited users won't get the invitation email, instead they will appear in the interface as if they already accepted the invitation. Organization admin then just needs to confirm them to be proper Organization members and to give them access to the shared secrets. +If you have [invitations disabled](#disable-invitations), the users must already be registered on your server to invite them. The invited users won't get the invitation email, instead they will appear in the interface as if they already accepted the invitation. (if the user has already registered) Organization admin then just needs to confirm them to be proper Organization members and to give them access to the shared secrets. ### Running on unencrypted connection diff --git a/migrations/2018-09-10-111213_add_invites/down.sql b/migrations/2018-09-10-111213_add_invites/down.sql new file mode 100644 index 0000000000000000000000000000000000000000..af3776cf781c2c02aa23fb94b6d8b5e7bbfc1e96 --- /dev/null +++ b/migrations/2018-09-10-111213_add_invites/down.sql @@ -0,0 +1 @@ +DROP TABLE invitations; \ No newline at end of file diff --git a/migrations/2018-09-10-111213_add_invites/up.sql b/migrations/2018-09-10-111213_add_invites/up.sql new file mode 100644 index 0000000000000000000000000000000000000000..b42e9a2a440709be549cae2b497396bc9990573e --- /dev/null +++ b/migrations/2018-09-10-111213_add_invites/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE invitations ( + email TEXT NOT NULL PRIMARY KEY +); \ No newline at end of file diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a551a8688d7391d6b2874f227d68653f2c90f305..ef0e917345b2830a278d220877b06867707393e6 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -30,15 +30,33 @@ struct KeysData { fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult { let data: RegisterData = data.into_inner().data; - if !CONFIG.signups_allowed { - err!("Signups not allowed") - } - - if User::find_by_mail(&data.Email, &conn).is_some() { - err!("Email already exists") - } - let mut user = User::new(data.Email, data.Key, data.MasterPasswordHash); + let mut user = match User::find_by_mail(&data.Email, &conn) { + Some(mut user) => { + if Invitation::take(&data.Email, &conn) { + for mut user_org in UserOrganization::find_invited_by_user(&user.uuid, &conn).iter_mut() { + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(&conn); + }; + user.set_password(&data.MasterPasswordHash); + user.key = data.Key; + user + } else { + if CONFIG.signups_allowed { + err!("Account with this email already exists") + } else { + err!("Registration not allowed") + } + } + }, + None => { + if CONFIG.signups_allowed || Invitation::take(&data.Email, &conn) { + User::new(data.Email, data.Key, data.MasterPasswordHash) + } else { + err!("Registration not allowed") + } + } + }; // Add extra fields if present if let Some(name) = data.Name { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3253eb5b79026a0f61bb601a7200afc2034d8dbb..b8067b65e20537ee51eb3bd1df7d352519e53ed8 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1,7 +1,7 @@ #![allow(unused_imports)] use rocket_contrib::{Json, Value}; - +use CONFIG; use db::DbConn; use db::models::*; @@ -373,36 +373,56 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade err!("Only Owners can invite Admins or Owners") } - for user_opt in data.Emails.iter().map(|email| User::find_by_mail(email, &conn)) { - match user_opt { - None => err!("User email does not exist"), - Some(user) => { - if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).is_some() { - err!("User already in organization") + for email in data.Emails.iter() { + let mut user_org_status = UserOrgStatus::Accepted as i32; + let user = match User::find_by_mail(&email, &conn) { + None => if CONFIG.invitations_allowed { // Invite user if that's enabled + let mut invitation = Invitation::new(email.clone()); + match invitation.save(&conn) { + Ok(()) => { + let mut user = User::new_invited(email.clone()); + if user.save(&conn) { + user_org_status = UserOrgStatus::Invited as i32; + user + } else { + err!("Failed to create placeholder for invited user") + } + } + Err(_) => err!(format!("Failed to invite: {}", email)) } + + } else { + err!(format!("User email does not exist: {}", email)) + }, + Some(user) => if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).is_some() { + err!(format!("User already in organization: {}", email)) + } else { + user + } - 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; - - // 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") - } - } + }; + + 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(()) diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 7032a39192923805504f993590f0459242d06979..1f8d10a88704ea298281c2a9c2f7a569d517c490 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -12,7 +12,7 @@ pub use self::attachment::Attachment; pub use self::cipher::Cipher; pub use self::device::Device; pub use self::folder::{Folder, FolderCipher}; -pub use self::user::User; +pub use self::user::{User, Invitation}; pub use self::organization::Organization; pub use self::organization::{UserOrganization, UserOrgStatus, UserOrgType}; pub use self::collection::{Collection, CollectionUser, CollectionCipher}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 18ac2a0dccaf2bec8c0e4468c0d6a685df85770f..a2d9f5c7f17d334ea0e5af12564c408f2b016b25 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -27,7 +27,7 @@ pub struct UserOrganization { } pub enum UserOrgStatus { - _Invited = 0, // Unused, users are accepted automatically + Invited = 0, Accepted = 1, Confirmed = 2, } @@ -284,6 +284,13 @@ impl UserOrganization { .load::<Self>(&**conn).unwrap_or(vec![]) } + pub fn find_invited_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(UserOrgStatus::Invited as i32)) + .load::<Self>(&**conn).unwrap_or(vec![]) + } + pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) diff --git a/src/db/models/user.rs b/src/db/models/user.rs index e100d893040e3a8f9e992c2386e544048c042425..312c2749d2770f5e4302b4b55d491473fbe9b200 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -73,6 +73,10 @@ impl User { } } + pub fn new_invited(mail: String) -> Self { + Self::new(mail,"".to_string(),"".to_string()) + } + pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash(password.as_bytes(), &self.salt, @@ -103,7 +107,7 @@ impl User { use diesel; use diesel::prelude::*; use db::DbConn; -use db::schema::users; +use db::schema::{users, invitations}; /// Database methods impl User { @@ -186,3 +190,47 @@ impl User { .first::<Self>(&**conn).ok() } } + +#[derive(Debug, Identifiable, Queryable, Insertable)] +#[table_name = "invitations"] +#[primary_key(email)] +pub struct Invitation { + pub email: String, +} + +impl Invitation { + pub fn new(email: String) -> Self { + Self { + email + } + } + + pub fn save(&mut self, conn: &DbConn) -> QueryResult<()> { + diesel::replace_into(invitations::table) + .values(&*self) + .execute(&**conn) + .and(Ok(())) + } + + pub fn delete(self, conn: &DbConn) -> QueryResult<()> { + diesel::delete(invitations::table.filter( + invitations::email.eq(self.email))) + .execute(&**conn) + .and(Ok(())) + } + + pub fn find_by_mail(mail: &str, conn: &DbConn) -> Option<Self> { + let lower_mail = mail.to_lowercase(); + invitations::table + .filter(invitations::email.eq(lower_mail)) + .first::<Self>(&**conn).ok() + } + + pub fn take(mail: &str, conn: &DbConn) -> bool { + CONFIG.invitations_allowed && + match Self::find_by_mail(mail, &conn) { + Some(invitation) => invitation.delete(&conn).is_ok(), + None => false + } + } +} \ No newline at end of file diff --git a/src/db/schema.rs b/src/db/schema.rs index 5382e697ae6ee3e016c93fbe3bdffe3c7854bb9d..457b84c23c40698c50139674d73e2d3ee4d87221 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -113,6 +113,12 @@ table! { } } +table! { + invitations (email) { + email -> Text, + } +} + table! { users_collections (user_uuid, collection_uuid) { user_uuid -> Text, diff --git a/src/main.rs b/src/main.rs index e715031c4ae3f840ef43dd2065bc4efca76edce9..2a68c0c6b2de0ba0670bc86d1a5d4cdcc8d9d490 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,6 +170,7 @@ pub struct Config { local_icon_extractor: bool, signups_allowed: bool, + invitations_allowed: bool, password_iterations: i32, show_password_hint: bool, domain: String, @@ -199,6 +200,7 @@ impl Config { local_icon_extractor: util::parse_option_string(env::var("LOCAL_ICON_EXTRACTOR").ok()).unwrap_or(false), signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(true), + invitations_allowed: util::parse_option_string(env::var("INVITATIONS_ALLOWED").ok()).unwrap_or(true), password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000), show_password_hint: util::parse_option_string(env::var("SHOW_PASSWORD_HINT").ok()).unwrap_or(true),