Hash UserTokens for security improvement

This commit is contained in:
Roman 2024-12-27 13:02:39 +01:00
parent caab707a17
commit 771fc8675f
10 changed files with 767 additions and 272 deletions

@ -167,7 +167,7 @@ namespace Core\API\GpgKey {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG key yet.");
return $this->createError("You have not added a GPG key yet");
} else if ($gpgKey->isConfirmed()) {
return $this->createError("Your GPG key is already confirmed");
}
@ -176,7 +176,7 @@ namespace Core\API\GpgKey {
$sql = $this->context->getSQL();
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereEq("token", $token)
->whereEq("token", hash("sha512", $token, false))
->where(new Compare("valid_until", $sql->now(), ">="))
->whereEq("user_id", $currentUser->getId())
->whereEq("token_type", UserToken::TYPE_GPG_CONFIRM));
@ -186,7 +186,7 @@ namespace Core\API\GpgKey {
return $this->createError("Invalid token");
} else {
if (!$gpgKey->confirm($sql)) {
return $this->createError("Error updating gpg key: " . $sql->getLastError());
return $this->createError("Error updating GPG key: " . $sql->getLastError());
}
$userToken->invalidate($sql);

@ -124,7 +124,7 @@ namespace Core\API {
protected function checkToken(string $token) : UserToken|bool {
$sql = $this->context->getSQL();
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereEq("UserToken.token", $token)
->whereEq("UserToken.token", hash("sha512", $token, false))
->whereGt("UserToken.valid_until", $sql->now())
->whereFalse("UserToken.used")
->fetchEntities());
@ -157,6 +157,7 @@ namespace Core\API\User {
use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Expression\Alias;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Session;
use Core\Objects\DatabaseEntity\UserToken;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
@ -727,6 +728,7 @@ namespace Core\API\User {
return $this->createError("You are not logged in.");
}
session_destroy();
$this->success = $session->destroy();
$this->lastError = $this->context->getSQL()->getLastError();
return $this->success;
@ -1153,23 +1155,11 @@ namespace Core\API\User {
return true;
}
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereFalse("used")
->whereEq("token_type", UserToken::TYPE_EMAIL_CONFIRM)
->whereEq("user_id", $user->getId()));
$validHours = 48;
if ($userToken === false) {
return $this->createError("Error retrieving token details: " . $sql->getLastError());
} else if ($userToken === null) {
// no token generated yet, let's generate one
$token = generateRandomString(36);
$userToken = new UserToken($user, $token, UserToken::TYPE_EMAIL_CONFIRM, $validHours);
if (!$userToken->save($sql)) {
return $this->createError("Error generating new token: " . $sql->getLastError());
}
} else {
$userToken->updateDurability($sql, $validHours);
$token = generateRandomString(36);
$userToken = new UserToken($user, $token, UserToken::TYPE_EMAIL_CONFIRM, $validHours);
if (!$userToken->save($sql)) {
return $this->createError("Error generating new token: " . $sql->getLastError());
}
$username = $user->name;
@ -1180,7 +1170,7 @@ namespace Core\API\User {
$this->success = $req->execute([
"file" => "mail/confirm_email.twig",
"parameters" => [
"link" => "$baseUrl/confirmEmail?token=" . $userToken->getToken(),
"link" => "$baseUrl/confirmEmail?token=" . $token,
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $username,
@ -1495,4 +1485,90 @@ namespace Core\API\User {
return "Allows users to validate a token received in an e-mail for various purposes";
}
}
class GetSessions extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"active" => new Parameter("active", Parameter::TYPE_BOOLEAN, true, true)
]);
$this->loginRequired = true;
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$activeOnly = $this->getParam("active");
$query = Session::createBuilder($sql, false)
->whereEq("user_id", $currentUser->getId());
if ($activeOnly) {
$query->whereTrue("active")
->whereGt("expires", $sql->now());
}
$sessions = Session::findBy($query);
if ($sessions === false) {
return $this->createError("Error fetching sessions:" . $sql->getLastError());
}
$this->result["sessions"] = Session::toJsonArray($sessions, [
"id", "expires", "ipAddress", "os", "browser", "lastOnline"
]);
return true;
}
public static function getDescription(): string {
return "Shows logged-in sessions for a users account";
}
public static function getDefaultPermittedGroups(): array {
return [];
}
}
class DestroySession extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT)
]);
$this->loginRequired = true;
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$id = $this->getParam("id");
$currentUser = $this->context->getUser();
$query = Session::createBuilder($sql, true)
->whereEq("id", $id)
->whereEq("user_id", $currentUser->getId());
$session = Session::findBy($query);
if ($session === false) {
return $this->createError("Error fetching session:" . $sql->getLastError());
} else if ($session === null) {
return $this->createError("Invalid session");
}
$session->destroy();
if ($session->getId() === $this->context->getSession()->getId()) {
session_destroy();
}
return true;
}
public static function getDescription(): string {
return "Terminates a given user session";
}
public static function getDefaultPermittedGroups(): array {
return [];
}
}
}

@ -0,0 +1,17 @@
<?php
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Column\StringColumn;
use Core\Driver\SQL\Expression\Hash;
use Core\Objects\DatabaseEntity\UserToken;
$handler = UserToken::getHandler($sql);
$columnSize = 512 / 8 * 2; // sha512 as hex
$tokenTable = $handler->getTableName();
$tokenColumn = $handler->getColumnName("token");
$queries[] = $sql->alterTable($tokenTable)
->modify(new StringColumn($tokenColumn, $columnSize));
$queries[] = $sql->update($tokenTable)
->set($tokenColumn, new Hash(Hash::SHA_512, new Column($tokenColumn)));

