Rewrote Sessions backend.

This commit is contained in:
flash 2023-07-28 20:06:12 +00:00
parent 5c8ffa09fc
commit 3148da4403
23 changed files with 539 additions and 374 deletions

View file

@ -3,9 +3,8 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
if(User::hasCurrent()) {
url_redirect('index');
return;
}
@ -38,7 +37,9 @@ $ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$sessions = $msz->getSessions();
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
$siteIsPrivate = $cfg->getBoolean('private.enable');
@ -80,6 +81,8 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
break;
}
$clientInfo = ClientInfo::fromRequest();
$attemptsRemainingError = sprintf(
"%d attempt%s remaining",
$remainingAttempts - 1,
@ -90,7 +93,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
try {
$userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']);
} catch(RuntimeException $ex) {
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest());
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo);
$notices[] = $loginFailedError;
break;
}
@ -101,7 +104,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
}
if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) {
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$notices[] = $loginFailedError;
break;
}
@ -111,7 +114,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
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.";
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
break;
}
@ -123,11 +126,10 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
return;
}
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
try {
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
$sessionInfo->setCurrent();
$sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo);
} catch(RuntimeException $ex) {
$notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!";
break;

View file

@ -2,18 +2,15 @@
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(!UserSession::hasCurrent()) {
if(!User::hasCurrent()) {
url_redirect('index');
return;
}
if(CSRF::validateRequest()) {
$msz->getSessions()->deleteSessions(sessionTokens: $authToken->getSessionToken());
AuthToken::nukeCookie();
UserSession::getCurrent()->delete();
UserSession::unsetCurrent();
User::unsetCurrent();
url_redirect('index');
return;
}

View file

@ -3,9 +3,8 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
if(User::hasCurrent()) {
url_redirect('settings-account');
return;
}

View file

@ -3,9 +3,8 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
if(User::hasCurrent()) {
url_redirect('index');
return;
}

View file

@ -3,9 +3,8 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
if(User::hasCurrent()) {
url_redirect('index');
return;
}
@ -16,8 +15,10 @@ $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
$notices = [];
$sessions = $msz->getSessions();
$tfaSessions = $msz->getTFASessions();
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
$tokenString = !empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : (
@ -58,22 +59,23 @@ while(!empty($twofactor)) {
break;
}
$clientInfo = ClientInfo::fromRequest();
if(!in_array($twofactor['code'], $userInfo->getValidTOTPTokens())) {
$notices[] = sprintf(
"Invalid two factor code, %d attempt%s remaining",
$remainingAttempts - 1,
$remainingAttempts === 2 ? '' : 's'
);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
break;
}
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$tfaSessions->deleteToken($tokenString);
try {
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
$sessionInfo->setCurrent();
$sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo);
} catch(RuntimeException $ex) {
$notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!";
break;
@ -82,9 +84,8 @@ while(!empty($twofactor)) {
$authToken = AuthToken::create($userInfo, $sessionInfo);
$authToken->applyCookie($sessionInfo->getExpiresTime());
if(!is_local_url($redirect)) {
if(!is_local_url($redirect))
$redirect = url('index');
}
redirect($redirect);
return;

View file

@ -2,7 +2,6 @@
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
$postMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
@ -10,7 +9,7 @@ $submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) &
$postRequestVerified = CSRF::validateRequest();
if(!empty($postMode) && !UserSession::hasCurrent()) {
if(!empty($postMode) && !User::hasCurrent()) {
echo render_info('You must be logged in to manage posts.', 401);
return;
}

View file

@ -2,7 +2,6 @@
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0;
@ -78,7 +77,7 @@ if(in_array($moderationMode, $validModerationModes, true)) {
return;
}
if(!UserSession::hasCurrent()) {
if(!User::hasCurrent()) {
echo render_info('You must be logged in to manage posts.', 401);
return;
}

View file

@ -7,7 +7,6 @@ use Index\ByteFormat;
use Misuzu\Parsers\Parser;
use Misuzu\Profile\ProfileFields;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use Misuzu\Users\Assets\UserBackgroundAsset;
$userId = !empty($_GET['u']) && is_string($_GET['u']) ? trim($_GET['u']) : 0;

View file

@ -3,11 +3,10 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
if(!UserSession::hasCurrent()) {
if(!User::hasCurrent()) {
echo render_error(401);
return;
}

View file

@ -5,9 +5,8 @@ use ZipArchive;
use Index\XString;
use Index\IO\FileStream;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(!UserSession::hasCurrent()) {
if(!User::hasCurrent()) {
echo render_error(401);
return;
}

View file

@ -1,9 +1,9 @@
<?php
namespace Misuzu;
use Misuzu\Users\UserSession;
use Misuzu\Users\User;
if(!UserSession::hasCurrent()) {
if(!User::hasCurrent()) {
echo render_error(401);
return;
}

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
if(!User::hasCurrent()) {
echo render_error(401);
@ -11,49 +10,52 @@ if(!User::hasCurrent()) {
}
$errors = [];
$sessions = $msz->getSessions();
$currentUser = User::getCurrent();
$currentSession = UserSession::getCurrent();
$currentUserId = $currentUser->getId();
$sessionActive = $currentSession->getId();;
$activeSessionToken = $authToken->getSessionToken();
if(!empty($_POST['session']) && CSRF::validateRequest()) {
$currentSessionKilled = false;
while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$sessionId = (string)filter_input(INPUT_POST, 'session');
$activeSessionKilled = false;
if(is_array($_POST['session'])) {
foreach($_POST['session'] as $sessionId) {
$sessionId = (int)$sessionId;
try {
$sessionInfo = UserSession::byId($sessionId);
} catch(RuntimeException $ex) {}
if(empty($sessionInfo) || $sessionInfo->getUserId() !== $currentUser->getId()) {
$errors[] = "Session #{$sessionId} does not exist.";
continue;
} elseif($sessionInfo->getId() === $sessionActive) {
$currentSessionKilled = true;
}
$sessionInfo->delete();
$msz->createAuditLog('PERSONAL_SESSION_DESTROY', [$sessionInfo->getId()]);
}
} elseif($_POST['session'] === 'all') {
$currentSessionKilled = true;
UserSession::purgeUser($currentUser);
if($sessionId === 'all') {
$activeSessionKilled = true;
$sessions->deleteSessions(userInfos: $currentUser);
$msz->createAuditLog('PERSONAL_SESSION_DESTROY_ALL');
} else {
try {
$sessionInfo = $sessions->getSession(sessionId: $sessionId);
} catch(RuntimeException $ex) {}
if(empty($sessionInfo) || $sessionInfo->getUserId() !== (string)$currentUser->getId()) {
$errors[] = "That session doesn't exist.";
break;
}
$activeSessionKilled = $sessionInfo->getToken() === $activeSessionToken;
$sessions->deleteSessions(sessionInfos: $sessionInfo);
$msz->createAuditLog('PERSONAL_SESSION_DESTROY', [$sessionInfo->getId()]);
}
if($currentSessionKilled) {
if($activeSessionKilled) {
url_redirect('index');
return;
}
} else break;
}
$pagination = new Pagination(UserSession::countAll($currentUser), 15);
$pagination = new Pagination($sessions->countSessions(userInfo: $currentUser), 10);
$sessionList = [];
$sessionInfos = $sessions->getSessions(userInfo: $currentUser, pagination: $pagination);
foreach($sessionInfos as $sessionInfo)
$sessionList[] = [
'info' => $sessionInfo,
'active' => $sessionInfo->getToken() === $activeSessionToken,
];
Template::render('settings.sessions', [
'errors' => $errors,
'session_list' => UserSession::all($pagination, $currentUser),
'session_current' => $currentSession,
'session_list' => $sessionList,
'session_pagination' => $pagination,
]);

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
require_once __DIR__ . '/../misuzu.php';
@ -95,20 +94,22 @@ if(!isset($authToken))
$authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? '');
if($authToken->isValid()) {
$sessions = $msz->getSessions();
$authToken->setCurrent();
try {
$sessionInfo = UserSession::byToken($authToken->getSessionToken());
if($sessionInfo->hasExpired()) {
$sessionInfo->delete();
} elseif($sessionInfo->getUserId() === $authToken->getUserId()) {
$userInfo = $sessionInfo->getUser();
if(!$userInfo->isDeleted()) {
$sessionInfo->setCurrent();
$userInfo->setCurrent();
$sessionInfo->bump($_SERVER['REMOTE_ADDR']);
$sessionInfo = $sessions->getSession(sessionToken: $authToken->getSessionToken());
if($sessionInfo->shouldBumpExpire())
if($sessionInfo->hasExpired()) {
$sessions->deleteSessions(sessionInfos: $sessionInfo);
} elseif($sessionInfo->getUserId() === (string)$authToken->getUserId()) {
$userInfo = User::byId((int)$sessionInfo->getUserId());
if(!$userInfo->isDeleted()) {
$userInfo->setCurrent();
$sessions->updateSession(sessionInfo: $sessionInfo, remoteAddr: $_SERVER['REMOTE_ADDR']);
if($sessionInfo->shouldBumpExpires())
$authToken->applyCookie($sessionInfo->getExpiresTime());
// only allow impersonation when super user
@ -128,11 +129,10 @@ if($authToken->isValid()) {
}
}
} catch(RuntimeException $ex) {
UserSession::unsetCurrent();
User::unsetCurrent();
}
if(UserSession::hasCurrent()) {
if(User::hasCurrent()) {
$userInfo->bumpActivity($_SERVER['REMOTE_ADDR']);
} else
AuthToken::nukeCookie();
@ -140,7 +140,7 @@ if($authToken->isValid()) {
CSRF::init(
$globals['csrf.secret'],
(UserSession::hasCurrent() ? UserSession::getCurrent()->getToken() : ($_SERVER['REMOTE_ADDR'] ?? '::1'))
(User::hasCurrent() ? $authToken->getSessionToken() : $_SERVER['REMOTE_ADDR'])
);
if(!empty($userInfo)) {

121
src/Auth/SessionInfo.php Normal file
View file

@ -0,0 +1,121 @@
<?php
namespace Misuzu\Auth;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
class SessionInfo {
private string $id;
private string $userId;
private string $token;
private string $firstRemoteAddr;
private ?string $lastRemoteAddr;
private string $userAgent;
private string $clientInfo;
private string $countryCode;
private int $expires;
private bool $bumpExpires;
private int $created;
private ?int $lastActive;
public function __construct(IDbResult $result) {
$this->id = (string)$result->getInteger(0);
$this->userId = (string)$result->getInteger(1);
$this->token = $result->getString(2);
$this->firstRemoteAddr = $result->getString(3);
$this->lastRemoteAddr = $result->isNull(4) ? null : $result->getString(4);
$this->userAgent = $result->getString(5);
$this->clientInfo = $result->getString(6);
$this->countryCode = $result->getString(7);
$this->expires = $result->getInteger(8);
$this->bumpExpires = $result->getInteger(9) !== 0;
$this->created = $result->getInteger(10);
$this->lastActive = $result->isNull(11) ? null : $result->getInteger(11);
}
public function getId(): string {
return $this->id;
}
public function getUserId(): string {
return $this->userId;
}
public function getToken(): string {
return $this->token;
}
public function getFirstRemoteAddressRaw(): string {
return $this->firstRemoteAddr;
}
public function getFirstRemoteAddress(): IPAddress {
return IPAddress::parse($this->firstRemoteAddr);
}
public function hasLastRemoteAddress(): bool {
return $this->lastRemoteAddr !== null;
}
public function getLastRemoteAddressRaw(): string {
return $this->lastRemoteAddr;
}
public function getLastRemoteAddress(): ?IPAddress {
return $this->lastRemoteAddr === null ? null : IPAddress::parse($this->lastRemoteAddr);
}
public function getUserAgentString(): string {
return $this->userAgent;
}
public function getClientInfoRaw(): string {
return $this->clientInfo;
}
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->clientInfo);
}
public function getCountryCode(): string {
return $this->countryCode;
}
public function getExpiresTime(): int {
return $this->expires;
}
public function getExpiresAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->expires);
}
public function shouldBumpExpires(): bool {
return $this->bumpExpires;
}
public function hasExpired(): bool {
return $this->expires < time();
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function hasLastActive(): bool {
return $this->lastActive !== null;
}
public function getLastActiveTime(): ?int {
return $this->lastActive;
}
public function getLastActiveAt(): ?DateTime {
return $this->lastActive === null ? null : DateTime::fromUnixTimeSeconds($this->lastActive);
}
}

285
src/Auth/Sessions.php Normal file
View file

@ -0,0 +1,285 @@
<?php
namespace Misuzu\Auth;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Misuzu\Pagination;
use Misuzu\Users\User;
class Sessions {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
// would like to un-hex this but need to make sure AuthToken doesn't have an aneurysm over it
public static function generateToken(): string {
return bin2hex(random_bytes(32));
}
public function countSessions(
User|string|null $userInfo = null
): int {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
//$args = 0;
$query = 'SELECT COUNT(*) FROM msz_sessions';
if($hasUserInfo) {
//++$args;
$query .= ' WHERE user_id = ?';
}
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
$count = 0;
if($result->next())
$count = $result->getInteger(0);
return $count;
}
public function getSessions(
User|string|null $userInfo = null,
?Pagination $pagination = null
): array {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$hasPagination = $pagination !== null;
//$args = 0;
$query = 'SELECT session_id, user_id, session_key, INET6_NTOA(session_ip), INET6_NTOA(session_ip_last), session_user_agent, session_client_info, session_country, UNIX_TIMESTAMP(session_expires), session_expires_bump, UNIX_TIMESTAMP(session_created), UNIX_TIMESTAMP(session_active) FROM msz_sessions';
if($hasUserInfo) {
//++$args;
$query .= ' WHERE user_id = ?';
}
$query .= ' ORDER BY session_active DESC';
if($hasPagination)
$query .= ' LIMIT ? OFFSET ?';
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasPagination) {
$stmt->addParameter(++$args, $pagination->getRange());
$stmt->addParameter(++$args, $pagination->getOffset());
}
$stmt->execute();
$result = $stmt->getResult();
$sessions = [];
while($result->next())
$sessions[] = new SessionInfo($result);
return $sessions;
}
public function getSession(
?string $sessionId = null,
?string $sessionToken = null
): SessionInfo {
if($sessionId === null && $sessionToken === null)
throw new InvalidArgumentException('At least one argument must be specified.');
if($sessionId !== null && $sessionToken !== null)
throw new InvalidArgumentException('Only one argument may be specified.');
$hasSessionId = $sessionId !== null;
$hasSessionToken = $sessionToken !== null;
$value = null;
$query = 'SELECT session_id, user_id, session_key, INET6_NTOA(session_ip), INET6_NTOA(session_ip_last), session_user_agent, session_client_info, session_country, UNIX_TIMESTAMP(session_expires), session_expires_bump, UNIX_TIMESTAMP(session_created), UNIX_TIMESTAMP(session_active) FROM msz_sessions';
if($hasSessionId) {
$query .= ' WHERE session_id = ?';
$value = $sessionId;
} elseif($hasSessionToken) {
$query .= ' WHERE session_key = ?';
$value = $sessionToken;
}
$stmt = $this->cache->get($query);
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Session not found.');
return new SessionInfo($result);
}
public function createSession(
User|string $userInfo,
IPAddress|string $remoteAddr,
string $countryCode,
string $userAgentString,
?ClientInfo $clientInfo = null
): SessionInfo {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$sessionToken = self::generateToken();
$clientInfo = json_encode($clientInfo ?? ClientInfo::parse($userAgentString));
$stmt = $this->cache->get('INSERT INTO msz_sessions (user_id, session_key, session_ip, session_user_agent, session_client_info, session_country, session_expires) VALUES (?, ?, INET6_ATON(?), ?, ?, ?, NOW() + INTERVAL 1 MONTH)');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $sessionToken);
$stmt->addParameter(3, $remoteAddr);
$stmt->addParameter(4, $userAgentString);
$stmt->addParameter(5, $clientInfo);
$stmt->addParameter(6, $countryCode);
$stmt->execute();
return $this->getSession(sessionId: (string)$this->dbConn->getLastInsertId());
}
public function deleteSessions(
SessionInfo|string|array|null $sessionInfos = null,
string|array|null $sessionTokens = null,
User|string|array|null $userInfos = null
): void {
$hasSessionInfos = $sessionInfos !== null;
$hasSessionTokens = $sessionTokens !== null;
$hasUserInfos = $userInfos !== null;
$args = 0;
$query = 'DELETE FROM msz_sessions';
if($hasSessionInfos) {
if(!is_array($sessionInfos))
$sessionInfos = [$sessionInfos];
if(empty($sessionInfos))
$hasSessionInfos = false;
else {
++$args;
$query .= sprintf(
' WHERE session_id IN (%s)',
DbTools::prepareListString($sessionInfos)
);
}
}
if($hasSessionTokens) {
if(!is_array($sessionTokens))
$sessionTokens = [$sessionTokens];
if(empty($sessionTokens))
$hasSessionTokens = false;
else
$query .= sprintf(
' %s session_key IN (%s)',
++$args > 1 ? 'OR' : 'WHERE',
DbTools::prepareListString($sessionTokens)
);
}
if($hasUserInfos) {
if(!is_array($userInfos))
$userInfos = [$userInfos];
if(empty($userInfos))
$hasUserInfos = false;
else
$query .= sprintf(
' %s user_id IN (%s)',
++$args > 1 ? 'OR' : 'WHERE',
DbTools::prepareListString($userInfos)
);
}
if(!$hasSessionInfos && !$hasSessionTokens && !$hasUserInfos)
throw new InvalidArgumentException('At least one argument must be specified.');
$args = 0;
$stmt = $this->cache->get($query);
if($hasSessionInfos)
foreach($sessionInfos as $sessionInfo) {
if($sessionInfo instanceof SessionInfo)
$sessionInfo = $sessionInfo->getId();
elseif(!is_string($sessionInfo))
throw new InvalidArgumentException('$sessionInfos must be strings or instances of SessionInfo.');
$stmt->addParameter(++$args, $sessionInfo);
}
if($hasSessionTokens)
foreach($sessionTokens as $sessionToken) {
if(!is_string($sessionToken))
throw new InvalidArgumentException('$sessionTokens must be strings.');
$stmt->addParameter(++$args, $sessionToken);
}
if($hasUserInfos)
foreach($userInfos as $userInfo) {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
elseif(!is_string($userInfo))
throw new InvalidArgumentException('$userInfos must be strings or instances of User.');
$stmt->addParameter(++$args, $userInfo);
}
$stmt->execute();
}
public function updateSession(
SessionInfo|string|null $sessionInfo = null,
?string $sessionToken = null,
IPAddress|string|null $remoteAddr = null
): void {
if($sessionInfo === null && $sessionToken === null)
throw new InvalidArgumentException('Either $sessionInfo or $sessionToken needs to be set.');
if($sessionInfo !== null && $sessionToken !== null)
throw new InvalidArgumentException('Only one of $sessionInfo and $sessionToken may be set at once.');
if($sessionInfo instanceof SessionInfo)
$sessionInfo = $sessionInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$hasSessionInfo = $sessionInfo !== null;
$hasSessionToken = $sessionToken !== null;
$value = null;
$query = 'UPDATE msz_sessions SET session_ip_last = COALESCE(INET6_ATON(?), session_ip_last), session_active = NOW(), session_expires = IF(session_expires_bump, NOW() + INTERVAL 1 MONTH, session_expires)';
if($hasSessionInfo) {
$query .= ' WHERE session_id = ?';
$value = $sessionInfo;
} elseif($hasSessionToken) {
$query .= ' WHERE session_key = ?';
$value = $sessionToken;
} else throw new RuntimeException('Failsafe to prevent all sessions from being updated at once somehow.');
$stmt = $this->cache->get($query);
$stmt->addParameter(1, $remoteAddr);
$stmt->addParameter(2, $value);
$stmt->execute();
}
public function purgeExpiredSessions(): void {
$this->dbConn->execute('DELETE FROM msz_sessions WHERE session_expires <= NOW()');
}
}

View file

@ -1,10 +1,10 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use Index\IO\MemoryStream;
use Index\Serialisation\UriBase64;
use Misuzu\Auth\SessionInfo;
use Misuzu\Users\User;
class AuthToken {
private const EPOCH = 1682985600;
@ -166,10 +166,10 @@ class AuthToken {
return time() - self::EPOCH;
}
public static function create(User $user, UserSession $session): self {
public static function create(User $userInfo, SessionInfo $sessionInfo): self {
$token = new AuthToken;
$token->setUserId($user->getId());
$token->setSessionToken($session->getToken());
$token->setUserId($userInfo->getId());
$token->setSessionToken($sessionInfo->getToken());
return $token;
}
@ -205,7 +205,7 @@ class AuthToken {
// please never use the below functions beyond the scope of the sharpchat auth stuff
// a better mechanism for keeping a global instance of this available
// that isn't a $GLOBAL variable or static instance needs to be established, for User and UserSession as well
// that isn't a $GLOBAL variable or static instance needs to be established, for User as well
private static $localToken = null;

View file

@ -4,11 +4,10 @@ namespace Misuzu\Http\Handlers;
use Misuzu\CSRF;
use Misuzu\Template;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
final class ForumHandler extends Handler {
public function markAsReadGET($response, $request) {
if(!UserSession::hasCurrent() || !User::hasCurrent())
if(!User::hasCurrent())
return 403;
$forumId = (int)$request->getParam('forum', FILTER_SANITIZE_NUMBER_INT);
@ -23,7 +22,7 @@ final class ForumHandler extends Handler {
}
public function markAsReadPOST($response, $request) {
if(!UserSession::hasCurrent() || !User::hasCurrent())
if(!User::hasCurrent())
return 403;
if(!$request->isFormContent())

View file

@ -7,11 +7,10 @@ use Misuzu\Pagination;
use Misuzu\Template;
use Misuzu\Comments\CommentsCategory;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
final class HomeHandler extends Handler {
public function index($response, $request): void {
if(UserSession::hasCurrent())
if(User::hasCurrent())
$this->home($response, $request);
else
$this->landing($response, $request);

View file

@ -4,6 +4,7 @@ namespace Misuzu;
use Misuzu\Template;
use Misuzu\Auth\LoginAttempts;
use Misuzu\Auth\RecoveryTokens;
use Misuzu\Auth\Sessions;
use Misuzu\Auth\TwoFactorAuthSessions;
use Misuzu\AuditLog\AuditLog;
use Misuzu\Changelog\Changelog;
@ -50,6 +51,7 @@ class MisuzuContext {
private TwoFactorAuthSessions $tfaSessions;
private Roles $roles;
private Users $users;
private Sessions $sessions;
public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn;
@ -67,6 +69,7 @@ class MisuzuContext {
$this->tfaSessions = new TwoFactorAuthSessions($this->dbConn);
$this->roles = new Roles($this->dbConn);
$this->users = new Users($this->dbConn);
$this->sessions = new Sessions($this->dbConn);
}
public function getDbConn(): IDbConnection {
@ -146,6 +149,10 @@ class MisuzuContext {
return $this->users;
}
public function getSessions(): Sessions {
return $this->sessions;
}
private array $activeBansCache = [];
public function tryGetActiveBan(User|string|null $userInfo = null): ?BanInfo {
@ -241,7 +248,7 @@ class MisuzuContext {
$this->router->get('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadGET'));
$this->router->post('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadPOST'));
new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes);
new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes, $this->sessions);
}
private function registerLegacyRedirects(): void {

View file

@ -6,24 +6,32 @@ use Index\Colour\Colour;
use Index\Routing\IRouter;
use Index\Http\HttpFx;
use Misuzu\AuthToken;
use Misuzu\Auth\Sessions;
use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
use Misuzu\Users\Bans;
// Replace
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
final class SharpChatRoutes {
private IConfig $config;
private Bans $bans;
private Emotes $emotes;
private Sessions $sessions;
private string $hashKey;
public function __construct(IRouter $router, IConfig $config, Bans $bans, Emotes $emotes) {
public function __construct(
IRouter $router,
IConfig $config,
Bans $bans,
Emotes $emotes,
Sessions $sessions
) {
$this->config = $config;
$this->bans = $bans;
$this->emotes = $emotes;
$this->sessions = $sessions;
$this->hashKey = $this->config->getString('hashKey', 'woomy');
// Simplify default error pages
@ -119,22 +127,28 @@ final class SharpChatRoutes {
if($request->getMethod() === 'OPTIONS')
return 204;
if(!UserSession::hasCurrent() || !AuthToken::hasCurrent())
return ['ok' => false];
if(!AuthToken::hasCurrent())
return ['ok' => false, 'err' => 'token'];
$token = AuthToken::getCurrent();
$session = UserSession::getCurrent();
if($session->getToken() !== $token->getSessionToken())
return ['ok' => false];
$user = $session->getUser();
$userId = $token->hasImpersonatedUserId() && $user->isSuper()
try {
$sessionInfo = $this->sessions->getSession(sessionToken: $token->getSessionToken());
} catch(RuntimeException $ex) {
return ['ok' => false, 'err' => 'session'];
}
if($sessionInfo->hasExpired())
return ['ok' => false, 'err' => 'expired'];
$userInfo = User::byId((int)$sessionInfo->getUserId());
$userId = $token->hasImpersonatedUserId() && $userInfo->isSuper()
? $token->getImpersonatedUserId()
: $user->getId();
: $userInfo->getId();
return [
'ok' => true,
'usr' => $userId,
'usr' => (int)$userId,
'tkn' => $token->pack(),
];
}
@ -198,19 +212,19 @@ final class SharpChatRoutes {
$authToken = $authTokenInfo->getSessionToken();
try {
$sessionInfo = UserSession::byToken($authToken);
$sessionInfo = $this->sessions->getSession(sessionToken: $authToken);
} catch(RuntimeException $ex) {
return ['success' => false, 'reason' => 'token'];
}
if($sessionInfo->hasExpired()) {
$sessionInfo->delete();
$this->sessions->deleteSessions(sessionInfos: $sessionInfo);
return ['success' => false, 'reason' => 'expired'];
}
$sessionInfo->bump($ipAddress);
$this->sessions->updateSession(sessionInfo: $sessionInfo, remoteAddr: $ipAddress);
$userInfo = $sessionInfo->getUser();
$userInfo = User::byId((int)$sessionInfo->getUserId());
if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuper()) {
$userInfoReal = $userInfo;

View file

@ -1,255 +0,0 @@
<?php
namespace Misuzu\Users;
use RuntimeException;
use Misuzu\ClientInfo;
use Misuzu\DB;
use Misuzu\Pagination;
class UserSession {
public const TOKEN_SIZE = 64;
public const LIFETIME = 60 * 60 * 24 * 31;
// Database fields
private $session_id = -1;
private $user_id = -1;
private $session_key = '';
private $session_ip = '::1';
private $session_ip_last = null;
private $session_user_agent = '';
private $session_client_info = '';
private $session_country = 'XX';
private $session_expires = null;
private $session_expires_bump = 1;
private $session_created = null;
private $session_active = null;
private $user = null;
private static $localSession = null;
private const QUERY_SELECT = 'SELECT %1$s FROM `msz_sessions`';
private const SELECT = '`session_id`, `user_id`, `session_key`, `session_user_agent`, `session_client_info`, `session_country`, `session_expires_bump`'
. ', INET6_NTOA(`session_ip`) AS `session_ip`'
. ', INET6_NTOA(`session_ip_last`) AS `session_ip_last`'
. ', UNIX_TIMESTAMP(`session_created`) AS `session_created`'
. ', UNIX_TIMESTAMP(`session_active`) AS `session_active`'
. ', UNIX_TIMESTAMP(`session_expires`) AS `session_expires`';
public function getId(): int {
return $this->session_id < 1 ? -1 : $this->session_id;
}
public function getUserId(): int {
return $this->user_id < 1 ? -1 : $this->user_id;
}
public function getUser(): User {
if($this->user === null)
$this->user = User::byId($this->getUserId());
return $this->user;
}
public function getToken(): string {
return $this->session_key;
}
public function getInitialRemoteAddress(): string {
return $this->session_ip;
}
public function getLastRemoteAddress(): string {
return $this->session_ip_last ?? '';
}
public function hasLastRemoteAddress(): bool {
return !empty($this->session_ip_last);
}
public function setLastRemoteAddress(string $remoteAddr): self {
$this->session_ip_last = $remoteAddr;
return $this;
}
public function getUserAgent(): string {
return $this->session_user_agent;
}
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->session_client_info);
}
public function getCountry(): string {
return $this->session_country;
}
public function getCountryName(): string {
return get_country_name($this->getCountry());
}
public function getCreatedTime(): int {
return $this->session_created === null ? -1 : $this->session_created;
}
public function getActiveTime(): int {
return $this->session_active === null ? -1 : $this->session_active;
}
public function hasActiveTime(): bool {
return $this->session_active !== null;
}
public function setActiveTime(int $timestamp): self {
if($timestamp > $this->session_active)
$this->session_active = $timestamp;
return $this;
}
public function getExpiresTime(): int {
return $this->session_expires === null ? -1 : $this->session_expires;
}
public function setExpiresTime(int $timestamp): self {
$this->session_expires = $timestamp;
return $this;
}
public function hasExpired(): bool {
return $this->getExpiresTime() <= time();
}
public function shouldBumpExpire(): bool {
return boolval($this->session_expires_bump);
}
public function bump(string $remoteAddr, int $timestamp = -1): void {
if($timestamp < 0)
$timestamp = time();
$this->setActiveTime($timestamp)
->setLastRemoteAddress($remoteAddr);
if($this->shouldBumpExpire())
$this->setExpiresTime($timestamp + self::LIFETIME);
$this->update();
}
public function delete(): void {
DB::prepare('DELETE FROM `msz_sessions` WHERE `session_id` = :session')
->bind('session', $this->getId())
->execute();
}
public static function purgeUser(User $user): void {
DB::prepare('DELETE FROM `msz_sessions` WHERE `user_id` = :user')
->bind('user', $user->getId())
->execute();
}
public function setCurrent(): void {
self::$localSession = $this;
}
public static function unsetCurrent(): void {
self::$localSession = null;
}
public static function getCurrent(): ?self {
return self::$localSession;
}
public static function hasCurrent(): bool {
return self::$localSession !== null;
}
public static function generateToken(): string {
return bin2hex(random_bytes(self::TOKEN_SIZE / 2));
}
public function update(): void {
DB::prepare(
'UPDATE `msz_sessions`'
. ' SET `session_active` = FROM_UNIXTIME(:active), `session_ip_last` = INET6_ATON(:remote_addr), `session_expires` = FROM_UNIXTIME(:expires)'
. ' WHERE `session_id` = :session'
) ->bind('active', $this->session_active)
->bind('remote_addr', $this->session_ip_last)
->bind('expires', $this->session_expires)
->bind('session', $this->session_id)
->execute();
}
public static function create(
User $user,
string $remoteAddr,
string $countryCode,
?string $userAgent = null,
?ClientInfo $clientInfo = null
): self {
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
$clientInfo ??= ClientInfo::parse($_SERVER);
$token = self::generateToken();
$sessionId = DB::prepare(
'INSERT INTO `msz_sessions`'
. ' (`user_id`, `session_ip`, `session_country`, `session_user_agent`, `session_client_info`, `session_key`, `session_created`, `session_expires`)'
. ' VALUES (:user, INET6_ATON(:remote_addr), :country, :user_agent, :client_info, :token, NOW(), NOW() + INTERVAL :expires SECOND)'
) ->bind('user', $user->getId())
->bind('remote_addr', $remoteAddr)
->bind('country', $countryCode)
->bind('user_agent', $userAgent)
->bind('client_info', $clientInfo->encode())
->bind('token', $token)
->bind('expires', self::LIFETIME)
->executeGetId();
if($sessionId < 1)
throw new RuntimeException('Failed to create new session.');
return self::byId($sessionId);
}
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, self::SELECT);
}
public static function byId(int $sessionId): self {
$session = DB::prepare(self::byQueryBase() . ' WHERE `session_id` = :session_id')
->bind('session_id', $sessionId)
->fetchObject(self::class);
if(!$session)
throw new RuntimeException('Could not find a session with that ID.');
return $session;
}
public static function byToken(string $token): self {
$session = DB::prepare(self::byQueryBase() . ' WHERE `session_key` = :token')
->bind('token', $token)
->fetchObject(self::class);
if(!$session)
throw new RuntimeException('Could not find a session with that token.');
return $session;
}
public static function all(?Pagination $pagination = null, ?User $user = null): array {
$sessionsQuery = self::byQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
. ' ORDER BY `session_created` DESC';
if($pagination !== null)
$sessionsQuery .= ' LIMIT :range OFFSET :offset';
$getSessions = DB::prepare($sessionsQuery);
if($user !== null)
$getSessions->bind('user', $user->getId());
if($pagination !== null)
$getSessions->bind('range', $pagination->getRange())
->bind('offset', $pagination->getOffset());
return $getSessions->fetchObjects(self::class);
}
}

View file

@ -31,7 +31,7 @@
<div class="settings__sessions__list">
{% for session in session_list %}
{{ user_session(session, session_current.id == session.id) }}
{{ user_session(session.info, session.active) }}
{% endfor %}
</div>

View file

@ -70,7 +70,7 @@
<div class="settings__session{% if is_current_session %} settings__session--current{% endif %}" id="session-{{ session.id }}">
<div class="settings__session__container">
<div class="settings__session__important">
<div class="flag flag--{{ session.country|lower }} settings__session__flag" title="{{ session.countryName }}">{{ session.country }}</div>
<div class="flag flag--{{ session.countryCode|lower }} settings__session__flag" title="{{ session.countryCode|country_name }}">{{ session.countryCode }}</div>
<div class="settings__session__description">
{{ session.clientInfo }}
@ -78,7 +78,7 @@
<form class="settings__session__actions" method="post" action="{{ url('settings-sessions') }}">
{{ input_csrf() }}
{{ input_hidden('session[]', session.id) }}
{{ input_hidden('session', session.id) }}
<button class="settings__session__action" title="{{ is_current_session ? 'Logout' : 'End Session' }}">
{% if is_current_session %}
@ -96,7 +96,7 @@
Created from IP
</div>
<div class="settings__session__detail__value">
{{ session.initialRemoteAddress }}
{{ session.firstRemoteAddress }}
</div>
</div>
@ -122,20 +122,20 @@
<div class="settings__session__detail" title="{{ session.expiresTime|date('r') }}">
<div class="settings__session__detail__title">
Expires{% if not session.shouldBumpExpire %} (static){% endif %}
Expires{% if not session.shouldBumpExpires %} (static){% endif %}
</div>
<time class="settings__session__detail__value" datetime="{{ session.expiresTime|date('c') }}">
{{ session.expiresTime|time_format }}
</time>
</div>
{% if session.hasActiveTime %}
<div class="settings__session__detail" title="{{ session.activeTime|date('r') }}">
{% if session.hasLastActive %}
<div class="settings__session__detail" title="{{ session.lastActiveTime|date('r') }}">
<div class="settings__session__detail__title">
Last Active
</div>
<time class="settings__session__detail__value" datetime="{{ session.activeTime|date('c') }}">
{{ session.activeTime|time_format }}
<time class="settings__session__detail__value" datetime="{{ session.lastActiveTime|date('c') }}">
{{ session.lastActiveTime|time_format }}
</time>
</div>
{% endif %}
@ -145,7 +145,7 @@
User Agent
</div>
<div class="settings__session__detail__value">
{{ session.userAgent is empty ? 'None' : session.userAgent }}
{{ session.userAgentString }}
</div>
</div>
</div>