From b09443a3862baec0478a86b64cc19dd80c0fd140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 5 Sep 2025 16:43:19 +0200 Subject: [PATCH 1/4] check failed logins before proceeding with TOTP verification --- crates/defguard_core/src/handlers/auth.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 7f47540e26..72fbca948c 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -137,8 +137,10 @@ pub(crate) async fn authenticate( ) -> Result<(CookieJar, PrivateCookieJar, ApiResponse), WebError> { let username_or_email = data.username; debug!("Authenticating user {username_or_email}"); + // check if user can proceed with login check_failed_logins(&appstate.failed_logins, &username_or_email)?; + let settings = Settings::get_current_settings(); // attempt to find user first by username and then by email @@ -690,6 +692,9 @@ pub async fn totp_code( ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { let username = user.username.clone(); + // check if user can proceed with login + check_failed_logins(&appstate.failed_logins, &username)?; + debug!("Verifying TOTP for user {}", username); if user.totp_enabled && user.verify_totp_code(&data.code) { session @@ -872,6 +877,10 @@ pub async fn email_mfa_code( ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { let username = user.username.clone(); + + // check if user can proceed with login + check_failed_logins(&appstate.failed_logins, &username)?; + debug!("Verifying email MFA code for user {}", username); if user.email_mfa_enabled && user.verify_email_mfa_code(&data.code) { session From c681492e6c62c9a20ca191c03b5b9c388f86f55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 5 Sep 2025 16:49:28 +0200 Subject: [PATCH 2/4] use username in failed login map whenever possible --- crates/defguard_core/src/handlers/auth.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 72fbca948c..e3fde86d3c 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -163,7 +163,7 @@ pub(crate) async fn authenticate( "Failed to authenticate user {username_or_email} internally and through LDAP. Internal error: {err}, LDAP error: {ldap_err}" ); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + log_failed_login_attempt(&appstate.failed_logins, &user.username); appstate.emit_event(ApiEvent { context: ApiRequestContext::new( user.id, @@ -182,7 +182,7 @@ pub(crate) async fn authenticate( } } else { warn!("Failed to authenticate user {username_or_email}: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + log_failed_login_attempt(&appstate.failed_logins, &user.username); appstate.emit_event(ApiEvent { context: ApiRequestContext::new( user.id, From 98fc83442ac7b9ed2cd82d3dc9809a61e2c5d634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 5 Sep 2025 16:51:43 +0200 Subject: [PATCH 3/4] log failed code verification attempts --- crates/defguard_core/src/handlers/auth.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index e3fde86d3c..4e629ee746 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -749,6 +749,8 @@ pub async fn totp_code( format!("TOTP authentication is disabled for {username}") }; + log_failed_login_attempt(&appstate.failed_logins, &username); + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires @@ -903,7 +905,7 @@ pub async fn email_mfa_code( }), })?; if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { - debug!("Found openid session cookie."); + debug!("Found OpenID session cookie."); let redirect_url = openid_cookie.value().to_string(); let private_cookies = private_cookies.remove(openid_cookie); Ok(( @@ -935,6 +937,8 @@ pub async fn email_mfa_code( format!("Email code authentication is disabled for {username}") }; + log_failed_login_attempt(&appstate.failed_logins, &username); + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires From 63e4e094db8a85422a402ddeea66d2f878e7057d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 5 Sep 2025 17:13:21 +0200 Subject: [PATCH 4/4] add tests for brute force login --- .../tests/integration/api/auth.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/defguard_core/tests/integration/api/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs index c483b3e200..f7150b9a36 100644 --- a/crates/defguard_core/tests/integration/api/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -284,6 +284,51 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } +#[sqlx::test] +async fn dg25_15_test_totp_brute_force(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let client = make_client(pool).await; + + // login + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // new TOTP secret + let response = client.post("/api/v1/auth/totp/init").send().await; + assert_eq!(response.status(), StatusCode::OK); + let auth_totp: AuthTotp = response.json().await; + + // enable TOTP + let code = totp_code(&auth_totp); + let response = client.post("/api/v1/auth/totp").json(&code).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // enable MFA + let response = client.put("/api/v1/auth/mfa").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // login again, this time a different status code is returned + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // provide wrong TOTP code more than 5 times in a row + let code = AuthCode::new("0"); + for i in 0..10 { + let response = client + .post("/api/v1/auth/totp/verify") + .json(&code) + .send() + .await; + if i >= 5 { + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } else { + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } +} + static EMAIL_CODE_REGEX: &str = r"(?\d{6})"; fn extract_email_code(content: &str) -> &str { let re = regex::Regex::new(EMAIL_CODE_REGEX).unwrap(); @@ -436,6 +481,69 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } +#[sqlx::test] +async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, state) = make_client_with_state(pool).await; + let pool = state.pool; + let mut mail_rx = state.mail_rx; + + // try to initialize email MFA setup before logging in + let response = client.post("/api/v1/auth/email/init").send().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // login + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + // remove login confirmation email from queue + let _mail = mail_rx.try_recv().unwrap(); + + // add dummy SMTP settings + let mut settings = Settings::get_current_settings(); + settings.smtp_server = Some("smtp_server".into()); + settings.smtp_port = Some(587); + settings.smtp_sender = Some("smtp@sender.pl".into()); + update_current_settings(&pool, settings).await.unwrap(); + + // initialize email MFA setup + let response = client.post("/api/v1/auth/email/init").send().await; + assert_eq!(response.status(), StatusCode::OK); + let mail = mail_rx.try_recv().unwrap(); + assert_eq!(mail.to, "h.potter@hogwart.edu.uk"); + assert_eq!(mail.subject, "Your Multi-Factor Authentication Activation"); + let code = extract_email_code(&mail.content); + + // finish setup + let code = AuthCode::new(code); + let response = client.post("/api/v1/auth/email").json(&code).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // enable MFA + let response = client.put("/api/v1/auth/mfa").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // login again, this time a different status code is returned + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // provide wrong code more than 5 times in a row + let code = AuthCode::new("0"); + for i in 0..10 { + let response = client + .post("/api/v1/auth/email/verify") + .json(&code) + .send() + .await; + if i >= 5 { + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } else { + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } +} + #[sqlx::test] async fn test_webauthn(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await;