diff --git a/.env.template b/.env.template index 92362088..33e825d0 100644 --- a/.env.template +++ b/.env.template @@ -244,6 +244,12 @@ ## This setting applies globally to all users. # INCOMPLETE_2FA_TIME_LIMIT=3 +## Number of consecutive failed login attempts (wrong master password) after which +## the affected user is notified by email. The counter resets on a successful login, +## or after an hour without further attempts. Set to 0 (the default) to disable. +## Requires SMTP to be configured. +# FAILED_LOGIN_EMAIL_THRESHOLD=0 + ## Disable icon downloading ## Set to true to disable icon downloading in the internal icon service. ## This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external diff --git a/src/api/identity.rs b/src/api/identity.rs index 146cec4c..26af90c3 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,4 +1,7 @@ +use std::sync::LazyLock; + use chrono::Utc; +use dashmap::DashMap; use num_traits::FromPrimitive; use rocket::{ Route, @@ -368,6 +371,40 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } +// Consecutive wrong-password attempts per user, as (count, unix timestamp of the last attempt). +// A streak expires after an hour without attempts and is cleared on successful login. +static FAILED_LOGIN_ATTEMPTS: LazyLock> = LazyLock::new(DashMap::new); + +const FAILED_LOGIN_STREAK_SECS: i64 = 3600; + +async fn track_failed_login(user: &User, ip: &ClientIp) { + let threshold = CONFIG.failed_login_email_threshold(); + if threshold == 0 || !CONFIG.mail_enabled() { + return; + } + + let now = Utc::now(); + let attempts = { + let mut entry = FAILED_LOGIN_ATTEMPTS.entry(user.uuid.clone()).or_insert((0, now.timestamp())); + let (count, last_attempt) = *entry; + let count = if now.timestamp() - last_attempt > FAILED_LOGIN_STREAK_SECS { + 1 + } else { + count + 1 + }; + *entry = (count, now.timestamp()); + count + }; + + // Only notify when the threshold is hit, not on every attempt past it + if attempts == threshold + && let Err(e) = + mail::send_failed_login_attempts(&user.email, &ip.ip.to_string(), &now.naive_utc(), attempts).await + { + error!("Error sending failed login notification: {e:#?}"); + } +} + async fn password_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -418,6 +455,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & ) } } else if !user.check_valid_password(password) { + track_failed_login(&user, ip).await; err!( "Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip), @@ -425,6 +463,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & event: EventType::UserFailedLogIn, } ) + } else { + FAILED_LOGIN_ATTEMPTS.remove(&user.uuid); } // Check if the user is disabled diff --git a/src/config.rs b/src/config.rs index 55d08259..0e507fee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -628,6 +628,12 @@ make_config! { /// This setting applies globally to all users. incomplete_2fa_time_limit: u16, true, def, 3; + /// Failed login email threshold |> Number of consecutive failed login attempts (wrong master + /// password) after which the affected user is notified by email. The counter resets on a + /// successful login, or after an hour without further attempts. Set to 0 to disable this + /// notification. This setting applies globally to all users. + failed_login_email_threshold: u32, true, def, 0; + /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external /// network requests. $ICON_CACHE_TTL must also be set to 0; otherwise, the existing icons @@ -1830,6 +1836,7 @@ 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/failed_login_attempts", ".html"); reg!("email/incomplete_2fa_login", ".html"); reg!("email/invite_accepted", ".html"); reg!("email/invite_confirmed", ".html"); diff --git a/src/mail.rs b/src/mail.rs index 14294213..4c255966 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -546,6 +546,22 @@ pub async fn send_recover_twofactor(address: &str, ip: &str, dt: &NaiveDateTime) send_email(address, &subject, body_html, body_text).await } +pub async fn send_failed_login_attempts(address: &str, ip: &str, dt: &NaiveDateTime, attempts: u32) -> EmptyResult { + let fmt = "%A, %B %_d, %Y at %r %Z"; + let (subject, body_html, body_text) = get_text( + "email/failed_login_attempts", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "ip": ip, + "attempts": attempts, + "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( address: &str, ip: &str, diff --git a/src/static/templates/email/failed_login_attempts.hbs b/src/static/templates/email/failed_login_attempts.hbs new file mode 100644 index 00000000..11694142 --- /dev/null +++ b/src/static/templates/email/failed_login_attempts.hbs @@ -0,0 +1,9 @@ +Failed Login Attempts On Your Account + +There have been {{attempts}} consecutive failed login attempts on your account. + +* Date: {{datetime}} +* IP Address: {{ip}} + +If this was not you, someone may be trying to guess your master password. If you suspect your master password is compromised, change it in the web vault ( {{url}} ) and review your two-step login settings. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/failed_login_attempts.html.hbs b/src/static/templates/email/failed_login_attempts.html.hbs new file mode 100644 index 00000000..8955b11e --- /dev/null +++ b/src/static/templates/email/failed_login_attempts.html.hbs @@ -0,0 +1,26 @@ +Failed Login Attempts On Your Account + +{{> email/email_header }} + + + + + + + + + + + + + +
+ There have been {{attempts}} consecutive failed login attempts on your account. +
+ Date: {{datetime}} +
+ IP Address: {{ip}} +
+ If this was not you, someone may be trying to guess your master password. If you suspect your master password is compromised, change it in the web vault and review your two-step login settings. +
+{{> email/email_footer }}