1
0
Fork 0
mirror of https://github.com/dani-garcia/vaultwarden.git synced 2025-06-19 18:00:07 +00:00

Add email notifications for incomplete 2FA logins

An incomplete 2FA login is one where the correct master password was provided,
but the 2FA token or action required to complete the login was not provided
within the configured time limit. This potentially indicates that the user's
master password has been compromised, but the login was blocked by 2FA.

Be aware that the 2FA step can usually still be completed after the email
notification has already been sent out, which could be confusing. Therefore,
the incomplete 2FA time limit should be long enough that this situation would
be unlikely. This feature can also be disabled entirely if desired.
This commit is contained in:
Jeremy Lin 2021-10-25 01:36:05 -07:00
parent 9f393cfd9d
commit c476e19796
24 changed files with 312 additions and 15 deletions

View file

@ -17,8 +17,7 @@ db_object! {
pub user_uuid: String,
pub name: String,
// https://github.com/bitwarden/core/tree/master/src/Core/Enums
pub atype: i32,
pub atype: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs
pub push_token: Option<String>,
pub refresh_token: String,

View file

@ -9,6 +9,7 @@ mod org_policy;
mod organization;
mod send;
mod two_factor;
mod two_factor_incomplete;
mod user;
pub use self::attachment::Attachment;
@ -22,4 +23,5 @@ pub use self::org_policy::{OrgPolicy, OrgPolicyType};
pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
pub use self::send::{Send, SendType};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, User, UserStampException};

View file

@ -1,8 +1,6 @@
use serde_json::Value;
use crate::api::EmptyResult;
use crate::db::DbConn;
use crate::error::MapResult;
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
use super::User;

View file

@ -0,0 +1,108 @@
use chrono::{NaiveDateTime, Utc};
use crate::{api::EmptyResult, auth::ClientIp, db::DbConn, error::MapResult, CONFIG};
use super::User;
db_object! {
#[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "twofactor_incomplete"]
#[belongs_to(User, foreign_key = "user_uuid")]
#[primary_key(user_uuid, device_uuid)]
pub struct TwoFactorIncomplete {
pub user_uuid: String,
// This device UUID is simply what's claimed by the device. It doesn't
// necessarily correspond to any UUID in the devices table, since a device
// must complete 2FA login before being added into the devices table.
pub device_uuid: String,
pub device_name: String,
pub login_time: NaiveDateTime,
pub ip_address: String,
}
}
impl TwoFactorIncomplete {
pub fn mark_incomplete(
user_uuid: &str,
device_uuid: &str,
device_name: &str,
ip: &ClientIp,
conn: &DbConn,
) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return Ok(());
}
// Don't update the data for an existing user/device pair, since that
// would allow an attacker to arbitrarily delay notifications by
// sending repeated 2FA attempts to reset the timer.
let existing = Self::find_by_user_and_device(user_uuid, device_uuid, conn);
if existing.is_some() {
return Ok(());
}
db_run! { conn: {
diesel::insert_into(twofactor_incomplete::table)
.values((
twofactor_incomplete::user_uuid.eq(user_uuid),
twofactor_incomplete::device_uuid.eq(device_uuid),
twofactor_incomplete::device_name.eq(device_name),
twofactor_incomplete::login_time.eq(Utc::now().naive_utc()),
twofactor_incomplete::ip_address.eq(ip.ip.to_string()),
))
.execute(conn)
.map_res("Error adding twofactor_incomplete record")
}}
}
pub fn mark_complete(user_uuid: &str, device_uuid: &str, conn: &DbConn) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return Ok(());
}
Self::delete_by_user_and_device(user_uuid, device_uuid, conn)
}
pub fn find_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &DbConn) -> Option<Self> {
db_run! { conn: {
twofactor_incomplete::table
.filter(twofactor_incomplete::user_uuid.eq(user_uuid))
.filter(twofactor_incomplete::device_uuid.eq(device_uuid))
.first::<TwoFactorIncompleteDb>(conn)
.ok()
.from_db()
}}
}
pub fn find_logins_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
db_run! {conn: {
twofactor_incomplete::table
.filter(twofactor_incomplete::login_time.lt(dt))
.load::<TwoFactorIncompleteDb>(conn)
.expect("Error loading twofactor_incomplete")
.from_db()
}}
}
pub fn delete(self, conn: &DbConn) -> EmptyResult {
Self::delete_by_user_and_device(&self.user_uuid, &self.device_uuid, conn)
}
pub fn delete_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(twofactor_incomplete::table
.filter(twofactor_incomplete::user_uuid.eq(user_uuid))
.filter(twofactor_incomplete::device_uuid.eq(device_uuid)))
.execute(conn)
.map_res("Error in twofactor_incomplete::delete_by_user_and_device()")
}}
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error in twofactor_incomplete::delete_all_by_user()")
}}
}
}

View file

@ -176,7 +176,10 @@ impl User {
}
}
use super::{Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, TwoFactorIncomplete, UserOrgType,
UserOrganization,
};
use crate::db::DbConn;
use crate::api::EmptyResult;
@ -273,6 +276,7 @@ impl User {
Folder::delete_all_by_user(&self.uuid, conn)?;
Device::delete_all_by_user(&self.uuid, conn)?;
TwoFactor::delete_all_by_user(&self.uuid, conn)?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn)?;
Invitation::take(&self.email, conn); // Delete invitation if any
db_run! {conn: {

View file

@ -140,6 +140,16 @@ table! {
}
}
table! {
twofactor_incomplete (user_uuid, device_uuid) {
user_uuid -> Text,
device_uuid -> Text,
device_name -> Text,
login_time -> Timestamp,
ip_address -> Text,
}
}
table! {
users (uuid) {
uuid -> Text,

View file

@ -140,6 +140,16 @@ table! {
}
}
table! {
twofactor_incomplete (user_uuid, device_uuid) {
user_uuid -> Text,
device_uuid -> Text,
device_name -> Text,
login_time -> Timestamp,
ip_address -> Text,
}
}
table! {
users (uuid) {
uuid -> Text,

View file

@ -140,6 +140,16 @@ table! {
}
}
table! {
twofactor_incomplete (user_uuid, device_uuid) {
user_uuid -> Text,
device_uuid -> Text,
device_name -> Text,
login_time -> Timestamp,
ip_address -> Text,
}
}
table! {
users (uuid) {
uuid -> Text,