Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin password reset #3116

Merged
merged 8 commits into from
Feb 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE users_organizations
ADD COLUMN reset_password_key TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE users_organizations
ADD COLUMN reset_password_key TEXT;
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE users_organizations
ADD COLUMN reset_password_key TEXT;
213 changes: 213 additions & 0 deletions src/api/core/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
]
}
Expand Down Expand Up @@ -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>")]
Expand Down Expand Up @@ -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 {
Expand All @@ -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?;
}
}
Expand Down Expand Up @@ -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 {
BlackDex marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions src/db/models/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion src/db/models/org_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions src/db/models/organization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ db_object! {
pub akey: String,
pub status: i32,
pub atype: i32,
pub reset_password_key: Option<String>,
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -194,6 +195,7 @@ impl UserOrganization {
akey: String::new(),
status: UserOrgStatus::Accepted as i32,
atype: UserOrgType::User as i32,
reset_password_key: None,
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
})
Expand Down
1 change: 1 addition & 0 deletions src/db/schemas/mysql/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ table! {
akey -> Text,
status -> Integer,
atype -> Integer,
reset_password_key -> Nullable<Text>,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/db/schemas/postgresql/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ table! {
akey -> Text,
status -> Integer,
atype -> Integer,
reset_password_key -> Nullable<Text>,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/db/schemas/sqlite/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ table! {
akey -> Text,
status -> Integer,
atype -> Integer,
reset_password_key -> Nullable<Text>,
}
}

Expand Down
Loading