Browse Source

Merge f893916022 into 58a980f850

pull/7695/merge
Tom 2 days ago
committed by GitHub
parent
commit
60be2645ce
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. 497
      src/api/core/two_factor/new_device_verification.rs
  11. 21
      src/api/identity.rs
  12. 10
      src/config.rs
  13. 14
      src/db/models/device.rs
  14. 17
      src/db/models/two_factor.rs
  15. 5
      src/db/models/user.rs
  16. 1
      src/db/schema.rs
  17. 24
      src/mail.rs
  18. 12
      src/static/templates/email/new_device_verification.hbs
  19. 36
      src/static/templates/email/new_device_verification.html.hbs
  20. 5
      src/static/templates/scss/vaultwarden.scss.hbs

6
.env.template

@ -409,6 +409,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

@ -24,6 +24,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;
@ -60,6 +61,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,
} }
} }
@ -73,6 +75,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
} }
@ -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. // 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 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"
}))
} }

497
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: <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,
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<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;
/// 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<Self, Error> {
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<String>,
#[serde(alias = "OTP", alias = "Otp")]
otp: Option<String>,
}
/// Current web clients identify the device via this header. Older mobile clients omit it.
struct ResendDeviceIdentifier(Option<String>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ResendDeviceIdentifier {
type Error = &'static str;
async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
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 = "<data>")]
async fn resend_new_device_otp(
data: Json<ResendNewDeviceOtpData>,
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<String>,
otp: Option<String>,
#[serde(alias = "VerifyDevices")]
verify_devices: bool,
}
/// Current clients use `POST`, older ones and the API docs use `PUT`.
#[post("/accounts/verify-devices", data = "<data>")]
async fn post_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
}
#[put("/accounts/verify-devices", data = "<data>")]
async fn put_verify_devices(data: Json<SetVerifyDevicesData>, 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::<Value>(&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());
}
}
}

21
src/api/identity.rs

@ -17,7 +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_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, identity_master_password_policy,
@ -476,6 +477,18 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, 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 mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).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( | Some(
TwoFactorType::Authenticator TwoFactorType::Authenticator
| TwoFactorType::EmailVerificationChallenge | TwoFactorType::EmailVerificationChallenge
| TwoFactorType::NewDeviceVerification
| TwoFactorType::OrganizationDuo | TwoFactorType::OrganizationDuo
| TwoFactorType::ProtectedActions | TwoFactorType::ProtectedActions
| TwoFactorType::RecoveryCode | TwoFactorType::RecoveryCode
@ -1156,6 +1170,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>,

10
src/config.rs

@ -729,6 +729,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;
@ -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!"); 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 // 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);
@ -1735,6 +1744,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");

14
src/db/models/device.rs

@ -201,6 +201,20 @@ impl Device {
result 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::<DeviceId>(conn)
.optional()
.expect("Error checking whether user has devices")
.is_some()
})
.await
}
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> { pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
devices::table.filter(devices::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading devices") devices::table.filter(devices::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading devices")

17
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
@ -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<bool, crate::error::Error> {
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 { pub async fn delete(self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| { conn.run(move |conn| {
diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid)))

5
src/db/models/user.rs

@ -72,6 +72,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)]
@ -159,6 +162,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,
} }
} }
@ -329,6 +333,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

@ -219,6 +219,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,
} }
} }

24
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 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( 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

@ -115,11 +115,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