Browse Source

Merge 574cf6721e into d2660324e6

pull/7695/merge
Tom 3 days ago
committed by GitHub
parent
commit
2fa25f6dba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      .env.template
  2. 2
      Cargo.toml
  3. 1
      migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql
  4. 2
      migrations/mysql/2026-09-05-120000_add_verify_devices/up.sql
  5. 1
      migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql
  6. 2
      migrations/postgresql/2026-09-05-120000_add_verify_devices/up.sql
  7. 1
      migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql
  8. 2
      migrations/sqlite/2026-09-05-120000_add_verify_devices/up.sql
  9. 18
      src/api/core/two_factor/mod.rs
  10. 491
      src/api/core/two_factor/new_device_verification.rs
  11. 22
      src/api/identity.rs
  12. 11
      src/config.rs
  13. 2
      src/db/models/two_factor.rs
  14. 5
      src/db/models/user.rs
  15. 1
      src/db/schema.rs
  16. 25
      src/mail.rs
  17. 12
      src/static/templates/email/new_device_verification.hbs
  18. 36
      src/static/templates/email/new_device_verification.html.hbs
  19. 5
      src/static/templates/scss/vaultwarden.scss.hbs

6
.env.template

@ -415,6 +415,12 @@
## If sending the email fails the login attempt will fail!! ## If sending the email fails the login attempt will fail!!
# REQUIRE_DEVICE_EMAIL=false # 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 ## Enable extended logging, which shows timestamps and targets in the logs
# EXTENDED_LOGGING=true # EXTENDED_LOGGING=true

2
Cargo.toml

@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
# A safe, extensible ORM and Query builder # 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" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [

1
migrations/mysql/2026-09-05-120000_add_verify_devices/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN verify_devices;

2
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;

1
migrations/postgresql/2026-09-05-120000_add_verify_devices/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN verify_devices;

2
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;

1
migrations/sqlite/2026-09-05-120000_add_verify_devices/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN verify_devices;

2
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

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

@ -28,6 +28,7 @@ pub mod authenticator;
pub mod duo; pub mod duo;
pub mod duo_oidc; pub mod duo_oidc;
pub mod email; pub mod email;
pub mod new_device_verification;
pub mod protected_actions; pub mod protected_actions;
pub mod webauthn; pub mod webauthn;
pub mod yubikey; pub mod yubikey;
@ -64,6 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
| TwoFactorType::EmailVerificationChallenge | TwoFactorType::EmailVerificationChallenge
| TwoFactorType::WebauthnRegisterChallenge | TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge | TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::NewDeviceVerification
| TwoFactorType::ProtectedActions => false, | TwoFactorType::ProtectedActions => false,
} }
} }
@ -83,6 +85,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut webauthn::routes()); routes.append(&mut webauthn::routes());
routes.append(&mut yubikey::routes()); routes.append(&mut yubikey::routes());
routes.append(&mut protected_actions::routes()); routes.append(&mut protected_actions::routes());
routes.append(&mut new_device_verification::routes());
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. // Kept to prevent 404 errors, current clients read `verifyDevices` from the profile and change it
// This also prevents 404 errors. // via `/api/accounts/verify-devices`. See `new_device_verification` for the details.
// //
// See the following Bitwarden PR's regarding this feature. // See the following Bitwarden PR's regarding this feature.
// https://github.com/bitwarden/clients/pull/2843 // https://github.com/bitwarden/clients/pull/2843
// https://github.com/bitwarden/clients/pull/2839 // https://github.com/bitwarden/clients/pull/2839
// https://github.com/bitwarden/server/pull/2016 // 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")] #[get("/two-factor/get-device-verification-settings")]
fn get_device_verification_settings(_headers: Headers, _conn: DbConn) -> Json<Value> { fn get_device_verification_settings(headers: Headers) -> Json<Value> {
Json(json!({ let user = headers.user;
"isDeviceVerificationSectionEnabled":false, Json(new_device_verification::device_verification_settings(&user))
"unknownDeviceVerificationEnabled":false,
"object":"deviceVerificationSettings"
}))
} }

491
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: <https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs>
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<Route> {
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<Self, Error> {
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 = "<data>")]
async fn resend_new_device_otp(data: Json<ResendNewDeviceOtpData>, 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<String>,
otp: Option<String>,
#[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 = "<data>")]
async fn put_verify_devices(data: Json<SetVerifyDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult {
set_verify_devices(data, headers, conn).await
}
#[post("/accounts/verify-devices", data = "<data>")]
async fn post_verify_devices(data: Json<SetVerifyDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult {
set_verify_devices(data, headers, conn).await
}
async fn set_verify_devices(data: Json<SetVerifyDevicesData>, 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));
}
}

