From b953411dd51bac05aba8821c1037bde0039b7e47 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Wed, 26 Nov 2025 01:35:02 +0100 Subject: [PATCH 1/6] fix email as 2fa for sso --- src/api/core/two_factor/email.rs | 45 ++++++++++++++++++++------------ src/api/identity.rs | 7 +++++ src/db/models/user.rs | 16 ++++++++++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index cc6909af..930611c7 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -10,7 +10,7 @@ use crate::{ auth::Headers, crypto, db::{ - models::{EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, }, error::{Error, MapResult}, @@ -24,10 +24,12 @@ pub fn routes() -> Vec { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailLoginData { + #[serde(alias = "DeviceIdentifier")] + device_identifier: DeviceId, #[serde(alias = "Email")] - email: String, + email: Option, #[serde(alias = "MasterPasswordHash")] - master_password_hash: String, + master_password_hash: Option, } /// User is trying to login and wants to use email 2FA. @@ -36,25 +38,36 @@ struct SendEmailLoginData { async fn send_email_login(data: Json, conn: DbConn) -> EmptyResult { let data: SendEmailLoginData = data.into_inner(); - use crate::db::models::User; - - // Get the user - let Some(user) = User::find_by_mail(&data.email, &conn).await else { - err!("Username or password is incorrect. Try again.") - }; - if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - // Check password - if !user.check_valid_password(&data.master_password_hash) { - err!("Username or password is incorrect. Try again.") - } + // Get the user + let user = if let Some(email) = &data.email { + let Some(master_password_hash) = &data.master_password_hash else { + err!("No password hash has been submitted.") + }; - send_token(&user.uuid, &conn).await?; + let Some(user) = User::find_by_mail(email, &conn).await else { + err!("Username or password is incorrect. Try again.") + }; - Ok(()) + // Check password + if !user.check_valid_password(master_password_hash) { + err!("Username or password is incorrect. Try again.") + } + + user + } else { + // SSO login only sends device id, so we get the user by the most recently used device + let Some(user) = User::find_by_device(&data.device_identifier, &conn).await else { + err!("Username or password is incorrect. Try again.") + }; + + user + }; + + send_token(&user.uuid, &conn).await } /// Generate the token, save the data for later verification and send email to user diff --git a/src/api/identity.rs b/src/api/identity.rs index 92b6c1e4..6c1c8bb1 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -267,6 +267,10 @@ async fn _sso_login( } Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; + + // Save to update `device.updated_at` to track usage and toggle new status + device.save(conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; if user.private_key.is_none() { @@ -431,6 +435,9 @@ async fn _password_login( let mut device = get_device(&data, conn, &user).await?; + // Save to update `device.updated_at` to track usage and toggle new status + device.save(conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); diff --git a/src/db/models/user.rs b/src/db/models/user.rs index c7f4e1bc..d2de7a21 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,4 +1,4 @@ -use crate::db::schema::{invitations, sso_users, users}; +use crate::db::schema::{devices, invitations, sso_users, users}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; @@ -10,7 +10,7 @@ use super::{ use crate::{ api::EmptyResult, crypto, - db::DbConn, + db::{models::DeviceId, DbConn}, error::MapResult, sso::OIDCIdentifier, util::{format_date, get_uuid, retry}, @@ -386,6 +386,18 @@ impl User { }} } + pub async fn find_by_device(device_uuid: &DeviceId, conn: &DbConn) -> Option { + db_run! { conn: { + users::table + .inner_join(devices::table.on(devices::user_uuid.eq(users::uuid))) + .filter(devices::uuid.eq(device_uuid)) + .select(users::all_columns) + .order_by(devices::updated_at.desc()) + .first::(conn) + .ok() + }} + } + pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option)> { db_run! { conn: { users::table From 293e1b8beba471e09cab4f36a8495b6e0c4ae2ce Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 28 Nov 2025 19:47:34 +0100 Subject: [PATCH 2/6] allow saving device without updating `updated_at` --- src/api/core/accounts.rs | 2 +- src/api/identity.rs | 37 +++++++++++++++----------- src/api/push.rs | 2 +- src/auth.rs | 2 +- src/db/models/device.rs | 56 +++++++++++++++++++--------------------- 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 536564d4..b9c47507 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1409,7 +1409,7 @@ async fn put_device_token(device_id: DeviceId, data: Json, headers: H } device.push_token = Some(token); - if let Err(e) = device.save(&conn).await { + if let Err(e) = device.save(true, &conn).await { err!(format!("An error occurred while trying to save the device push token: {e}")); } diff --git a/src/api/identity.rs b/src/api/identity.rs index 6c1c8bb1..42a3fc56 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDateTime, Utc}; +use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ form::{Form, FromForm}, @@ -147,7 +147,7 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json } Ok((mut device, auth_tokens)) => { // Save to update `device.updated_at` to track usage and toggle new status - device.save(conn).await?; + device.save(true, conn).await?; let result = json!({ "refresh_token": auth_tokens.refresh_token(), @@ -267,9 +267,10 @@ async fn _sso_login( } Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; - - // Save to update `device.updated_at` to track usage and toggle new status - device.save(conn).await?; + if !device.is_new() { + // Update `device.updated_at` only if it's not a new device + device.save(true, conn).await?; + } let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; @@ -317,7 +318,7 @@ async fn _sso_login( auth_user.expires_in, )?; - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } async fn _password_login( @@ -434,15 +435,16 @@ async fn _password_login( } let mut device = get_device(&data, conn, &user).await?; - - // Save to update `device.updated_at` to track usage and toggle new status - device.save(conn).await?; + if !device.is_new() { + // Update `device.updated_at` only if it's not a new device + device.save(true, conn).await?; + } let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } async fn authenticated_response( @@ -450,12 +452,12 @@ async fn authenticated_response( device: &mut Device, auth_tokens: auth::AuthTokens, twofactor_token: Option, - now: &NaiveDateTime, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { if CONFIG.mail_enabled() && device.is_new() { - if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await { + let now = Utc::now().naive_utc(); + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, device).await { error!("Error sending new device email: {e:#?}"); if CONFIG.require_device_email() { @@ -475,7 +477,7 @@ async fn authenticated_response( } // Save to update `device.updated_at` to track usage and toggle new status - device.save(conn).await?; + device.save(true, conn).await?; let master_password_policy = master_password_policy(user, conn).await; @@ -592,7 +594,7 @@ async fn _user_api_key_login( let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id); // Save to update `device.updated_at` to track usage and toggle new status - device.save(conn).await?; + device.save(true, conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); @@ -655,7 +657,12 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult // Find device or create new match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { Some(device) => Ok(device), - None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await, + None => { + let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type); + // save device without updating `device.updated_at` + device.save(false, conn).await?; + Ok(device) + } } } diff --git a/src/api/push.rs b/src/api/push.rs index 4394e7d2..a7e88455 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -128,7 +128,7 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe err!(format!("An error occurred while proceeding registration of a device: {e}")); } - if let Err(e) = device.save(conn).await { + if let Err(e) = device.save(true, conn).await { err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}")); } diff --git a/src/auth.rs b/src/auth.rs index e10de615..6360aaf6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1223,7 +1223,7 @@ pub async fn refresh_tokens( }; // Save to update `updated_at`. - device.save(conn).await?; + device.save(true, conn).await?; let user = match User::find_by_uuid(&device.user_uuid, conn).await { None => err!("Impossible to find user"), diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 0d86870f..5f54ac1d 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -35,6 +35,25 @@ pub struct Device { /// Local methods impl Device { + pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self { + let now = Utc::now().naive_utc(); + + Self { + uuid, + created_at: now, + updated_at: now, + + user_uuid, + name, + atype, + + push_uuid: Some(PushId(get_uuid())), + push_token: None, + refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL), + twofactor_remember: None, + } + } + pub fn to_json(&self) -> Value { json!({ "id": self.uuid, @@ -110,38 +129,21 @@ impl DeviceWithAuthRequest { } use crate::db::DbConn; -use crate::api::{ApiResult, EmptyResult}; +use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Device { - pub async fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32, conn: &DbConn) -> ApiResult { - let now = Utc::now().naive_utc(); - - let device = Self { - uuid, - created_at: now, - updated_at: now, - - user_uuid, - name, - atype, - - push_uuid: Some(PushId(get_uuid())), - push_token: None, - refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL), - twofactor_remember: None, - }; - - device.inner_save(conn).await.map(|()| device) - } + pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult { + if update_time { + self.updated_at = Utc::now().naive_utc(); + } - async fn inner_save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { crate::util::retry(|| diesel::replace_into(devices::table) - .values(self) + .values(&*self) .execute(conn), 10, ).map_res("Error saving device") @@ -149,7 +151,7 @@ impl Device { postgresql { crate::util::retry(|| diesel::insert_into(devices::table) - .values(self) + .values(&*self) .on_conflict((devices::uuid, devices::user_uuid)) .do_update() .set(self) @@ -160,12 +162,6 @@ impl Device { } } - // Should only be called after user has passed authentication - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.updated_at = Utc::now().naive_utc(); - self.inner_save(conn).await - } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) From 531bf440b6b2ca61aac6d62903cad4ddba936697 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 2 Dec 2025 03:14:58 +0100 Subject: [PATCH 3/6] check if email is some --- src/api/core/two_factor/email.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 930611c7..fe1b9a8d 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -43,7 +43,11 @@ async fn send_email_login(data: Json, conn: DbConn) -> Empty } // Get the user - let user = if let Some(email) = &data.email { + let email = match &data.email { + Some(email) if !email.is_empty() => Some(email), + _ => None, + }; + let user = if let Some(email) = email { let Some(master_password_hash) = &data.master_password_hash else { err!("No password hash has been submitted.") }; From 7b56458e89d966bb1b7af83fa9419e9b02c99901 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 2 Dec 2025 03:23:00 +0100 Subject: [PATCH 4/6] allow device to be saved in postgresql --- src/db/models/device.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 5f54ac1d..4e3d0197 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -154,7 +154,7 @@ impl Device { .values(&*self) .on_conflict((devices::uuid, devices::user_uuid)) .do_update() - .set(self) + .set(&*self) .execute(conn), 10, ).map_res("Error saving device") From c5fe9a134fbee7625f4c4ab614ce280c13dfefbe Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 2 Dec 2025 03:55:02 +0100 Subject: [PATCH 5/6] use twofactor_incomplete table --- src/api/core/two_factor/email.rs | 2 +- src/db/models/user.rs | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index fe1b9a8d..b8724cf1 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -64,7 +64,7 @@ async fn send_email_login(data: Json, conn: DbConn) -> Empty user } else { // SSO login only sends device id, so we get the user by the most recently used device - let Some(user) = User::find_by_device(&data.device_identifier, &conn).await else { + let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { err!("Username or password is incorrect. Try again.") }; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index d2de7a21..c96e0fe7 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,4 +1,4 @@ -use crate::db::schema::{devices, invitations, sso_users, users}; +use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; @@ -386,16 +386,18 @@ impl User { }} } - pub async fn find_by_device(device_uuid: &DeviceId, conn: &DbConn) -> Option { - db_run! { conn: { - users::table - .inner_join(devices::table.on(devices::user_uuid.eq(users::uuid))) - .filter(devices::uuid.eq(device_uuid)) - .select(users::all_columns) - .order_by(devices::updated_at.desc()) - .first::(conn) + pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option { + if let Some(user_uuid) = db_run! ( conn: { + twofactor_incomplete::table + .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) + .order_by(twofactor_incomplete::login_time.desc()) + .select(twofactor_incomplete::user_uuid) + .first::(conn) .ok() - }} + }) { + return Self::find_by_uuid(&user_uuid, conn).await; + } + None } pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option)> { From 72a83130ae88f74afece62958fc7d83108bfa0f7 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 2 Dec 2025 03:57:11 +0100 Subject: [PATCH 6/6] no need to update device.updated_at --- src/api/identity.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 42a3fc56..5f5ba327 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -267,10 +267,6 @@ async fn _sso_login( } Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; - if !device.is_new() { - // Update `device.updated_at` only if it's not a new device - device.save(true, conn).await?; - } let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; @@ -435,10 +431,6 @@ async fn _password_login( } let mut device = get_device(&data, conn, &user).await?; - if !device.is_new() { - // Update `device.updated_at` only if it's not a new device - device.save(true, conn).await?; - } let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;