From 57eb05cb548c832647caf41e0f44950460f14acb Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:43:31 +0200 Subject: [PATCH 1/2] Add new device verification --- .env.template | 6 + Cargo.toml | 2 +- .../down.sql | 1 + .../up.sql | 2 + .../down.sql | 1 + .../up.sql | 2 + .../down.sql | 1 + .../up.sql | 2 + src/api/core/two_factor/mod.rs | 18 +- .../two_factor/new_device_verification.rs | 491 ++++++++++++++++++ src/api/identity.rs | 22 +- src/config.rs | 11 + src/db/models/two_factor.rs | 2 + src/db/models/user.rs | 5 + src/db/schema.rs | 1 + src/mail.rs | 25 + .../email/new_device_verification.hbs | 12 + .../email/new_device_verification.html.hbs | 36 ++ 18 files changed, 627 insertions(+), 13 deletions(-) create mode 100644 migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql create mode 100644 migrations/mysql/2026-09-05-120000_add_verify_devices/up.sql create mode 100644 migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql create mode 100644 migrations/postgresql/2026-09-05-120000_add_verify_devices/up.sql create mode 100644 migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql create mode 100644 migrations/sqlite/2026-09-05-120000_add_verify_devices/up.sql create mode 100644 src/api/core/two_factor/new_device_verification.rs create mode 100644 src/static/templates/email/new_device_verification.hbs create mode 100644 src/static/templates/email/new_device_verification.html.hbs diff --git a/.env.template b/.env.template index 62231776..a47389f4 100644 --- a/.env.template +++ b/.env.template @@ -415,6 +415,12 @@ ## If sending the email fails the login attempt will fail!! # REQUIRE_DEVICE_EMAIL=false +## New device verification (Bitwarden "New device login protection"). +## Users without 2FA logging in from a device that is not known yet must first enter a code sent to +## their account email address. Users can turn this off for their own account in the web vault. +## Requires a mail transport to be configured, otherwise the server refuses to start. +# NEW_DEVICE_VERIFICATION=false + ## Enable extended logging, which shows timestamps and targets in the logs # EXTENDED_LOGGING=true diff --git a/Cargo.toml b/Cargo.toml index 7dcae503..a419b12b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } +diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } diesel_migrations = "2.3.2" derive_more = { version = "2.1.1", features = [ diff --git a/migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql b/migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql new file mode 100644 index 00000000..463d66c0 --- /dev/null +++ b/migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN verify_devices; diff --git a/migrations/mysql/2026-09-05-120000_add_verify_devices/up.sql b/migrations/mysql/2026-09-05-120000_add_verify_devices/up.sql new file mode 100644 index 00000000..af4b9d41 --- /dev/null +++ b/migrations/mysql/2026-09-05-120000_add_verify_devices/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql b/migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql new file mode 100644 index 00000000..463d66c0 --- /dev/null +++ b/migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN verify_devices; diff --git a/migrations/postgresql/2026-09-05-120000_add_verify_devices/up.sql b/migrations/postgresql/2026-09-05-120000_add_verify_devices/up.sql new file mode 100644 index 00000000..af4b9d41 --- /dev/null +++ b/migrations/postgresql/2026-09-05-120000_add_verify_devices/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql b/migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql new file mode 100644 index 00000000..463d66c0 --- /dev/null +++ b/migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN verify_devices; diff --git a/migrations/sqlite/2026-09-05-120000_add_verify_devices/up.sql b/migrations/sqlite/2026-09-05-120000_add_verify_devices/up.sql new file mode 100644 index 00000000..5dde56f0 --- /dev/null +++ b/migrations/sqlite/2026-09-05-120000_add_verify_devices/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT 1; -- TRUE diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 0eb6563e..077733ab 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -28,6 +28,7 @@ pub mod authenticator; pub mod duo; pub mod duo_oidc; pub mod email; +pub mod new_device_verification; pub mod protected_actions; pub mod webauthn; pub mod yubikey; @@ -64,6 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data | TwoFactorType::EmailVerificationChallenge | TwoFactorType::WebauthnRegisterChallenge | TwoFactorType::WebauthnLoginChallenge + | TwoFactorType::NewDeviceVerification | TwoFactorType::ProtectedActions => false, } } @@ -83,6 +85,7 @@ pub fn routes() -> Vec { routes.append(&mut webauthn::routes()); routes.append(&mut yubikey::routes()); routes.append(&mut protected_actions::routes()); + routes.append(&mut new_device_verification::routes()); routes } @@ -276,20 +279,15 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { } } -// This function currently is just a dummy and the actual part is not implemented yet. -// This also prevents 404 errors. +// Kept to prevent 404 errors, current clients read `verifyDevices` from the profile and change it +// via `/api/accounts/verify-devices`. See `new_device_verification` for the details. // // See the following Bitwarden PR's regarding this feature. // https://github.com/bitwarden/clients/pull/2843 // https://github.com/bitwarden/clients/pull/2839 // https://github.com/bitwarden/server/pull/2016 -// -// The HTML part is hidden via the CSS patches done via the bw_web_build repo #[get("/two-factor/get-device-verification-settings")] -fn get_device_verification_settings(_headers: Headers, _conn: DbConn) -> Json { - Json(json!({ - "isDeviceVerificationSectionEnabled":false, - "unknownDeviceVerificationEnabled":false, - "object":"deviceVerificationSettings" - })) +fn get_device_verification_settings(headers: Headers) -> Json { + let user = headers.user; + Json(new_device_verification::device_verification_settings(&user)) } diff --git a/src/api/core/two_factor/new_device_verification.rs b/src/api/core/two_factor/new_device_verification.rs new file mode 100644 index 00000000..b0f23490 --- /dev/null +++ b/src/api/core/two_factor/new_device_verification.rs @@ -0,0 +1,491 @@ +//! New device verification, the Bitwarden "New device login protection" feature. +//! +//! A password login from a device that is not known yet first has to be confirmed with a code that +//! is mailed to the account address. The device is only stored once that code was accepted, so a +//! correct master password on its own never turns an unknown device into a known one. +//! +//! Reference: + +use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds}; +use rocket::{Route, serde::json::Json}; +use serde_json::Value; + +use crate::{ + CONFIG, + api::{EmptyResult, PasswordOrOtpData}, + auth::{ClientIp, Headers}, + crypto, + db::{ + DbConn, + models::{Device, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, + }, + error::{Error, ErrorEvent}, + mail, +}; + +pub fn routes() -> Vec { + routes![resend_new_device_otp, put_verify_devices, post_verify_devices] +} + +/// Accounts younger than this are exempt upstream. +const NEW_ACCOUNT_EXEMPTION_HOURS: i64 = 24; + +/// Minimum time between two verification mails, so repeated logins cannot flood a mailbox. +/// Matches the protected actions resend delay. +const RESEND_DELAY_SECONDS: i64 = 30; + +/// Data stored in the `twofactor` table under [`TwoFactorType::NewDeviceVerification`]. Only read +/// and written here, so a code issued for a new device can never authorize anything else. +#[derive(Debug, Serialize, Deserialize)] +pub struct NewDeviceVerificationData { + /// Code the user has to send back as `NewDeviceOtp`. + pub token: String, + #[serde(with = "ts_seconds")] + pub token_sent: NaiveDateTime, + /// Failed validation attempts for the current token. + pub attempts: u64, +} + +impl NewDeviceVerificationData { + fn new(token: String) -> Self { + Self { + token, + token_sent: Utc::now().naive_utc(), + attempts: 0, + } + } + + fn to_json(&self) -> String { + serde_json::to_string(&self).unwrap() + } + + fn from_json(string: &str) -> Result { + if let Ok(data) = serde_json::from_str(string) { + Ok(data) + } else { + err!("Could not decode NewDeviceVerificationData from string") + } + } + + fn add_attempt(&mut self) { + self.attempts = self.attempts.saturating_add(1); + } + + fn time_since_sent(&self) -> TimeDelta { + Utc::now().naive_utc() - self.token_sent + } + + fn is_expired(&self, max_age_seconds: i64) -> bool { + self.time_since_sent().num_seconds() > max_age_seconds + } +} + +/// Everything the decision in [`new_device_action`] depends on. +#[expect(clippy::struct_excessive_bools, reason = "Every condition upstream checks, kept separate to stay testable")] +#[derive(Clone, Copy)] +pub struct NewDeviceState { + pub enforced: bool, + pub verify_devices: bool, + /// The account is younger than the Bitwarden exemption period. + pub recently_created: bool, + pub has_two_factor: bool, + pub known_device: bool, + pub has_devices: bool, + /// A `NewDeviceOtp` field was sent, an empty one included. + pub otp_supplied: bool, + pub otp_not_empty: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewDeviceAction { + /// Continue the login unchanged. + Skip, + /// Validate the supplied `NewDeviceOtp` before continuing. + Verify, + /// Mail a code and reject this login attempt. + Challenge, +} + +/// Mirrors `DeviceValidator.HandleNewDeviceVerificationAsync` of the Bitwarden server. +pub fn new_device_action(state: NewDeviceState) -> NewDeviceAction { + // A code implies an unknown device, upstream skips the lookup for it. + if !state.otp_not_empty && state.known_device { + return NewDeviceAction::Skip; + } + + // Upstream skips device verification for 2FA users entirely, they keep their existing flow. + if !state.enforced || !state.verify_devices || state.recently_created || state.has_two_factor { + return NewDeviceAction::Skip; + } + + // An empty code counts as a wrong code upstream. + if state.otp_supplied { + return NewDeviceAction::Verify; + } + + // A user without any device is a freshly registered user. + if !state.has_devices { + return NewDeviceAction::Skip; + } + + NewDeviceAction::Challenge +} + +/// The clients match `ErrorModel.Message` literally to switch to their new device verification +/// screen and show `error_description`. See `api.service.ts` and +/// `new-device-verification.component.ts` in `bitwarden/clients`. +fn verification_required_error() -> Error { + let body = json!({ + "error": "device_error", + "error_description": "New device verification required", + "ErrorModel": { + "Message": "new device verification required", + "Object": "error" + } + }); + Error::from(("New device verification required", body)).with_event(ErrorEvent { + event: EventType::UserFailedLogIn, + }) +} + +fn invalid_otp_error() -> Error { + let body = json!({ + "error": "device_error", + "error_description": "Invalid New Device OTP", + "ErrorModel": { + "Message": "invalid new device otp", + "Object": "error" + } + }); + Error::from(("Invalid new device OTP", body)).with_event(ErrorEvent { + event: EventType::UserFailedLogIn, + }) +} + +/// Runs new device verification for a password login, before the device is stored. `Ok(())` means +/// the login may continue, an error carries the response the Bitwarden clients expect. +pub async fn validate_new_device_login( + user: &mut User, + device_id: &DeviceId, + device_type: i32, + new_device_otp: Option<&str>, + is_auth_request: bool, + ip: &ClientIp, + conn: &DbConn, +) -> EmptyResult { + // Login with device re-uses the password grant but is only ever approved from a known device. + let enforced = CONFIG.new_device_verification() && CONFIG.mail_enabled() && !is_auth_request; + + let recently_created = Utc::now().naive_utc() - user.created_at < TimeDelta::hours(NEW_ACCOUNT_EXEMPTION_HOURS); + + // Skip the extra queries when the feature cannot apply anyway. Every condition here also + // makes `new_device_action` return `Skip`. + if !enforced || !user.verify_devices || recently_created { + return Ok(()); + } + + let devices = Device::find_by_user(&user.uuid, conn).await; + let state = NewDeviceState { + enforced, + verify_devices: user.verify_devices, + recently_created, + has_two_factor: !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(), + known_device: devices.iter().any(|d| &d.uuid == device_id), + has_devices: !devices.is_empty(), + otp_supplied: new_device_otp.is_some(), + otp_not_empty: new_device_otp.is_some_and(|otp| !otp.is_empty()), + }; + + match new_device_action(state) { + NewDeviceAction::Skip => Ok(()), + NewDeviceAction::Verify => { + validate_otp(new_device_otp.unwrap_or_default(), &user.uuid, conn).await?; + + // The user proved access to their mailbox, so upstream marks the address as verified. + if user.verified_at.is_none() { + user.verified_at = Some(Utc::now().naive_utc()); + user.save(conn).await?; + } + Ok(()) + } + NewDeviceAction::Challenge => { + send_otp(user, device_type, ip, conn).await?; + Err(verification_required_error()) + } + } +} + +/// Generates and mails a new code, unless a still valid one was sent very recently. +async fn send_otp(user: &User, device_type: i32, ip: &ClientIp, conn: &DbConn) -> EmptyResult { + let type_ = TwoFactorType::NewDeviceVerification as i32; + + if let Some(ref tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, conn).await { + let data = NewDeviceVerificationData::from_json(&tf.data)?; + if !data.is_expired(CONFIG.email_expiration_time().cast_signed()) + && data.time_since_sent().num_seconds() < RESEND_DELAY_SECONDS + { + // Keep the code the user just received valid instead of mailing another one. + return Ok(()); + } + } + + // Saving replaces any previous code, only the most recent one stays valid. + let data = NewDeviceVerificationData::new(crypto::generate_email_token(CONFIG.email_token_size())); + let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::NewDeviceVerification, data.to_json()); + twofactor.save(conn).await?; + + if let Err(e) = + mail::send_new_device_verification(&user.email, &data.token, &ip.ip.to_string(), &data.token_sent, device_type) + .await + { + error!("Error sending new device verification email: {e:#?}"); + // Drop the code that never went out, the resend delay would otherwise suppress the next + // attempt and ask the user for a code they cannot have. + if let Err(e) = twofactor.delete(conn).await { + error!("Error removing the unsent new device verification code: {e:#?}"); + } + err!( + "Could not send the new device verification email. Please contact your administrator.", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + Ok(()) +} + +/// Validates a `NewDeviceOtp` and consumes it when it is correct. +async fn validate_otp(otp: &str, user_id: &UserId, conn: &DbConn) -> EmptyResult { + let type_ = TwoFactorType::NewDeviceVerification as i32; + let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { + return Err(invalid_otp_error()); + }; + + let mut data = NewDeviceVerificationData::from_json(&tf.data)?; + + if data.is_expired(CONFIG.email_expiration_time().cast_signed()) { + tf.delete(conn).await?; + return Err(invalid_otp_error()); + } + + if !crypto::ct_eq(&data.token, otp) { + data.add_attempt(); + if data.attempts >= CONFIG.email_attempts_limit() { + // Force a new code to be requested instead of allowing endless guesses. + tf.delete(conn).await?; + } else { + tf.data = data.to_json(); + tf.save(conn).await?; + } + return Err(invalid_otp_error()); + } + + // Consume the code so it cannot be replayed. + tf.delete(conn).await?; + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResendNewDeviceOtpData { + email: String, + master_password_hash: String, +} + +/// Mirrors `POST /accounts/resend-new-device-otp` upstream, which answers successfully whatever +/// happens so it cannot be used to probe for accounts. +#[post("/accounts/resend-new-device-otp", data = "")] +async fn resend_new_device_otp(data: Json, ip: ClientIp, conn: DbConn) -> EmptyResult { + crate::ratelimit::check_limit_login(&ip.ip)?; + + let data: ResendNewDeviceOtpData = data.into_inner(); + + if !CONFIG.new_device_verification() || !CONFIG.mail_enabled() { + return Ok(()); + } + + let Some(user) = User::find_by_mail(data.email.trim(), &conn).await else { + return Ok(()); + }; + + if !user.enabled || !user.verify_devices || !user.check_valid_password(&data.master_password_hash) { + return Ok(()); + } + + // The device type is not part of this request, `Unknown Browser` matches upstream. + if let Err(e) = send_otp(&user, 14, &ip, &conn).await { + error!("Error resending new device verification code: {e:#?}"); + } + + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetVerifyDevicesData { + #[serde(alias = "MasterPasswordHash")] + master_password_hash: Option, + otp: Option, + #[serde(alias = "VerifyDevices")] + verify_devices: bool, +} + +/// Changes the account setting that controls whether new devices need to be verified. +/// Current clients use `POST`, older ones and the API docs use `PUT`. +#[put("/accounts/verify-devices", data = "")] +async fn put_verify_devices(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + set_verify_devices(data, headers, conn).await +} + +#[post("/accounts/verify-devices", data = "")] +async fn post_verify_devices(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + set_verify_devices(data, headers, conn).await +} + +async fn set_verify_devices(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let data: SetVerifyDevicesData = data.into_inner(); + let mut user = headers.user; + + // Same user verification upstream requires for this setting. + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; + + user.verify_devices = data.verify_devices; + user.save(&conn).await +} + +/// Reports the state of this feature to the pre-2023 web vault, the only client that used it. +/// The section stays disabled because its setter was never part of Vaultwarden, so showing it +/// would only produce a broken toggle. +pub fn device_verification_settings(user: &User) -> Value { + let enabled = CONFIG.new_device_verification() && CONFIG.mail_enabled() && user.verify_devices; + + json!({ + "isDeviceVerificationSectionEnabled": false, + "unknownDeviceVerificationEnabled": enabled, + "object": "deviceVerificationSettings" + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A state in which a login gets challenged, so single fields can be flipped per case. + fn challenged() -> NewDeviceState { + NewDeviceState { + enforced: true, + verify_devices: true, + recently_created: false, + has_two_factor: false, + known_device: false, + has_devices: true, + otp_supplied: false, + otp_not_empty: false, + } + } + + /// Case name, whether a code was sent along, what else differs from a challenged login, outcome. + type Case = (&'static str, bool, fn(&mut NewDeviceState), NewDeviceAction); + + #[test] + fn decision_matches_upstream() { + use NewDeviceAction::{Challenge, Skip, Verify}; + + let cases: [Case; 11] = [ + ("unknown device without 2fa", false, |_| (), Challenge), + ("feature disabled", false, |s| s.enforced = false, Skip), + ("user opted out", false, |s| s.verify_devices = false, Skip), + ("account within the exemption period", false, |s| s.recently_created = true, Skip), + ("2fa configured", false, |s| s.has_two_factor = true, Skip), + ("2fa configured and a code sent", true, |s| s.has_two_factor = true, Skip), + ("known device", false, |s| s.known_device = true, Skip), + ("account without any device", false, |s| s.has_devices = false, Skip), + ("code sent", true, |_| (), Verify), + ("code sent from a known device", true, |s| s.known_device = true, Verify), + ("code sent without any device", true, |s| s.has_devices = false, Verify), + ]; + + for (case, sends_code, setup, expected) in cases { + let mut state = challenged(); + state.otp_supplied = sends_code; + state.otp_not_empty = sends_code; + setup(&mut state); + assert_eq!(new_device_action(state), expected, "{case}"); + } + } + + /// Upstream only skips the known device lookup for a non-empty code, but still treats an empty + /// one as a wrong code. + #[test] + fn empty_code_is_treated_as_a_wrong_code() { + let sent_empty = NewDeviceState { + otp_supplied: true, + ..challenged() + }; + assert_eq!(new_device_action(sent_empty), NewDeviceAction::Verify); + + let known_device = NewDeviceState { + known_device: true, + ..sent_empty + }; + assert_eq!(new_device_action(known_device), NewDeviceAction::Skip); + } + + /// The shortcut in `validate_new_device_login` must never skip a login the decision would challenge. + #[test] + fn shortcut_only_skips_what_the_decision_skips() { + for enforced in [false, true] { + for verify_devices in [false, true] { + for recently_created in [false, true] { + if enforced && verify_devices && !recently_created { + continue; + } + let state = NewDeviceState { + enforced, + verify_devices, + recently_created, + ..challenged() + }; + assert_eq!(new_device_action(state), NewDeviceAction::Skip); + } + } + } + } + + /// The clients compare these strings literally, changing them breaks the flow silently. + #[test] + fn client_matched_response_fields_are_stable() { + let required: Value = serde_json::from_str(&verification_required_error().to_string()).unwrap(); + assert_eq!(required["error"], "device_error"); + assert_eq!(required["error_description"], "New device verification required"); + assert_eq!(required["ErrorModel"]["Message"], "new device verification required"); + // Must not look like a 2FA response, the clients check that first. + assert!(required.get("TwoFactorProviders2").is_none()); + + let invalid: Value = serde_json::from_str(&invalid_otp_error().to_string()).unwrap(); + assert_eq!(invalid["error_description"], "Invalid New Device OTP"); + assert_eq!(invalid["ErrorModel"]["Message"], "invalid new device otp"); + } + + #[test] + fn stored_code_survives_json_and_expires() { + let mut data = NewDeviceVerificationData::from_json(&NewDeviceVerificationData::new("123456".into()).to_json()) + .expect("stored data must round trip"); + assert_eq!(data.token, "123456"); + assert_eq!(data.attempts, 0); + assert!(!data.is_expired(600)); + + data.add_attempt(); + assert_eq!(data.attempts, 1); + + data.token_sent -= TimeDelta::seconds(601); + assert!(data.is_expired(600)); + assert!(!data.is_expired(3600)); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 6808ddde..800ce6eb 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -17,8 +17,8 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, - yubikey, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, + new_device_verification, webauthn, yubikey, }, }, master_password_policy, @@ -498,6 +498,18 @@ async fn password_login( ) } + // Runs before the device is stored, so a correct master password alone never makes it known. + new_device_verification::validate_new_device_login( + &mut user, + data.device_identifier.as_ref().unwrap(), + util::try_parse_string(data.device_type.as_ref()).unwrap_or(14), + data.new_device_otp.as_deref(), + data.auth_request.is_some(), + ip, + conn, + ) + .await?; + let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; @@ -1038,6 +1050,7 @@ async fn json_err_twofactor( | Some( TwoFactorType::Authenticator | TwoFactorType::EmailVerificationChallenge + | TwoFactorType::NewDeviceVerification | TwoFactorType::OrganizationDuo | TwoFactorType::ProtectedActions | TwoFactorType::RecoveryCode @@ -1199,6 +1212,11 @@ struct ConnectData { #[field(name = uncased("authrequest"))] auth_request: Option, + // Needed for new device verification, the clients send this as `newDeviceOtp` + #[field(name = uncased("new_device_otp"))] + #[field(name = uncased("newdeviceotp"))] + new_device_otp: Option, + // Needed for authorization code #[field(name = uncased("code"))] code: Option, diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..967a937f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -728,6 +728,10 @@ make_config! { /// If sending the email fails the login attempt will fail. require_device_email: bool, true, def, false; + /// New device verification |> Users without 2FA logging in from an unknown device must first enter + /// a code emailed to their account address. Requires a mail transport to be configured. + new_device_verification: bool, true, def, false; + /// Reload templates (Dev) |> When this is set to true, the templates get reloaded with every request. /// ONLY use this during development, as it can slow down the server reload_templates: bool, true, def, false; @@ -1201,6 +1205,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("To use email 2FA as automatic fallback, email 2fa has to be enabled!"); } + // Without a mail transport the verification code can never be delivered, which would lock + // affected users out of every device they have not logged in from before. + if cfg.new_device_verification && !(cfg._enable_smtp && (cfg.smtp_host.is_some() || cfg.use_sendmail)) { + err!("To enable new device verification, a mail transport must be configured") + } + // Check if the HTTP request block regex is valid if let Some(ref r) = cfg.http_request_block_regex { let validate_regex = regex::Regex::new(r); @@ -1765,6 +1775,7 @@ where reg!("email/invite_accepted", ".html"); reg!("email/invite_confirmed", ".html"); reg!("email/new_device_logged_in", ".html"); + reg!("email/new_device_verification", ".html"); reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..0e4d8ee1 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -45,6 +45,8 @@ pub enum TwoFactorType { // Special type for Protected Actions verification via email ProtectedActions = 2000, + // Special type for New Device Verification via email + NewDeviceVerification = 2001, } /// Local methods diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..25e953ab 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -71,6 +71,9 @@ pub struct User { pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. pub key_id: Option, + + /// Verify a new device via an emailed code, when `NEW_DEVICE_VERIFICATION` is enabled. + pub verify_devices: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -158,6 +161,7 @@ impl User { external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. key_id: None, + verify_devices: true, } } @@ -315,6 +319,7 @@ impl User { "avatarColor": self.avatar_color, "usesKeyConnector": false, "creationDate": format_date(&self.created_at), + "verifyDevices": self.verify_devices, "object": "profile", }) } diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..8e5480e8 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -218,6 +218,7 @@ table! { avatar_color -> Nullable, external_id -> Nullable, key_id -> Nullable, + verify_devices -> Bool, } } diff --git a/src/mail.rs b/src/mail.rs index b20f2853..9f29767a 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -531,6 +531,31 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi send_email(address, &subject, body_html, body_text).await } +/// Sends the code a user has to enter before an unknown device may log in. Not the same as +/// `send_new_device_logged_in`, which only notifies after a login already succeeded. +pub async fn send_new_device_verification( + address: &str, + token: &str, + ip: &str, + dt: &NaiveDateTime, + device_type: i32, +) -> EmptyResult { + let fmt = "%A, %B %_d, %Y at %r %Z"; + let (subject, body_html, body_text) = get_text( + "email/new_device_verification", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "token": token, + "ip": ip, + "device_type": DeviceType::from_i32(device_type).to_string(), + "datetime": crate::util::format_naive_datetime_local(dt, fmt), + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_incomplete_2fa_login( address: &str, ip: &str, diff --git a/src/static/templates/email/new_device_verification.hbs b/src/static/templates/email/new_device_verification.hbs new file mode 100644 index 00000000..25608e0f --- /dev/null +++ b/src/static/templates/email/new_device_verification.hbs @@ -0,0 +1,12 @@ +Your Vaultwarden New Device Verification Code + +A login attempt was made from a device that has not been used with your account before. To finish logging in, enter the code below in the client that is asking for it. + +Your new device verification code is: {{token}} + +* Date: {{datetime}} +* IP Address: {{ip}} +* Device Type: {{device_type}} + +If this was not you, do not enter the code. Someone knows your master password and you should change it as soon as possible. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/new_device_verification.html.hbs b/src/static/templates/email/new_device_verification.html.hbs new file mode 100644 index 00000000..6bb4f062 --- /dev/null +++ b/src/static/templates/email/new_device_verification.html.hbs @@ -0,0 +1,36 @@ +Your Vaultwarden New Device Verification Code + +{{> email/email_header }} + + + + + + + + + + + + + + + + + + + +
+ A login attempt was made from a device that has not been used with your account before. To finish logging in, enter the code below in the client that is asking for it. +
+ Your new device verification code is: {{token}} +
+ Date: {{datetime}} +
+ IP Address: {{ip}} +
+ Device Type: {{device_type}} +
+ If this was not you, do not enter the code. Someone knows your master password and you should change it as soon as possible. +
+{{> email/email_footer }} From 574cf6721ec4018de788f74ab9c271a8156f8dcf Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:19:00 +0200 Subject: [PATCH 2/2] Remove SCSS Entry --- src/static/templates/scss/vaultwarden.scss.hbs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 5bbe5db2..23a526bd 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -133,11 +133,6 @@ bit-nav-logo bit-nav-item a:before { bit-nav-logo bit-nav-item .bwi-shield { @extend %vw-hide; } -/* Hide Device Login Protection button on user settings page */ -app-user-layout app-danger-zone button:nth-child(1) { - @extend %vw-hide; -} - /* Hide unsupported Forwarding email alias options */ ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) { @extend %vw-hide;