22
src/api/identity.rs

@ -17,8 +17,8 @@ use crate::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event, log_user_event,
two_factor::{ two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable,
yubikey, new_device_verification, webauthn, yubikey,
}, },
}, },
master_password_policy, 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 mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).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( | Some(
TwoFactorType::Authenticator TwoFactorType::Authenticator
| TwoFactorType::EmailVerificationChallenge | TwoFactorType::EmailVerificationChallenge
| TwoFactorType::NewDeviceVerification
| TwoFactorType::OrganizationDuo | TwoFactorType::OrganizationDuo
| TwoFactorType::ProtectedActions | TwoFactorType::ProtectedActions
| TwoFactorType::RecoveryCode | TwoFactorType::RecoveryCode
@ -1199,6 +1212,11 @@ struct ConnectData {
#[field(name = uncased("authrequest"))] #[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>, auth_request: Option<AuthRequestId>,
// 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<String>,
// Needed for authorization code // Needed for authorization code
#[field(name = uncased("code"))] #[field(name = uncased("code"))]
code: Option<OIDCCode>, code: Option<OIDCCode>,

11
src/config.rs

@ -728,6 +728,10 @@ make_config! {
/// If sending the email fails the login attempt will fail. /// If sending the email fails the login attempt will fail.
require_device_email: bool, true, def, false; 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. /// 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 /// ONLY use this during development, as it can slow down the server
reload_templates: bool, true, def, false; 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!"); 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 // Check if the HTTP request block regex is valid
if let Some(ref r) = cfg.http_request_block_regex { if let Some(ref r) = cfg.http_request_block_regex {
let validate_regex = regex::Regex::new(r); let validate_regex = regex::Regex::new(r);
@ -1765,6 +1775,7 @@ where
reg!("email/invite_accepted", ".html"); reg!("email/invite_accepted", ".html");
reg!("email/invite_confirmed", ".html"); reg!("email/invite_confirmed", ".html");
reg!("email/new_device_logged_in", ".html"); reg!("email/new_device_logged_in", ".html");
reg!("email/new_device_verification", ".html");
reg!("email/protected_action", ".html"); reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html"); reg!("email/pw_hint_some", ".html");

2
src/db/models/two_factor.rs

@ -45,6 +45,8 @@ pub enum TwoFactorType {
// Special type for Protected Actions verification via email // Special type for Protected Actions verification via email
ProtectedActions = 2000, ProtectedActions = 2000,
// Special type for New Device Verification via email
NewDeviceVerification = 2001,
} }
/// Local methods /// Local methods

5
src/db/models/user.rs

@ -71,6 +71,9 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>, pub key_id: Option<KeyId>,
/// Verify a new device via an emailed code, when `NEW_DEVICE_VERIFICATION` is enabled.
pub verify_devices: bool,
} }
#[derive(Identifiable, Queryable, Insertable)] #[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. external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None, key_id: None,
verify_devices: true,
} }
} }
@ -315,6 +319,7 @@ impl User {
"avatarColor": self.avatar_color, "avatarColor": self.avatar_color,
"usesKeyConnector": false, "usesKeyConnector": false,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"verifyDevices": self.verify_devices,
"object": "profile", "object": "profile",
}) })
} }

1
src/db/schema.rs

@ -218,6 +218,7 @@ table! {
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
key_id -> Nullable<Text>, key_id -> Nullable<Text>,
verify_devices -> Bool,
} }
} }

25
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 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( pub async fn send_incomplete_2fa_login(
address: &str, address: &str,
ip: &str, ip: &str,

12
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 }}

36
src/static/templates/email/new_device_verification.html.hbs

@ -0,0 +1,36 @@
Your Vaultwarden New Device Verification Code
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
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.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Your new device verification code is: <b>{{token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<b>Date:</b> {{datetime}}
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<b>IP Address:</b> {{ip}}
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<b>Device Type:</b> {{device_type}}
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
If this was not you, do not enter the code. Someone knows your master password and you should change it as soon as possible.
</td>
</tr>
</table>
{{> email/email_footer }}

5
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 { bit-nav-logo bit-nav-item .bwi-shield {
@extend %vw-hide; @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 */ /* Hide unsupported Forwarding email alias options */
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) { ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) {
@extend %vw-hide; @extend %vw-hide;

Loading…
Cancel
Save