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");