Skip to content

Commit e0383f1

Browse files
committed
Add option to ignore subsequent auth failures for specified milliseconds
1 parent c8e7186 commit e0383f1

File tree

11 files changed

+190
-25
lines changed

11 files changed

+190
-25
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ version = "1.1.0"
44
edition = "2021"
55

66
[dependencies]
7+
chrono = "0.4.40"
78
inotify = "0.11.0"
89
signal-hook = "0.3.17"
910

1011
[dev-dependencies]
11-
chrono = "0.4.40"

etc/default/auth-monitor

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ MAX_FAILED_ATTEMPTS=5
66
# Default: 1800
77
RESET_AFTER_SECONDS=1800
88

9+
# The subsequent authentication failures will be ignored for the specified milliseconds.
10+
# Default: 0
11+
IGNORE_SUBSEQUENT_FAILS_MS=100
12+
913
# The path to the file were authentication logs are stored.
1014
# Default: /var/log/auth.log
1115
LOG_FILE=/var/log/auth.log

etc/systemd/system/auth-monitor.service

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ Description=AuthMonitor
33

44
[Service]
55
EnvironmentFile=/etc/default/auth-monitor
6-
ExecStart=/usr/local/bin/auth-monitor ${LOG_FILE} --max-failed-attempts=${MAX_FAILED_ATTEMPTS} --reset-after-seconds=${RESET_AFTER_SECONDS}
6+
ExecStart=/usr/local/bin/auth-monitor "${LOG_FILE}" \
7+
--max-failed-attempts=${MAX_FAILED_ATTEMPTS} \
8+
--reset-after-seconds=${RESET_AFTER_SECONDS} \
9+
--ignore-subsequent-fails-ms=${IGNORE_SUBSEQUENT_FAILS_MS}
710
Restart=always
811
User=auth-monitor
912

src/auth_message_parser.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
use chrono::DateTime;
2+
3+
pub const DATE_FORMAT_ISO_8601: &str = "%Y-%m-%dT%H:%M:%S.%6f%:z";
4+
15
pub struct AuthMessageParser {
26
patterns: Vec<AuthFailedMessagePattern>,
37
}
@@ -36,6 +40,14 @@ impl AuthMessageParser {
3640
}
3741
return false;
3842
}
43+
44+
pub fn get_message_timestamp_millis(&self, message: &str) -> i64 {
45+
let date_time_str = message.get(0..32).unwrap_or("");
46+
return match DateTime::parse_from_str(date_time_str, DATE_FORMAT_ISO_8601) {
47+
Ok(date_time) => date_time.timestamp_millis(),
48+
Err(_) => 0,
49+
};
50+
}
3951
}
4052

4153
#[cfg(test)]

src/auth_message_parser_tests.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use crate::auth_message_parser::AuthMessageParser;
1+
use chrono::{Duration, Local};
2+
use std::ops::Sub;
3+
4+
use crate::auth_message_parser::{AuthMessageParser, DATE_FORMAT_ISO_8601};
25
use crate::test_utils::test_file::AUTH_FAILED_TEST_MESSAGES;
36

47
#[test]
@@ -55,3 +58,36 @@ fn when_message_is_not_auth_failed_message_then_returns_false() {
5558
assert!(!parser.is_auth_failed_message(message));
5659
}
5760
}
61+
62+
#[test]
63+
fn when_parsing_message_with_incorrect_date_time_format_then_return_0() {
64+
let messages = "2024-02-10T14:26:03.323862+01:0 workstation systemd-logind[2089]: The system will power off now!
65+
2024-02-10T14:26:03.341715 workstation systemd-logind[2089]: System is powering down.
66+
2024-02-10T14:34:24.37471+01:00 workstation sudo: pam_unix(sudo:session): session closed for user root
67+
workstation sudo: pam_unix(sudo:session): session closed for user root";
68+
let parser = AuthMessageParser::new();
69+
for message in messages.split('\n') {
70+
let message_timestamp = parser.get_message_timestamp_millis(message);
71+
assert_eq!(message_timestamp, 0);
72+
}
73+
}
74+
75+
#[test]
76+
fn when_parsing_message_with_correct_date_time_format_then_return_timestamp_in_millis() {
77+
let now = Local::now();
78+
let date_times = [
79+
now,
80+
now.sub(Duration::milliseconds(123)),
81+
now.sub(Duration::seconds(1234)),
82+
now.sub(Duration::minutes(1234)),
83+
now.sub(Duration::hours(4)),
84+
];
85+
let parser = AuthMessageParser::new();
86+
for date_time in date_times {
87+
let formatted_date_time = date_time.format(DATE_FORMAT_ISO_8601);
88+
let message = format!("{} {}", formatted_date_time, AUTH_FAILED_TEST_MESSAGES[0]);
89+
let message_timestamp = parser.get_message_timestamp_millis(&message);
90+
let expected_timestamp = date_time.timestamp_millis();
91+
assert_eq!(message_timestamp, expected_timestamp);
92+
}
93+
}

