Browse Source

Merge branch 'main' into main

pull/7566/head
kittygaming99 2 weeks ago
committed by GitHub
parent
commit
aca1b04a44
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .dockerignore
  2. 1
      .env.template
  3. 7
      playwright/tests/organization.smtp.spec.ts
  4. 16
      src/api/core/accounts.rs
  5. 101
      src/api/core/organizations.rs
  6. 15
      src/api/core/two_factor/email.rs
  7. 5
      src/api/core/two_factor/mod.rs
  8. 14
      src/api/identity.rs
  9. 12
      src/auth.rs
  10. 3
      src/config.rs
  11. 12
      src/db/models/device.rs
  12. 8
      src/db/models/event.rs
  13. 14
      src/mail.rs
  14. 12
      src/static/templates/email/admin_account_recovery.hbs
  15. 11
      src/static/templates/email/admin_account_recovery.html.hbs
  16. 4
      src/static/templates/email/admin_reset_password.hbs

4
.dockerignore

@ -1,7 +1,7 @@
// Ignore everything # Ignore everything
* *
// Allow what is needed # Allow what is needed
!.git !.git
!docker/healthcheck.sh !docker/healthcheck.sh
!docker/start.sh !docker/start.sh

1
.env.template

@ -390,6 +390,7 @@
## ##
## The following flags are available: ## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)

7
playwright/tests/organization.smtp.spec.ts

@ -127,6 +127,9 @@ test('Organization is visible', async ({ page }) => {
}); });
test('Recover user password', async ({ page }) => { test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer }); await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword"; let newPassword = "TotoNewPassword";
@ -138,9 +141,10 @@ test('Recover user password', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Recover account' }).click(); await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword); await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success'); await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed')); await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
}); });
let user2 = { let user2 = {
@ -150,6 +154,7 @@ test('Recover user password', async ({ page }) => {
}; };
await logUser(test, page, user2, { await logUser(test, page, user2, {
mailBuffer: mail2Buffer, mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true, notNewDevice: true,
}); });
}); });

16
src/api/core/accounts.rs

@ -1340,11 +1340,13 @@ pub struct PreloginData {
} }
#[post("/accounts/prelogin", data = "<data>")] #[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, conn).await prelogin(data, ip, conn).await
} }
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner(); let data: PreloginData = data.into_inner();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
@ -1352,7 +1354,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
}; };
Json(json!({ Ok(Json(json!({
"kdf": kdf_type, "kdf": kdf_type,
"kdfIterations": kdf_iter, "kdfIterations": kdf_iter,
"kdfMemory": kdf_mem, "kdfMemory": kdf_mem,
@ -1364,7 +1366,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"parallelism": kdf_para "parallelism": kdf_para
}, },
"salt": null, "salt": null,
})) })))
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
@ -1595,6 +1597,8 @@ async fn post_auth_request(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let data = data.into_inner(); let data = data.into_inner();
let Some(user) = User::find_by_mail(&data.email, &conn).await else { let Some(user) = User::find_by_mail(&data.email, &conn).await else {
@ -1756,6 +1760,8 @@ async fn get_auth_request_response(
client_headers: ClientHeaders, client_headers: ClientHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else { let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found") err!("AuthRequest doesn't exist", "User not found")
}; };

101
src/api/core/organizations.rs

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json}; use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
@ -17,7 +17,8 @@ use crate::{
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
}, },
}, },
mail, mail,
@ -132,7 +133,6 @@ struct FullCollectionData {
name: String, name: String,
groups: Vec<CollectionGroupData>, groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>, users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>, external_id: Option<String>,
} }
@ -391,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
} }
if !headers.membership.has_full_access() { if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
} }
Ok(Json(json!({ Ok(Json(json!({
@ -887,11 +887,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")] #[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid { if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
} }
if !headers.membership.has_full_access() { if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
} }
Ok(Json(json!({ Ok(Json(json!({
@ -955,7 +955,7 @@ async fn get_members(
} }
if !headers.membership.has_full_access() { if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
} }
let mut users_json = Vec::new(); let mut users_json = Vec::new();
@ -1793,11 +1793,22 @@ async fn bulk_public_keys(
use super::ciphers::CipherData; use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data; use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ImportData { struct ImportData {
ciphers: Vec<CipherData>, ciphers: Vec<CipherData>,
collections: Vec<FullCollectionData>, collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>, collection_relationships: Vec<RelationsData>,
} }
@ -2476,7 +2487,7 @@ async fn get_groups_data(
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
}; };
if !allowed { if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
} }
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@ -2927,8 +2938,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest { struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String, new_master_password_hash: Option<String>,
key: String, key: Option<String>,
#[serde(default)] #[serde(default)]
reset_master_password: bool, reset_master_password: bool,
@ -2972,12 +2983,7 @@ async fn put_recover_account(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let req = data.into_inner(); recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
} }
// Deprecated since `v2026.4.2` // Deprecated since `v2026.4.2`
@ -2997,7 +3003,7 @@ async fn recover_account(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest, req: OrganizationUserRecoverAccountRequest,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@ -3012,7 +3018,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization") err!("User to reset isn't member of required organization")
}; };
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found") err!("User not found")
}; };
@ -3025,29 +3031,56 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality"); err!("Organization user must be confirmed for password reset functionality");
} }
// Sending email before resetting password to ensure working email configuration and the resulting let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
// user notification. Also this might add some protection against security flaws and misuse TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { } else {
false
};
// Sending email first 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_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
err!(format!("Error sending user reset password email: {e:#?}")); err!(format!("Error sending user reset password email: {e:#?}"));
} }
let mut user = user; if req.reset_master_password {
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?; .await?;
}
}
user.save(&conn).await?; user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await; nt.send_logout(&user, None, &conn).await;
log_event( if req.reset_master_password {
EventType::OrganizationUserAdminResetPassword, headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
&member_id, }
&org_id,
&headers.user.uuid, if req.reset_two_factor {
headers.device.atype, headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
&headers.ip.ip, }
&conn,
)
.await;
Ok(()) Ok(())
} }

