From f8939160225f66ef9bc467aa67390987c25e4e7f Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:32:36 +0200 Subject: [PATCH] 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 | 497 ++++++++++++++++++ src/api/identity.rs | 21 +- src/config.rs | 10 + src/db/models/device.rs | 14 + src/db/models/two_factor.rs | 17 + src/db/models/user.rs | 5 + src/db/schema.rs | 1 + src/mail.rs | 24 + .../email/new_device_verification.hbs | 12 + .../email/new_device_verification.html.hbs | 36 ++ .../templates/scss/vaultwarden.scss.hbs | 5 - 20 files changed, 660 insertions(+), 17 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 272c7738..d58d34a1 100644 --- a/.env.template +++ b/.env.template @@ -409,6 +409,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 764fbd53..a2c0e51d 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 61cb05df..59877c67 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -24,6 +24,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; @@ -60,6 +61,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data | TwoFactorType::EmailVerificationChallenge | TwoFactorType::WebauthnRegisterChallenge | TwoFactorType::WebauthnLoginChallenge + | TwoFactorType::NewDeviceVerification | TwoFactorType::ProtectedActions => false, } } @@ -73,6 +75,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 } @@ -236,20 +239,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 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..d0eb70a4 --- /dev/null +++ b/src/api/core/two_factor/new_device_verification.rs @@ -0,0 +1,497 @@ +//! New device verification, the Bitwarden "New device login protection" feature: a password login +//! from an unknown device first has to be confirmed with a code mailed to the account address. +//! +//! Reference: + +use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds}; +use rocket::{ + Route, + request::{FromRequest, Outcome}, + 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}, + }, + 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; + +/// Preserve the pending device identifier for resend requests from older mobile clients. +const PENDING_DEVICE_SECONDS: i64 = 15 * 60; + +/// 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 { + pub token: String, + #[serde(with = "ts_seconds")] + pub token_sent: NaiveDateTime, + pub device_identifier: DeviceId, + pub security_stamp: String, +} + +impl NewDeviceVerificationData { + fn new(token: String, device_identifier: DeviceId, security_stamp: String) -> Self { + Self { + token, + token_sent: Utc::now().naive_utc(), + device_identifier, + security_stamp, + } + } + + 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 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 + } + + fn matches_device_and_stamp(&self, device_id: &DeviceId, security_stamp: &str) -> bool { + &self.device_identifier == device_id && self.security_stamp == security_stamp + } +} + +#[expect(clippy::struct_excessive_bools, reason = "Every condition upstream checks, kept separate to stay testable")] +#[derive(Clone, Copy)] +pub struct NewDeviceState<'a> { + 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, + pub new_device_otp: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewDeviceAction { + Skip, + Verify, + Challenge, +} + +/// Mirrors `DeviceValidator.HandleNewDeviceVerificationAsync` of the Bitwarden server. +pub fn new_device_action(state: NewDeviceState<'_>) -> NewDeviceAction { + // A non-empty code implies an unknown device, upstream skips the lookup for it. + if state.new_device_otp.is_none_or(str::is_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.new_device_otp.is_some() { + 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 device_error(description: &str, message: &str) -> Error { + let body = json!({ + "error": "device_error", + "error_description": description, + "ErrorModel": { + "Message": message, + "Object": "error" + } + }); + Error::from((description, body)).with_event(ErrorEvent { + event: EventType::UserFailedLogIn, + }) +} + +fn verification_required_error() -> Error { + device_error("New device verification required", "new device verification required") +} + +fn invalid_otp_error() -> Error { + device_error("Invalid New Device OTP", "invalid new device otp") +} + +/// Has to run before the device is stored. Errors are the exact responses 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); + + // Avoids the queries below, `new_device_action` would return `Skip` for each of these too. + if !enforced || !user.verify_devices || recently_created { + return Ok(()); + } + + let known_device = Device::find_by_uuid_and_user(device_id, &user.uuid, conn).await.is_some(); + let has_devices = known_device || Device::has_any_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, + has_devices, + new_device_otp, + }; + + match new_device_action(state) { + NewDeviceAction::Skip => Ok(()), + NewDeviceAction::Verify => { + validate_otp(new_device_otp.unwrap_or_default(), user, device_id, 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_id, device_type, ip, conn).await?; + Err(verification_required_error()) + } + } +} + +async fn send_otp(user: &User, device_id: &DeviceId, device_type: i32, ip: &ClientIp, conn: &DbConn) -> EmptyResult { + // Bitwarden replaces the pending code on each new challenge or resend. + let data = NewDeviceVerificationData::new( + crypto::generate_email_token(CONFIG.email_token_size()), + device_id.clone(), + user.security_stamp.clone(), + ); + 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:#?}"); + // Only remove our own unsent code; a concurrent resend may already have replaced it. + 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(()) +} + +/// Validate and atomically consume the code only if no newer code has replaced it. +async fn validate_otp(otp: &str, user: &User, device_id: &DeviceId, conn: &DbConn) -> EmptyResult { + let type_ = TwoFactorType::NewDeviceVerification; + let tf = TwoFactor::find_by_user_and_type(&user.uuid, type_, conn).await.ok_or_else(invalid_otp_error)?; + let data = NewDeviceVerificationData::from_json(&tf.data)?; + + if !data.matches_device_and_stamp(device_id, &user.security_stamp) + || data.is_expired(CONFIG.email_expiration_time().cast_signed()) + || !crypto::ct_eq(&data.token, otp) + { + // Keep the expired record briefly for mobile clients that resend without a device header. + return Err(invalid_otp_error()); + } + + let expected_data = tf.data.clone(); + if !tf.delete_if_data_matches(&expected_data, conn).await? { + return Err(invalid_otp_error()); + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResendNewDeviceOtpData { + #[serde(alias = "Email")] + email: String, + #[serde(alias = "MasterPasswordHash")] + master_password_hash: Option, + #[serde(alias = "OTP", alias = "Otp")] + otp: Option, +} + +/// Current web clients identify the device via this header. Older mobile clients omit it. +struct ResendDeviceIdentifier(Option); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ResendDeviceIdentifier { + type Error = &'static str; + + async fn from_request(request: &'r rocket::Request<'_>) -> Outcome { + Outcome::Success(Self(request.headers().get_one("Device-Identifier").map(str::to_owned))) + } +} + +/// Mirrors the upstream resend endpoint, including its silent success for invalid secrets. +#[post("/accounts/resend-new-device-otp", data = "")] +async fn resend_new_device_otp( + data: Json, + device_identifier: ResendDeviceIdentifier, + ip: ClientIp, + conn: DbConn, +) -> EmptyResult { + crate::ratelimit::check_limit_login(&ip.ip)?; + + let ResendDeviceIdentifier(request_device_id) = device_identifier; + // Reject malformed identifiers before looking up the user, just like Bitwarden. + if request_device_id.as_ref().is_some_and(|id| id.len() > 50) { + err!("Invalid device identifier"); + } + + 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 { + return Ok(()); + } + + let secret = PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + }; + if secret.validate(&user, true, &conn).await.is_err() { + return Ok(()); + } + + // Old Android/iOS clients do not send the Device-Identifier header. Reuse the + // pending device for up to 15 minutes, independently of the OTP's validity. + let device_id = if let Some(id) = request_device_id.filter(|id| !id.trim().is_empty()) { + DeviceId::from(id) + } else { + let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::NewDeviceVerification, &conn).await + else { + return Ok(()); + }; + let pending = NewDeviceVerificationData::from_json(&tf.data)?; + if pending.security_stamp != user.security_stamp || pending.is_expired(PENDING_DEVICE_SECONDS) { + return Ok(()); + } + pending.device_identifier + }; + + if let Err(e) = send_otp(&user, &device_id, 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, +} + +/// Current clients use `POST`, older ones and the API docs use `PUT`. +#[post("/accounts/verify-devices", data = "")] +async fn post_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 +} + +#[put("/accounts/verify-devices", data = "")] +async fn put_verify_devices(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + post_verify_devices(data, headers, conn).await +} + +/// Only the pre-2023 web vault reads this. Its section stays disabled because the setter it needs 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::*; + + fn challenged() -> NewDeviceState<'static> { + NewDeviceState { + enforced: true, + verify_devices: true, + recently_created: false, + has_two_factor: false, + known_device: false, + has_devices: true, + new_device_otp: None, + } + } + + type Case = (&'static str, Option<&'static str>, fn(&mut NewDeviceState<'static>), NewDeviceAction); + + #[test] + fn decision_matches_upstream() { + use NewDeviceAction::{Challenge, Skip, Verify}; + + let cases: [Case; 13] = [ + ("unknown device without 2fa", None, |_| (), Challenge), + ("feature disabled", None, |s| s.enforced = false, Skip), + ("user opted out", None, |s| s.verify_devices = false, Skip), + ("account within the exemption period", None, |s| s.recently_created = true, Skip), + ("2fa configured", None, |s| s.has_two_factor = true, Skip), + ("2fa configured and a code sent", Some("123456"), |s| s.has_two_factor = true, Skip), + ("known device", None, |s| s.known_device = true, Skip), + ("account without any device", None, |s| s.has_devices = false, Skip), + ("code sent", Some("123456"), |_| (), Verify), + ("code sent from a known device", Some("123456"), |s| s.known_device = true, Verify), + ("code sent without any device", Some("123456"), |s| s.has_devices = false, Verify), + // An empty code counts as wrong, but unlike a real one it does not skip the known device lookup. + ("empty code sent", Some(""), |_| (), Verify), + ("empty code sent from a known device", Some(""), |s| s.known_device = true, Skip), + ]; + + for (case, new_device_otp, setup, expected) in cases { + let mut state = challenged(); + state.new_device_otp = new_device_otp; + setup(&mut state); + assert_eq!(new_device_action(state), expected, "{case}"); + } + } + + /// Guards the early return in `validate_new_device_login`. + #[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() { + for (error, description, message) in [ + (verification_required_error(), "New device verification required", "new device verification required"), + (invalid_otp_error(), "Invalid New Device OTP", "invalid new device otp"), + ] { + let expected = json!({ + "error": "device_error", + "error_description": description, + "ErrorModel": { "Message": message, "Object": "error" } + }); + // An exact match also keeps out `TwoFactorProviders2`, which the clients check first. + assert_eq!(serde_json::from_str::(&error.to_string()).unwrap(), expected); + } + } + + #[test] + fn stored_code_is_device_and_security_stamp_bound() { + let device_id = DeviceId::from("device-1".to_owned()); + let mut sent = NewDeviceVerificationData::new("123456".into(), device_id.clone(), "security-stamp-1".into()); + let mut data = NewDeviceVerificationData::from_json(&sent.to_json()).expect("stored data must round trip"); + assert_eq!(data.token, "123456"); + assert!(data.matches_device_and_stamp(&device_id, "security-stamp-1")); + assert!(!data.matches_device_and_stamp(&DeviceId::from("device-2".to_owned()), "security-stamp-1")); + assert!(!data.matches_device_and_stamp(&device_id, "security-stamp-2")); + assert!(!data.is_expired(600)); + + sent.token_sent -= TimeDelta::seconds(601); + data = NewDeviceVerificationData::from_json(&sent.to_json()).unwrap(); + assert!(data.is_expired(600)); + assert!(!data.is_expired(PENDING_DEVICE_SECONDS)); + } + + #[test] + fn resend_accepts_bitwarden_client_field_names() { + for raw in [ + r#"{"Email":"user@example.com","MasterPasswordHash":"hash"}"#, + r#"{"email":"user@example.com","masterPasswordHash":"hash"}"#, + r#"{"Email":"user@example.com","MasterPasswordHash":null,"OTP":"654321"}"#, + ] { + let data: ResendNewDeviceOtpData = serde_json::from_str(raw).expect("client request must deserialize"); + assert_eq!(data.email, "user@example.com"); + assert!(data.master_password_hash.is_some() || data.otp.is_some()); + } + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..07a0185a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -17,7 +17,8 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, + authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, + new_device_verification, webauthn, yubikey, }, }, identity_master_password_policy, @@ -476,6 +477,18 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & ) } + // 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, conn).await?; @@ -999,6 +1012,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, | Some( TwoFactorType::Authenticator | TwoFactorType::EmailVerificationChallenge + | TwoFactorType::NewDeviceVerification | TwoFactorType::OrganizationDuo | TwoFactorType::ProtectedActions | TwoFactorType::RecoveryCode @@ -1156,6 +1170,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 a56748f4..db62f490 100644 --- a/src/config.rs +++ b/src/config.rs @@ -729,6 +729,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; @@ -1198,6 +1202,11 @@ 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 code can never arrive, locking users out of every new device. + 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); @@ -1735,6 +1744,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/device.rs b/src/db/models/device.rs index 5e5f1f97..6fb843f4 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -201,6 +201,20 @@ impl Device { result } + /// A lightweight existence check avoids loading every registered device on login. + pub async fn has_any_by_user(user_uuid: &UserId, conn: &DbConn) -> bool { + conn.run(move |conn| { + devices::table + .filter(devices::user_uuid.eq(user_uuid)) + .select(devices::uuid) + .first::(conn) + .optional() + .expect("Error checking whether user has devices") + .is_some() + }) + .await + } + pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { conn.run(move |conn| { devices::table.filter(devices::user_uuid.eq(user_uuid)).load::(conn).expect("Error loading devices") diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 051ea4df..8bbf21e7 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 @@ -117,6 +119,21 @@ impl TwoFactor { } } + /// Atomically consume a code only if its stored content still matches what was validated. + /// A newer code can replace the row between the lookup and this delete. + pub async fn delete_if_data_matches(self, expected_data: &str, conn: &DbConn) -> Result { + let expected_data = expected_data.to_owned(); + conn.run(move |conn| { + diesel::delete( + twofactor::table.filter(twofactor::uuid.eq(self.uuid)).filter(twofactor::data.eq(expected_data)), + ) + .execute(conn) + .map(|deleted| deleted == 1) + .map_res("Error consuming new device verification code") + }) + .await + } + pub async fn delete(self, conn: &DbConn) -> EmptyResult { conn.run(move |conn| { diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 2f3afc02..e1bd8f4d 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -72,6 +72,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)] @@ -159,6 +162,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, } } @@ -329,6 +333,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 ae1e3216..f5f15675 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -219,6 +219,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 2efb8d70..8c6da4f1 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -546,6 +546,30 @@ pub async fn send_recover_twofactor(address: &str, ip: &str, dt: &NaiveDateTime) send_email(address, &subject, body_html, body_text).await } +/// Mails the code for an unknown device, unlike `send_new_device_logged_in`, which notifies after a login. +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 }} diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index d062c026..a0761c36 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -115,11 +115,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;