From 16b8a58eec848b925214f737ca92007e73b12d76 Mon Sep 17 00:00:00 2001 From: Timshel Date: Fri, 18 Sep 2026 18:49:55 +0200 Subject: [PATCH] Improve and expand kdf_upgrade --- src/api/core/accounts.rs | 25 +++++--------------- src/api/core/ciphers.rs | 6 ++--- src/api/core/organizations.rs | 20 +++++++++------- src/api/core/two_factor/authenticator.rs | 4 ++-- src/api/core/two_factor/duo.rs | 4 ++-- src/api/core/two_factor/email.rs | 4 ++-- src/api/core/two_factor/mod.rs | 5 ++-- src/api/core/two_factor/webauthn.rs | 4 ++-- src/api/core/two_factor/yubikey.rs | 5 ++-- src/api/identity.rs | 4 ++-- src/api/mod.rs | 4 +++- src/db/models/user.rs | 30 ++++++++++++++++++++++++ 12 files changed, 69 insertions(+), 46 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69918212..71f73fd3 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -11,7 +11,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + AnonymousNotify, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, }, @@ -1086,7 +1086,7 @@ async fn post_sstamp(data: Json, headers: Headers, conn: DbCo let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; user.reset_security_stamp(&conn).await?; let save_result = user.save(&conn).await; @@ -1344,9 +1344,9 @@ async fn post_delete_account( #[delete("/accounts", data = "")] async fn delete_account(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; // The devices are deleted together with the user, so check for push devices beforehand let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; @@ -1472,19 +1472,6 @@ struct SecretVerificationRequest { master_password_hash: String, } -// Change the KDF Iterations if necessary -pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> { - if user.password_iterations < CONFIG.password_iterations() { - user.password_iterations = CONFIG.password_iterations(); - user.set_password(pwd_hash, None, false, None, conn).await?; - - if let Err(e) = user.save(conn).await { - error!("Error updating user: {e:#?}"); - } - } - Ok(()) -} - #[post("/accounts/verify-password", data = "")] async fn verify_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); @@ -1494,7 +1481,7 @@ async fn verify_password(data: Json, headers: Headers err!("Invalid password") } - kdf_upgrade(&mut user, &data.master_password_hash, &conn).await?; + user.check_persist_kdf_upgrade(&data.master_password_hash, &conn).await?; Ok(Json(master_password_policy(&user, &conn).await)) } @@ -1503,7 +1490,7 @@ async fn update_api_key(data: Json, rotate: bool, headers: He let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; if rotate || user.api_key.is_none() { user.api_key = Some(crypto::generate_api_key()); diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 97f5fd49..1afe2db3 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1812,9 +1812,9 @@ async fn purge_org_vault( } let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await { Some(member) if member.atype == MembershipType::Owner => { @@ -1848,7 +1848,7 @@ async fn purge_personal_vault( let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await { cipher.delete(&conn).await?; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 607db45b..89621a52 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -225,12 +225,14 @@ async fn delete_organization( headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { + let mut user = headers.user; + if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); - data.validate(&headers.user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; match Organization::find_by_uuid(&org_id, &conn).await { None => err!("Organization not found"), @@ -3090,12 +3092,13 @@ async fn put_reset_password_enrollment( data: Json, conn: DbConn, ) -> EmptyResult { - if user_id != headers.user.uuid { + let mut membership = headers.membership; + let mut user = headers.user; + + if user_id != user.uuid { err!("User to enroll isn't member of required organization", "The user_id and acting user do not match"); } - let mut membership = headers.membership; - check_reset_password_applicable(&org_id, &conn).await?; let reset_request = data.into_inner(); @@ -3115,7 +3118,7 @@ async fn put_reset_password_enrollment( master_password_hash: reset_request.master_password_hash, otp: reset_request.otp, } - .validate(&headers.user, true, &conn) + .validate(&mut user, true, &conn) .await?; } @@ -3128,8 +3131,7 @@ async fn put_reset_password_enrollment( EventType::OrganizationUserResetPasswordWithdraw }; - log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_event(event_type, &membership.uuid, &org_id, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(()) } @@ -3163,10 +3165,10 @@ async fn api_key( err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; // Validate the owner users password/otp - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { if rotate { diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 55984443..16ae1019 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -20,9 +20,9 @@ pub fn routes() -> Vec { #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let user = headers.user; + let mut user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&mut user, false, &conn).await?; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 23116b2c..69b6734b 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -43,9 +43,9 @@ const DISABLED_MESSAGE_DEFAULT: &str = ", headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&mut user, false, &conn).await?; let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { DuoStatus::Global(_) => (true, Some(DuoData::secret())), diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 15bfdbc3..0de1e2bb 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -163,9 +163,9 @@ pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { #[post("/two-factor/get-email", data = "")] async fn get_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&mut user, false, &conn).await?; let (enabled, mfa_email) = if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index cfec98c7..6bf2c8ec 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -104,9 +104,9 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { #[post("/two-factor/get-recover", data = "")] async fn get_recover(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&mut user, true, &conn).await?; Ok(Json(json!({ "code": user.totp_recover, @@ -129,6 +129,7 @@ pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAdd if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() { enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?; } + Ok(()) } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 7eab5546..b432f10a 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -123,9 +123,9 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC } let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&mut user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; let keys: Vec = registrations.iter().map(|r| r.id).collect(); diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 17c4e9a0..e9fcee41 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -122,9 +122,9 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: get_yubico_credentials()?; let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; + let mut user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&mut user, false, &conn).await?; let user_id = &user.uuid; @@ -148,6 +148,7 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); + let yubikeys = parse_yubikeys(&data); let mut user = headers.user; diff --git a/src/api/identity.rs b/src/api/identity.rs index 4608ed77..946c5c1f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -14,7 +14,7 @@ use crate::{ api::{ ApiResult, EmptyResult, JsonResult, core::{ - accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, + accounts::{PreloginData, RegisterData, prelogin, register}, log_user_event, two_factor::{ authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, @@ -440,7 +440,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { - kdf_upgrade(&mut user, password, conn).await?; + user.check_persist_kdf_upgrade(password, conn).await?; } let now = Utc::now().naive_utc(); diff --git a/src/api/mod.rs b/src/api/mod.rs index 450c2113..e32eca65 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -58,7 +58,7 @@ impl PasswordOrOtpData { /// Tokens used via this struct can be used multiple times during the process /// First for the validation to continue, after that to enable or validate the following actions /// This is different per caller, so it can be adjusted to delete the token or not - pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult { + pub async fn validate(&self, user: &mut User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult { use crate::api::core::two_factor::protected_actions::validate_protected_action_otp; match (self.master_password_hash.as_deref(), self.otp.as_deref()) { @@ -66,6 +66,8 @@ impl PasswordOrOtpData { if !user.check_valid_password(pw_hash) { err!("Invalid password"); } + + user.check_persist_kdf_upgrade(pw_hash, conn).await?; } (None, Some(otp)) => { validate_protected_action_otp(otp, &user.uuid, delete_if_valid, conn).await?; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 30541428..c9c00313 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -2,6 +2,7 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; +use std::cmp; use crate::{ CONFIG, @@ -206,6 +207,7 @@ impl User { allow_next_route: Option>, conn: &DbConn, ) -> EmptyResult { + self.password_iterations = cmp::max(self.password_iterations, CONFIG.password_iterations()); self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations.cast_unsigned()); @@ -362,6 +364,34 @@ impl User { } } + pub async fn check_persist_kdf_upgrade(&mut self, pwd_hash: &str, conn: &DbConn) -> EmptyResult { + if self.password_iterations < CONFIG.password_iterations() { + let old_iteration = self.password_iterations; + let old_hash = self.password_hash.clone(); + + self.set_password(pwd_hash, None, false, None, conn).await?; + + conn.run(move |conn| { + diesel::update( + users::table + .filter(users::uuid.eq(&self.uuid)) + .filter(users::password_iterations.eq(old_iteration)) + .filter(users::password_hash.eq(old_hash)), + ) + .set(( + users::updated_at.eq(Utc::now().naive_utc()), + users::password_iterations.eq(&self.password_iterations), + users::password_hash.eq(&self.password_hash), + )) + .execute(conn) + .map_res("Error updating user KDF iterations") + }) + .await + } else { + Ok(()) + } + } + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await { if member.atype == MembershipType::Owner