15
src/api/core/two_factor/email.rs

@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email { let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else { let Some(user) = User::find_by_mail(email, &conn).await else {
err!("Username or password is incorrect. Try again.") err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
}; };
if let Some(master_password_hash) = master_password_hash { if let Some(master_password_hash) = master_password_hash {
// Check password // Check password
if !user.check_valid_password(master_password_hash) { if !user.check_valid_password(master_password_hash) {
err!("Username or password is incorrect. Try again.") err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
} }
} else if let Some(auth_request_id) = auth_request_id { } else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@ -96,7 +102,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
}; };
// SSO login only sends device id, so we get the user by the most recently used device // SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.") err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
}; };
user user

5
src/api/core/two_factor/mod.rs

@ -16,8 +16,8 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactorIncomplete, TwoFactorType, User, UserId, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
}, },
mail, mail,
@ -151,6 +151,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?; twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
} }

14
src/api/identity.rs

@ -905,6 +905,12 @@ async fn twofactor_auth(
// Remove all twofactors from the user // Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?; TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
@ -1050,13 +1056,13 @@ async fn json_err_twofactor(
} }
#[post("/accounts/prelogin", data = "<data>")] #[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, conn).await prelogin(data, ip, conn).await
} }
#[post("/accounts/prelogin/password", data = "<data>")] #[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> { async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, conn).await prelogin(data, ip, conn).await
} }
#[post("/accounts/register", data = "<data>")] #[post("/accounts/register", data = "<data>")]

12
src/auth.rs

@ -23,14 +23,14 @@ use rocket::{
use crate::{ use crate::{
CONFIG, CONFIG,
api::ApiResult, api::{ApiResult, core::log_event},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbConn,
models::{ models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendId, User, UserId, UserStampException, SendFileId, SendId, User, UserId, UserStampException,
}, },
}, },
error::Error, error::Error,
@ -822,6 +822,12 @@ pub struct AdminHeaders {
pub org_id: OrganizationId, pub org_id: OrganizationId,
} }
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders { impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str; type Error = &'static str;

3
src/config.rs

@ -1427,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"desktop-ui-migration-milestone-4", "desktop-ui-migration-milestone-4",
// Auth Team // Auth Team
"pm-5594-safari-account-switching", "pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team // Autofill Team
"ssh-agent", "ssh-agent",
"ssh-agent-v2", "ssh-agent-v2",
@ -1746,7 +1747,7 @@ where
reg!("email/email_footer"); reg!("email/email_footer");
reg!("email/email_footer_text"); reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html"); reg!("email/admin_account_recovery", ".html");
reg!("email/change_email_existing", ".html"); reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html"); reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");

12
src/db/models/device.rs

@ -266,10 +266,22 @@ impl Device {
let devices = Self::find_by_user(user_uuid, conn).await; let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices { for mut device in devices {
device.refresh_token = Device::generate_refresh_token(); device.refresh_token = Device::generate_refresh_token();
device.twofactor_remember = None;
device.save(false, conn).await?; device.save(false, conn).await?;
} }
Ok(()) Ok(())
} }
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table)
.filter(devices::user_uuid.eq(user_uuid))
.set(devices::twofactor_remember.eq::<Option<String>>(None))
.execute(conn)
.map_res("Error removing two factor remember tokens")
})
.await
}
} }
#[derive(Display)] #[derive(Display)]

8
src/db/models/event.rs

@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>, pub provider_org_uuid: Option<String>,
} }
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs // Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub enum EventType { pub enum EventType {
// User // User
@ -108,6 +108,12 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514, OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
// OrganizationUserNotificationBannerActionClicked = 1522,
// Organization // Organization
OrganizationUpdated = 1600, OrganizationUpdated = 1600,

14
src/mail.rs

@ -633,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await 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 { pub async fn send_admin_account_recovery(
address: &str,
user_name: &str,
org_name: &str,
reset_password: bool,
reset_2fa: bool,
fallback_2fa_email: bool,
) -> EmptyResult {
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/admin_reset_password", "email/admin_account_recovery",
json!({ json!({
"url": CONFIG.domain(), "url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(), "img_src": CONFIG._smtp_img_src(),
"user_name": user_name, "user_name": user_name,
"org_name": org_name, "org_name": org_name,
"reset_password": reset_password,
"reset_2fa": reset_2fa,
"fallback_2fa_email": fallback_2fa_email,
}), }),
)?; )?;
send_email(address, &subject, body_html, body_text).await send_email(address, &subject, body_html, body_text).await

12
src/static/templates/email/admin_account_recovery.hbs

@ -0,0 +1,12 @@
Admin account recovery from {{org_name}} organization
<!---------------->
{{#if reset_password}}
The master password for {{user_name}} has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

11
src/static/templates/email/admin_reset_password.html.hbs → src/static/templates/email/admin_account_recovery.html.hbs

@ -1,10 +1,17 @@
Master Password Has Been Changed Admin account recovery from {{org_name}} organization
<!----------------> <!---------------->
{{> email/email_header }} {{> 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;"> <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;"> <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"> <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. {{#if reset_password}}
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.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
<br>
If you did not initiate this request, please reach out to your administrator immediately.
</td> </td>
</tr> </tr>
</table> </table>

4
src/static/templates/email/admin_reset_password.hbs

@ -1,4 +0,0 @@
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.
{{> email/email_footer_text }}
Loading…
Cancel
Save