diff --git a/README.md b/README.md index 3377edc20a35bc61180a860ebe9a7f1dd9b907cf..8b52cb314be65bc7d3b9f94e46daaff01a9d67dd 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,50 @@ Heavily based on [Device cookie example](https://github.com/Rundiz/device-cookie ## Installation ``` -composer install php-device-cookies +composer require cesnet/php-device-cookies ``` ## Usage Use the provided `DeviceCookies` class. + +### Example + +```php +$login = filter_input(INPUT_POST, 'login', FILTER_SANITIZE_EMAIL); +$password = filter_input(INPUT_POST, 'password'); + +if (!empty($login) && !empty($password)) { + $user = getUserByLogin($login); + $user_id = $user !== null ? $login : null; + $isPasswordCorrect = password_verify($password, $user->password); + $dbh = new PDO('dsn', 'username', 'password'); + + $deviceCookies = new DeviceCookies([ + 'Dbh' => $dbh, + 'deviceCookieExpire' => 730, // The number of days that this cookie will be expired + 'maxAttempt' => 10, // max number of authentication attempts allowed during "time period" + 'secretKey' => 'SkfEED4aKrNWFUNqgqf6hrFsJQ6K6Jhh', // server’s secret cryptographic key + 'timePeriod' => 60, // time period (in seconds) + ]); + + $result = $deviceCookies->check($login, $user_id, $isPasswordCorrect); + + switch ($result) { + case DeviceCookiesResult::SUCCESS: + echo 'Logged in'; + break; + case DeviceCookiesResult::REJECT: + case DeviceCookiesResult::REJECT_VALID: + http_response_code(403); + exit; + case DeviceCookiesResult::WRONG_PASSWORD: + case DeviceCookiesResult::LOCKOUT: + http_response_code(401); + exit; + } +} + +// This should be called from a cron job once a day. +$deviceCookies->cleanup($dbh); +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..16997183f4eacecfd8389c4802cbeb4f7899e6b7 --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "name": "cesnet/php-device-cookies", + "description": "Implementation of Device Cookies in PHP", + "authors": [ + { + "name": "Pavel Břoušek", + "email": "brousek@ics.muni.cz" + } + ], + "require": { + "php": ">=7.1.0" + } +} diff --git a/db.sql b/db.sql new file mode 100644 index 0000000000000000000000000000000000000000..1e240b528cef4c802b6bbc987f5c6d23fa26472d --- /dev/null +++ b/db.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS `user_devicecookie_failedattempts` ( + `attempt_id` bigint(20) NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL COMMENT 'refer to users.id', + `login` varchar(255) DEFAULT NULL, + `datetime` datetime DEFAULT current_timestamp() COMMENT 'failed authentication on date/time', + `devicecookie_nonce` varchar(50) DEFAULT NULL COMMENT 'device cookie NONCE (if present).', + `devicecookie_signature` longtext DEFAULT NULL COMMENT 'device cookie signature (if present).', + PRIMARY KEY (`attempt_id`), + KEY `user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='contain login failed attempt for existing users.'; + +CREATE TABLE IF NOT EXISTS `user_devicecookie_lockout` ( + `lockout_id` bigint(20) NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) DEFAULT NULL COMMENT 'refer to users.id', + `devicecookie_nonce` varchar(50) DEFAULT NULL COMMENT 'device cookie NONCE.', + `devicecookie_signature` longtext DEFAULT NULL COMMENT 'device cookie signature.', + `lockout_untrusted_clients` int(1) NOT NULL DEFAULT 0 COMMENT '0=just lockout selected device cookie, 1=lockout all untrusted clients.', + `lockout_until` datetime DEFAULT NULL COMMENT 'lockout selected user (user_id) until date/time.', + PRIMARY KEY (`lockout_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='contain user account lockout.'; diff --git a/ecs.yaml b/ecs.yaml new file mode 100644 index 0000000000000000000000000000000000000000..891f2c0d83fb7e6dbbb067ebbd63213fe0aef4e3 --- /dev/null +++ b/ecs.yaml @@ -0,0 +1,8 @@ +parameters: + sets: + - 'clean-code' + - 'common' + - 'psr12' + skip: + PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer: ~ +services: diff --git a/src/DeviceCookieResult.php b/src/DeviceCookieResult.php new file mode 100644 index 0000000000000000000000000000000000000000..a54768b6e4406d077b817a5fbaf52af5d05eeec2 --- /dev/null +++ b/src/DeviceCookieResult.php @@ -0,0 +1,39 @@ +<?php + +namespace DeviceCookies; + +/** + * Device cookies check result. + */ +abstract class DeviceCookieResult +{ + /** + * Untrusted clients or invalid device cookie and is in lockout + */ + public const REJECT = 'reject'; + + /** + * There is valid device cookie but entered wrong credentials too many attempts until gets lockout + */ + public const REJECT_VALID = 'rejectvalid'; + + /** + * Able to continue authentication + */ + public const AUTHENTICATE = 'authenticate'; + + /** + * Logged in successfully + */ + public const SUCCESS = 'success'; + + /** + * Incorrect login or password (threshold has not been reached) + */ + public const WRONG_PASSWORD = 'wrongpassword'; + + /** + * Incorrect login or password too many times resulted in lockout + */ + public const LOCKOUT = 'lockout'; +} diff --git a/src/DeviceCookies.php b/src/DeviceCookies.php new file mode 100644 index 0000000000000000000000000000000000000000..6749161ad0d0880d7c946f78789b176296aad8b7 --- /dev/null +++ b/src/DeviceCookies.php @@ -0,0 +1,389 @@ +<?php + +namespace DeviceCookies; + +use DeviceCookies\Models\UserDeviceCookieFailedAttempts; +use DeviceCookies\Models\UserDeviceCookieLockout; + +/** + * Device cookies class for help prevent brute-force attack. + * @see https://www.owasp.org/index.php/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies + */ +class DeviceCookies +{ + /** + * @var \PDO + */ + protected $Dbh; + + /** + * @var string The name of device cookie. + */ + protected $deviceCookieName = 'deviceCookie'; + + /** + * @var int The number of days that this cookie will be expired. + */ + protected $deviceCookieExpire = 730; + + /** + * @var int Current failed attempts with in time period. + */ + protected $currentFailedAttempt = 0; + + /** + * @var array|null Contain lockout result object from `$UserDeviceCookieLockout->isInLockoutList()` method. + */ + protected $lockoutResult; + + /** + * @var int Max number of authentication attempts allowed during "time period". + */ + protected $maxAttempt = 10; + + /** + * @var string Server’s secret cryptographic key. + */ + protected $secretKey = 'SkfEED4aKrNWFUNqgqf6hrFsJQ6K6Jhh'; + + /** + * @var int Time period (in seconds). + */ + protected $timePeriod = 60; + + /** + * Class constructor. + * + * @param array $options The options in associative array format. + */ + public function __construct(array $options) + { + foreach ($options as $option => $value) { + if (property_exists($this, $option)) { + $this->{$option} = $value; + } + }// endforeach; + unset($option, $value); + } + + /** + * Check for brute force. + * @return DeviceCookiesResult What to do. + */ + public function check(string $login, ?string $userId, bool $isPasswordCorrect) + { + $entrypoint = $this->checkEntryPoint($login, $userId); + if ($entrypoint !== DeviceCookiesResult::AUTHENTICATE) { + return $entrypoint; + } + + return $this->checkAuthenticate($login, $isPasswordCorrect); + } + + /** + * Garbage collection to remove old data that is no longer used from DB. + */ + public function cleanup($dbh) + { + $sql = 'DELETE FROM `user_devicecookie_failedattempts` WHERE `datetime` < NOW() - INTERVAL :time_period SECOND'; + $Sth = $dbh->prepare($sql); + $Sth->bindValue(':time_period', $this->timePeriod + 10); + $Sth->execute(); + $affected1 = $Sth->rowCount(); + + $sql = 'DELETE FROM `user_devicecookie_lockout` WHERE `lockout_until` < NOW()'; + $Sth = $dbh->prepare($sql); + $Sth->execute(); + $affected2 = $Sth->rowCount(); + + return $affected1 + $affected2; + } + + private function checkAuthenticate(string $login, ?string $userId, bool $isPasswordCorrect) + { + // 1. check user credentials + if ($isPasswordCorrect) { + // 2. if credentials are valid. + // a. issue new device cookie to user’s client + $this->issueNewDeviceCookie($login); + // b. proceed with authenticated user + $output = DeviceCookiesResult::SUCCESS; + } else { + // 3. else + // a. register failed authentication attempt + if ($userId !== null) { + $this->registerFailedAuth($login, $userId); + } + // b. finish with failed user’s authentication + if ( + is_numeric($this->currentFailedAttempt) && + $this->currentFailedAttempt > 0 && + is_numeric($this->maxAttempt) && + is_numeric($this->timePeriod) + ) { + $output = DeviceCookiesResult::LOCKOUT; + } else { + $output = DeviceCookiesResult::WRONG_PASSWORD; + } + } + return $output; + } + + /** + * Entry point for authentication request + * + * @param string $login The login ID such as email. + * @return DeviceCookiesResult What to do next. + */ + private function checkEntryPoint(string $login, ?string $userId): string + { + $UserDeviceCookieLockout = new UserDeviceCookieLockout($this->Dbh); + $output = ''; + if ($this->hasDeviceCookie() === true) { + // 1. if the incoming request contains a device cookie. + // --- a. validate device cookie + $validateDeviceCookieResult = $this->validateDeviceCookie($login); + if ($validateDeviceCookieResult !== true) { + // b. if device cookie is not valid. + // proceed to step 2. + $this->removeDeviceCookie(); + $step2 = true; + } elseif ($UserDeviceCookieLockout->isInLockoutList($this->getDeviceCookie()) === true) { + // c. if the device cookie is in the lockout list (valid but in lockout list). + // reject authentication attempt∎ + $output = DeviceCookiesResult::REJECT_VALID; + $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult(); + } else { + // d. else + // authenticate user∎ + $output = DeviceCookiesResult::AUTHENTICATE; + } + } else { + $step2 = true; + }// endif; + if (isset($step2) && $step2 === true) { + if ($UserDeviceCookieLockout->isInLockoutList(null, $userId) === true) { + // 2. if authentication from untrusted clients is locked out for the specific user. + // reject authentication attempt∎ + $output = DeviceCookiesResult::REJECT; + $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult(); + } else { + // 3. else + // authenticate user∎ + $output = DeviceCookiesResult::AUTHENTICATE; + }// endif; + } else { + if (empty($output)) { + // i don't think someone will be in this condition. + $output = DeviceCookiesResult::REJECT; + $this->lockoutResult = $UserDeviceCookieLockout->getLockoutResult(); + } + }// endif; + return $output; + } + + /** + * Get device cookie content + * + * @return string Return cookie value or content. + */ + private function getDeviceCookie(): string + { + if ($this->hasDeviceCookie() === true) { + return $_COOKIE[$this->deviceCookieName]; + } + return ''; + } + + /** + * Get device cookie as array. + * + * @param string|null $cookieValue The cookie value. Leave null to get it from cookie variable. + *@return array Return array where 0 is login, 1 is nonce, 2 is signature. + */ + private function getDeviceCookieArray(string $cookieValue = null): array + { + if ($cookieValue === null) { + $cookieValue = $this->getDeviceCookie(); + } + $exploded = explode(',', $cookieValue); + if (is_array($exploded) && count($exploded) >= 3) { + $output = $exploded; + } else { + $output = [ + '', + null, + null, + ]; + } + unset($cookieValue, $exploded); + return $output; + } + + /** + * Check if the incoming request contains a device cookie. + * + * This is just check that there is device cookie or not. It was not check for valid or invalid device cookie. + * + * @return bool Return `true` if there is device cookie. Return `false` if not. + */ + private function hasDeviceCookie(): bool + { + if (isset($_COOKIE[$this->deviceCookieName])) { + return true; + } + return false; + } + + /** + * Issue new device cookie to user’s client. + * + * Issue a browser cookie with a value. + * + * @param string $login The login name (or internal ID). + */ + private function issueNewDeviceCookie(string $login) + { + $nonce = $this->generateNonce(); + $signature = $this->getHmacSignature($login, $nonce); + setcookie( + $this->deviceCookieName, + $login . ',' . $nonce . ',' . $signature, + time() + ($this->deviceCookieExpire * 24 * 60 * 60), + '/' + ); + } + + /** + * Register failed authentication attempt. + */ + private function registerFailedAuth(string $login, ?string $userId) + { + $data = [ + 'login' => $login, + 'user_id' => $userId, + ]; + // get additional data from previous cookie. + if (isset($data['login']) && $this->validateDeviceCookie($data['login']) === true) { + // if a valid device cookie presented. + $validDeviceCookie = true; // mark that valid device cookie is presented. + list($login, $nonce, $signature) = $this->getDeviceCookieArray(); + $data['devicecookie_nonce'] = $nonce; + $data['devicecookie_signature'] = $signature; + unset($login, $nonce, $signature); + } + // sanitize $data + if (isset($data['devicecookie_nonce']) && empty($data['devicecookie_nonce'])) { + $data['devicecookie_nonce'] = null; + unset($validDeviceCookie); + } + if (isset($data['devicecookie_signature']) && empty($data['devicecookie_signature'])) { + $data['devicecookie_signature'] = null; + unset($validDeviceCookie); + } + // 1. register a failed authentication attempt + $UserDeviceCookieFailedAttempts = new UserDeviceCookieFailedAttempts($this->Dbh); + $UserDeviceCookieFailedAttempts->addFailedAttempt($data); + // 2. depending on whether a valid device cookie is present in the request, + // count the number of failed authentication attempts within period T + if (!isset($data['devicecookie_nonce']) || !isset($data['devicecookie_signature'])) { + // a. all untrusted clients + $where = []; + $where['devicecookie_signature'] = null; + $failedAttempts = $UserDeviceCookieFailedAttempts->countFailedAttemptInPeriod($this->timePeriod, $where); + } else { + // b. a specific device cookie + $where = []; + $where['devicecookie_signature'] = $data['devicecookie_signature']; + $failedAttempts = $UserDeviceCookieFailedAttempts->countFailedAttemptInPeriod($this->timePeriod, $where); + } + $this->currentFailedAttempt = $failedAttempts; + unset($UserDeviceCookieFailedAttempts, $where); + // 3. if "number of failed attempts within period T" > N + if ($failedAttempts > $this->maxAttempt) { + $dataUpdate = []; + $dataUpdate['user_id'] = $data['user_id']; + $Datetime = new \Datetime(); + $Datetime->add(new \DateInterval('PT' . $this->timePeriod . 'S')); + $dataUpdate['lockout_until'] = $Datetime->format('Y-m-d H:i:s'); + unset($Datetime); + if ( + isset($validDeviceCookie) && + $validDeviceCookie === true + ) { + // a. if a valid device cookie is presented + // put the device cookie into the lockout list for device cookies until now+T + $dataUpdate['devicecookie_nonce'] = $data['devicecookie_nonce']; + $dataUpdate['devicecookie_signature'] = $data['devicecookie_signature']; + } else { + // b. else + // lockout all authentication attempts for a specific user from all untrusted clients until now+T + $dataUpdate['lockout_untrusted_clients'] = 1; + } + $UserDeviceCookieLockout = new UserDeviceCookieLockout($this->Dbh); + $UserDeviceCookieLockout->addUpdateLockoutList($dataUpdate); + unset($UserDeviceCookieLockout); + } + } + + /** + * Remove a device cookie. + */ + private function removeDeviceCookie() + { + setcookie($this->deviceCookieName, '', time() - ($this->deviceCookieExpire * 24 * 60 * 60), '/'); + } + + /** + * Validate device cookie. + * + * @partam string $userLogin The login ID input from user. + * @return bool Return `true` if device cookie is correct and the `login` contained in the cookie is matches. + */ + private function validateDeviceCookie(string $userLogin): bool + { + if ($this->hasDeviceCookie() === true) { + $cookieValue = $_COOKIE[$this->deviceCookieName]; + list($login, $nonce, $signature) = $this->getDeviceCookieArray($cookieValue); + if ($userLogin . ',' . $nonce . ',' . $signature === $cookieValue) { + // 1. Validate that the device cookie is formatted as described + if ( + hash_equals( + $this->getHmacSignature($userLogin, $nonce), + $signature + ) + ) { + // 2. Validate that SIGNATURE == HMAC(secret-key, “LOGIN,NONCE”) + if ($login === $userLogin) { + // 3. Validate that LOGIN represents the user who is actually trying to authenticate + return true; + } + } + } + } + return false; + } + + /** + * Generate nonce + * + * @param int $length The string length. + * @return string Return generated nonce. + */ + private function generateNonce(int $length = 32): string + { + return base64_encode(random_bytes($length)); + } + + /** + * Get HMAC signature content. + * + * @param string $login The login name. + * @param string $nonce NONCE. + * @return string Return generated string from HMAC. + */ + private function getHmacSignature(string $login, string $nonce): string + { + return hash_hmac('sha512', $login . ',' . $nonce, $this->secretKey); + } +} diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php new file mode 100644 index 0000000000000000000000000000000000000000..d481b1c99677bb6f1a7e0b7e5e8bb10cc1388574 --- /dev/null +++ b/src/Models/BaseModel.php @@ -0,0 +1,143 @@ +<?php + +namespace DeviceCookies\Models; + +abstract class BaseModel +{ + /** + * @var \PDO + */ + protected $Dbh; + + /** + * @var \PDOStatement + */ + protected $Sth; + + /** + * Class constructor. + */ + public function __construct(\PDO $dbh) + { + $this->Dbh = $dbh; + } + + /** + * Delete data from DB table. + * + * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L643 + * @param string $tableName The table name. This table name will NOT auto add prefix. + * @param array $identifier The identifier for use in `WHERE` statement. + * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise. + * @throws \InvalidArgumentException Throw the error if `$identifier` is incorrect value. + */ + public function delete(string $tableName, array $identifier) + { + if (empty($identifier)) { + throw new \InvalidArgumentException( + 'The argument $identifier is required associative array column - value pairs.' + ); + } + $columns = []; + $placeholders = []; + $values = []; + $conditions = []; + foreach ($identifier as $columnName => $value) { + $columns[] = '`' . $columnName . '`'; + $conditions[] = '`' . $columnName . '` = ?'; + $values[] = $value; + }// endforeach; + unset($columnName, $value); + $sql = 'DELETE FROM `' . $tableName . '` WHERE ' . implode(' AND ', $conditions); + $this->Sth = $this->Dbh->prepare($sql); + unset($columns, $placeholders, $sql); + return $this->Sth->execute($values); + } + + /** + * Insert data into DB table. + * + * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L749 + * @param string $tableName The table name. This table name will NOT auto add prefix. + * @param array $data The associative array where column name is the key and its value is the value pairs. + * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise. + * @throws \InvalidArgumentException Throw the error if `$data` is invalid. + */ + public function insert(string $tableName, array $data): bool + { + if (empty($data)) { + throw new \InvalidArgumentException( + 'The argument $data is required associative array column - value pairs.' + ); + } + $columns = []; + $placeholders = []; + $values = []; + foreach ($data as $columnName => $value) { + $columns[] = '`' . $columnName . '`'; + $placeholders[] = '?'; + $values[] = $value; + }// endforeach; + unset($columnName, $value); + $sql = 'INSERT INTO `' . $tableName . '` (' . implode(', ', $columns) . ') VALUES (' + . implode(', ', $placeholders) . ')'; + $this->Sth = $this->Dbh->prepare($sql); + unset($columns, $placeholders, $sql); + return $this->Sth->execute($values); + } + + /** + * Get PDO statement after called `insert()`, `update()`, `delete()`. + * + * @return \PDOStatement|null Return `\PDOStatement` object if exists, `null` if not exists. + */ + public function PDOStatement() + { + return $this->Sth; + } + + /** + * Update data into DB table. + * + * @see https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Connection.php#L714 + * @param string $tableName The table name. This table name will NOT auto add prefix. + * @param array $data The associative array where column name is the key and its value is the value pairs. + * @param array $identifier The identifier for use in `WHERE` statement. + * @return bool Return PDOStatement::execute(). Return `true` on success, `false` for otherwise. + * @throws \InvalidArgumentException Throw the error if `$data` or `$identifier` is incorrect value. + */ + public function update(string $tableName, array $data, array $identifier): bool + { + if (empty($data)) { + throw new \InvalidArgumentException( + 'The argument $data is required associative array column - value pairs.' + ); + } + if (empty($identifier)) { + throw new \InvalidArgumentException( + 'The argument $identifier is required associative array column - value pairs.' + ); + } + $columns = []; + $placeholders = []; + $values = []; + $conditions = []; + foreach ($data as $columnName => $value) { + $columns[] = '`' . $columnName . '`'; + $placeholders[] = '`' . $columnName . '` = ?'; + $values[] = $value; + }// endforeach; + unset($columnName, $value); + foreach ($identifier as $columnName => $value) { + $columns[] = '`' . $columnName . '`'; + $conditions[] = '`' . $columnName . '` = ?'; + $values[] = $value; + }// endforeach; + unset($columnName, $value); + $sql = 'UPDATE `' . $tableName . '` SET ' . implode(', ', $placeholders) . ' WHERE ' + . implode(' AND ', $conditions); + $this->Sth = $this->Dbh->prepare($sql); + unset($columns, $placeholders, $sql); + return $this->Sth->execute($values); + } +} diff --git a/src/Models/UserDeviceCookieFailedAttempts.php b/src/Models/UserDeviceCookieFailedAttempts.php new file mode 100644 index 0000000000000000000000000000000000000000..020eedcb38b50055e1ce588dfb2b12cacd267cc9 --- /dev/null +++ b/src/Models/UserDeviceCookieFailedAttempts.php @@ -0,0 +1,61 @@ +<?php + +namespace DeviceCookies\Models; + +/** + * User device cookie failed attempts model. + */ +class UserDeviceCookieFailedAttempts extends BaseModel +{ + protected $tableName = 'user_devicecookie_failedattempts'; + + /** + * Add/register failed authentication attempt. + * + * @param array $data The associative array where key is field. + * @throws \InvalidArgumentException Throw the error if `$data` is invalid. + */ + public function addFailedAttempt(array $data) + { + if (!isset($data['user_id'])) { + throw new \InvalidArgumentException('The `$data` must contain `user_id` in the array key.'); + } + $this->insert($this->tableName, $data); + } + + /** + * Count the number of failed authentication within period. + * + * @param int $timePeriod The time period. + * @param array $where The associative array where key is field. + * @return int Return total number of failed authentication counted. + */ + public function countFailedAttemptInPeriod(int $timePeriod, array $where = []): int + { + $sql = 'SELECT COUNT(*) AS `total_failed` FROM `' . $this->tableName + . '`WHERE `datetime` >= NOW() - INTERVAL :time_period SECOND'; + $values = []; + $values[':time_period'] = $timePeriod; + foreach ($where as $field => $value) { + $sql .= ' AND '; + if ($value === null) { + $sql .= ' `' . $field . '` IS NULL'; + } else { + $sql .= ' `' . $field . '` = :' . $field; + $values[':' . $field] = $value; + } + }// endforeach; + unset($field, $value); + $this->Sth = $this->Dbh->prepare($sql); + foreach ($values as $placeholder => $value) { + $this->Sth->bindValue($placeholder, $value); + }// endforeach; + unset($placeholder, $sql, $value, $values); + $this->Sth->execute(); + $result = $this->Sth->fetchObject(); + if (is_object($result) && isset($result->total_failed)) { + return intval($result->total_failed); + } + return 0; + } +} diff --git a/src/Models/UserDeviceCookieLockout.php b/src/Models/UserDeviceCookieLockout.php new file mode 100644 index 0000000000000000000000000000000000000000..b1d0743097e6d4a13bb45e22f07df4ece111fd6b --- /dev/null +++ b/src/Models/UserDeviceCookieLockout.php @@ -0,0 +1,161 @@ +<?php + +namespace DeviceCookies\Models; + +/** + * User device cookie lockout model. + */ +class UserDeviceCookieLockout extends BaseModel +{ + /** + * @var array|null Contain lockout result object from `isInLockoutList()` method. + */ + protected $lockoutResult; + + protected $tableName = 'user_devicecookie_lockout'; + + /** + * Put out the device cookie in the lockout list or lockout all untrusted clients. + * + * Check first that the specific data is already exists then update the data, otherwise add the data. + * + * @param array $data The associative array where key is field. + * @throws \InvalidArgumentException Throw the error if `$data` is invalid. + */ + public function addUpdateLockoutList(array $data) + { + if (!isset($data['user_id'])) { + throw new \InvalidArgumentException('The `$data` must contain `user_id` in the array key.'); + } + if (!isset($data['lockout_until'])) { + throw new \InvalidArgumentException('The `$data` must contain `lockout_until` in the array key.'); + } + if (!isset($data['devicecookie_signature']) && !isset($data['lockout_untrusted_clients'])) { + throw new \InvalidArgumentException( + 'The `$data` must contain `devicecookie_signature` OR `lockout_untrusted_clients` in the array key.' + ); + } + // check that data already exists in DB. + $sql = 'SELECT `user_id`, `devicecookie_nonce`, `devicecookie_signature`, `lockout_untrusted_clients`,' + . ' `lockout_until` FROM `' . $this->tableName . '` WHERE `user_id` = :user_id'; + $where = []; + $where[':user_id'] = $data['user_id']; + if (isset($data['devicecookie_signature'])) { + $sql .= ' AND`devicecookie_signature` = :devicecookie_signature'; + $where[':devicecookie_signature'] = $data['devicecookie_signature']; + } + if (isset($data['lockout_untrusted_clients'])) { + $sql .= ' AND `lockout_untrusted_clients` = :lockout_untrusted_clients'; + $where[':lockout_untrusted_clients'] = $data['lockout_untrusted_clients']; + } + $this->Sth = $this->Dbh->prepare($sql); + foreach ($where as $field => $value) { + $this->Sth->bindValue($field, $value); + }// endforeach; + $this->Sth->execute(); + $result = $this->Sth->fetchAll(); + if (is_array($result) && count($result) >= 1) { + $useInsert = false; + } else { + $useInsert = true; + } + // if not exists use insert, otherwise use update. + if (isset($useInsert) && $useInsert === true) { + $this->insert($this->tableName, $data); + } else { + $where = []; + $where['user_id'] = $data['user_id']; + $dataUpdate = []; + $dataUpdate['lockout_until'] = $data['lockout_until']; + if (isset($data['devicecookie_signature'])) { + $where['devicecookie_signature'] = $data['devicecookie_signature']; + } + if (isset($data['lockout_untrusted_clients'])) { + $where['lockout_untrusted_clients'] = $data['lockout_untrusted_clients']; + } + // reset + $this->update($this->tableName, $dataUpdate, $where); + } + } + + /** + * Get result object after called to `isInLockoutList()` method. + * + *@return array Return result object if required method was called, return null if nothing. + */ + public function getLockoutResult(): array + { + if (is_array($this->lockoutResult)) { + return $this->lockoutResult; + } + return []; + } + + /** + * Check if current user is in lockout list. + * + * If device cookie content is specified, then it will check for specific device cookie.<br> + * If device cookie is `null` then it will check for untrusted clients. + * + * @param string $deviceCookie The device cookie content. + * @param int $user_id The user id. For checking from untrusted clients. + * @return bool Return `true` if current user is in the lockout list, return `false` for otherwise. + */ + public function isInLockoutList(string $deviceCookie = null, int $user_id = null): bool + { + $sql = 'SELECT `user_id`, `devicecookie_nonce`, `devicecookie_signature`, `lockout_untrusted_clients`,' + . ' `lockout_until` FROM `' . $this->tableName . '` WHERE `lockout_until` >= NOW()'; + $where = []; + if ($deviceCookie !== null) { + // if there is device cookie. + $exploded = explode(',', $deviceCookie); + if (is_array($exploded) && count($exploded) >= 3) { + list($login, $nonce, $signature) = $exploded; + } else { + list($login, $nonce, $signature) = [ + '', + null, + null, + ]; + } + unset($exploded); + if ($signature === null) { + $sql .= ' AND `devicecookie_signature` IS NULL'; + } else { + $sql .= ' AND `devicecookie_signature` = :devicecookie_signature'; + $where[':devicecookie_signature'] = $signature; + } + unset($login, $nonce, $signature); + } else { + // if there is NO device cookie. + if ($user_id !== null && !empty($user_id)) { + // if there is a specific user_id. + $sql .= ' AND `user_id` = :user_id'; + $where[':user_id'] = $user_id; + } elseif ($user_id === null) { + // if no specific user_id. + // this is may because of client enter invalid user login ID that cause this to be null. + // just return false and let them authenticate. + unset($sql, $where); + return false; + } + $sql .= ' AND `lockout_untrusted_clients` = :lockout_untrusted_clients'; + $where[':lockout_untrusted_clients'] = 1; + }// endif; + $this->Sth = $this->Dbh->prepare($sql); + foreach ($where as $field => $value) { + $this->Sth->bindValue($field, $value); + }// endforeach; + unset($field, $sql, $value, $where); + $this->Sth->execute(); + $result = $this->Sth->fetchAll(); + if (is_array($result) && count($result) >= 1) { + // if found in lockout list + $this->lockoutResult = $result; + unset($result); + return true; + } + unset($result); + return false; + } +}