diff --git a/config.example.php b/config.example.php index 8bcd40a..f87ddf1 100644 --- a/config.example.php +++ b/config.example.php @@ -110,4 +110,9 @@ 'admin' => 4, 'banned' => 5, ], + + // Secure keys + 'keys' => [ + 'tokens' => '', + ], ]; diff --git a/database/2018_06_06_004113_add_login_tables.php b/database/2018_06_06_004113_add_login_tables.php new file mode 100644 index 0000000..2037d00 --- /dev/null +++ b/database/2018_06_06_004113_add_login_tables.php @@ -0,0 +1,52 @@ +create('login_attempts', function (Blueprint $table) { + $table->increments('attempt_id'); + + $table->tinyInteger('attempt_success') + ->unsigned(); + + $table->integer('attempt_timestamp') + ->unsigned(); + + $table->binary('attempt_ip'); + + $table->integer('user_id') + ->unsigned(); + }); + + $schema->table('users', function (Blueprint $table) { + $table->string('password') + ->nullable(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $schema = DB::getSchemaBuilder(); + + $schema->table('users', function (Blueprint $table) { + $table->dropColumn('password'); + }); + } +} diff --git a/src/Pages/Auth.php b/src/Pages/Auth.php new file mode 100644 index 0000000..958d882 --- /dev/null +++ b/src/Pages/Auth.php @@ -0,0 +1,129 @@ +insert([ + 'attempt_success' => $success ? 1 : 0, + 'attempt_timestamp' => time(), + 'attempt_ip' => Net::pton(Net::ip()), + 'user_id' => $user, + ]); + } + + protected function authenticate(User $user) : void + { + // Generate a session key + $session = CurrentSession::create( + $user->id, + Net::ip(), + get_country_code() + ); + + $cookiePrefix = config('cookie.prefix'); + setcookie("{$cookiePrefix}id", $user->id, time() + 604800, '/'); + setcookie("{$cookiePrefix}session", $session->key, time() + 604800, '/'); + + $this->touchRateLimit($user->id, true); + } + + /** + * End the current session. + * + * @throws HttpMethodNotAllowedException + */ + public function logout() + { + if (!session_check('z')) { + throw new HttpMethodNotAllowedException(); + } + + // Destroy the active session + CurrentSession::stop(); + + return redirect(route('main.index')); + } + + /** + * Login page. + * + * @return string + */ + public function login() : string + { + if (!validateToken('login', 'post', false)) { + return redirect(route('auth.loginform').'?mes='.urlencode(__('auth.errors.invalid_token'))); + } + + // Get request variables + $username = $_POST['username'] ?? null; + $password = $_POST['password'] ?? null; + + // Check if we haven't hit the rate limit + $rates = DB::table('login_attempts')->where('attempt_ip', Net::pton(Net::ip()))->where('attempt_timestamp', '>', time() - 1800)->where('attempt_success', '0')->count(); + + if ($rates > 4) { + return redirect(route('auth.loginform').'?mes='.urlencode(__('auth.errors.too_many_attempts'))); + } + + $user = User::construct(clean_string($username, true)); + + // Check if the user that's trying to log in actually exists + if ($user->id === 0) { + $this->touchRateLimit($user->id); + + return redirect(route('auth.loginform').'?mes='.urlencode(__('auth.errors.invalid_details'))); + } + + if ($user->passwordExpired()) { + return redirect(route('auth.loginform').'?mes='.urlencode(__('auth.errors.password_expired'))); + } + + if (!$user->verifyPassword($password)) { + $this->touchRateLimit($user->id); + + return redirect(route('auth.loginform').'?mes='.__('auth.errors.invalid_details')); + } + + $this->authenticate($user); + + return redirect(route('main.index')); + } + + /** + * Serve the login form. + * + * @return string + */ + public function login_form() : string + { + if (CurrentSession::$user->id !== 0) { + return redirect(route('main.index')); + } + + return view('members/login'); + } +} diff --git a/utility.php b/utility.php index 5121136..b407e0e 100644 --- a/utility.php +++ b/utility.php @@ -271,3 +271,55 @@ function __(string $key, array $replace = []) { return Translation::get($key, $replace); } + +function createToken(string $action, string $type = 'post') : array +{ + $token = md5(mt_rand().session_id().(string) microtime().config('keys.tokens').$type); + $token_var = substr(preg_replace('~^\d+~', '', md5(mt_rand().(string) microtime().mt_rand())), 0, mt_rand(7, 12)); + + $_SESSION['token'][$type.'-'.$action] = [$token_var, md5($token.$_SERVER['HTTP_USER_AGENT']), time(), $token]; + + return [$action.'_token_var' => $token_var, $action.'_token' => $token]; +} + +function validateToken(string $action, string $type = 'post', bool $reset = true) +{ + $type = $type == 'get' || $type == 'request' ? $type : 'post'; + + if (isset($_SESSION['token'][$type.'-'.$action], $GLOBALS['_'.strtoupper($type)][$_SESSION['token'][$type.'-'.$action][0]]) && md5($GLOBALS['_'.strtoupper($type)][$_SESSION['token'][$type.'-'.$action][0]].$_SERVER['HTTP_USER_AGENT']) == $_SESSION['token'][$type.'-'.$action][1]) { + // Invalidate this token now. + unset($_SESSION['token'][$type.'-'.$action]); + + return true; + } + + if ($reset) { + cleanTokens(); + + createToken($action, $type); + + http_response_code(400); + die('Invalid token'); + } else { + unset($_SESSION['token'][$type.'-'.$action]); + } + + if (mt_rand(0, 138) == 23) { + cleanTokens(); + } + + return false; +} + +function cleanTokens(bool $complete = false) : void +{ + if (!isset($_SESSION['token'])) { + return; + } + + foreach ($_SESSION['token'] as $key => $data) { + if ($data[2] + 10800 < time() || $complete) { + unset($_SESSION['token'][$key]); + } + } +}