From c476e197963f1fb8a558555b553416f6f2289088 Mon Sep 17 00:00:00 2001 From: Jeremy Lin Date: Mon, 25 Oct 2021 01:36:05 -0700 Subject: [PATCH] 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. --- .env.template | 11 ++ .../down.sql | 1 + .../up.sql | 9 ++ .../down.sql | 1 + .../up.sql | 9 ++ .../down.sql | 1 + .../up.sql | 9 ++ src/api/core/mod.rs | 1 + src/api/core/two_factor/mod.rs | 33 +++++- src/api/identity.rs | 9 +- src/api/mod.rs | 1 + src/config.rs | 15 ++- src/db/models/device.rs | 3 +- src/db/models/mod.rs | 2 + src/db/models/two_factor.rs | 4 +- src/db/models/two_factor_incomplete.rs | 108 ++++++++++++++++++ src/db/models/user.rs | 6 +- src/db/schemas/mysql/schema.rs | 10 ++ src/db/schemas/postgresql/schema.rs | 10 ++ src/db/schemas/sqlite/schema.rs | 10 ++ src/mail.rs | 25 +++- src/main.rs | 8 ++ .../templates/email/incomplete_2fa_login.hbs | 10 ++ .../email/incomplete_2fa_login.html.hbs | 31 +++++ 24 files changed, 312 insertions(+), 15 deletions(-) create mode 100644 migrations/mysql/2021-10-24-164321_add_2fa_incomplete/down.sql create mode 100644 migrations/mysql/2021-10-24-164321_add_2fa_incomplete/up.sql create mode 100644 migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/down.sql create mode 100644 migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/up.sql create mode 100644 migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/down.sql create mode 100644 migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/up.sql create mode 100644 src/db/models/two_factor_incomplete.rs create mode 100644 src/static/templates/email/incomplete_2fa_login.hbs create mode 100644 src/static/templates/email/incomplete_2fa_login.html.hbs diff --git a/.env.template b/.env.template index 4dd8c585..6af6b53b 100644 --- a/.env.template +++ b/.env.template @@ -82,6 +82,10 @@ ## Defaults to daily (5 minutes after midnight). Set blank to disable this job. # TRASH_PURGE_SCHEDULE="0 5 0 * * *" ## +## Cron schedule of the job that checks for incomplete 2FA logins. +## Defaults to once every minute. Set blank to disable this job. +# INCOMPLETE_2FA_SCHEDULE="30 * * * * *" +## ## Cron schedule of the job that sends expiration reminders to emergency access grantors. ## Defaults to hourly (5 minutes after the hour). Set blank to disable this job. # EMERGENCY_NOTIFICATION_REMINDER_SCHEDULE="0 5 * * * *" @@ -220,6 +224,13 @@ ## This setting applies globally, so make sure to inform all users of any changes to this setting. # TRASH_AUTO_DELETE_DAYS= +## Number of minutes to wait before a 2FA-enabled login is considered incomplete, +## resulting in an email notification. An incomplete 2FA login is one where the correct +## master password was provided but the required 2FA step was not completed, which +## potentially indicates a master password compromise. Set to 0 to disable this check. +## This setting applies globally to all users. +# INCOMPLETE_2FA_TIME_LIMIT=3 + ## Controls the PBBKDF password iterations to apply on the server ## The change only applies when the password is changed # PASSWORD_ITERATIONS=100000 diff --git a/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/down.sql b/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/down.sql new file mode 100644 index 00000000..31165c92 --- /dev/null +++ b/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_incomplete; diff --git a/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/up.sql b/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/up.sql new file mode 100644 index 00000000..fb9aae15 --- /dev/null +++ b/migrations/mysql/2021-10-24-164321_add_2fa_incomplete/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE twofactor_incomplete ( + user_uuid CHAR(36) NOT NULL REFERENCES users(uuid), + device_uuid CHAR(36) NOT NULL, + device_name TEXT NOT NULL, + login_time DATETIME NOT NULL, + ip_address TEXT NOT NULL, + + PRIMARY KEY (user_uuid, device_uuid) +); diff --git a/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/down.sql b/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/down.sql new file mode 100644 index 00000000..31165c92 --- /dev/null +++ b/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_incomplete; diff --git a/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/up.sql b/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/up.sql new file mode 100644 index 00000000..5dd6f920 --- /dev/null +++ b/migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE twofactor_incomplete ( + user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), + device_uuid VARCHAR(40) NOT NULL, + device_name TEXT NOT NULL, + login_time DATETIME NOT NULL, + ip_address TEXT NOT NULL, + + PRIMARY KEY (user_uuid, device_uuid) +); diff --git a/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/down.sql b/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/down.sql new file mode 100644 index 00000000..31165c92 --- /dev/null +++ b/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_incomplete; diff --git a/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/up.sql b/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/up.sql new file mode 100644 index 00000000..dbf106a5 --- /dev/null +++ b/migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE twofactor_incomplete ( + user_uuid TEXT NOT NULL REFERENCES users(uuid), + device_uuid TEXT NOT NULL, + device_name TEXT NOT NULL, + login_time DATETIME NOT NULL, + ip_address TEXT NOT NULL, + + PRIMARY KEY (user_uuid, device_uuid) +); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 9f181ed8..f828dc44 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -9,6 +9,7 @@ pub mod two_factor; pub use ciphers::purge_trashed_ciphers; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use sends::purge_sends; +pub use two_factor::send_incomplete_2fa_notifications; pub fn routes() -> Vec { let mut mod_routes = diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index d8448f45..2c48b9cf 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -1,3 +1,4 @@ +use chrono::{Duration, Utc}; use data_encoding::BASE32; use rocket::Route; use rocket_contrib::json::Json; @@ -7,7 +8,7 @@ use crate::{ api::{JsonResult, JsonUpcase, NumberOrString, PasswordData}, auth::Headers, crypto, - db::{models::*, DbConn}, + db::{models::*, DbConn, DbPool}, mail, CONFIG, }; @@ -156,3 +157,33 @@ fn disable_twofactor(data: JsonUpcase, headers: Headers, c fn disable_twofactor_put(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { disable_twofactor(data, headers, conn) } + +pub fn send_incomplete_2fa_notifications(pool: DbPool) { + debug!("Sending notifications for incomplete 2FA logins"); + + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { + return; + } + + let conn = match pool.get() { + Ok(conn) => conn, + _ => { + error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); + return; + } + }; + + let now = Utc::now().naive_utc(); + let time_limit = Duration::minutes(CONFIG.incomplete_2fa_time_limit()); + let incomplete_logins = TwoFactorIncomplete::find_logins_before(&(now - time_limit), &conn); + for login in incomplete_logins { + let user = User::find_by_uuid(&login.user_uuid, &conn).expect("User not found"); + info!( + "User {} did not complete a 2FA login within the configured time limit. IP: {}", + user.email, login.ip_address + ); + mail::send_incomplete_2fa_login(&user.email, &login.ip_address, &login.login_time, &login.device_name) + .expect("Error sending incomplete 2FA email"); + login.delete(&conn).expect("Error deleting incomplete 2FA record"); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index bfc47570..356364b1 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,4 +1,4 @@ -use chrono::Local; +use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ request::{Form, FormItems, FromForm}, @@ -102,10 +102,9 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult err!("This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, username)) } - let now = Local::now(); + let now = Utc::now().naive_utc(); if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { - let now = now.naive_utc(); if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() > CONFIG.signups_verify_resend_time() as i64 @@ -219,6 +218,8 @@ fn twofactor_auth( return Ok(None); } + TwoFactorIncomplete::mark_incomplete(user_uuid, &device.uuid, &device.name, ip, conn)?; + let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect(); let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, asume the first one @@ -262,6 +263,8 @@ fn twofactor_auth( _ => err!("Invalid two factor provider"), } + TwoFactorIncomplete::mark_complete(user_uuid, &device.uuid, conn)?; + if !CONFIG.disable_2fa_remember() && remember == 1 { Ok(Some(device.refresh_twofactor_remember())) } else { diff --git a/src/api/mod.rs b/src/api/mod.rs index e7482cdd..3546acd7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -13,6 +13,7 @@ pub use crate::api::{ core::purge_sends, core::purge_trashed_ciphers, core::routes as core_routes, + core::two_factor::send_incomplete_2fa_notifications, core::{emergency_notification_reminder_job, emergency_request_timeout_job}, icons::routes as icons_routes, identity::routes as identity_routes, diff --git a/src/config.rs b/src/config.rs index 9dbaed29..ebf2b66f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -332,6 +332,9 @@ make_config! { /// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently. /// Defaults to daily. Set blank to disable this job. trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string(); + /// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins. + /// Defaults to once every minute. Set blank to disable this job. + incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string(); /// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors. /// Defaults to hourly. Set blank to disable this job. emergency_notification_reminder_schedule: String, false, def, "0 5 * * * *".to_string(); @@ -371,6 +374,13 @@ make_config! { /// sure to inform all users of any changes to this setting. trash_auto_delete_days: i64, true, option; + /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is + /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one + /// where the correct master password was provided but the required 2FA step was not completed, + /// which potentially indicates a master password compromise. Set to 0 to disable this check. + /// This setting applies globally to all users. + incomplete_2fa_time_limit: i64, true, def, 3; + /// Disable icon downloads |> Set to true to disable icon downloading, this would still serve icons from /// $ICON_CACHE_FOLDER, but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0, /// otherwise it will delete them and they won't be downloaded again. @@ -863,8 +873,6 @@ where reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); - reg!("email/invite_accepted", ".html"); - reg!("email/invite_confirmed", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_recovery_approved", ".html"); @@ -872,6 +880,9 @@ where reg!("email/emergency_access_recovery_rejected", ".html"); reg!("email/emergency_access_recovery_reminder", ".html"); reg!("email/emergency_access_recovery_timed_out", ".html"); + reg!("email/incomplete_2fa_login", ".html"); + reg!("email/invite_accepted", ".html"); + reg!("email/invite_confirmed", ".html"); reg!("email/new_device_logged_in", ".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 1633ceba..2fbdea01 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -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, pub refresh_token: String, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 8b4aeebc..251511da 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -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}; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 6b400889..01505ecd 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -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; diff --git a/src/db/models/two_factor_incomplete.rs b/src/db/models/two_factor_incomplete.rs new file mode 100644 index 00000000..d58398ec --- /dev/null +++ b/src/db/models/two_factor_incomplete.rs @@ -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 { + db_run! { conn: { + twofactor_incomplete::table + .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) + .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) + .first::(conn) + .ok() + .from_db() + }} + } + + pub fn find_logins_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { + db_run! {conn: { + twofactor_incomplete::table + .filter(twofactor_incomplete::login_time.lt(dt)) + .load::(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()") + }} + } +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 17cd7fab..0197535b 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -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: { diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index de717702..8bfeae4c 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -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, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 614a4506..06939ab6 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -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, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 614a4506..06939ab6 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -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, diff --git a/src/mail.rs b/src/mail.rs index f81f3cb2..bc1ab0f0 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use chrono::{DateTime, Local}; +use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use lettre::{ @@ -394,7 +394,7 @@ pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text) } -pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &DateTime, device: &str) -> EmptyResult { +pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult { use crate::util::upcase_first; let device = upcase_first(device); @@ -405,7 +405,26 @@ pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &DateTime, "url": CONFIG.domain(), "ip": ip, "device": device, - "datetime": crate::util::format_datetime_local(dt, fmt), + "datetime": crate::util::format_naive_datetime_local(dt, fmt), + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult { + use crate::util::upcase_first; + let device = upcase_first(device); + + let fmt = "%A, %B %_d, %Y at %r %Z"; + let (subject, body_html, body_text) = get_text( + "email/incomplete_2fa_login", + json!({ + "url": CONFIG.domain(), + "ip": ip, + "device": device, + "datetime": crate::util::format_naive_datetime_local(dt, fmt), + "time_limit": CONFIG.incomplete_2fa_time_limit(), }), )?; diff --git a/src/main.rs b/src/main.rs index 11a67174..f86efb2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -345,6 +345,14 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Send email notifications about incomplete 2FA logins, which potentially + // indicates that a user's master password has been compromised. + if !CONFIG.incomplete_2fa_schedule().is_empty() { + sched.add(Job::new(CONFIG.incomplete_2fa_schedule().parse().unwrap(), || { + api::send_incomplete_2fa_notifications(pool.clone()); + })); + } + // Grant emergency access requests that have met the required wait time. // This job should run before the emergency access reminders job to avoid // sending reminders for requests that are about to be granted anyway. diff --git a/src/static/templates/email/incomplete_2fa_login.hbs b/src/static/templates/email/incomplete_2fa_login.hbs new file mode 100644 index 00000000..d9ff3950 --- /dev/null +++ b/src/static/templates/email/incomplete_2fa_login.hbs @@ -0,0 +1,10 @@ +Incomplete Two-Step Login From {{{device}}} + +Someone attempted to log into your account with the correct master password, but did not provide the correct token or action required to complete the two-step login process within {{time_limit}} minutes of the initial login attempt. + +* Date: {{datetime}} +* IP Address: {{ip}} +* Device Type: {{device}} + +If this was not you or someone you authorized, then you should change your master password as soon as possible, as it is likely to be compromised. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/incomplete_2fa_login.html.hbs b/src/static/templates/email/incomplete_2fa_login.html.hbs new file mode 100644 index 00000000..8bc1ce21 --- /dev/null +++ b/src/static/templates/email/incomplete_2fa_login.html.hbs @@ -0,0 +1,31 @@ +Incomplete Two-Step Login From {{{device}}} + +{{> email/email_header }} + + + + + + + + + + + + + + + + +
+ Someone attempted to log into your account with the correct master password, but did not provide the correct token or action required to complete the two-step login process within {{time_limit}} minutes of the initial login attempt. +
+ Date: {{datetime}} +
+ IP Address: {{ip}} +
+ Device Type: {{device}} +
+ If this was not you or someone you authorized, then you should change your master password as soon as possible, as it is likely to be compromised. +
+{{> email/email_footer }}