From a34aa1cd217476f38fe33edbe002892822a94d37 Mon Sep 17 00:00:00 2001 From: Ksenia Vazhdaeva Date: Thu, 8 Aug 2024 13:10:55 +0700 Subject: [PATCH] Issue 29. Add basic authentication quarantine --- docs/source/cookbook/2_basic_auth.rst | 18 +- docs/source/miscellaneous/2_caching.rst | 33 +- docs/source/reference/0_configuration.rst | 20 +- media_gateway_server/src/main.rs | 86 ++--- .../src/server/configuration.rs | 7 + media_gateway_server/src/server/security.rs | 220 ++++++++++++- .../src/server/security/quarantine.rs | 296 ++++++++++++++++++ .../src/server/service/cache.rs | 214 ++++++++++++- .../server/basic_auth_config.json | 7 + 9 files changed, 837 insertions(+), 64 deletions(-) create mode 100644 media_gateway_server/src/server/security/quarantine.rs diff --git a/docs/source/cookbook/2_basic_auth.rst b/docs/source/cookbook/2_basic_auth.rst index 41cbf71..150ddda 100644 --- a/docs/source/cookbook/2_basic_auth.rst +++ b/docs/source/cookbook/2_basic_auth.rst @@ -1,7 +1,7 @@ HTTP Basic authentication ========================= -Media Gateway can control access to the server via a username/password authentication (HTTP Basic Authentication). HTTP Basic authentication should be used with HTTPS (see :doc:`0_https`) to provide confidentiality. The only endpoint that is available to anyone is :ref:`a health endpoint `. Usernames and passwords are taken from `etcd `__. +Media Gateway can control access to the server via a username/password authentication (HTTP Basic Authentication). HTTP Basic authentication should be used with HTTPS (see :doc:`0_https`) to provide confidentiality. The only endpoint that is available to anyone is :ref:`a health endpoint `. Usernames and passwords are taken from `etcd `__. An optional quarantine feature is available. A user is quarantined for a period if the amount of failed attempts to authenticate reaches the maximum. An error response without password checks is returned if the user is quarantined which reduces risks of attacks (e.g. password hacking, DoS). `Savant messages `__ contain routing labels. Media Gateway server can be configured to accept messages from a user only if routing labels are allowed. Allowed labels in the form of `a label filter rule `__ are taken from `etcd`. @@ -68,6 +68,13 @@ To use HTTP Basic authentication update server and client configurations. Exampl }, "evicted_threshold": 10 } + }, + "quarantine": { + "failed_attempt_limit": 3, + "period": { + "secs": 60, + "nanos": 0 + } } } } @@ -352,6 +359,13 @@ To test the server only prepare a configuration file. The configuration below do }, "evicted_threshold": 10 } + }, + "quarantine": { + "failed_attempt_limit": 3, + "period": { + "secs": 60, + "nanos": 0 + } } } }, @@ -418,6 +432,8 @@ Send a request with an invalid user name and password. HTTP response with ``401 Unauthorized`` status code should be returned. It means that authentication fails. +Send the last request two more times. Each time HTTP response with ``401 Unauthorized`` status code should be returned. After that send the request with the valid password. HTTP response with ``401 Unauthorized`` status code should be returned during 1 minute, after 1 minute - HTTP response with ``400 Bad Request`` status code. + Add a new user `user2` with a password `password2` and send a request using it to test that new users are loaded. .. code-block:: bash diff --git a/docs/source/miscellaneous/2_caching.rst b/docs/source/miscellaneous/2_caching.rst index 136edd9..d14a70b 100644 --- a/docs/source/miscellaneous/2_caching.rst +++ b/docs/source/miscellaneous/2_caching.rst @@ -8,15 +8,28 @@ User data cache User data such as usernames, passwords and allowed routing labels are required for HTTP Basic authentication and authorization and stored in `etcd` (see :doc:`/cookbook/2_basic_auth`). User data is cached and automatically reloaded from `etcd` when its checksum is changed. -Authentication cache --------------------- +Authentication caching structures +--------------------------------- + +Check result cache +^^^^^^^^^^^^^^^^^^ A separate cache is used to decrease cryptographic costs for HTTP Basic authentication (see :doc:`/cookbook/2_basic_auth`). It holds results of authentication checks which are used for subsequent requests if provided credentials are the same and the user's password is not changed. +Failed attempt cache +^^^^^^^^^^^^^^^^^^^^ + +If a quarantine feature is enabled a separate cache to track failed attempts to authenticate by users is used. It uses the same configuration as authentication check result cache. + +Quarantine +^^^^^^^^^^ + +If a quarantine feature is enabled a separate structure is used to hold names of users that are in quarantine for the specified duration. It uses the same configuration as authentication check result cache and an additional parameter - the duration. + Cache configuration ------------------- -Caches use LRU eviction policy. The maximum number of entries the cache may contain is specified in the configuration. The cache might be inefficient if its size is not suitable. To detect such cases cache usage tracking is supported. Cache usage statistics includes evicted entries per period metric. If the metric value exceeds the threshold a warning is reported to logs. +Caching structures use LRU eviction policy. The maximum number of entries the caching structure may contain is specified in the configuration. The caching structure might be inefficient if its size is not suitable. To detect such cases usage tracking is supported. Usage statistics includes evicted entries per period metric. If the metric value exceeds the threshold a warning is reported to logs. .. code-block:: :caption: logs for the exceeded evicted entries threshold in user data cache @@ -24,9 +37,19 @@ Caches use LRU eviction policy. The maximum number of entries the cache may cont [2024-08-05T04:40:20Z WARN media_gateway_server::server::service::cache] Evicted entities threshold is exceeded for user: 7 per 60.001 seconds .. code-block:: - :caption: logs for the exceeded evicted entries threshold in authentication cache + :caption: logs for the exceeded evicted entries threshold in authentication check result cache + + [2024-08-05T04:40:20Z WARN media_gateway_server::server::service::cache] Evicted entities threshold is exceeded for auth check result: 14 per 60.001 seconds + +.. code-block:: + :caption: logs for the exceeded evicted entries threshold in authentication failed attempt cache + + [2024-08-05T04:40:20Z WARN media_gateway_server::server::service::cache] Evicted entities threshold is exceeded for auth failed attempt: 14 per 60.001 seconds + +.. code-block:: + :caption: logs for the exceeded evicted entries threshold in authentication quarantine - [2024-08-05T04:40:20Z WARN media_gateway_server::server::service::cache] Evicted entities threshold is exceeded for auth: 14 per 60.001 seconds + [2024-08-05T04:40:20Z WARN media_gateway_server::server::service::cache] Evicted entities threshold is exceeded for auth quarantine: 14 per 60.001 seconds The period and the threshold are specified in the configuration (see :ref:`cache configuration `). diff --git a/docs/source/reference/0_configuration.rst b/docs/source/reference/0_configuration.rst index 94464b1..5a9aa4f 100644 --- a/docs/source/reference/0_configuration.rst +++ b/docs/source/reference/0_configuration.rst @@ -413,8 +413,11 @@ Authentication settings for the server. - etcd configuration. See below. - true * - cache - - Settings for authentication cache. See :ref:`cache configuration section `. + - Settings for authentication caching structures. See :ref:`cache configuration section `. - true + * - quarantine + - Settings for authentication quarantine. See below. + - false **etcd** @@ -449,6 +452,21 @@ Authentication settings for the server. - Settings for user data cache. See :ref:`cache configuration section `. - true +**authentication quarantine** + +.. list-table:: + :header-rows: 1 + + * - Field + - Description + - Mandatory + * - failed_attempt_limit + - A number of failed attempts after which a quarantine will start for a user. + - true + * - period + - A period to quarantine a user. See :ref:`duration configuration `. + - true + .. _statistics configuration: Statistics diff --git a/media_gateway_server/src/main.rs b/media_gateway_server/src/main.rs index 96f7b5a..7412c16 100644 --- a/media_gateway_server/src/main.rs +++ b/media_gateway_server/src/main.rs @@ -69,10 +69,11 @@ use media_gateway_common::health::HealthService; use server::configuration::GatewayConfiguration; use crate::server::api::gateway; -use crate::server::security::{basic_auth_validator, BasicAuthCheckResult}; -use crate::server::service::cache::{ - Cache, CacheUsageFactory, CacheUsageTracker, NoOpCacheUsageTracker, +use crate::server::security::quarantine::{ + AuthQuarantine, AuthQuarantineFactory, NoOpAuthQuarantine, }; +use crate::server::security::{basic_auth_validator, BasicAuthCheckResult}; +use crate::server::service::cache::{Cache, CacheUsageFactory, NoOpCacheUsageTracker}; use crate::server::service::crypto::argon2::Argon2PasswordService; use crate::server::service::crypto::PasswordService; use crate::server::service::gateway::GatewayService; @@ -84,8 +85,8 @@ mod server; type AuthAppData = ( Box + Sync + Send>, - NonZeroUsize, - Arc>, + Cache, + Box, ); fn main() -> Result<()> { @@ -110,42 +111,50 @@ fn main() -> Result<()> { let gateway_service = web::Data::new(Mutex::new(GatewayService::try_from(&conf)?)); let health_service = web::Data::new(HealthService::new()); let auth_enabled = conf.auth.is_some(); - let (storage, cache_size, cache_usage_tracker): AuthAppData = if let Some(auth_conf) = conf.auth - { - let auth_cache_usage_tracker = CacheUsageFactory::from( - auth_conf.basic.cache.usage.as_ref(), - "auth".to_string(), - &runtime, - ); - let storage_cache_usage_tracker = CacheUsageFactory::from( - auth_conf.basic.etcd.cache.usage.as_ref(), - "user".to_string(), - &runtime, - ); - ( - Box::new( - EtcdStorage::try_from(( - &auth_conf.basic.etcd, - &runtime, - storage_cache_usage_tracker.clone(), - )) - .unwrap(), - ), - auth_conf.basic.cache.size, - auth_cache_usage_tracker, - ) - } else { - ( - Box::new(EmptyStorage {}), - NonZeroUsize::new(1).unwrap(), - Arc::new(Box::new(NoOpCacheUsageTracker {})), - ) - }; + let (user_storage, auth_cache, auth_quarantine): AuthAppData = + if let Some(auth_conf) = conf.auth { + let auth_check_result_cache_usage_tracker = CacheUsageFactory::from( + auth_conf.basic.cache.usage.as_ref(), + "auth check result".to_string(), + &runtime, + ); + let auth_quarantine = AuthQuarantineFactory::from(&auth_conf.basic, &runtime)?; + let storage_cache_usage_tracker = CacheUsageFactory::from( + auth_conf.basic.etcd.cache.usage.as_ref(), + "user".to_string(), + &runtime, + ); + ( + Box::new( + EtcdStorage::try_from(( + &auth_conf.basic.etcd, + &runtime, + storage_cache_usage_tracker.clone(), + )) + .unwrap(), + ), + Cache::new( + auth_conf.basic.cache.size, + auth_check_result_cache_usage_tracker, + ), + auth_quarantine, + ) + } else { + ( + Box::new(EmptyStorage {}), + Cache::new( + NonZeroUsize::new(1).unwrap(), + Arc::new(Box::new(NoOpCacheUsageTracker {})), + ), + Box::new(NoOpAuthQuarantine {}), + ) + }; let basic_auth_cache: web::Data> = - web::Data::new(Cache::new(cache_size, cache_usage_tracker)); + web::Data::new(auth_cache); + let basic_auth_quarantine = web::Data::new(auth_quarantine); let password_service: web::Data> = web::Data::new(Box::new(Argon2PasswordService {})); - let user_service = web::Data::new(UserService::new(storage)); + let user_service = web::Data::new(UserService::new(user_storage)); let mut http_server = HttpServer::new(move || { App::new() @@ -155,6 +164,7 @@ fn main() -> Result<()> { .app_data(user_service.clone()) .app_data(password_service.clone()) .app_data(basic_auth_cache.clone()) + .app_data(basic_auth_quarantine.clone()) .route("", web::post().to(gateway)) .wrap(Condition::new( auth_enabled, diff --git a/media_gateway_server/src/server/configuration.rs b/media_gateway_server/src/server/configuration.rs index 7fddd51..cbebf96 100644 --- a/media_gateway_server/src/server/configuration.rs +++ b/media_gateway_server/src/server/configuration.rs @@ -48,6 +48,13 @@ pub struct AuthConfiguration { pub struct BasicAuthConfiguration { pub etcd: EtcdConfiguration, pub cache: CacheConfiguration, + pub quarantine: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthQuarantineConfiguration { + pub period: Duration, + pub failed_attempt_limit: u32, } #[derive(Debug, Serialize, Deserialize)] diff --git a/media_gateway_server/src/server/security.rs b/media_gateway_server/src/server/security.rs index 74c81d4..e7b0559 100644 --- a/media_gateway_server/src/server/security.rs +++ b/media_gateway_server/src/server/security.rs @@ -1,14 +1,19 @@ -use crate::server::service::cache::Cache; -use crate::server::service::crypto::PasswordService; -use crate::server::service::user::UserService; use actix_web::dev::ServiceRequest; use actix_web::web::Data; use actix_web::{Error, HttpMessage}; use actix_web_httpauth::extractors::basic::BasicAuth; use anyhow::anyhow; use log::error; + use media_gateway_common::configuration::Credentials; +use crate::server::security::quarantine::AuthQuarantine; +use crate::server::service::cache::Cache; +use crate::server::service::crypto::PasswordService; +use crate::server::service::user::UserService; + +pub mod quarantine; + fn to_credentials(value: &BasicAuth) -> Result { if let Some(password) = value.password() { Ok(Credentials { @@ -73,12 +78,24 @@ pub async fn basic_auth_validator( } let password_service = password_service.unwrap(); - let basic_auth_cache = req.app_data::>>(); - if basic_auth_cache.is_none() { - error!("No basic auth cache"); + let basic_auth_check_result_cache = + req.app_data::>>(); + if basic_auth_check_result_cache.is_none() { + error!("No basic auth check result cache"); return Err((actix_web::error::ErrorInternalServerError(""), req)); } - let basic_auth_cache = basic_auth_cache.unwrap(); + let basic_auth_check_result_cache = basic_auth_check_result_cache.unwrap(); + + let basic_auth_quarantine = req.app_data::>>(); + if basic_auth_quarantine.is_none() { + error!("No basic auth quarantine"); + return Err((actix_web::error::ErrorInternalServerError(""), req)); + } + let basic_auth_quarantine = basic_auth_quarantine.unwrap(); + + if basic_auth_quarantine.in_quarantine(credentials.user_id()) { + return Err((actix_web::error::ErrorUnauthorized(""), req)); + } let password = credentials.password(); if password.is_none() { @@ -92,15 +109,20 @@ pub async fn basic_auth_validator( error!("Error while retrieving user data: {:?}", e); Err((actix_web::error::ErrorInternalServerError(""), req)) } - Ok(None) => Err((actix_web::error::ErrorUnauthorized(""), req)), + Ok(None) => { + basic_auth_quarantine.register_failure(credentials.username.as_str()); + Err((actix_web::error::ErrorUnauthorized(""), req)) + } Ok(Some(user_data)) => { - let cache_result = basic_auth_cache.get(&credentials); + let cache_result = basic_auth_check_result_cache.get(&credentials); match cache_result { Some(e) if e.password_hash == user_data.password_hash => { if e.valid { + basic_auth_quarantine.register_success(credentials.username.as_str()); req.extensions_mut().insert(user_data); Ok(req) } else { + basic_auth_quarantine.register_failure(credentials.username.as_str()); Err((actix_web::error::ErrorUnauthorized(""), req)) } } @@ -111,14 +133,16 @@ pub async fn basic_auth_validator( ) { Err(e) => { error!("Error while verifying a user password: {:?}", e); - basic_auth_cache.push( + basic_auth_quarantine.register_failure(credentials.username.as_str()); + basic_auth_check_result_cache.push( credentials, BasicAuthCheckResult::invalid(user_data.password_hash.clone()), ); Err((actix_web::error::ErrorUnauthorized(""), req)) } Ok(true) => { - basic_auth_cache.push( + basic_auth_quarantine.register_success(credentials.username.as_str()); + basic_auth_check_result_cache.push( credentials, BasicAuthCheckResult::valid(user_data.password_hash.clone()), ); @@ -126,7 +150,8 @@ pub async fn basic_auth_validator( Ok(req) } Ok(false) => { - basic_auth_cache.push( + basic_auth_quarantine.register_failure(credentials.username.as_str()); + basic_auth_check_result_cache.push( credentials, BasicAuthCheckResult::invalid(user_data.password_hash.clone()), ); @@ -150,6 +175,7 @@ mod tests { use anyhow::anyhow; use mockall::predicate::eq; + use crate::server::security::quarantine::MockAuthQuarantine; use crate::server::service::cache::NoOpCacheUsageTracker; use crate::server::service::crypto::MockPasswordService; use crate::server::service::user::UserData; @@ -205,12 +231,59 @@ mod tests { check_error(result, StatusCode::INTERNAL_SERVER_ERROR); } + #[actix_web::test] + async fn basic_auth_no_basic_auth_quarantine() { + let user_service = Data::new(UserService::new(storage_to_box(MockStorage::new()))); + let password_service = Data::new(password_service_to_box(MockPasswordService::new())); + let cache = new_cache(); + let service_request = test::TestRequest::default() + .app_data(user_service.clone()) + .app_data(password_service.clone()) + .app_data(cache.clone()) + .to_srv_request(); + + let result = basic_auth_validator( + service_request, + BasicAuth::from(Basic::new(ID, Some(PASSWORD))), + ) + .await; + check_error(result, StatusCode::INTERNAL_SERVER_ERROR); + } + + #[actix_web::test] + async fn basic_auth_in_quarantine() { + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| true); + + basic_auth_error( + storage_to_box(MockStorage::new()), + password_service_to_box(MockPasswordService::new()), + new_cache(), + auth_quarantine_to_box(auth_quarantine), + BasicAuth::from(Basic::new(ID, None::)), + StatusCode::UNAUTHORIZED, + ) + .await; + } + #[actix_web::test] async fn basic_auth_no_password() { + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + basic_auth_error( storage_to_box(MockStorage::new()), password_service_to_box(MockPasswordService::new()), new_cache(), + auth_quarantine_to_box(auth_quarantine), BasicAuth::from(Basic::new(ID, None::)), StatusCode::UNAUTHORIZED, ) @@ -225,11 +298,18 @@ mod tests { .with(eq(ID)) .times(1) .returning(|_x| Err(anyhow!("error"))); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); basic_auth_error( storage_to_box(storage), password_service_to_box(MockPasswordService::new()), new_cache(), + auth_quarantine_to_box(auth_quarantine), BasicAuth::from(Basic::new(ID, Some(PASSWORD))), StatusCode::INTERNAL_SERVER_ERROR, ) @@ -245,10 +325,23 @@ mod tests { .times(1) .returning(|_x| Ok(None)); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); + basic_auth_error( storage_to_box(storage), password_service_to_box(MockPasswordService::new()), new_cache(), + auth_quarantine_to_box(auth_quarantine), BasicAuth::from(Basic::new(ID, Some(PASSWORD))), StatusCode::UNAUTHORIZED, ) @@ -263,6 +356,17 @@ mod tests { .with(eq(ID)) .times(1) .returning(|_x| Ok(None)); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let cache = new_cache(); let basic_auth = BasicAuth::from(Basic::new(ID, Some(PASSWORD))); let credentials = to_credentials(&basic_auth).unwrap(); @@ -275,6 +379,7 @@ mod tests { storage_to_box(storage), password_service_to_box(MockPasswordService::new()), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, StatusCode::UNAUTHORIZED, ) @@ -290,6 +395,17 @@ mod tests { allowed_routing_labels: None, })) }); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let mut password_service = MockPasswordService::new(); password_service .expect_verify() @@ -304,6 +420,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, StatusCode::UNAUTHORIZED, ) @@ -324,6 +441,17 @@ mod tests { allowed_routing_labels: None, })) }); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let mut password_service = MockPasswordService::new(); password_service .expect_verify() @@ -338,6 +466,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, StatusCode::UNAUTHORIZED, ) @@ -358,6 +487,17 @@ mod tests { allowed_routing_labels: None, })) }); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let password_service = MockPasswordService::new(); let cache: Data> = new_cache(); let basic_auth = BasicAuth::from(Basic::new(ID, Some(PASSWORD))); @@ -371,6 +511,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, StatusCode::UNAUTHORIZED, ) @@ -392,6 +533,17 @@ mod tests { allowed_routing_labels: None, })) }); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_failure() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let mut password_service = MockPasswordService::new(); password_service .expect_verify() @@ -410,6 +562,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, StatusCode::UNAUTHORIZED, ) @@ -433,6 +586,17 @@ mod tests { .expect_get() .with(eq(ID)) .return_once(|_x| Ok(storage_user_data)); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_success() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let mut password_service = MockPasswordService::new(); password_service .expect_verify() @@ -447,6 +611,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, ) .await; @@ -475,6 +640,17 @@ mod tests { .expect_get() .with(eq(ID)) .return_once(|_x| Ok(storage_user_data)); + let mut auth_quarantine = MockAuthQuarantine::new(); + auth_quarantine + .expect_in_quarantine() + .with(eq(ID)) + .times(1) + .returning(|_x| false); + auth_quarantine + .expect_register_success() + .with(eq(ID.to_string())) + .times(1) + .returning(|_x| {}); let password_service = MockPasswordService::new(); let cache: Data> = new_cache(); let basic_auth = BasicAuth::from(Basic::new(ID, Some(PASSWORD))); @@ -488,6 +664,7 @@ mod tests { storage_to_box(storage), password_service_to_box(password_service), cache.clone(), + auth_quarantine_to_box(auth_quarantine), basic_auth, ) .await; @@ -508,10 +685,18 @@ mod tests { storage: Box + Send + Sync>, password_service: Box, cache: Data>, + auth_quarantine: Box, basic_auth: BasicAuth, expected_status_code: StatusCode, ) { - let result = get_result(storage, password_service, cache, basic_auth).await; + let result = get_result( + storage, + password_service, + cache, + auth_quarantine, + basic_auth, + ) + .await; check_error(result, expected_status_code); } @@ -519,14 +704,17 @@ mod tests { storage: Box + Send + Sync>, password_service: Box, cache: Data>, + auth_quarantine: Box, basic_auth: BasicAuth, ) -> Result { let user_service = Data::new(UserService::new(storage)); let password_service = Data::new(password_service); + let auth_quarantine = Data::new(auth_quarantine); let service_request = test::TestRequest::default() .app_data(user_service.clone()) .app_data(password_service.clone()) .app_data(cache.clone()) + .app_data(auth_quarantine.clone()) .to_srv_request(); basic_auth_validator(service_request, basic_auth).await @@ -555,6 +743,12 @@ mod tests { Box::new(password_service) } + fn auth_quarantine_to_box( + auth_quarantine: MockAuthQuarantine, + ) -> Box { + Box::new(auth_quarantine) + } + fn new_cache() -> Data> { Data::new(Cache::new( NonZeroUsize::new(1).unwrap(), diff --git a/media_gateway_server/src/server/security/quarantine.rs b/media_gateway_server/src/server/security/quarantine.rs new file mode 100644 index 0000000..f10efc3 --- /dev/null +++ b/media_gateway_server/src/server/security/quarantine.rs @@ -0,0 +1,296 @@ +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use log::warn; +use mockall::automock; +use parking_lot::Mutex; +use tokio::runtime::Runtime; + +use crate::server::configuration::BasicAuthConfiguration; +use crate::server::service::cache::{Cache, CacheUsageFactory, LruTtlSet}; + +#[automock] +pub trait AuthQuarantine { + fn register_failure(&self, username: &str); + + fn register_success(&self, username: &str); + + fn in_quarantine(&self, username: &str) -> bool; +} + +pub struct AuthQuarantineImpl { + failed_attempt_limit: u32, + failed_attempt_cache: Cache, + quarantine: LruTtlSet, + mutex: Mutex<()>, +} + +impl AuthQuarantineImpl { + pub fn new( + failed_attempt_limit: u32, + failed_attempt_cache: Cache, + quarantine: LruTtlSet, + ) -> Self { + Self { + failed_attempt_limit, + failed_attempt_cache, + quarantine, + mutex: Mutex::new(()), + } + } +} + +impl AuthQuarantine for AuthQuarantineImpl { + fn register_failure(&self, username: &str) { + let _unused = self.mutex.lock(); + let username = username.to_string(); + if self.quarantine.contains(&username) { + // by usage logic this case should never happen + warn!("Failure is registered while being in quarantine"); + return; + } + let failed_attempts = self + .failed_attempt_cache + .get(&username) + .map_or(1, |e| e + 1); + if failed_attempts == self.failed_attempt_limit { + self.failed_attempt_cache.pop(&username); + self.quarantine.add(username); + } else { + self.failed_attempt_cache.push(username, failed_attempts); + } + } + + fn register_success(&self, username: &str) { + let _unused = self.mutex.lock(); + let username = username.to_string(); + if self.quarantine.contains(&username) { + // by usage logic this case should never happen + warn!("Success is registered while being in quarantine"); + return; + } + self.failed_attempt_cache.pop(&username); + } + + fn in_quarantine(&self, username: &str) -> bool { + let _unused = self.mutex.lock(); + self.quarantine.contains(username) + } +} + +pub struct NoOpAuthQuarantine {} + +impl AuthQuarantine for NoOpAuthQuarantine { + fn register_failure(&self, _username: &str) {} + + fn register_success(&self, _username: &str) {} + + fn in_quarantine(&self, _username: &str) -> bool { + false + } +} + +pub struct AuthQuarantineFactory {} + +impl AuthQuarantineFactory { + pub fn from( + configuration: &BasicAuthConfiguration, + runtime: &Runtime, + ) -> Result> { + if let Some(quarantine_config) = &configuration.quarantine { + if quarantine_config.period == Duration::ZERO { + return Err(anyhow!("Invalid quarantine period: zero")); + } + if quarantine_config.failed_attempt_limit == 0 { + return Err(anyhow!("Invalid quarantine failed_attempt_limit: zero")); + } + let failed_attempt_usage_tracker = CacheUsageFactory::from( + configuration.cache.usage.as_ref(), + "auth failed attempt".to_string(), + runtime, + ); + let quarantine_usage_tracker = CacheUsageFactory::from( + configuration.cache.usage.as_ref(), + "auth quarantine".to_string(), + runtime, + ); + Ok(Box::new(AuthQuarantineImpl::new( + quarantine_config.failed_attempt_limit, + Cache::new(configuration.cache.size, failed_attempt_usage_tracker), + LruTtlSet::new( + configuration.cache.size, + quarantine_config.period, + quarantine_usage_tracker, + ), + ))) + } else { + Ok(Box::new(NoOpAuthQuarantine {})) + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + use std::sync::Arc; + use std::thread::sleep; + use std::time::Duration; + + use crate::server::security::quarantine::{AuthQuarantine, AuthQuarantineImpl}; + use crate::server::service::cache::{Cache, LruTtlSet, NoOpCacheUsageTracker}; + + const USERNAME: &str = "user"; + + #[test] + pub fn in_quarantine_empty() { + let auth_quarantine = new_auth_quarantine(10, Duration::from_secs(1)); + + let result = auth_quarantine.in_quarantine(USERNAME); + + assert_eq!(result, false); + } + + #[test] + pub fn in_quarantine_existing_value() { + let cache = new_cache(); + let lru_ttl_set = new_lru_ttl_set(Duration::from_secs(1)); + lru_ttl_set.add(USERNAME.to_string()); + let auth_quarantine = AuthQuarantineImpl::new(10, cache, lru_ttl_set); + + let result = auth_quarantine.in_quarantine(USERNAME); + + assert_eq!(result, true); + } + + #[test] + pub fn in_quarantine_another_value() { + let cache = new_cache(); + let lru_ttl_set = new_lru_ttl_set(Duration::from_secs(1)); + lru_ttl_set.add("abc".to_string()); + let auth_quarantine = AuthQuarantineImpl::new(10, cache, lru_ttl_set); + + let result = auth_quarantine.in_quarantine(USERNAME); + + assert_eq!(result, false); + } + + #[test] + pub fn in_quarantine_expired_value() { + let duration = Duration::from_millis(10); + let cache = new_cache(); + let lru_ttl_set = new_lru_ttl_set(duration); + lru_ttl_set.add(USERNAME.to_string()); + let auth_quarantine = AuthQuarantineImpl::new(10, cache, lru_ttl_set); + + let result = auth_quarantine.in_quarantine(USERNAME); + + assert_eq!(result, true); + + sleep(duration); + + let result = auth_quarantine.in_quarantine(USERNAME); + + assert_eq!(result, false); + } + + #[test] + pub fn register_failure_in_quarantine() { + let cache = new_cache(); + let lru_ttl_set = new_lru_ttl_set(Duration::from_secs(1)); + lru_ttl_set.add(USERNAME.to_string()); + let auth_quarantine = AuthQuarantineImpl::new(10, cache, lru_ttl_set); + + auth_quarantine.register_failure(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), true); + } + + #[test] + pub fn register_failure_first_attempt() { + let auth_quarantine = new_auth_quarantine(10, Duration::from_secs(1)); + + auth_quarantine.register_failure(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + } + + #[test] + pub fn register_failure_last_attempt() { + let auth_quarantine = new_auth_quarantine(2, Duration::from_secs(1)); + + auth_quarantine.register_failure(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), true); + } + + #[test] + pub fn register_success_in_quarantine() { + let cache = new_cache(); + let lru_ttl_set = new_lru_ttl_set(Duration::from_secs(1)); + lru_ttl_set.add(USERNAME.to_string()); + let auth_quarantine = AuthQuarantineImpl::new(10, cache, lru_ttl_set); + + auth_quarantine.register_success(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), true); + } + + #[test] + pub fn register_success_first_attempt() { + let auth_quarantine = new_auth_quarantine(2, Duration::from_secs(1)); + + auth_quarantine.register_failure(USERNAME); + auth_quarantine.register_success(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + assert_eq!(auth_quarantine.in_quarantine(USERNAME), true); + } + + #[test] + pub fn register_success_penultimate_attempt() { + let auth_quarantine = new_auth_quarantine(3, Duration::from_secs(1)); + + auth_quarantine.register_failure(USERNAME); + auth_quarantine.register_failure(USERNAME); + auth_quarantine.register_success(USERNAME); + + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + assert_eq!(auth_quarantine.in_quarantine(USERNAME), false); + + auth_quarantine.register_failure(USERNAME); + assert_eq!(auth_quarantine.in_quarantine(USERNAME), true); + } + + fn new_auth_quarantine(failed_attempt_limit: u32, ttl: Duration) -> AuthQuarantineImpl { + AuthQuarantineImpl::new(failed_attempt_limit, new_cache(), new_lru_ttl_set(ttl)) + } + + fn new_cache() -> Cache { + Cache::new( + NonZeroUsize::new(1).unwrap(), + Arc::new(Box::new(NoOpCacheUsageTracker {})), + ) + } + + fn new_lru_ttl_set(ttl: Duration) -> LruTtlSet { + LruTtlSet::new( + NonZeroUsize::new(1).unwrap(), + ttl, + Arc::new(Box::new(NoOpCacheUsageTracker {})), + ) + } +} diff --git a/media_gateway_server/src/server/service/cache.rs b/media_gateway_server/src/server/service/cache.rs index 216434e..1c4ff02 100644 --- a/media_gateway_server/src/server/service/cache.rs +++ b/media_gateway_server/src/server/service/cache.rs @@ -1,6 +1,7 @@ use std::borrow::Borrow; use std::hash::Hash; use std::num::NonZeroUsize; +use std::ops::Add; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -52,6 +53,78 @@ impl Cache { } result } + + pub fn pop(&self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + let mut cache = self.inner.lock(); + cache.pop(key) + } +} + +pub struct LruTtlSet { + inner: Arc>>, + ttl: Duration, + size: NonZeroUsize, + cache_usage_tracker: Arc>, +} + +impl LruTtlSet { + pub fn new( + size: NonZeroUsize, + ttl: Duration, + cache_usage_tracker: Arc>, + ) -> Self { + LruTtlSet { + inner: Arc::new(Mutex::new(LruCache::new(size))), + ttl, + size, + cache_usage_tracker, + } + } + + pub fn contains(&self, key: &Q) -> bool + where + T: Borrow, + Q: Hash + Eq + ?Sized, + { + let mut cache = self.inner.lock(); + match cache.get(key) { + Some(expiration) => { + if *expiration <= Instant::now() { + cache.pop(key); + false + } else { + true + } + } + None => false, + } + } + + pub fn add(&self, key: T) { + let mut cache = self.inner.lock(); + let now = Instant::now(); + if cache.len() == self.size.get() { + let keys = cache + .iter() + .filter(|e| *e.1 <= now) + .map(|e| e.0.clone()) + .collect::>(); + for key in keys { + cache.pop(&key); + } + } + let pushed_key = key.clone(); + let result = cache.push(key, now.add(self.ttl)); + if let Some((cached_key, _)) = result.as_ref() { + if cached_key != &pushed_key { + self.cache_usage_tracker.register_evicted(); + } + } + } } pub(crate) struct CacheStatistics { @@ -257,14 +330,16 @@ impl CacheUsageFactory { mod tests { use std::num::NonZeroUsize; use std::sync::Arc; + use std::thread::sleep; + use std::time::Duration; - use crate::server::service::cache::{Cache, MockCacheUsageTracker}; + use crate::server::service::cache::{Cache, LruTtlSet, MockCacheUsageTracker}; const KEY: u32 = 1; const VALUE: &str = "value"; #[test] - pub fn get_no_entry() { + pub fn cache_get_no_entry() { let cache: Cache = Cache::new( NonZeroUsize::new(1).unwrap(), Arc::new(Box::new(MockCacheUsageTracker::new())), @@ -276,7 +351,7 @@ mod tests { } #[test] - pub fn get_existing_entry() { + pub fn cache_get_existing_entry() { let cache: Cache = Cache::new( NonZeroUsize::new(1).unwrap(), Arc::new(Box::new(MockCacheUsageTracker::new())), @@ -290,7 +365,7 @@ mod tests { } #[test] - pub fn push_no_entries() { + pub fn cache_push_no_entries() { let cache: Cache = Cache::new( NonZeroUsize::new(1).unwrap(), Arc::new(Box::new(MockCacheUsageTracker::new())), @@ -302,7 +377,7 @@ mod tests { } #[test] - pub fn push_same_entity() { + pub fn cache_push_same_entity() { let cache: Cache = Cache::new( NonZeroUsize::new(1).unwrap(), Arc::new(Box::new(MockCacheUsageTracker::new())), @@ -318,7 +393,7 @@ mod tests { } #[test] - pub fn push_evicted_entity() { + pub fn cache_push_evicted_entity() { let mut cache_usage_tracker = MockCacheUsageTracker::new(); cache_usage_tracker .expect_register_evicted() @@ -337,4 +412,131 @@ mod tests { assert_eq!(result, Some((KEY, VALUE))); } + + #[test] + pub fn cache_pop_no_entry() { + let cache: Cache = Cache::new( + NonZeroUsize::new(1).unwrap(), + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + let result = cache.pop(&KEY); + + assert_eq!(result, None); + } + #[test] + pub fn cache_pop_existing_entry() { + let cache: Cache = Cache::new( + NonZeroUsize::new(1).unwrap(), + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + cache.push(KEY, VALUE); + + let result = cache.pop(&KEY); + + assert_eq!(result, Some(VALUE)); + } + + #[test] + pub fn lru_ttl_set_contains_no_entries() { + let set: LruTtlSet = LruTtlSet::new( + NonZeroUsize::new(10).unwrap(), + Duration::from_secs(1), + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + assert!(!set.contains(&1)); + } + + #[test] + pub fn lru_ttl_set_add() { + let val = 1; + let set = LruTtlSet::new( + NonZeroUsize::new(10).unwrap(), + Duration::from_secs(1), + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + set.add(&val); + + assert!(set.contains(&val)); + } + + #[test] + pub fn lru_ttl_set_contains_another_entry() { + let set = LruTtlSet::new( + NonZeroUsize::new(10).unwrap(), + Duration::from_secs(1), + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + set.add(&1); + + assert!(!set.contains(&2)); + } + + #[test] + pub fn lru_ttl_set_contains_expired_entry() { + let val = 1; + let duration = Duration::from_millis(10); + let set = LruTtlSet::new( + NonZeroUsize::new(10).unwrap(), + duration, + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + set.add(&val); + assert!(set.contains(&val)); + + sleep(duration); + + assert!(!set.contains(&val)); + } + + #[test] + pub fn lru_ttl_add_full_capacity_evicted_entity() { + let existing_value = 1; + let new_value = 2; + let mut cache_usage_tracker = MockCacheUsageTracker::new(); + cache_usage_tracker + .expect_register_evicted() + .return_const(()) + .once(); + let set = LruTtlSet::new( + NonZeroUsize::new(1).unwrap(), + Duration::from_millis(1000), + Arc::new(Box::new(cache_usage_tracker)), + ); + + set.add(&existing_value); + set.add(&new_value); + + assert!(!set.contains(&existing_value)); + assert!(set.contains(&new_value)); + } + + #[test] + pub fn lru_ttl_add_full_capacity_expired_entity() { + let duration = Duration::from_millis(10); + let existing_value = 1; + let expired_value = 2; + let new_value = 3; + let set = LruTtlSet::new( + NonZeroUsize::new(2).unwrap(), + duration, + Arc::new(Box::new(MockCacheUsageTracker::new())), + ); + + set.add(&expired_value); + + sleep(duration); + + set.add(&existing_value); + set.add(&new_value); + + assert!(!set.contains(&expired_value)); + assert!(set.contains(&existing_value)); + assert!(set.contains(&new_value)); + } } diff --git a/samples/configuration/server/basic_auth_config.json b/samples/configuration/server/basic_auth_config.json index 3692d07..6a069e1 100644 --- a/samples/configuration/server/basic_auth_config.json +++ b/samples/configuration/server/basic_auth_config.json @@ -44,6 +44,13 @@ }, "evicted_threshold": 10 } + }, + "quarantine": { + "failed_attempt_limit": 3, + "period": { + "secs": 60, + "nanos": 0 + } } } },