diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml index 4255c24ef6..e2c60d6c8e 100644 --- a/.github/workflows/test-web.yml +++ b/.github/workflows/test-web.yml @@ -1,5 +1,12 @@ -on: [] - +on: + push: + branches: + - main + - "release/**" + pull_request: + branches: + - main + - "release/**" permissions: contents: read @@ -19,6 +26,9 @@ jobs: run: | npm i -g npm pnpm pnpm i --frozen-lockfile + - name: Generate paraglide messages + working-directory: ./web + run: pnpm exec paraglide-js compile --project ./project.inlang --outdir ./src/paraglide - name: Run tests working-directory: ./web run: pnpm run test diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 0366f9a31e..7f3555aa42 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -102,6 +102,7 @@ pub enum ApiEventType { mfa_method: MFAMethod, message: String, }, + RecoveryCodeLoginFailed, RecoveryCodeUsed, PasswordChangedByAdmin { user: User, diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 64ba738cf2..f68e152ea5 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -1058,6 +1058,11 @@ pub async fn recovery_code( ), )); } + + appstate.emit_event(ApiEvent { + context: ApiRequestContext::new(user.id, username, insecure_ip, user_agent.to_string()), + event: Box::new(ApiEventType::RecoveryCodeLoginFailed), + })?; } Err(WebError::Http(StatusCode::UNAUTHORIZED)) } diff --git a/crates/defguard_core/tests/integration/api/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs index 8f2ec71ec3..dd7a5dad31 100644 --- a/crates/defguard_core/tests/integration/api/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -241,7 +241,7 @@ fn totp_code(auth_totp: &AuthTotp) -> AuthCode { async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let client = make_client(pool).await; + let mut client = make_client(pool).await; // login let auth = Auth::new("hpotter", "pass123"); @@ -274,6 +274,8 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { let response = client.get("/api/v1/me").send().await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + client.drain_all_events(); + // provide wrong TOTP code let code = AuthCode::new("0"); let response = client @@ -282,6 +284,10 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + client.verify_api_events(&[ApiEventType::UserMfaLoginFailed { + mfa_method: MFAMethod::OneTimePassword, + message: "TOTP code verification failed".into(), + }]); // provide recovery code let code = recovery_codes.codes.unwrap().first().unwrap().clone(); @@ -291,6 +297,7 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); + client.verify_api_events(&[ApiEventType::RecoveryCodeUsed]); assert_eq!( response.json::().await.user.username, @@ -309,6 +316,8 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::CREATED); + client.drain_all_events(); + // reuse the same recovery code - shouldn't work let response = client .post("/api/v1/auth/recovery") @@ -316,6 +325,7 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + client.verify_api_events(&[ApiEventType::RecoveryCodeLoginFailed]); // logout let response = client.post("/api/v1/auth/logout").send().await; diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index 03b34d5876..46f2227daa 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -26,6 +26,9 @@ pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { } => Some(format!( "User login using {mfa_method} failed with: {message}" )), + DefguardEvent::RecoveryCodeLoginFailed => { + Some("User login with recovery code failed".to_string()) + } DefguardEvent::UserLogout => None, DefguardEvent::RecoveryCodeUsed => None, DefguardEvent::PasswordChanged => None, diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index a7ffc68ec7..6713365e8f 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -166,6 +166,13 @@ async fn process_batch( }) .ok(), ), + DefguardEvent::RecoveryCodeLoginFailed => ( + EventType::UserMfaLoginFailed, + serde_json::to_value(LoginFailedMetadata { + message: "Recovery code verification failed".to_string(), + }) + .ok(), + ), DefguardEvent::UserLogout => (EventType::UserLogout, None), DefguardEvent::UserDeviceAdded { owner, device } => ( EventType::DeviceAdded, diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 5a2075ebe5..20a11cc392 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -123,6 +123,7 @@ pub enum DefguardEvent { mfa_method: MFAMethod, message: String, }, + RecoveryCodeLoginFailed, RecoveryCodeUsed, PasswordChangedByAdmin { user: User, diff --git a/crates/defguard_event_router/src/handlers/api.rs b/crates/defguard_event_router/src/handlers/api.rs index 15495143ff..bb13a83a1f 100644 --- a/crates/defguard_event_router/src/handlers/api.rs +++ b/crates/defguard_event_router/src/handlers/api.rs @@ -30,6 +30,10 @@ impl EventRouter { })), None, ), + ApiEventType::RecoveryCodeLoginFailed => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::RecoveryCodeLoginFailed)), + None, + ), ApiEventType::RecoveryCodeUsed => ( LoggerEvent::Defguard(Box::new(DefguardEvent::RecoveryCodeUsed)), None, diff --git a/web/src/pages/LocationsPage/components/LocationsTable.tsx b/web/src/pages/LocationsPage/components/LocationsTable.tsx index 55ef965bf0..fba5423588 100644 --- a/web/src/pages/LocationsPage/components/LocationsTable.tsx +++ b/web/src/pages/LocationsPage/components/LocationsTable.tsx @@ -68,7 +68,7 @@ export const LocationsTable = () => { onClick: () => { if ( license?.limits && - license.limits.locations.current === license.limits.locations.limit + license.limits.locations.current >= license.limits.locations.limit ) { openModal(ModalName.LimitReached); } else { diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index b54fc814a8..a52a1c0b7e 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -79,7 +79,7 @@ export const UsersTable = () => { onClick: () => { if ( license?.limits && - license.limits.users.current === license.limits.users.limit + license.limits.users.current >= license.limits.users.limit ) { openModal(ModalName.LimitReached); } else {