diff --git a/pfSense-pkg-API/files/etc/inc/api/framework/APIAuth.inc b/pfSense-pkg-API/files/etc/inc/api/framework/APIAuth.inc index 44885ee97..a4c5f7bbd 100644 --- a/pfSense-pkg-API/files/etc/inc/api/framework/APIAuth.inc +++ b/pfSense-pkg-API/files/etc/inc/api/framework/APIAuth.inc @@ -118,8 +118,9 @@ class APIAuth { $resp = false; } - # Set our class is_authenticated attribute to our authentication resp and return the resp + # Set our object is_authenticated attribute to our authentication resp, log auth, and return the resp $this->is_authenticated = $resp; + $this->__log_authentication($this->is_authenticated); return $this->is_authenticated; } @@ -174,4 +175,27 @@ class APIAuth { return true; } } + + # Logs the authentication attempt if login protection is enabled for the API + private function __log_authentication($authenticated) { + # Variables + $username = ($this->username) ?: "unknown"; + $ip_address = $this->ip_address; + + # Log authentication attempts if enabled + if (isset($this->api_config["enable_login_protection"])) { + # Log successful authentication + if ($authenticated) { + log_auth( + gettext("Successful login for user '{$username}' from: {$ip_address} (Local Database)") + ); + } + # Log failed authentication + else { + log_auth( + gettext("webConfigurator authentication error for user '{$username}' from: {$ip_address}") + ); + } + } + } } diff --git a/pfSense-pkg-API/files/etc/inc/api/models/APISystemAPIUpdate.inc b/pfSense-pkg-API/files/etc/inc/api/models/APISystemAPIUpdate.inc index 921d36bf8..1078343da 100644 --- a/pfSense-pkg-API/files/etc/inc/api/models/APISystemAPIUpdate.inc +++ b/pfSense-pkg-API/files/etc/inc/api/models/APISystemAPIUpdate.inc @@ -67,6 +67,15 @@ class APISystemAPIUpdate extends APIModel { } } + private function __validate_enable_login_protection() { + # Check for our optional 'enable_login_protection' payload value + if ($this->initial_data['enable_login_protection'] === true) { + $this->validated_data["enable_login_protection"] = ""; + } elseif ($this->initial_data['enable_login_protection'] === false) { + unset($this->validated_data["enable_login_protection"]); + } + } + private function __validate_hasync() { # Check for our optional 'hasync' payload value if ($this->initial_data['hasync'] === true) { @@ -259,6 +268,7 @@ class APISystemAPIUpdate extends APIModel { $this->__validate_keybytes(); $this->__validate_custom_headers(); $this->__validate_access_list(); + $this->__validate_enable_login_protection(); $this->__validate_hasync(); $this->__validate_hasync_hosts(); $this->__validate_hasync_username(); diff --git a/pfSense-pkg-API/files/usr/local/share/pfSense-pkg-API/info.xml b/pfSense-pkg-API/files/usr/local/share/pfSense-pkg-API/info.xml index 1128b031e..6ed49468e 100644 --- a/pfSense-pkg-API/files/usr/local/share/pfSense-pkg-API/info.xml +++ b/pfSense-pkg-API/files/usr/local/share/pfSense-pkg-API/info.xml @@ -20,6 +20,7 @@ sha256 16 + \ No newline at end of file diff --git a/pfSense-pkg-API/files/usr/local/www/api/documentation/openapi.yml b/pfSense-pkg-API/files/usr/local/www/api/documentation/openapi.yml index 92a52db2e..db26fc3c6 100644 --- a/pfSense-pkg-API/files/usr/local/www/api/documentation/openapi.yml +++ b/pfSense-pkg-API/files/usr/local/www/api/documentation/openapi.yml @@ -12073,6 +12073,9 @@ paths: will be disabled and you will no longer be able to API requests until the API enabled via the webConfigurator. type: boolean + enable_login_protection: + description: Enable or disable Login Protection for API authentication requests. + type: boolean hasync: description: Enable or disable HA sync for API configurations. type: boolean diff --git a/pfSense-pkg-API/files/usr/local/www/api/index.php b/pfSense-pkg-API/files/usr/local/www/api/index.php index 1499fbcb9..e4607071a 100644 --- a/pfSense-pkg-API/files/usr/local/www/api/index.php +++ b/pfSense-pkg-API/files/usr/local/www/api/index.php @@ -158,6 +158,13 @@ $pkg_config["access_list"] = ""; } + # Validate login protection settings + if (!empty($_POST["enable_login_protection"])) { + $pkg_config["enable_login_protection"] = ""; + } else { + unset($pkg_config["enable_login_protection"]); + } + # Validate HA Sync settings if enabled if (!empty($_POST["hasync"])) { $pkg_config["hasync"] = ""; @@ -319,6 +326,16 @@ ### Populate the ADVANCED section of the UI form $advanced_section->addClass("hide-api-advanced-settings"); +$advanced_section->addInput(new Form_Checkbox( + 'enable_login_protection', + 'Login Protection', + 'Enable API Login Protection', + isset($pkg_config["enable_login_protection"]) +))->setHelp( + "Include API authentication in pfSense's Login Protection feature. When enabled, all API authentication requests + will be logged and monitored for brute force attacks. Login Protection can be configured in + System > Advanced" +); $advanced_section->addInput(new Form_Checkbox( 'hasync', 'Sync API Configuration', diff --git a/tests/e2e_test_framework/__init__.py b/tests/e2e_test_framework/__init__.py index 66f38c48b..eae0ca7e9 100644 --- a/tests/e2e_test_framework/__init__.py +++ b/tests/e2e_test_framework/__init__.py @@ -54,6 +54,7 @@ def __init__(self): self.get() self.put() self.delete() + self.custom_tests() sys.exit(self.exit_code) except KeyboardInterrupt: sys.exit(1) @@ -106,6 +107,9 @@ def delete(self): if test_params.get("status", 200) == 200: time.sleep(self.time_delay) + def custom_tests(self): + """Allows child classes to specify custom tests. This is inteded to be overwritten by the child class.""" + # PRE/POST REQUEST METHODS. These are intended to be overwritten by a child class. def pre_post(self): """ diff --git a/tests/test_login_protection.py b/tests/test_login_protection.py new file mode 100644 index 000000000..dee48882d --- /dev/null +++ b/tests/test_login_protection.py @@ -0,0 +1,35 @@ +"""Script used to test the login protection API integration""" +import sys +import requests +import e2e_test_framework + + +class APIE2ETestLoginProtection(e2e_test_framework.APIE2ETest): + """Class used to test the login protection API integration.""" + def custom_tests(self): + self.test_login_protection() + + def test_login_protection(self): + """Custom test method to ensure login protection locks out too many failed auth attempts.""" + # Variables + test_params = {"name": "Ensure login protection blocks many failed auth attempts"} + + # Fail authentication many times to initiate the lockout + for _ in range(0, 5): + try: + requests.get( + self.format_url("/api/v1/system/api"), + auth=("bad_username", "bad_password"), + verify=False, + timeout=(5, 5) + ) + except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectTimeout): + print(self.__format_msg__("GET", test_params, "Response is valid", mode="ok")) + sys.exit(0) + + # Test fails if we were not locked out + print(self.__format_msg__("GET", test_params, "Expected lockout for too many failed requests")) + sys.exit(1) + + +APIE2ETestLoginProtection()