src/auth_monitor.rs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
use chrono::Local;
12
use std::error::Error;
2-
use std::time::{Duration, SystemTime};
33

44
use crate::auth_file_watcher::AuthFileWatcher;
55
use crate::auth_message_parser::AuthMessageParser;
@@ -11,7 +11,7 @@ pub struct AuthMonitor {
1111
options: AuthMonitorOptions,
1212
file_watcher: AuthFileWatcher,
1313
auth_message_parser: AuthMessageParser,
14-
last_failed_auth: SystemTime,
14+
last_failed_auth_timestamp: i64,
1515
}
1616

1717
impl AuthMonitor {
@@ -22,48 +22,71 @@ impl AuthMonitor {
2222
options: params.options,
2323
file_watcher: AuthFileWatcher::new(&params.filepath)?,
2424
auth_message_parser: AuthMessageParser::new(),
25-
last_failed_auth: SystemTime::UNIX_EPOCH,
25+
last_failed_auth_timestamp: 0,
2626
});
2727
}
2828

2929
pub fn update(&mut self, on_max_failed_attempts: impl FnOnce()) {
3030
if self.should_reset_failed_attempts() {
3131
self.reset_failed_attempts();
3232
}
33+
3334
let mut failed_attempts = 0;
35+
3436
self.file_watcher.update(|line| {
35-
if self.auth_message_parser.is_auth_failed_message(line) {
36-
failed_attempts += 1;
37+
if !self.auth_message_parser.is_auth_failed_message(line) {
38+
return;
39+
}
40+
print!("Auth failed message: {}", line);
41+
let mut auth_failed_timestamp =
42+
self.auth_message_parser.get_message_timestamp_millis(line);
43+
if auth_failed_timestamp == 0 {
44+
auth_failed_timestamp = Local::now().timestamp_millis();
45+
}
46+
if self.options.ignore_subsequent_fails_ms > 0 {
47+
let millis_since_last_failed_auth =
48+
auth_failed_timestamp - self.last_failed_auth_timestamp;
49+
if millis_since_last_failed_auth <= self.options.ignore_subsequent_fails_ms as i64 {
50+
println!(
51+
"Auth fail ignored ({} ms since last)",
52+
millis_since_last_failed_auth
53+
);
54+
return;
55+
}
3756
}
57+
self.last_failed_auth_timestamp = auth_failed_timestamp;
58+
failed_attempts += 1;
3859
});
60+
3961
if failed_attempts > 0 {
4062
self.increase_failed_attempts(failed_attempts, on_max_failed_attempts);
4163
}
4264
}
4365

4466
fn should_reset_failed_attempts(&self) -> bool {
67+
if self.last_failed_auth_timestamp == 0 {
68+
return false;
69+
}
4570
if self.failed_attempts <= 0 || self.failed_attempts >= self.options.max_failed_attempts {
4671
return false;
4772
}
48-
let seconds_from_last_error = SystemTime::now()
49-
.duration_since(self.last_failed_auth)
50-
.unwrap_or(Duration::ZERO)
51-
.as_secs();
52-
return seconds_from_last_error > self.options.reset_after_seconds as u64;
73+
let millis_since_last_auth_fail =
74+
Local::now().timestamp_millis() - self.last_failed_auth_timestamp;
75+
let reset_after_millis = self.options.reset_after_seconds as i64 * 1000;
76+
return millis_since_last_auth_fail > reset_after_millis;
5377
}
5478

5579
fn reset_failed_attempts(&mut self) {
5680
println!("Resetting failed attempts");
5781
self.failed_attempts = 0;
58-
self.last_failed_auth = SystemTime::now();
82+
self.last_failed_auth_timestamp = 0;
5983
}
6084

6185
fn increase_failed_attempts(
6286
&mut self,
6387
failed_attempts: i32,
6488
on_max_failed_attempts: impl FnOnce(),
6589
) {
66-
self.last_failed_auth = SystemTime::now();
6790
self.failed_attempts += failed_attempts;
6891
println!("Authentication failed {} time(s)", self.failed_attempts);
6992
if self.failed_attempts >= self.options.max_failed_attempts {

src/auth_monitor_options.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ use std::fmt::{Display, Formatter};
44
pub struct AuthMonitorOptions {
55
pub max_failed_attempts: i32,
66
pub reset_after_seconds: i32,
7+
pub ignore_subsequent_fails_ms: i32,
78
}
89

910
impl Default for AuthMonitorOptions {
1011
fn default() -> Self {
1112
return AuthMonitorOptions {
1213
max_failed_attempts: 5,
1314
reset_after_seconds: 1800,
15+
ignore_subsequent_fails_ms: 0,
1416
};
1517
}
1618
}
@@ -19,8 +21,8 @@ impl Display for AuthMonitorOptions {
1921
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
2022
return write!(
2123
formatter,
22-
"max-failed-attempts={}, reset-after-seconds={}",
23-
self.max_failed_attempts, self.reset_after_seconds
24+
"max-failed-attempts={}, reset-after-seconds={}, ignore-subsequent-fails-ms={}",
25+
self.max_failed_attempts, self.reset_after_seconds, self.ignore_subsequent_fails_ms
2426
);
2527
}
2628
}

src/auth_monitor_params.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const OPTION_VALUE_SEPARATOR_LENGTH: usize = 1;
1212

1313
const MAX_FAILED_ATTEMPTS_OPTION: &str = "max-failed-attempts";
1414
const RESET_AFTER_SECONDS_OPTION: &str = "reset-after-seconds";
15+
const IGNORE_SUBSEQUENT_FAILS_MS_OPTION: &str = "ignore-subsequent-fails-ms";
1516

1617
pub struct AuthMonitorParams {
1718
pub filepath: String,
@@ -47,6 +48,10 @@ impl AuthMonitorParams {
4748
params.options.reset_after_seconds =
4849
Self::parse_option_value(option_name, option_value)?;
4950
}
51+
IGNORE_SUBSEQUENT_FAILS_MS_OPTION => {
52+
params.options.ignore_subsequent_fails_ms =
53+
Self::parse_option_value(option_name, option_value)?;
54+
}
5055
_ => Err(format!("Unknown option {}", argument))?,
5156
}
5257
}
@@ -92,6 +97,12 @@ impl AuthMonitorParams {
9297
RESET_AFTER_SECONDS_OPTION
9398
))?;
9499
}
100+
if self.options.ignore_subsequent_fails_ms < 0 {
101+
return Err(format!(
102+
"{} must be greater than or equal 0",
103+
IGNORE_SUBSEQUENT_FAILS_MS_OPTION
104+
))?;
105+
}
95106
return Ok(());
96107
}
97108
}

