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