diff --git a/migrations/mysql/2023-01-06-151600_add_reset_password_support/down.sql b/migrations/mysql/2023-01-06-151600_add_reset_password_support/down.sql new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/migrations/mysql/2023-01-06-151600_add_reset_password_support/up.sql b/migrations/mysql/2023-01-06-151600_add_reset_password_support/up.sql new file mode 100644 index 0000000000000000000000000000000000000000..326b310692f9cd6bd1cef7dc1fff2b1d7c12986f --- /dev/null +++ b/migrations/mysql/2023-01-06-151600_add_reset_password_support/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users_organizations +ADD COLUMN reset_password_key TEXT; diff --git a/migrations/postgresql/2023-01-06-151600_add_reset_password_support/down.sql b/migrations/postgresql/2023-01-06-151600_add_reset_password_support/down.sql new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/migrations/postgresql/2023-01-06-151600_add_reset_password_support/up.sql b/migrations/postgresql/2023-01-06-151600_add_reset_password_support/up.sql new file mode 100644 index 0000000000000000000000000000000000000000..326b310692f9cd6bd1cef7dc1fff2b1d7c12986f --- /dev/null +++ b/migrations/postgresql/2023-01-06-151600_add_reset_password_support/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users_organizations +ADD COLUMN reset_password_key TEXT; diff --git a/migrations/sqlite/2023-01-06-151600_add_reset_password_support/down.sql b/migrations/sqlite/2023-01-06-151600_add_reset_password_support/down.sql new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/migrations/sqlite/2023-01-06-151600_add_reset_password_support/up.sql b/migrations/sqlite/2023-01-06-151600_add_reset_password_support/up.sql new file mode 100644 index 0000000000000000000000000000000000000000..326b310692f9cd6bd1cef7dc1fff2b1d7c12986f --- /dev/null +++ b/migrations/sqlite/2023-01-06-151600_add_reset_password_support/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users_organizations +ADD COLUMN reset_password_key TEXT; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c0af8f6e276ba44b5ab1870da6a67a179bb083bd..9250f92913692776e283f5960407453873f3c8b2 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -62,6 +62,7 @@ pub fn routes() -> Vec<Route> { get_plans_tax_rates, import, post_org_keys, + get_organization_keys, bulk_public_keys, deactivate_organization_user, bulk_deactivate_organization_user, @@ -86,6 +87,9 @@ pub fn routes() -> Vec<Route> { put_user_groups, delete_group_user, post_delete_group_user, + put_reset_password_enrollment, + get_reset_password_details, + put_reset_password, get_org_export ] } @@ -882,6 +886,7 @@ async fn _reinvite_user(org_id: &str, user_org: &str, invited_by_email: &str, co #[allow(non_snake_case)] struct AcceptData { Token: String, + ResetPasswordKey: Option<String>, } #[post("/organizations/<org_id>/users/<_org_user_id>/accept", data = "<data>")] @@ -909,6 +914,11 @@ async fn accept_invite( err!("User already accepted the invitation") } + let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; + if data.ResetPasswordKey.is_none() && master_password_required { + err!("Reset password key is required, but not provided."); + } + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type // It returns different error messages per function. if user_org.atype < UserOrgType::Admin { @@ -924,6 +934,11 @@ async fn accept_invite( } user_org.status = UserOrgStatus::Accepted as i32; + + if master_password_required { + user_org.reset_password_key = data.ResetPasswordKey; + } + user_org.save(&mut conn).await?; } } @@ -2460,6 +2475,204 @@ async fn delete_group_user( GroupUser::delete_by_group_id_and_user_id(&group_id, &org_user_id, &mut conn).await } +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrganizationUserResetPasswordEnrollmentRequest { + ResetPasswordKey: Option<String>, +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrganizationUserResetPasswordRequest { + NewMasterPasswordHash: String, + Key: String, +} + +#[get("/organizations/<org_id>/keys")] +async fn get_organization_keys(org_id: String, mut conn: DbConn) -> JsonResult { + let org = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(organization) => organization, + None => err!("Organization not found"), + }; + + Ok(Json(json!({ + "Object": "organizationKeys", + "PublicKey": org.public_key, + "PrivateKey": org.private_key, + }))) +} + +#[put("/organizations/<org_id>/users/<org_user_id>/reset-password", data = "<data>")] +async fn put_reset_password( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + data: JsonUpcase<OrganizationUserResetPasswordRequest>, + mut conn: DbConn, + ip: ClientIp, + nt: Notify<'_>, +) -> EmptyResult { + let org = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(org) => org, + None => err!("Required organization not found"), + }; + + let org_user = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org.uuid, &mut conn).await { + Some(user) => user, + None => err!("User to reset isn't member of required organization"), + }; + + let mut user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await { + Some(user) => user, + None => err!("User not found"), + }; + + check_reset_password_applicable_and_permissions(&org_id, &org_user_id, &headers, &mut conn).await?; + + if org_user.reset_password_key.is_none() { + err!("Password reset not or not correctly enrolled"); + } + if org_user.status != (UserOrgStatus::Confirmed as i32) { + err!("Organization user must be confirmed for password reset functionality"); + } + + // Sending email before resetting password to ensure working email configuration and the resulting + // user notification. Also this might add some protection against security flaws and misuse + if let Err(e) = mail::send_admin_reset_password(&user.email, &user.name, &org.name).await { + error!("Error sending user reset password email: {:#?}", e); + } + + let reset_request = data.into_inner().data; + + user.set_password(reset_request.NewMasterPasswordHash.as_str(), Some(reset_request.Key), true, None); + user.save(&mut conn).await?; + + nt.send_logout(&user, None).await; + + log_event( + EventType::OrganizationUserAdminResetPassword as i32, + &org_user_id, + org.uuid.clone(), + headers.user.uuid.clone(), + headers.device.atype, + &ip.ip, + &mut conn, + ) + .await; + + Ok(()) +} + +#[get("/organizations/<org_id>/users/<org_user_id>/reset-password-details")] +async fn get_reset_password_details( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + mut conn: DbConn, +) -> JsonResult { + let org = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(org) => org, + None => err!("Required organization not found"), + }; + + let org_user = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &mut conn).await { + Some(user) => user, + None => err!("User to reset isn't member of required organization"), + }; + + let user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await { + Some(user) => user, + None => err!("User not found"), + }; + + check_reset_password_applicable_and_permissions(&org_id, &org_user_id, &headers, &mut conn).await?; + + Ok(Json(json!({ + "Object": "organizationUserResetPasswordDetails", + "Kdf":user.client_kdf_type, + "KdfIterations":user.client_kdf_iter, + "ResetPasswordKey":org_user.reset_password_key, + "EncryptedPrivateKey":org.private_key , + + }))) +} + +async fn check_reset_password_applicable_and_permissions( + org_id: &str, + org_user_id: &str, + headers: &AdminHeaders, + conn: &mut DbConn, +) -> EmptyResult { + check_reset_password_applicable(org_id, conn).await?; + + let target_user = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("Reset target user not found"), + }; + + // Resetting user must be higher/equal to user to reset + match headers.org_user_type { + UserOrgType::Owner => Ok(()), + UserOrgType::Admin if target_user.atype <= UserOrgType::Admin => Ok(()), + _ => err!("No permission to reset this user's password"), + } +} + +async fn check_reset_password_applicable(org_id: &str, conn: &mut DbConn) -> EmptyResult { + if !CONFIG.mail_enabled() { + err!("Password reset is not supported on an email-disabled instance."); + } + + let policy = match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await { + Some(p) => p, + None => err!("Policy not found"), + }; + + if !policy.enabled { + err!("Reset password policy not enabled"); + } + + Ok(()) +} + +#[put("/organizations/<org_id>/users/<org_user_id>/reset-password-enrollment", data = "<data>")] +async fn put_reset_password_enrollment( + org_id: String, + org_user_id: String, + headers: Headers, + data: JsonUpcase<OrganizationUserResetPasswordEnrollmentRequest>, + mut conn: DbConn, + ip: ClientIp, +) -> EmptyResult { + let mut org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &mut conn).await { + Some(u) => u, + None => err!("User to enroll isn't member of required organization"), + }; + + check_reset_password_applicable(&org_id, &mut conn).await?; + + let reset_request = data.into_inner().data; + + if reset_request.ResetPasswordKey.is_none() + && OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &mut conn).await + { + err!("Reset password can't be withdrawed due to an enterprise policy"); + } + + org_user.reset_password_key = reset_request.ResetPasswordKey; + org_user.save(&mut conn).await?; + + let log_id = if org_user.reset_password_key.is_some() { + EventType::OrganizationUserResetPasswordEnroll as i32 + } else { + EventType::OrganizationUserResetPasswordWithdraw as i32 + }; + + log_event(log_id, &org_user_id, org_id, headers.user.uuid.clone(), headers.device.atype, &ip.ip, &mut conn).await; + + Ok(()) +} + // This is a new function active since the v2022.9.x clients. // It combines the previous two calls done before. // We call those two functions here and combine them our selfs. diff --git a/src/config.rs b/src/config.rs index 46deed546f7b8bb6fe1e5a86cdabf81d808c456f..68a6811c0f3c85b5ddf4ca1586445300bfd5c5d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1136,6 +1136,7 @@ where reg!("email/email_footer"); reg!("email/email_footer_text"); + reg!("email/admin_reset_password", ".html"); reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 9196b8a81aa0409c7bd9283135cbc204ac331bd4..6431227347c9fa4091ed709f7344ca23da781f3a 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -87,9 +87,9 @@ pub enum EventType { OrganizationUserRemoved = 1503, OrganizationUserUpdatedGroups = 1504, // OrganizationUserUnlinkedSso = 1505, // Not supported - // OrganizationUserResetPasswordEnroll = 1506, // Not supported - // OrganizationUserResetPasswordWithdraw = 1507, // Not supported - // OrganizationUserAdminResetPassword = 1508, // Not supported + OrganizationUserResetPasswordEnroll = 1506, + OrganizationUserResetPasswordWithdraw = 1507, + OrganizationUserAdminResetPassword = 1508, // OrganizationUserResetSsoLink = 1509, // Not supported // OrganizationUserFirstSsoLogin = 1510, // Not supported OrganizationUserRevoked = 1511, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index caa3335f5c001acc5892825d753b5ceb8357f838..c9fd5c348516132aad364af4c418843563a56157 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -32,7 +32,7 @@ pub enum OrgPolicyType { PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, - // ResetPassword = 8, // Not supported + ResetPassword = 8, // MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed) // DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed) } @@ -44,6 +44,13 @@ pub struct SendOptionsPolicyData { pub DisableHideEmail: bool, } +// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs +#[derive(Deserialize)] +#[allow(non_snake_case)] +pub struct ResetPasswordDataModel { + pub AutoEnrollEnabled: bool, +} + pub type OrgPolicyResult = Result<(), OrgPolicyErr>; #[derive(Debug)] @@ -298,6 +305,20 @@ impl OrgPolicy { Ok(()) } + pub async fn org_is_reset_password_auto_enroll(org_uuid: &str, conn: &mut DbConn) -> bool { + match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await { + Some(policy) => match serde_json::from_str::<UpCase<ResetPasswordDataModel>>(&policy.data) { + Ok(opts) => { + return opts.data.AutoEnrollEnabled; + } + _ => error!("Failed to deserialize ResetPasswordDataModel: {}", policy.data), + }, + None => return false, + } + + false + } + /// Returns true if the user belongs to an org that has enabled the `DisableHideEmail` /// option of the `Send Options` policy, and the user is not an owner or admin of that org. pub async fn is_hide_email_disabled(user_uuid: &str, conn: &mut DbConn) -> bool { diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 331e10070f38710903fe3bdcd125e7f97d6183d1..a6e4be2124dc7f585ee31eab45f326fd9d8e9c1c 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -29,6 +29,7 @@ db_object! { pub akey: String, pub status: i32, pub atype: i32, + pub reset_password_key: Option<String>, } } @@ -158,7 +159,7 @@ impl Organization { "SelfHost": true, "UseApi": false, // Not supported "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), - "UseResetPassword": false, // Not supported + "UseResetPassword": CONFIG.mail_enabled(), "BusinessName": null, "BusinessAddress1": null, @@ -194,6 +195,7 @@ impl UserOrganization { akey: String::new(), status: UserOrgStatus::Accepted as i32, atype: UserOrgType::User as i32, + reset_password_key: None, } } @@ -311,7 +313,8 @@ impl UserOrganization { "UseApi": false, // Not supported "SelfHost": true, "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), - "ResetPasswordEnrolled": false, // Not supported + "ResetPasswordEnrolled": self.reset_password_key.is_some(), + "UseResetPassword": CONFIG.mail_enabled(), "SsoBound": false, // Not supported "UseSso": false, // Not supported "ProviderId": null, @@ -377,6 +380,7 @@ impl UserOrganization { "Type": self.atype, "AccessAll": self.access_all, "TwoFactorEnabled": twofactor_enabled, + "ResetPasswordEnrolled":self.reset_password_key.is_some(), "Object": "organizationUserUserDetails", }) diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 27cd24c35f4dfa2f2d197f5929a330218db039d4..cdb3e059dbf8fc500117b70260896536befe290e 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -222,6 +222,7 @@ table! { akey -> Text, status -> Integer, atype -> Integer, + reset_password_key -> Nullable<Text>, } } diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 0233e0c9752c75c18b968fccb021b3df45c97f18..6ec8a979de3d5e6bc53b2c2a8b9e2a5fe18cdbe7 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -222,6 +222,7 @@ table! { akey -> Text, status -> Integer, atype -> Integer, + reset_password_key -> Nullable<Text>, } } diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 391e67001898b8483facd81cf6d741bd82e50715..faaf6fae9ea324303234775f49e9c21379ba7a81 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -222,6 +222,7 @@ table! { akey -> Text, status -> Integer, atype -> Integer, + reset_password_key -> Nullable<Text>, } } diff --git a/src/mail.rs b/src/mail.rs index 8ecb11c61fc3b1fdb67e17538d8d4677a35127b1..cffa65fbcb6fb637631b69593f090cfb298dc6e9 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -496,6 +496,19 @@ pub async fn send_test(address: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/admin_reset_password", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "user_name": user_name, + "org_name": org_name, + }), + )?; + send_email(address, &subject, body_html, body_text).await +} + async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { let smtp_from = &CONFIG.smtp_from(); diff --git a/src/static/templates/email/admin_reset_password.hbs b/src/static/templates/email/admin_reset_password.hbs new file mode 100644 index 0000000000000000000000000000000000000000..8d38177254b3638df8a33bfccc51214170517231 --- /dev/null +++ b/src/static/templates/email/admin_reset_password.hbs @@ -0,0 +1,6 @@ +Master Password Has Been Changed +<!----------------> +The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately. + +=== +Github: https://github.com/dani-garcia/vaultwarden diff --git a/src/static/templates/email/admin_reset_password.html.hbs b/src/static/templates/email/admin_reset_password.html.hbs new file mode 100644 index 0000000000000000000000000000000000000000..d9749d22a9d68f08047b0ff7840cd0d410643c56 --- /dev/null +++ b/src/static/templates/email/admin_reset_password.html.hbs @@ -0,0 +1,11 @@ +Master Password Has Been Changed +<!----------------> +{{> email/email_header }} +<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> + <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> + <td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top"> + The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed by an administrator in your <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization. If you did not initiate this request, please reach out to your administrator immediately. + </td> + </tr> +</table> +{{> email/email_footer }}