Made login attempts functions OOP.

This commit is contained in:
flash 2020-05-25 14:15:17 +00:00
parent efee8cb67b
commit 174d25d40f
11 changed files with 196 additions and 106 deletions

View file

@ -0,0 +1,21 @@
<?php
namespace Misuzu\DatabaseMigrations\LoginAttemptsTableFixes;
use PDO;
function migrate_up(PDO $conn): void {
$conn->exec("
ALTER TABLE `msz_login_attempts`
DROP COLUMN `attempt_id`,
ADD INDEX `login_attempts_created_index` (`attempt_created`);
");
}
function migrate_down(PDO $conn): void {
$conn->exec("
ALTER TABLE `msz_login_attempts`
ADD COLUMN `attempt_id` INT UNSIGNED NOT NULL AUTO_INCREMENT FIRST,
DROP INDEX `login_attempts_created_index`,
ADD PRIMARY KEY (`attempt_id`);
");
}

View file

@ -77,7 +77,6 @@ require_once 'src/Forum/validate.php';
require_once 'src/Users/auth.php';
require_once 'src/Users/avatar.php';
require_once 'src/Users/background.php';
require_once 'src/Users/login_attempt.php';
require_once 'src/Users/recovery.php';
require_once 'src/Users/relations.php';
require_once 'src/Users/role.php';

View file

@ -4,6 +4,7 @@ namespace Misuzu;
use Misuzu\Net\IPAddress;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserLoginAttempt;
require_once '../../misuzu.php';
@ -23,7 +24,7 @@ $siteIsPrivate = Config::get('private.enable', Config::TYPE_BOOL);
$loginPermCat = $siteIsPrivate ? Config::get('private.perm.cat', Config::TYPE_STR) : '';
$loginPermVal = $siteIsPrivate ? Config::get('private.perm.val', Config::TYPE_INT) : 0;
$ipAddress = IPAddress::remote();
$remainingAttempts = user_login_attempts_remaining($ipAddress);
$remainingAttempts = UserLoginAttempt::remaining();
while(!empty($_POST['login']) && is_array($_POST['login'])) {
if(!CSRF::validateRequest()) {
@ -55,7 +56,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
try {
$userData = User::findForLogin($_POST['login']['username']);
} catch(UserNotFoundException $ex) {
user_login_attempt_record(false, null, $ipAddress, $userAgent);
UserLoginAttempt::create(false);
$notices[] = $loginFailedError;
break;
}
@ -66,7 +67,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
}
if($userData->isDeleted() || !$userData->checkPassword($_POST['login']['password'])) {
user_login_attempt_record(false, $userData->getId(), $ipAddress, $userAgent);
UserLoginAttempt::create(false, $userData);
$notices[] = $loginFailedError;
break;
}
@ -77,7 +78,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userData->getId(), $loginPermVal)) {
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent);
UserLoginAttempt::create(true, $userData);
break;
}
@ -88,7 +89,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
return;
}
user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent);
UserLoginAttempt::create(true, $userData);
$sessionKey = user_session_create($userData->getId(), $ipAddress, $userAgent);
if(empty($sessionKey)) {

View file

@ -5,6 +5,7 @@ use UnexpectedValueException;
use Misuzu\AuditLog;
use Misuzu\Net\IPAddress;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
require_once '../../misuzu.php';
@ -29,7 +30,7 @@ $notices = [];
$siteIsPrivate = Config::get('private.enable', Config::TYPE_BOOL);
$canResetPassword = $siteIsPrivate ? Config::get('private.allow_password_reset', Config::TYPE_BOOL, true) : true;
$ipAddress = IPAddress::remote();
$remainingAttempts = user_login_attempts_remaining($ipAddress);
$remainingAttempts = UserLoginAttempt::remaining();
while($canResetPassword) {
if(!empty($reset) && $userId > 0) {

View file

@ -4,6 +4,7 @@ namespace Misuzu;
use Misuzu\Net\IPAddress;
use Misuzu\Net\IPAddressBlacklist;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
require_once '../../misuzu.php';
@ -15,7 +16,7 @@ if(user_session_active()) {
$register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST['register'] : [];
$notices = [];
$ipAddress = IPAddress::remote();
$remainingAttempts = user_login_attempts_remaining($ipAddress);
$remainingAttempts = UserLoginAttempt::remaining();
$restricted = IPAddressBlacklist::check($ipAddress) ? 'blacklist'
: (user_warning_check_ip($ipAddress) ? 'ban' : '');

View file

@ -2,6 +2,8 @@
namespace Misuzu;
use Misuzu\Net\IPAddress;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
require_once '../../misuzu.php';
@ -13,16 +15,18 @@ if(user_session_active()) {
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
$notices = [];
$ipAddress = IPAddress::remote();
$remainingAttempts = user_login_attempts_remaining($ipAddress);
$remainingAttempts = UserLoginAttempt::remaining();
$tokenInfo = user_auth_tfa_token_info(
!empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : (
!empty($twofactor['token']) && is_string($twofactor['token']) ? $twofactor['token'] : ''
)
);
$userData = User::byId($tokenInfo['user_id']);
// checking user_totp_key specifically because there's a fringe chance that
// there's a token present, but totp is actually disabled
if(empty($tokenInfo['user_totp_key'])) {
if(!$userData->hasTOTP()) {
url_redirect('auth-login');
return;
}
@ -46,24 +50,17 @@ while(!empty($twofactor)) {
break;
}
$totp = new TOTP($tokenInfo['user_totp_key']);
$accepted = [
$totp->generate(time()),
$totp->generate(time() - 30),
$totp->generate(time() + 30),
];
if(!in_array($twofactor['code'], $accepted)) {
if(!in_array($twofactor['code'], $userData->getValidTOTPTokens())) {
$notices[] = sprintf(
"Invalid two factor code, %d attempt%s remaining",
$remainingAttempts - 1,
$remainingAttempts === 2 ? '' : 's'
);
user_login_attempt_record(false, $tokenInfo['user_id'], $ipAddress, $userAgent);
UserLoginAttempt::create(false, $userData);
break;
}
user_login_attempt_record(true, $tokenInfo['user_id'], $ipAddress, $userAgent);
UserLoginAttempt::create(true, $userData);
$sessionKey = user_session_create($tokenInfo['user_id'], $ipAddress, $userAgent);
if(empty($sessionKey)) {

View file

@ -4,26 +4,22 @@ namespace Misuzu;
use Misuzu\AuditLog;
use Misuzu\Pagination;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
require_once '../../misuzu.php';
if(!user_session_active()) {
$currentUser = User::getCurrent();
if($currentUser === null) {
echo render_error(401);
return;
}
$currentUser = User::getCurrent();
$loginHistoryPagination = new Pagination(user_login_attempts_count($currentUser->getId()), 15, 'hp');
$loginHistoryPagination = new Pagination(UserLoginAttempt::countAll($currentUser), 15, 'hp');
$accountLogPagination = new Pagination(AuditLog::countAll($currentUser), 15, 'ap');
$loginHistoryList = user_login_attempts_list(
$loginHistoryPagination->getOffset(),
$loginHistoryPagination->getRange(),
$currentUser->getId()
);
Template::render('settings.logs', [
'login_history_list' => $loginHistoryList,
'login_history_list' => UserLoginAttempt::all($loginHistoryPagination, $currentUser),
'login_history_pagination' => $loginHistoryPagination,
'account_log_list' => AuditLog::all($accountLogPagination, $currentUser),
'account_log_pagination' => $accountLogPagination,

View file

@ -4,6 +4,7 @@ namespace Misuzu\Users;
use Misuzu\Colour;
use Misuzu\DB;
use Misuzu\Memoizer;
use Misuzu\TOTP;
use Misuzu\Net\IPAddress;
class UserException extends UsersException {} // this naming definitely won't lead to confusion down the line!
@ -36,6 +37,8 @@ class User {
private static $localUser = null;
private $totp = null;
private const USER_SELECT = '
SELECT u.`user_id`, u.`username`, u.`password`, u.`email`, u.`user_super`, u.`user_title`,
u.`user_country`, u.`user_colour`, u.`display_role`, u.`user_totp_key`,
@ -110,6 +113,21 @@ class User {
public function hasTOTP(): bool {
return !empty($this->user_totp_key);
}
public function getTOTP(): TOTP {
if($this->totp === null)
$this->totp = new TOTP($this->user_totp_key);
return $this->totp;
}
public function getValidTOTPTokens(): array {
if(!$this->hasTOTP())
return [];
$totp = $this->getTOTP();
return [
$totp->generate(time()),
$totp->generate(time() - 30),
$totp->generate(time() + 30),
];
}
public function getBackgroundSettings(): int { // Use the below methods instead
return $this->user_background_settings;

View file

@ -0,0 +1,123 @@
<?php
namespace Misuzu\Users;
use Misuzu\DB;
use Misuzu\Pagination;
use Misuzu\Net\IPAddress;
class UserLoginAttempt {
// Database fields
private $user_id = null;
private $attempt_success = false;
private $attempt_ip = '::1';
private $attempt_country = 'XX';
private $attempt_created = null;
private $attempt_user_agent = '';
private $user = null;
private $userLookedUp = false;
public const TABLE = 'login_attempts';
private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
private const SELECT = '%1$s.`user_id`, %1$s.`attempt_success`, %1$s.`attempt_country`, %1$s.`attempt_user_agent`'
. ', INET6_NTOA(%1$s.`attempt_ip`) AS `attempt_ip`'
. ', UNIX_TIMESTAMP(%1$s.`attempt_created`) AS `attempt_created`';
public function getUserId(): int {
return $this->user_id < 1 ? -1 : $this->user_id;
}
public function getUser(): ?User {
if(!$this->userLookedUp && ($userId = $this->getUserId()) > 0) {
$this->userLookedUp = true;
try {
$this->user = User::byId($userId);
} catch(UserNotFoundException $ex) {}
}
return $this->user;
}
public function isSuccess(): bool {
return boolval($this->attempt_success);
}
public function getRemoteAddress(): string {
return $this->attempt_ip;
}
public function getCountry(): string {
return $this->attempt_country;
}
public function getCountryName(): string {
return get_country_name($this->getCountry());
}
public function getCreatedTime(): int {
return $this->attempt_created === null ? -1 : $this->attempt_created;
}
public function getUserAgent(): string {
return $this->attempt_user_agent;
}
public static function remaining(?string $remoteAddr = null): int {
$remoteAddr = $ipAddress ?? IPAddress::remote();
return (int)DB::prepare(
'SELECT 5 - COUNT(*)'
. ' FROM `' . DB::PREFIX . self::TABLE . '`'
. ' WHERE `attempt_success` = 0'
. ' AND `attempt_created` > NOW() - INTERVAL 1 HOUR'
. ' AND `attempt_ip` = INET6_ATON(:remote_ip)'
) ->bind('remote_ip', $remoteAddr)
->fetchColumn();
}
public static function create(bool $success, ?User $user = null, ?string $remoteAddr = null, string $userAgent = null): void {
$remoteAddr = $ipAddress ?? IPAddress::remote();
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT', FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH) ?? '';
$createLog = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `attempt_success`, `attempt_ip`, `attempt_country`, `attempt_user_agent`)'
. ' VALUES (:user, :success, INET6_ATON(:ip), :country, :user_agent)'
) ->bind('user', $user === null ? null : $user->getId()) // this null situation should never ever happen but better safe than sorry !
->bind('success', $success ? 1 : 0)
->bind('ip', $remoteAddr)
->bind('country', IPAddress::country($remoteAddr))
->bind('user_agent', $userAgent)
->execute();
}
private static function countQueryBase(): string {
return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE));
}
public static function countAll(?User $user = null): int {
$getCount = DB::prepare(
self::countQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
);
if($user !== null)
$getCount->bind('user', $user->getId());
return (int)$getCount->fetchColumn();
}
private static function byQueryBase(): string {
return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
}
public static function all(?Pagination $pagination = null, ?User $user = null): array {
$attemptsQuery = self::byQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
. ' ORDER BY `attempt_created` DESC';
if($pagination !== null)
$attemptsQuery .= ' LIMIT :range OFFSET :offset';
$getAttempts = DB::prepare($attemptsQuery);
if($user !== null)
$getAttempts->bind('user', $user->getId());
if($pagination !== null)
$getAttempts->bind('range', $pagination->getRange())
->bind('offset', $pagination->getOffset());
return $getAttempts->fetchObjects(self::class);
}
}

View file

@ -1,67 +0,0 @@
<?php
function user_login_attempt_record(bool $success, ?int $userId, string $ipAddress, string $userAgent): void {
$storeAttempt = \Misuzu\DB::prepare('
INSERT INTO `msz_login_attempts`
(`attempt_success`, `attempt_ip`, `attempt_country`, `user_id`, `attempt_user_agent`)
VALUES
(:attempt_success, INET6_ATON(:attempt_ip), :attempt_country, :user_id, :attempt_user_agent)
');
$storeAttempt->bind('attempt_success', $success ? 1 : 0);
$storeAttempt->bind('attempt_ip', $ipAddress);
$storeAttempt->bind('attempt_country', \Misuzu\Net\IPAddress::country($ipAddress));
$storeAttempt->bind('attempt_user_agent', $userAgent);
$storeAttempt->bind('user_id', $userId, $userId === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
$storeAttempt->execute();
}
function user_login_attempts_remaining(string $ipAddress): int {
$getRemaining = \Misuzu\DB::prepare('
SELECT 5 - COUNT(`attempt_id`)
FROM `msz_login_attempts`
WHERE `attempt_success` = 0
AND `attempt_created` > NOW() - INTERVAL 1 HOUR
AND `attempt_ip` = INET6_ATON(:remote_ip)
');
$getRemaining->bind('remote_ip', $ipAddress);
return (int)$getRemaining->fetchColumn();
}
function user_login_attempts_count($userId = 0): int {
$getCount = \Misuzu\DB::prepare(sprintf('
SELECT COUNT(`attempt_id`)
FROM `msz_login_attempts`
WHERE %s
', $userId < 1 ? '1' : '`user_id` = :user_id'));
if($userId >= 1) {
$getCount->bind('user_id', $userId);
}
return (int)$getCount->fetchColumn();
}
function user_login_attempts_list(int $offset, int $take, int $userId = 0): array {
$offset = max(0, $offset);
$take = max(1, $take);
$getAttempts = \Misuzu\DB::prepare(sprintf('
SELECT
`attempt_id`, `attempt_country`, `attempt_success`, `attempt_user_agent`, `attempt_created`,
INET6_NTOA(`attempt_ip`) as `attempt_ip`
FROM `msz_login_attempts`
WHERE %s
ORDER BY `attempt_id` DESC
LIMIT :offset, :take
', $userId < 1 ? '1' : '`user_id` = :user_id'));
if($userId > 0) {
$getAttempts->bind('user_id', $userId);
}
$getAttempts->bind('offset', $offset);
$getAttempts->bind('take', $take);
return $getAttempts->fetchAll();
}

View file

@ -211,12 +211,12 @@
{% endmacro %}
{% macro user_login_attempt(attempt) %}
{% set browser = get_browser(attempt.attempt_user_agent) %}
{% set browser = get_browser(attempt.userAgent) %}
<div class="settings__login-attempt{% if not attempt.attempt_success %} settings__login-attempt--failed{% endif %}" id="login-attempt-{{ attempt.attempt_id }}">
<div class="settings__login-attempt{% if not attempt.success %} settings__login-attempt--failed{% endif %}">
<div class="settings__login-attempt__container">
<div class="settings__login-attempt__important">
<div class="flag flag--{{ attempt.attempt_country|lower }} settings__login-attempt__flag" title="{{ attempt.attempt_country|country_name }}">{{ attempt.attempt_country }}</div>
<div class="flag flag--{{ attempt.country|lower }} settings__login-attempt__flag" title="{{ attempt.countryName }}">{{ attempt.country }}</div>
<div class="settings__login-attempt__description">
{{ browser.browser }} on {{ browser.platform }}
@ -229,7 +229,7 @@
IP Address
</div>
<div class="settings__login-attempt__detail__value">
{{ attempt.attempt_ip }}
{{ attempt.remoteAddress }}
</div>
</div>
@ -238,16 +238,16 @@
Succeeded
</div>
<div class="settings__login-attempt__detail__value">
{{ attempt.attempt_success ? 'Yes' : 'No' }}
{{ attempt.success ? 'Yes' : 'No' }}
</div>
</div>
<div class="settings__login-attempt__detail" title="{{ attempt.attempt_created|date('r') }}">
<div class="settings__login-attempt__detail" title="{{ attempt.createdTime|date('r') }}">
<div class="settings__login-attempt__detail__title">
Attempted
</div>
<time class="settings__login-attempt__detail__value" datetime="{{ attempt.attempt_created|date('c') }}">
{{ attempt.attempt_created|time_diff }}
<time class="settings__login-attempt__detail__value" datetime="{{ attempt.createdTime|date('c') }}">
{{ attempt.createdTime|time_diff }}
</time>
</div>
@ -256,7 +256,7 @@
User Agent
</div>
<div class="settings__login-attempt__detail__value">
{{ attempt.attempt_user_agent|length > 0 ? attempt.attempt_user_agent : 'None' }}
{{ attempt.userAgent is empty ? 'None' : attempt.userAgent }}
</div>
</div>
</div>