Rewrite login attempts log to use new database backend.
This commit is contained in:
parent
d0e3f6ce65
commit
6e3023a772
12 changed files with 289 additions and 162 deletions
|
@ -23,7 +23,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
|
||||||
while($selectLoginAttempts->next()) {
|
while($selectLoginAttempts->next()) {
|
||||||
$updateLoginAttempts->reset();
|
$updateLoginAttempts->reset();
|
||||||
$userAgent = $selectLoginAttempts->getString(0);
|
$userAgent = $selectLoginAttempts->getString(0);
|
||||||
$updateLoginAttempts->addParameter(1, ClientInfo::parse($userAgent)->encode());
|
$updateLoginAttempts->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
|
||||||
$updateLoginAttempts->addParameter(2, $userAgent);
|
$updateLoginAttempts->addParameter(2, $userAgent);
|
||||||
$updateLoginAttempts->execute();
|
$updateLoginAttempts->execute();
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
|
||||||
while($selectSessions->next()) {
|
while($selectSessions->next()) {
|
||||||
$updateSessions->reset();
|
$updateSessions->reset();
|
||||||
$userAgent = $selectSessions->getString(0);
|
$userAgent = $selectSessions->getString(0);
|
||||||
$updateSessions->addParameter(1, ClientInfo::parse($userAgent)->encode());
|
$updateSessions->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
|
||||||
$updateSessions->addParameter(2, $userAgent);
|
$updateSessions->addParameter(2, $userAgent);
|
||||||
$updateSessions->execute();
|
$updateSessions->execute();
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,8 @@
|
||||||
namespace Misuzu;
|
namespace Misuzu;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Misuzu\AuthToken;
|
|
||||||
use Misuzu\Users\User;
|
use Misuzu\Users\User;
|
||||||
use Misuzu\Users\UserAuthSession;
|
use Misuzu\Users\UserAuthSession;
|
||||||
use Misuzu\Users\UserLoginAttempt;
|
|
||||||
use Misuzu\Users\UserSession;
|
use Misuzu\Users\UserSession;
|
||||||
|
|
||||||
if(UserSession::hasCurrent()) {
|
if(UserSession::hasCurrent()) {
|
||||||
|
@ -39,7 +37,10 @@ if(!empty($_GET['resolve'])) {
|
||||||
$notices = [];
|
$notices = [];
|
||||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
||||||
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
|
||||||
|
$loginAttempts = $msz->getLoginAttempts();
|
||||||
|
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
|
||||||
|
|
||||||
$siteIsPrivate = $cfg->getBoolean('private.enable');
|
$siteIsPrivate = $cfg->getBoolean('private.enable');
|
||||||
if($siteIsPrivate) {
|
if($siteIsPrivate) {
|
||||||
|
@ -90,7 +91,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
|
||||||
try {
|
try {
|
||||||
$userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']);
|
$userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']);
|
||||||
} catch(RuntimeException $ex) {
|
} catch(RuntimeException $ex) {
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, false);
|
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest());
|
||||||
$notices[] = $loginFailedError;
|
$notices[] = $loginFailedError;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -101,7 +102,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) {
|
if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) {
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
|
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
|
||||||
$notices[] = $loginFailedError;
|
$notices[] = $loginFailedError;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -111,7 +112,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
|
||||||
|
|
||||||
if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userInfo->getId(), $loginPermVal)) {
|
if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userInfo->getId(), $loginPermVal)) {
|
||||||
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
|
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
|
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,7 +123,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
|
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
|
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
|
||||||
|
|
|
@ -3,7 +3,6 @@ namespace Misuzu;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Misuzu\Users\User;
|
use Misuzu\Users\User;
|
||||||
use Misuzu\Users\UserLoginAttempt;
|
|
||||||
use Misuzu\Users\UserRecoveryToken;
|
use Misuzu\Users\UserRecoveryToken;
|
||||||
use Misuzu\Users\UserSession;
|
use Misuzu\Users\UserSession;
|
||||||
|
|
||||||
|
@ -30,7 +29,9 @@ $notices = [];
|
||||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
$siteIsPrivate = $cfg->getBoolean('private.enable');
|
$siteIsPrivate = $cfg->getBoolean('private.enable');
|
||||||
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
|
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
|
||||||
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
|
|
||||||
|
$loginAttempts = $msz->getLoginAttempts();
|
||||||
|
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
|
||||||
|
|
||||||
while($canResetPassword) {
|
while($canResetPassword) {
|
||||||
if(!empty($reset) && $userId > 0) {
|
if(!empty($reset) && $userId > 0) {
|
||||||
|
|
|
@ -3,7 +3,6 @@ namespace Misuzu;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Misuzu\Users\User;
|
use Misuzu\Users\User;
|
||||||
use Misuzu\Users\UserLoginAttempt;
|
|
||||||
use Misuzu\Users\UserRole;
|
use Misuzu\Users\UserRole;
|
||||||
use Misuzu\Users\UserSession;
|
use Misuzu\Users\UserSession;
|
||||||
use Misuzu\Users\UserWarning;
|
use Misuzu\Users\UserWarning;
|
||||||
|
@ -17,9 +16,11 @@ $register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST[
|
||||||
$notices = [];
|
$notices = [];
|
||||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
||||||
$remainingAttempts = UserLoginAttempt::remaining($_SERVER['REMOTE_ADDR']);
|
|
||||||
$restricted = UserWarning::countByRemoteAddress($ipAddress) > 0 ? 'ban' : '';
|
$restricted = UserWarning::countByRemoteAddress($ipAddress) > 0 ? 'ban' : '';
|
||||||
|
|
||||||
|
$loginAttempts = $msz->getLoginAttempts();
|
||||||
|
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
|
||||||
|
|
||||||
while(!$restricted && !empty($register)) {
|
while(!$restricted && !empty($register)) {
|
||||||
if(!CSRF::validateRequest()) {
|
if(!CSRF::validateRequest()) {
|
||||||
$notices[] = 'Was unable to verify the request, please try again!';
|
$notices[] = 'Was unable to verify the request, please try again!';
|
||||||
|
|
|
@ -3,7 +3,6 @@ namespace Misuzu;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Misuzu\Users\User;
|
use Misuzu\Users\User;
|
||||||
use Misuzu\Users\UserLoginAttempt;
|
|
||||||
use Misuzu\Users\UserSession;
|
use Misuzu\Users\UserSession;
|
||||||
use Misuzu\Users\UserAuthSession;
|
use Misuzu\Users\UserAuthSession;
|
||||||
|
|
||||||
|
@ -14,9 +13,12 @@ if(UserSession::hasCurrent()) {
|
||||||
|
|
||||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
|
||||||
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
|
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
|
||||||
$notices = [];
|
$notices = [];
|
||||||
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
|
|
||||||
|
$loginAttempts = $msz->getLoginAttempts();
|
||||||
|
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$tokenInfo = UserAuthSession::byToken(
|
$tokenInfo = UserAuthSession::byToken(
|
||||||
|
@ -65,11 +67,11 @@ while(!empty($twofactor)) {
|
||||||
$remainingAttempts - 1,
|
$remainingAttempts - 1,
|
||||||
$remainingAttempts === 2 ? '' : 's'
|
$remainingAttempts === 2 ? '' : 's'
|
||||||
);
|
);
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
|
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
|
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
|
||||||
$tokenInfo->delete();
|
$tokenInfo->delete();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -3,7 +3,6 @@ namespace Misuzu;
|
||||||
|
|
||||||
use Misuzu\Pagination;
|
use Misuzu\Pagination;
|
||||||
use Misuzu\Users\User;
|
use Misuzu\Users\User;
|
||||||
use Misuzu\Users\UserLoginAttempt;
|
|
||||||
|
|
||||||
$currentUser = User::getCurrent();
|
$currentUser = User::getCurrent();
|
||||||
|
|
||||||
|
@ -12,15 +11,17 @@ if($currentUser === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$loginAttempts = $msz->getLoginAttempts();
|
||||||
$auditLog = $msz->getAuditLog();
|
$auditLog = $msz->getAuditLog();
|
||||||
|
|
||||||
$loginHistoryPagination = new Pagination(UserLoginAttempt::countAll($currentUser), 15, 'hp');
|
$loginHistoryPagination = new Pagination($loginAttempts->countAttempts(userInfo: $currentUser), 5, 'hp');
|
||||||
$accountLogPagination = new Pagination($auditLog->countLogs(userInfo: $currentUser), 15, 'ap');
|
$accountLogPagination = new Pagination($auditLog->countLogs(userInfo: $currentUser), 10, 'ap');
|
||||||
|
|
||||||
|
$loginHistory = $loginAttempts->getAttempts(userInfo: $currentUser, pagination: $loginHistoryPagination);
|
||||||
$auditLogs = $auditLog->getLogs(userInfo: $currentUser, pagination: $accountLogPagination);
|
$auditLogs = $auditLog->getLogs(userInfo: $currentUser, pagination: $accountLogPagination);
|
||||||
|
|
||||||
Template::render('settings.logs', [
|
Template::render('settings.logs', [
|
||||||
'login_history_list' => UserLoginAttempt::all($loginHistoryPagination, $currentUser),
|
'login_history_list' => $loginHistory,
|
||||||
'login_history_pagination' => $loginHistoryPagination,
|
'login_history_pagination' => $loginHistoryPagination,
|
||||||
'account_log_list' => $auditLogs,
|
'account_log_list' => $auditLogs,
|
||||||
'account_log_pagination' => $accountLogPagination,
|
'account_log_pagination' => $accountLogPagination,
|
||||||
|
|
71
src/Auth/LoginAttemptInfo.php
Normal file
71
src/Auth/LoginAttemptInfo.php
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
namespace Misuzu\Auth;
|
||||||
|
|
||||||
|
use Index\DateTime;
|
||||||
|
use Index\Data\IDbResult;
|
||||||
|
use Index\Net\IPAddress;
|
||||||
|
use Misuzu\ClientInfo;
|
||||||
|
|
||||||
|
class LoginAttemptInfo {
|
||||||
|
private ?string $userId;
|
||||||
|
private bool $success;
|
||||||
|
private string $remoteAddr;
|
||||||
|
private string $countryCode;
|
||||||
|
private int $created;
|
||||||
|
private string $userAgent;
|
||||||
|
private string $clientInfo;
|
||||||
|
|
||||||
|
public function __construct(IDbResult $result) {
|
||||||
|
$this->userId = $result->isNull(0) ? null : (string)$result->getInteger(0);
|
||||||
|
$this->success = $result->getInteger(1) !== 0;
|
||||||
|
$this->remoteAddr = $result->getString(2);
|
||||||
|
$this->countryCode = $result->getString(3);
|
||||||
|
$this->created = $result->getInteger(4);
|
||||||
|
$this->userAgent = $result->getString(5);
|
||||||
|
$this->clientInfo = $result->getString(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasUserId(): bool {
|
||||||
|
return $this->userId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): string {
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSuccess(): bool {
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemoteAddressRaw(): string {
|
||||||
|
return $this->remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemoteAddress(): IPAddress {
|
||||||
|
return IPAddress::parse($this->remoteAddr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountryCode(): string {
|
||||||
|
return $this->countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedTime(): int {
|
||||||
|
return $this->created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTime {
|
||||||
|
return DateTime::fromUnixTimeSeconds($this->created);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgentString(): string {
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientInfoRaw(): string {
|
||||||
|
return $this->clientInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientInfo(): ClientInfo {
|
||||||
|
return ClientInfo::decode($this->clientInfo);
|
||||||
|
}
|
||||||
|
}
|
167
src/Auth/LoginAttempts.php
Normal file
167
src/Auth/LoginAttempts.php
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
<?php
|
||||||
|
namespace Misuzu\Auth;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
use Index\Data\DbStatementCache;
|
||||||
|
use Index\Data\DbTools;
|
||||||
|
use Index\Data\IDbConnection;
|
||||||
|
use Index\Data\IDbResult;
|
||||||
|
use Index\Net\IPAddress;
|
||||||
|
use Misuzu\ClientInfo;
|
||||||
|
use Misuzu\Pagination;
|
||||||
|
use Misuzu\Users\User;
|
||||||
|
|
||||||
|
class LoginAttempts {
|
||||||
|
public const REMAINING_MAX = 5;
|
||||||
|
public const REMAINING_WINDOW = 60 * 60;
|
||||||
|
|
||||||
|
private DbStatementCache $cache;
|
||||||
|
|
||||||
|
public function __construct(IDbConnection $dbConn) {
|
||||||
|
$this->cache = new DbStatementCache($dbConn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countAttempts(
|
||||||
|
?bool $success = null,
|
||||||
|
User|string|null $userInfo = null,
|
||||||
|
IPAddress|string|null $remoteAddr = null,
|
||||||
|
TimeSpan|int|null $timeRange = null
|
||||||
|
): int {
|
||||||
|
if($userInfo instanceof User)
|
||||||
|
$userInfo = (string)$userInfo->getId();
|
||||||
|
if($remoteAddr instanceof IPAddress)
|
||||||
|
$remoteAddr = (string)$remoteAddr;
|
||||||
|
if($timeRange instanceof TimeSpan)
|
||||||
|
$timeRange = (int)$timeRange->totalSeconds();
|
||||||
|
|
||||||
|
$hasSuccess = $success !== null;
|
||||||
|
$hasUserInfo = $userInfo !== null;
|
||||||
|
$hasRemoteAddr = $remoteAddr !== null;
|
||||||
|
$hasTimeRange = $timeRange !== null;
|
||||||
|
|
||||||
|
$args = 0;
|
||||||
|
$query = 'SELECT COUNT(*) FROM msz_login_attempts';
|
||||||
|
if($hasSuccess) {
|
||||||
|
++$args;
|
||||||
|
$query .= sprintf(' WHERE attempt_success %s 0', $success ? '<>' : '=');
|
||||||
|
}
|
||||||
|
if($hasUserInfo)
|
||||||
|
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
if($hasRemoteAddr)
|
||||||
|
$query .= sprintf(' %s attempt_ip = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
if($hasTimeRange)
|
||||||
|
$query .= sprintf(' %s attempt_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
|
||||||
|
$args = 0;
|
||||||
|
$stmt = $this->cache->get($query);
|
||||||
|
if($hasUserInfo)
|
||||||
|
$stmt->addParameter(++$args, $userInfo);
|
||||||
|
if($hasRemoteAddr)
|
||||||
|
$stmt->addParameter(++$args, $remoteAddr);
|
||||||
|
if($hasTimeRange)
|
||||||
|
$stmt->addParameter(++$args, $timeRange);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$result = $stmt->getResult();
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
if($result->next())
|
||||||
|
$count = $result->getInteger(0);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countRemainingAttempts(IPAddress|string $remoteAddr): int {
|
||||||
|
return self::REMAINING_MAX - $this->countAttempts(
|
||||||
|
success: false,
|
||||||
|
timeRange: self::REMAINING_WINDOW,
|
||||||
|
remoteAddr: $remoteAddr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttempts(
|
||||||
|
?bool $success = null,
|
||||||
|
User|string|null $userInfo = null,
|
||||||
|
IPAddress|string|null $remoteAddr = null,
|
||||||
|
TimeSpan|int|null $timeRange = null,
|
||||||
|
?Pagination $pagination = null
|
||||||
|
): array {
|
||||||
|
if($userInfo instanceof User)
|
||||||
|
$userInfo = (string)$userInfo->getId();
|
||||||
|
if($remoteAddr instanceof IPAddress)
|
||||||
|
$remoteAddr = (string)$remoteAddr;
|
||||||
|
if($timeRange instanceof TimeSpan)
|
||||||
|
$timeRange = (int)$timeRange->totalSeconds();
|
||||||
|
|
||||||
|
$hasSuccess = $success !== null;
|
||||||
|
$hasUserInfo = $userInfo !== null;
|
||||||
|
$hasRemoteAddr = $remoteAddr !== null;
|
||||||
|
$hasTimeRange = $timeRange !== null;
|
||||||
|
$hasPagination = $pagination !== null;
|
||||||
|
|
||||||
|
$args = 0;
|
||||||
|
$query = 'SELECT user_id, attempt_success, INET6_NTOA(attempt_ip), attempt_country, UNIX_TIMESTAMP(attempt_created), attempt_user_agent, attempt_client_info FROM msz_login_attempts';
|
||||||
|
if($hasSuccess) {
|
||||||
|
++$args;
|
||||||
|
$query .= sprintf(' WHERE attempt_success %s 0', $success ? '<>' : '=');
|
||||||
|
}
|
||||||
|
if($hasUserInfo)
|
||||||
|
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
if($hasRemoteAddr)
|
||||||
|
$query .= sprintf(' %s attempt_ip = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
if($hasTimeRange)
|
||||||
|
$query .= sprintf(' %s attempt_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
|
||||||
|
$query .= ' ORDER BY attempt_created DESC';
|
||||||
|
if($hasPagination)
|
||||||
|
$query .= ' LIMIT ? OFFSET ?';
|
||||||
|
|
||||||
|
$args = 0;
|
||||||
|
$stmt = $this->cache->get($query);
|
||||||
|
if($hasUserInfo)
|
||||||
|
$stmt->addParameter(++$args, $userInfo);
|
||||||
|
if($hasRemoteAddr)
|
||||||
|
$stmt->addParameter(++$args, $remoteAddr);
|
||||||
|
if($hasTimeRange)
|
||||||
|
$stmt->addParameter(++$args, $timeRange);
|
||||||
|
if($hasPagination) {
|
||||||
|
$stmt->addParameter(++$args, $pagination->getRange());
|
||||||
|
$stmt->addParameter(++$args, $pagination->getOffset());
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$result = $stmt->getResult();
|
||||||
|
$attempts = [];
|
||||||
|
|
||||||
|
while($result->next())
|
||||||
|
$attempts[] = new LoginAttemptInfo($result);
|
||||||
|
|
||||||
|
return $attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordAttempt(
|
||||||
|
bool $success,
|
||||||
|
IPAddress|string $remoteAddr,
|
||||||
|
string $countryCode,
|
||||||
|
string $userAgentString,
|
||||||
|
?ClientInfo $clientInfo = null,
|
||||||
|
User|string|null $userInfo = null
|
||||||
|
): void {
|
||||||
|
if($remoteAddr instanceof IPAddress)
|
||||||
|
$remoteAddr = (string)$remoteAddr;
|
||||||
|
if($userInfo instanceof User)
|
||||||
|
$userInfo = (string)$userInfo->getId();
|
||||||
|
|
||||||
|
$hasUserInfo = $userInfo !== null;
|
||||||
|
$clientInfo = json_encode($clientInfo ?? ClientInfo::parse($userAgentString));
|
||||||
|
|
||||||
|
$stmt = $this->cache->get('INSERT INTO msz_login_attempts (user_id, attempt_success, attempt_ip, attempt_country, attempt_user_agent, attempt_client_info) VALUES (?, ?, INET6_ATON(?), ?, ?, ?)');
|
||||||
|
$stmt->addParameter(1, $userInfo);
|
||||||
|
$stmt->addParameter(2, $success ? 1 : 0);
|
||||||
|
$stmt->addParameter(3, $remoteAddr);
|
||||||
|
$stmt->addParameter(4, $countryCode);
|
||||||
|
$stmt->addParameter(5, $userAgentString);
|
||||||
|
$stmt->addParameter(6, $clientInfo);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,12 +2,13 @@
|
||||||
namespace Misuzu;
|
namespace Misuzu;
|
||||||
|
|
||||||
use stdClass;
|
use stdClass;
|
||||||
|
use JsonSerializable;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Stringable;
|
use Stringable;
|
||||||
use DeviceDetector\ClientHints;
|
use DeviceDetector\ClientHints;
|
||||||
use DeviceDetector\DeviceDetector;
|
use DeviceDetector\DeviceDetector;
|
||||||
|
|
||||||
class ClientInfo implements Stringable {
|
class ClientInfo implements Stringable, JsonSerializable {
|
||||||
private const SERIALIZE_VERSION = 1;
|
private const SERIALIZE_VERSION = 1;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
@ -60,6 +61,10 @@ class ClientInfo implements Stringable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function encode(): string {
|
public function encode(): string {
|
||||||
|
return json_encode($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): mixed {
|
||||||
$data = new stdClass;
|
$data = new stdClass;
|
||||||
$data->version = self::SERIALIZE_VERSION;
|
$data->version = self::SERIALIZE_VERSION;
|
||||||
|
|
||||||
|
@ -74,7 +79,7 @@ class ClientInfo implements Stringable {
|
||||||
if($this->modelName !== '')
|
if($this->modelName !== '')
|
||||||
$data->model = $this->modelName;
|
$data->model = $this->modelName;
|
||||||
|
|
||||||
return json_encode($data);
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function decode(string $encoded): self {
|
public static function decode(string $encoded): self {
|
||||||
|
@ -116,4 +121,8 @@ class ClientInfo implements Stringable {
|
||||||
$dd->getModel()
|
$dd->getModel()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function fromRequest(): self {
|
||||||
|
return self::parse($_SERVER);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
namespace Misuzu;
|
namespace Misuzu;
|
||||||
|
|
||||||
use Misuzu\Template;
|
use Misuzu\Template;
|
||||||
|
use Misuzu\Auth\LoginAttempts;
|
||||||
use Misuzu\AuditLog\AuditLog;
|
use Misuzu\AuditLog\AuditLog;
|
||||||
use Misuzu\Changelog\Changelog;
|
use Misuzu\Changelog\Changelog;
|
||||||
use Misuzu\Comments\Comments;
|
use Misuzu\Comments\Comments;
|
||||||
|
@ -21,6 +22,9 @@ use Index\Routing\Router;
|
||||||
// this class should function as the root for everything going forward
|
// this class should function as the root for everything going forward
|
||||||
// no more magical static classes that are just kind of assumed to exist
|
// no more magical static classes that are just kind of assumed to exist
|
||||||
// it currently looks Pretty Messy, but most everything else will be holding instances of other classes
|
// it currently looks Pretty Messy, but most everything else will be holding instances of other classes
|
||||||
|
// instances of certain classes should only be made as needed,
|
||||||
|
// dunno if i want null checks some maybe some kind of init func should be called first like is the case
|
||||||
|
// with the http shit
|
||||||
class MisuzuContext {
|
class MisuzuContext {
|
||||||
private IDbConnection $dbConn;
|
private IDbConnection $dbConn;
|
||||||
private IConfig $config;
|
private IConfig $config;
|
||||||
|
@ -30,6 +34,7 @@ class MisuzuContext {
|
||||||
private Changelog $changelog;
|
private Changelog $changelog;
|
||||||
private News $news;
|
private News $news;
|
||||||
private Comments $comments;
|
private Comments $comments;
|
||||||
|
private LoginAttempts $loginAttempts;
|
||||||
|
|
||||||
public function __construct(IDbConnection $dbConn, IConfig $config) {
|
public function __construct(IDbConnection $dbConn, IConfig $config) {
|
||||||
$this->dbConn = $dbConn;
|
$this->dbConn = $dbConn;
|
||||||
|
@ -39,6 +44,7 @@ class MisuzuContext {
|
||||||
$this->changelog = new Changelog($this->dbConn);
|
$this->changelog = new Changelog($this->dbConn);
|
||||||
$this->news = new News($this->dbConn);
|
$this->news = new News($this->dbConn);
|
||||||
$this->comments = new Comments($this->dbConn);
|
$this->comments = new Comments($this->dbConn);
|
||||||
|
$this->loginAttempts = new LoginAttempts($this->dbConn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDbConn(): IDbConnection {
|
public function getDbConn(): IDbConnection {
|
||||||
|
@ -86,6 +92,10 @@ class MisuzuContext {
|
||||||
return $this->auditLog;
|
return $this->auditLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLoginAttempts(): LoginAttempts {
|
||||||
|
return $this->loginAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
|
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
|
||||||
if($userInfo === null && User::hasCurrent())
|
if($userInfo === null && User::hasCurrent())
|
||||||
$userInfo = User::getCurrent();
|
$userInfo = User::getCurrent();
|
||||||
|
|
|
@ -1,136 +0,0 @@
|
||||||
<?php
|
|
||||||
namespace Misuzu\Users;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
use Misuzu\ClientInfo;
|
|
||||||
use Misuzu\DB;
|
|
||||||
use Misuzu\Pagination;
|
|
||||||
|
|
||||||
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 $attempt_client_info = '';
|
|
||||||
|
|
||||||
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`, %1$s.`attempt_client_info`'
|
|
||||||
. ', 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(RuntimeException $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 function getClientInfo(): ClientInfo {
|
|
||||||
return ClientInfo::decode($this->attempt_client_info);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function remaining(string $remoteAddr): int {
|
|
||||||
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(
|
|
||||||
string $remoteAddr,
|
|
||||||
string $countryCode,
|
|
||||||
bool $success,
|
|
||||||
?User $user = null,
|
|
||||||
string $userAgent = null,
|
|
||||||
?ClientInfo $clientInfo = null
|
|
||||||
): void {
|
|
||||||
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
|
|
||||||
$clientInfo ??= ClientInfo::parse($_SERVER);
|
|
||||||
|
|
||||||
$createLog = DB::prepare(
|
|
||||||
'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `attempt_success`, `attempt_ip`, `attempt_country`, `attempt_user_agent`, `attempt_client_info`)'
|
|
||||||
. ' VALUES (:user, :success, INET6_ATON(:ip), :country, :user_agent, :client_info)'
|
|
||||||
) ->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', $countryCode)
|
|
||||||
->bind('user_agent', $userAgent)
|
|
||||||
->bind('client_info', $clientInfo->encode())
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function countQueryBase(): string {
|
|
||||||
return sprintf(self::QUERY_SELECT, 'COUNT(*)');
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -157,7 +157,7 @@
|
||||||
<div class="settings__login-attempt{% if not attempt.success %} settings__login-attempt--failed{% endif %}">
|
<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__container">
|
||||||
<div class="settings__login-attempt__important">
|
<div class="settings__login-attempt__important">
|
||||||
<div class="flag flag--{{ attempt.country|lower }} settings__login-attempt__flag" title="{{ attempt.countryName }}">{{ attempt.country }}</div>
|
<div class="flag flag--{{ attempt.countryCode|lower }} settings__login-attempt__flag" title="{{ attempt.countryCode|country_name }}">{{ attempt.countryCode }}</div>
|
||||||
|
|
||||||
<div class="settings__login-attempt__description">
|
<div class="settings__login-attempt__description">
|
||||||
{{ attempt.clientInfo }}
|
{{ attempt.clientInfo }}
|
||||||
|
@ -170,7 +170,7 @@
|
||||||
IP Address
|
IP Address
|
||||||
</div>
|
</div>
|
||||||
<div class="settings__login-attempt__detail__value">
|
<div class="settings__login-attempt__detail__value">
|
||||||
{{ attempt.remoteAddress }}
|
{{ attempt.remoteAddressRaw }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -197,7 +197,7 @@
|
||||||
User Agent
|
User Agent
|
||||||
</div>
|
</div>
|
||||||
<div class="settings__login-attempt__detail__value">
|
<div class="settings__login-attempt__detail__value">
|
||||||
{{ attempt.userAgent is empty ? 'None' : attempt.userAgent }}
|
{{ attempt.userAgentString }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue