From 877a7b1d47ffb94e435c863eade4296003fed51a Mon Sep 17 00:00:00 2001 From: flashwave Date: Thu, 22 Mar 2018 04:45:59 +0100 Subject: [PATCH] Add login rate limiting. --- ..._22_040816_create_login_attempts_table.php | 48 +++++++++++++ public/auth.php | 21 +++++- src/Net/IPAddress.php | 5 ++ src/Users/LoginAttempt.php | 67 +++++++++++++++++++ src/Users/User.php | 7 +- 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 database/2018_03_22_040816_create_login_attempts_table.php create mode 100644 src/Users/LoginAttempt.php diff --git a/database/2018_03_22_040816_create_login_attempts_table.php b/database/2018_03_22_040816_create_login_attempts_table.php new file mode 100644 index 00000000..d1dbb0a7 --- /dev/null +++ b/database/2018_03_22_040816_create_login_attempts_table.php @@ -0,0 +1,48 @@ +getSchemaBuilder(); + $schema->create('login_attempts', function (Blueprint $table) { + $table->increments('attempt_id'); + + $table->boolean('was_successful'); + + $table->binary('attempt_ip'); + + $table->char('attempt_country', 2) + ->default('XX'); + + $table->integer('user_id') + ->nullable() + ->default(null) + ->unsigned(); + + $table->timestamps(); + + $table->foreign('user_id') + ->references('user_id') + ->on('users') + ->onUpdate('cascade') + ->onDelete('set null'); + }); + } + + /** + * @SuppressWarnings(PHPMD) + */ + public function down() + { + $schema = Database::connection()->getSchemaBuilder(); + $schema->drop('login_attempts'); + } +} diff --git a/public/auth.php b/public/auth.php index 0ae68161..ba5a1d72 100644 --- a/public/auth.php +++ b/public/auth.php @@ -6,6 +6,7 @@ use Misuzu\Database; use Misuzu\Net\IPAddress; use Misuzu\Users\User; use Misuzu\Users\Session; +use Misuzu\Users\LoginAttempt; require_once __DIR__ . '/../misuzu.php'; @@ -61,27 +62,43 @@ switch ($mode) { $auth_login_error = ''; while ($_SERVER['REQUEST_METHOD'] === 'POST') { + $ipAddress = IPAddress::remote(); + if (!isset($_POST['username'], $_POST['password'])) { $auth_login_error = "You didn't fill all the forms!"; break; } + $loginAttempts = LoginAttempt::fromIpAddress(IPAddress::remote()) + ->where('was_successful', false) + ->where('created_at', '>', Carbon::now()->subHour()->toDateTimeString()) + ->get(); + + if ($loginAttempts->count() >= 5) { + $auth_login_error = 'Too many failed login attempts, try again later.'; + break; + } + $username = $_POST['username'] ?? ''; $password = $_POST['password'] ?? ''; try { $user = User::where('username', $username)->orWhere('email', $username)->firstOrFail(); } catch (ModelNotFoundException $e) { + LoginAttempt::recordFail($ipAddress); $auth_login_error = 'Invalid username or password!'; break; } if (!$user->validatePassword($password)) { + LoginAttempt::recordFail($ipAddress, $user); $auth_login_error = 'Invalid username or password!'; break; } - $session = Session::createSession($user, 'Misuzu T2'); + LoginAttempt::recordSuccess($ipAddress, $user); + + $session = Session::createSession($user, 'Misuzu T2', null, $ipAddress); $app->setSession($session); set_cookie_m('uid', $session->user_id, 604800); set_cookie_m('sid', $session->session_key, 604800); @@ -89,7 +106,7 @@ switch ($mode) { // Temporary key generation for chat login. // Should eventually be replaced with a callback login system. // Also uses different cookies since $httponly is required to be false for these. - $user->last_ip = IPAddress::remote(); + $user->last_ip = $ipAddress; $user->user_chat_key = bin2hex(random_bytes(16)); $user->save(); diff --git a/src/Net/IPAddress.php b/src/Net/IPAddress.php index e8998657..9dd212c9 100644 --- a/src/Net/IPAddress.php +++ b/src/Net/IPAddress.php @@ -39,6 +39,11 @@ final class IPAddress return inet_ntop($this->ipRaw); } + public function getCountryCode(): string + { + return get_country_code($this->getString()); + } + public function __construct(int $version, string $rawIp) { if (!array_key_exists($version, self::BYTE_COUNT)) { diff --git a/src/Users/LoginAttempt.php b/src/Users/LoginAttempt.php new file mode 100644 index 00000000..45b05a59 --- /dev/null +++ b/src/Users/LoginAttempt.php @@ -0,0 +1,67 @@ +was_successful = $success; + $attempt->attempt_ip = $ipAddress; + + if ($user !== null) { + $attempt->user_id = $user; + } + + $attempt->save(); + + return $attempt; + } + + public static function fromIpAddress(IPAddress $ipAddress): Builder + { + return static::where('attempt_ip', $ipAddress->getRaw()); + } + + public function setAttemptIpAttribute(IPAddress $ipAddress): void + { + $this->attributes['attempt_ip'] = $ipAddress->getRaw(); + $this->attributes['attempt_country'] = $ipAddress->getCountryCode(); + } + + public function getAttemptIpAttribute(string $ipAddress): IPAddress + { + return IPAddress::fromRaw($ipAddress); + } + + public function setUserIdAttribute(User $user): void + { + $this->attributes['user_id'] = $user->user_id; + } + + public function getUserIdAttribute(int $userId): User + { + return User::findOrFail($userId); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/src/Users/User.php b/src/Users/User.php index dcc5ad33..6add7b51 100644 --- a/src/Users/User.php +++ b/src/Users/User.php @@ -34,7 +34,7 @@ class User extends Model $user->email = $email; $user->register_ip = $ipAddress; $user->last_ip = $ipAddress; - $user->user_country = get_country_code($ipAddress->getString()); + $user->user_country = $ipAddress->getCountryCode(); $user->save(); return $user; @@ -173,4 +173,9 @@ class User extends Model { return $this->hasMany(UserRole::class, 'user_id'); } + + public function loginAttempts() + { + return $this->hasMany(LoginAttempt::class, 'user_id'); + } }