How to protect your website from Brute force attacks using queues. 1 CREATE TABLE `login_attempt_queue` ( 2 `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 `last_checked` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 `ip_address` INT UNSIGNED NOT NULL, 5 `username` VARCHAR(100) NOT NULL, 6 PRIMARY KEY (`id`), 7 UNIQUE KEY(`ip_address`) 8 ) ENGINE=MEMORY; Additionally, since this is a user login system, I will be using this as the table where the password hashes we are validating are stored: 1 CREATE TABLE `users` ( 2 `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 `username` VARCHAR(100) NOT NULL, 4 `password` CHAR(60) NOT NULL, 5 PRIMARY KEY (`id`) 6 ) ENGINE=InnoDB; To manage the entire process, the creation of a class can be used to create login attempts, which waits for them to be processed, and then handle the validation result. Note that this code uses the PHP 5.5 password hashing functions. If you do not have PHP 5.5, there are 3rd party libraries that let you easily add this functionality to older versions. (Down to 5.3, with that particular library.) 001 <?php 002 003 /** 004 * Provides everything needed to manage and use a login queue. 005 * The login queue will store each login attempt in a database table 006 * and process the entries one at at time, with a delay between 007 * each one. The idea here is to make brute-force attacks on the 008 * login system impractically slow. 009 * 010 * 011 * 012 * 013 * added the single IP restriction. 014 */ 015 class LoginAttempt 016 { 017 /** 018 * @var int The number of milliseconds to sleep between login attempts. 019 */ 020 const ATTEMPT_DELAY = 1000; 021 022 /** 023 * @var int The number of milliseconds before an unchecked attempt is 024 * considered dead. 025 * 026 */ 027 const ATTEMPT_EXPIRATION_TIMEOUT = 5000; 028 029 /** 030 * @var int Number of queued attempts allowed per user. 031 */ 032 const MAX_PER_USER = 5; 033 034 /** 035 * @var int Number of queued attempts allowed overall. 036 */ 037 const MAX_OVERALL = 30; 038 039 /** 040 * The ID assigned to this attempt in the database. 041 * 042 * @var int 043 */ 044 private $attemptID; 045 046 /** 047 * @var string 048 */ 049 private $username; 050 051 /** 052 * @var string 053 */ 054 private $password; 055 056 /** 057 * After the login has been validated, this attribute will hold the 058 * result. Subsequent calls to isValid will return this value, rather 059 * that try to validate it again. 060 * 061 * @var bool 062 */ 063 private $isLoginValid; 064 065 /** 066 * An open PDO instance. 067 * 068 * @var PDO 069 */ 070 private $pdo; 071 072 /** 073 * Stores the statement used to check whether the attempt is ready to be processed. 074 * As it may be used multiple times per attempt, it makes sense not to initialize 075 * it each ready check. 076 * 077 * @var PDOStatement 078 */ 079 private $readyCheckStatement; 080 081 /** 082 * The statement used to update the attempt entry in the database on 083 * each isReady call. 084 * 085 * @var PDOStatement 086 */ 087 private $checkUpdateStatement; 088 089 /** 090 * Creates a login attempt and queues it. 091 * 092 * @param string $username 093 * @param string $password 094 * @var \PDO $pdo 095 * @throws Exception 096 */ 097 public function __construct($username, $password, \PDO $pdo) 098 { 099 $this->pdo = $pdo; 100 if ($this->pdo->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION) { 101 $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 102 } 103 104 $this->username = $username; 105 $this->password = $password; 106 107 if (!$this->isQueueSizeExceeded()) { 108 $this->addToQueue(); 109 } 110 else { 111 throw new Exception("Queue size has been exceeded.", 503); 112 } 113 } 114 115 /** 116 * Creates an entry for the attempt in the database, fetching the id 117 * of it and storing it in the class. Note that no values need to 118 * be entered in the database; the defaults for both columns are fine. 119 */ 120 private function addToQueue() 121 { 122 $sql = "INSERT INTO login_attempt_queue (ip_address, username) 123 VALUES (?, ?)"; 124 $stmt = $this->pdo->prepare($sql); 125 try { 126 $stmt->execute(array( 127 sprintf('%u', ip2long($_SERVER"REMOTE_ADDR")), 128 $this->username 129 )); 130 $this->attemptID = (int)$this->pdo->lastInsertId(); 131 } 132 catch (PDOException $e) { 133 throw new Exception("IP address is already in queue.", 403); 134 } 135 } 136 137 /** 138 * Checks the queue size. Throws an exception if it has been exceeded. Otherwise it does nothing. 139 * 140 * @throws Exception 141 * @return bool 142 */ 143 private function isQueueSizeExceeded() 144 { 145 $sql = "SELECT 146 COUNT(*) AS overall, 147 COUNT(IF(username = ?, TRUE, NULL)) AS user 148 FROM login_attempt_queue 149 WHERE last_checked > NOW() - INTERVAL ? MICROSECOND"; 150 $stmt = $this->pdo->prepare($sql); 151 $stmt->execute(array( 152 $this->username, 153 self::ATTEMPT_EXPIRATION_TIMEOUT * 1000 154 )); 155 156 $count = $stmt->fetch(PDO::FETCH_OBJ); 157 if (!$count) { 158 throw new Exception("Failed to query queue size", 500); 159 } 160 161 return ($count->overall >= self::MAX_OVERALL || $count->user >= self::MAX_PER_USER); 162 } 163 164 /** 165 * Checks if the login attempt is ready to be processed, and updates the 166 * last_checked timestamp to keep the attempt alive. 167 * 168 * @return bool 169 */ 170 private function isReady() 171 { 172 if (!$this->readyCheckStatement) { 173 $sql = "SELECT id FROM login_attempt_queue 174 WHERE 175 last_checked > NOW() - INTERVAL ? MICROSECOND AND 176 username = ? 177 ORDER BY id ASC 178 LIMIT 1"; 179 $this->readyCheckStatement = $this->pdo->prepare($sql); 180 } 181 $this->readyCheckStatement->execute(array( 182 self::ATTEMPT_EXPIRATION_TIMEOUT * 1000, 183 $this->username 184 )); 185 $result = (int)$this->readyCheckStatement->fetchColumn(); 186 187 if (!$this->checkUpdateStatement) { 188 $sql = "UPDATE login_attempt_queue 189 SET last_checked = CURRENT_TIMESTAMP 190 WHERE id = ? LIMIT 1"; 191 $this->checkUpdateStatement = $this->pdo->prepare($sql); 192 } 193 $this->checkUpdateStatement->execute(array($this->attemptID)); 194 195 return $result === $this->attemptID; 196 } 197 198 /** 199 * Checks if the login attempt is valid. Note that this function will cause 200 * the delay between attempts when first called. If called multiple times, 201 * only the first call will do so. 202 * 203 * @return bool 204 */ 205 public function isValid() 206 { 207 if ($this->isLoginValid === None) { 208 $sql = "SELECT password 209 FROM users 210 WHERE username = ?"; 211 $stmt = $this->pdo->prepare($sql); 212 $stmt->execute(array($this->username)); 213 $realHash = $stmt->fetchColumn(); 214 215 if ($realHash) { 216 $this->isLoginValid = password_verify($this->password, $realHash); 217 } 218 else { 219 $this->isLoginValid = False; 220 } 221 222 // Sleep at this point, to enforce a delay between login attempts. 223 usleep(self::ATTEMPT_DELAY * 1000); 224 225 // Remove the login attempt from the queue, as well as any login 226 // attempt that has timed out. 227 $sql = "DELETE FROM login_attempt_queue 228 WHERE 229 id = ? OR 230 last_checked < NOW() - INTERVAL ? MICROSECOND"; 231 $stmt = $this->pdo->prepare($sql); 232 $stmt->execute(array( 233 $this->attemptID, 234 self::ATTEMPT_EXPIRATION_TIMEOUT * 1000 235 )); 236 } 237 238 return $this->isLoginValid; 239 } 240 241 /** 242 * Calls the callback function when the login attempt is ready, passing along the 243 * result of the validation as the first parameter. 244 * 245 * @param callable|string $callback 246 * @param int $checkTimer Delay between checks, in milliseconds. 247 */ 248 public function whenReady($callback, $checkTimer=250) 249 { 250 while (!$this->isReady()) { 251 usleep($checkTimer * 1000); 252 } 253 254 if (is_callable($callback)) { 255 call_user_func($callback, $this->isValid()); 256 } 257 } 258 } Usage To sum up the functionality of the class, we only have two public methods we need to concern ourselves with. __construct creates the attempt, taking the username, the password and a PDO instance. It sets their respective class attributes to those values, and then triggers the addToQueue function, which goes on to create a new database entry for the class and set the attemptID attribute. whenReady is our “listener”, so to speak. It takes a callable function as the first parameter, and optionally a delay timer value as the second parameter. It will keep calling the isReady function in a loop, each iteration delayed by the value of that second parameter, until it returns TRUE, thus reporting that the attempt is next in line to be processed. Then it will go on to call the the isValid function, which checks the validity of the attempt and removes it from the database. Finally it calls the callback function, and passes the validity value with it as it’s only parameter. Here is an example of how this could be used on a login form’s action page: 01 <?php 02 require "LoginAttempt.php"; 03 04 if (!empty($_POST"username") && !empty($_POST"password")) { 05 $dsn = "mysql:host=localhost;dbname=test"; 06 $pdo = new PDO($dsn, "username", "password"); 07 08 try { 09 $attempt = new LoginAttempt($_POST"username", $_POST"password", $pdo); 10 $attempt->whenReady(function($success) { 11 echo $success ? "Valid" : "Invalid"; 12 }); 13 } 14 catch (Exception $e) { 15 if ($e->getCode() == 503) { 16 header("HTTP/1.1 503 Service Unavailable"); 17 exit; 18 } 19 else if ($e->getCode() == 403) { 20 header("HTTP/1.1 403 Forbidden"); 21 exit; 22 } 23 else { 24 echo "Error: " . $e->getMessage(); 25 } 26 27 // Note here that it may be advisable to show the 28 // same response for error messages that you show 29 // for invalid requests. That way it'll be less 30 // obvious to attackers that their requests are 31 // being rejected rather than processed and 32 // invalidated. 33 } 34 } 35 else { 36 echo "Error: Missing user input."; 37 } For PHP 5.2 or lower, the above method of using a closure for the whenReady function is not possible. Instead you would have to define a function, and then pass the name of it as the first parameter to whenReady: 1 function onReady($isValid) { 2 echo $isValid ? "Valid" : "Invalid"; 3 } 4 5 $attempt = new LoginAttempt($_POST"username", $_POST"password", $pdo); 6 $attempt->whenReady("onReady");

Теги: PHP security database

Теги других блогов: PHP security database