src/auth_monitor_params_tests.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ use std::error::Error;
33
use crate::assert_error;
44
use crate::auth_monitor_options::AuthMonitorOptions;
55
use crate::auth_monitor_params::{
6-
AuthMonitorParams, MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION,
6+
AuthMonitorParams, IGNORE_SUBSEQUENT_FAILS_MS_OPTION, MAX_FAILED_ATTEMPTS_OPTION,
7+
RESET_AFTER_SECONDS_OPTION,
78
};
89

910
const FILEPATH: &str = "/var/log/auth.log";
10-
const ALL_OPTIONS: [&str; 2] = [MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION];
11+
const ALL_OPTIONS: [&str; 3] = [
12+
MAX_FAILED_ATTEMPTS_OPTION,
13+
RESET_AFTER_SECONDS_OPTION,
14+
IGNORE_SUBSEQUENT_FAILS_MS_OPTION,
15+
];
16+
17+
const ZERO_VALUE_ALLOWED_OPTIONS: [&str; 1] = [IGNORE_SUBSEQUENT_FAILS_MS_OPTION];
18+
19+
const ZERO_VALUE_NOT_ALLOWED_OPTIONS: [&str; 2] =
20+
[MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION];
1121

1222
type AuthMonitorResult = Result<AuthMonitorParams, Box<dyn Error>>;
1323

