Browse Source

Merge e3225cbf30 into 904b80115d

pull/7914/merge
bonderaustria 1 week ago
committed by GitHub
parent
commit
dea10337d5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      .env.template
  2. 40
      src/api/identity.rs
  3. 7
      src/config.rs
  4. 16
      src/mail.rs
  5. 9
      src/static/templates/email/failed_login_attempts.hbs
  6. 26
      src/static/templates/email/failed_login_attempts.html.hbs

6
.env.template

@ -244,6 +244,12 @@
## This setting applies globally to all users. ## This setting applies globally to all users.
# INCOMPLETE_2FA_TIME_LIMIT=3 # 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 ## Disable icon downloading
## Set to true to disable icon downloading in the internal icon service. ## 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 ## This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external

40
src/api/identity.rs

@ -1,4 +1,7 @@
use std::sync::LazyLock;
use chrono::Utc; use chrono::Utc;
use dashmap::DashMap;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{ use rocket::{
Route, Route,
@ -368,6 +371,40 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await 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<DashMap<UserId, (u32, i64)>> = 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<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@ -418,6 +455,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
) )
} }
} else if !user.check_valid_password(password) { } else if !user.check_valid_password(password) {
track_failed_login(&user, ip).await;
err!( err!(
"Username or password is incorrect. Try again", "Username or password is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip), format!("IP: {}. Username: {log_username}.", ip.ip),
@ -425,6 +463,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
event: EventType::UserFailedLogIn, event: EventType::UserFailedLogIn,
} }
) )
} else {
FAILED_LOGIN_ATTEMPTS.remove(&user.uuid);
} }
// Check if the user is disabled // Check if the user is disabled

7
src/config.rs

@ -628,6 +628,12 @@ make_config! {
/// This setting applies globally to all users. /// This setting applies globally to all users.
incomplete_2fa_time_limit: u16, true, def, 3; 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. /// 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 /// 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 /// network requests. $ICON_CACHE_TTL must also be set to 0; otherwise, the existing icons
@ -1834,6 +1840,7 @@ where
reg!("email/emergency_access_recovery_rejected", ".html"); reg!("email/emergency_access_recovery_rejected", ".html");
reg!("email/emergency_access_recovery_reminder", ".html"); reg!("email/emergency_access_recovery_reminder", ".html");
reg!("email/emergency_access_recovery_timed_out", ".html"); reg!("email/emergency_access_recovery_timed_out", ".html");
reg!("email/failed_login_attempts", ".html");
reg!("email/incomplete_2fa_login", ".html"); reg!("email/incomplete_2fa_login", ".html");
reg!("email/invite_accepted", ".html"); reg!("email/invite_accepted", ".html");
reg!("email/invite_confirmed", ".html"); reg!("email/invite_confirmed", ".html");

16
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 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( pub async fn send_incomplete_2fa_login(
address: &str, address: &str,
ip: &str, ip: &str,

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

26
src/static/templates/email/failed_login_attempts.html.hbs

@ -0,0 +1,26 @@
Failed Login Attempts On Your Account
<!---------------->
{{> 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">
There have been {{attempts}} consecutive failed login attempts on your account.
</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 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, someone may be trying to guess your master password. If you suspect your master password is compromised, change it in the <a href="{{url}}/">web vault</a> and review your two-step login settings.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save