@ -0,0 +1,9 @@
<?php
use Core\Driver\SQL\Column\DateTimeColumn;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Objects\DatabaseEntity\Session;
$handler = Session::getHandler($sql);
$queries[] = $sql->alterTable($handler->getTableName())
->add(new DateTimeColumn($handler->getColumnName("lastOnline"), false, new CurrentTimeStamp()));

@ -0,0 +1,44 @@
<?php
namespace Core\Driver\SQL\Expression;
use Core\Driver\SQL\MySQL;
use Core\Driver\SQL\PostgreSQL;
use Core\Driver\SQL\SQL;
class Hash extends Expression {
const SHA_128 = 0;
const SHA_256 = 1;
const SHA_512 = 2;
private int $hashType;
private mixed $value;
public function __construct(int $hashType, mixed $value) {
$this->hashType = $hashType;
$this->value = $value;
}
function getExpression(SQL $sql, array &$params): string {
if ($sql instanceof MySQL) {
$val = $sql->addValue($this->value, $params);
return match ($this->hashType) {
self::SHA_128 => "SHA2($val, 128)",
self::SHA_256 => "SHA2($val, 256)",
self::SHA_512 => "SHA2($val, 512)",
default => throw new \Exception("HASH() not implemented for hash type: " . $this->hashType),
};
} elseif ($sql instanceof PostgreSQL) {
$val = $sql->addValue($this->value, $params);
return match ($this->hashType) {
self::SHA_128 => "digest($val, 'sha128')",
self::SHA_256 => "digest($val, 'sha256')",
self::SHA_512 => "digest($val, 'sha512')",
default => throw new \Exception("HASH() not implemented for hash type: " . $this->hashType),
};
} else {
throw new \Exception("HASH() not implemented for driver type: " . get_class($sql));
}
}
}

@ -6,7 +6,8 @@
"christian-riesen/base32": "^1.6",
"spomky-labs/cbor-php": "^3.0",
"web-auth/cose-lib": "^4.0",
"html2text/html2text": "^4.3"
"html2text/html2text": "^4.3",
"geoip2/geoip2": "~2.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6"

824
Core/External/composer.lock generated vendored

File diff suppressed because it is too large Load Diff

@ -2,6 +2,7 @@
namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use DateTime;
use Exception;
use Core\Objects\Context;
@ -20,6 +21,7 @@ class Session extends DatabaseEntity {
private User $user;
private DateTime $expires;
#[MaxLength(45)] private string $ipAddress;
#[MaxLength(36)] protected string $uuid;
#[DefaultValue(true)] private bool $active;
#[MaxLength(64)] private ?string $os;
@ -28,6 +30,9 @@ class Session extends DatabaseEntity {
#[MaxLength(16)] private string $csrfToken;
#[Json] private mixed $data;
#[DefaultValue(CurrentTimeStamp::class)]
private DateTime $lastOnline;
public function __construct(Context $context, User $user, ?string $csrfToken = null) {
parent::__construct();
$this->context = $context;
@ -81,7 +86,7 @@ class Session extends DatabaseEntity {
$userAgent = @get_browser($_SERVER['HTTP_USER_AGENT'], true);
$this->os = $userAgent['platform'] ?? "Unknown";
$this->browser = $userAgent['parent'] ?? "Unknown";
} catch (Exception $ex) {
} catch (Exception) {
$this->os = "Unknown";
$this->browser = "Unknown";
}
@ -112,7 +117,6 @@ class Session extends DatabaseEntity {
}
public function destroy(): bool {
session_destroy();
$this->active = false;
return $this->save($this->context->getSQL(), ["active"]);
}
@ -120,6 +124,7 @@ class Session extends DatabaseEntity {
public function update(): bool {
$this->updateMetaData();
$this->lastOnline = new DateTime();
$this->expires = (new DateTime())->modify(sprintf("+%d second", Session::DURATION));
$this->data = json_encode($_SESSION ?? []);

@ -21,7 +21,7 @@ class UserToken extends DatabaseEntity {
self::TYPE_INVITE, self::TYPE_GPG_CONFIRM
];
#[MaxLength(36)]
#[MaxLength(128)]
#[Visibility(Visibility::NONE)]
private string $token;
@ -37,7 +37,7 @@ class UserToken extends DatabaseEntity {
public function __construct(User $user, string $token, string $type, int $validHours) {
parent::__construct();
$this->user = $user;
$this->token = $token;
$this->token = hash("sha512", $token, false);
$this->tokenType = $type;
$this->validUntil = (new \DateTime())->modify("+$validHours HOUR");
$this->used = false;
@ -55,13 +55,4 @@ class UserToken extends DatabaseEntity {
public function getUser(): User {
return $this->user;
}
public function updateDurability(SQL $sql, int $validHours): bool {
$this->validUntil = (new \DateTime())->modify("+$validHours HOURS");
return $this->save($sql, ["validUntil"]);
}
public function getToken(): string {
return $this->token;
}
}

@ -10,7 +10,7 @@ if (is_file($autoLoad)) {
require_once $autoLoad;
}
const WEBBASE_VERSION = "2.4.4";
const WEBBASE_VERSION = "2.4.5";
spl_autoload_extensions(".php");
spl_autoload_register(function ($class) {