@@ -94,11 +104,16 @@ fn when_parsing_filepath_with_one_option_with_correct_value_then_return_params_w
94104
true => value,
95105
false => default_params.options.reset_after_seconds,
96106
};
107+
let ignore_subsequent_fails_ms = match option == IGNORE_SUBSEQUENT_FAILS_MS_OPTION {
108+
true => value,
109+
false => default_params.options.ignore_subsequent_fails_ms,
110+
};
97111
let expected = AuthMonitorParams {
98112
filepath: String::from(FILEPATH),
99113
options: AuthMonitorOptions {
100114
max_failed_attempts,
101115
reset_after_seconds,
116+
ignore_subsequent_fails_ms,
102117
},
103118
};
104119
expect_equals(AuthMonitorParams::from_arguments(&arguments), &expected);
@@ -141,32 +156,48 @@ fn when_parsing_option_with_no_value_then_return_no_value_error() {
141156
}
142157

143158
#[test]
144-
fn when_parsing_option_with_value_less_than_0_then_invalid_value_error() {
145-
let invalid_values = [0, -1, -1024, i32::MIN];
146-
for option in ALL_OPTIONS {
147-
for value in invalid_values {
159+
fn when_parsing_option_with_out_of_range_value_then_return_value_out_of_range_error() {
160+
let zero_not_allowed_invalid_values = [0, -1, -1024, i32::MIN];
161+
for option in ZERO_VALUE_NOT_ALLOWED_OPTIONS {
162+
for value in zero_not_allowed_invalid_values {
148163
let option_argument = format!("--{}={}", option, value);
149164
let arguments = [String::from(FILEPATH), option_argument];
150165
let expected = format!("{} must be greater than 0", option);
151166
assert_error!(AuthMonitorParams::from_arguments(&arguments), expected);
152167
}
153168
}
169+
170+
let zero_allowed_invalid_values = [-1, -1024, i32::MIN];
171+
for option in ZERO_VALUE_ALLOWED_OPTIONS {
172+
for value in zero_allowed_invalid_values {
173+
let option_argument = format!("--{}={}", option, value);
174+
let arguments = [String::from(FILEPATH), option_argument];
175+
let expected = format!("{} must be greater than or equal 0", option);
176+
assert_error!(AuthMonitorParams::from_arguments(&arguments), expected);
177+
}
178+
}
154179
}
155180

156181
#[test]
157182
fn when_parsing_filename_and_multiple_options_then_return_params_with_parsed_values() {
158183
let max_failed_attempts = 10;
159184
let reset_after_seconds = 3600;
185+
let ignore_subsequent_fails_ms = 350;
160186
let arguments = [
161187
String::from(FILEPATH),
162188
format!("--{}={}", MAX_FAILED_ATTEMPTS_OPTION, max_failed_attempts),
163189
format!("--{}={}", RESET_AFTER_SECONDS_OPTION, reset_after_seconds),
190+
format!(
191+
"--{}={}",
192+
IGNORE_SUBSEQUENT_FAILS_MS_OPTION, ignore_subsequent_fails_ms
193+
),
164194
];
165195
let expected = AuthMonitorParams {
166196
filepath: String::from(FILEPATH),
167197
options: AuthMonitorOptions {
168198
max_failed_attempts,
169199
reset_after_seconds,
200+
ignore_subsequent_fails_ms,
170201
},
171202
};
172203
expect_equals(AuthMonitorParams::from_arguments(&arguments), &expected);

0 commit comments

Comments
 (0)