From b5b8f9b856f027a36387f3a61dfc5b2ecd7803aa Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 19 Nov 2022 01:15:34 +0100 Subject: [PATCH] UserToken / UserAPI --- Core/API/MailAPI.class.php | 11 +- Core/API/Request.class.php | 10 +- Core/API/TemplateAPI.class.php | 21 +- Core/API/TfaAPI.class.php | 3 +- Core/API/UserAPI.class.php | 809 ++++++------------ Core/Configuration/CreateDatabase.class.php | 8 - Core/Configuration/Settings.class.php | 46 +- Core/Documents/Install.class.php | 2 +- Core/Driver/Logger/Logger.class.php | 2 +- Core/Elements/TemplateDocument.class.php | 2 +- Core/Objects/Context.class.php | 29 +- .../Attribute/EnumArr.class.php | 12 + .../DatabaseEntity/DatabaseEntityHandler.php | 6 +- .../DatabaseEntityQuery.class.php | 17 + Core/Objects/DatabaseEntity/GpgKey.class.php | 15 +- Core/Objects/DatabaseEntity/Session.class.php | 2 +- Core/Objects/DatabaseEntity/User.class.php | 8 +- .../DatabaseEntity/UserToken.class.php | 72 ++ Site/Templates/.gitkeep | 0 cli.php | 25 +- docker/php/Dockerfile | 9 +- 21 files changed, 496 insertions(+), 613 deletions(-) create mode 100644 Core/Objects/DatabaseEntity/Attribute/EnumArr.class.php create mode 100644 Core/Objects/DatabaseEntity/UserToken.class.php create mode 100644 Site/Templates/.gitkeep diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php index 79d3476..19365f3 100644 --- a/Core/API/MailAPI.class.php +++ b/Core/API/MailAPI.class.php @@ -32,6 +32,7 @@ namespace Core\API { $connectionData->setProperty("from", $settings["mail_from"] ?? ""); $connectionData->setProperty("last_sync", $settings["mail_last_sync"] ?? ""); $connectionData->setProperty("mail_footer", $settings["mail_footer"] ?? ""); + $connectionData->setProperty("mail_async", $settings["mail_async"] ?? false); return $connectionData; } @@ -89,7 +90,7 @@ namespace Core\API\Mail { 'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null), 'replyName' => new StringType('replyName', 32, true, ""), 'gpgFingerprint' => new StringType("gpgFingerprint", 64, true, null), - 'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, true) + 'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, null) )); $this->isPublic = false; } @@ -110,7 +111,13 @@ namespace Core\API\Mail { $body = $this->getParam('body'); $gpgFingerprint = $this->getParam("gpgFingerprint"); - if ($this->getParam("async")) { + $mailAsync = $this->getParam("async"); + if ($mailAsync === null) { + // not set? grab from settings + $mailAsync = $mailConfig->getProperty("mail_async", false); + } + + if ($mailAsync) { $sql = $this->context->getSQL(); $this->success = $sql->insert("MailQueue", ["from", "to", "subject", "body", "replyTo", "replyName", "gpgFingerprint"]) diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index 3b6294d..59a5f83 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -223,7 +223,7 @@ abstract class Request { } // Check for permission - if (!($this instanceof \API\Permission\Save)) { + if (!($this instanceof \Core\API\Permission\Save)) { $req = new \Core\API\Permission\Check($this->context); $this->success = $req->execute(array("method" => $this->getMethod())); $this->lastError = $req->getLastError(); @@ -242,8 +242,8 @@ abstract class Request { } $sql = $this->context->getSQL(); - if (!$sql->isConnected()) { - $this->lastError = $sql->getLastError(); + if ($sql === null || !$sql->isConnected()) { + $this->lastError = $sql ? $sql->getLastError() : "Database not connected yet."; return false; } @@ -265,8 +265,8 @@ abstract class Request { return false; } - protected function getParam($name, $obj = NULL) { - // i don't know why phpstorm + protected function getParam($name, $obj = NULL): mixed { + // I don't know why phpstorm if ($obj === NULL) { $obj = $this->params; } diff --git a/Core/API/TemplateAPI.class.php b/Core/API/TemplateAPI.class.php index 39a7edc..cebc25b 100644 --- a/Core/API/TemplateAPI.class.php +++ b/Core/API/TemplateAPI.class.php @@ -45,16 +45,23 @@ namespace Core\API\Template { return $this->createError("Invalid template file extension. Allowed: " . implode(",", $allowedExtensions)); } - $templateDir = WEBROOT . "/Core/Templates/"; $templateCache = WEBROOT . "/Core/Cache/Templates/"; - $path = realpath($templateDir . $templateFile); - if (!startsWith($path, realpath($templateDir))) { - return $this->createError("Template file not in template directory"); - } else if (!is_file($path)) { - return $this->createError("Template file not found"); + $baseDirs = ["Site", "Core"]; + $valid = false; + + foreach ($baseDirs as $baseDir) { + $path = realpath(implode("/", [WEBROOT, $baseDir, "Templates", $templateFile])); + if ($path && is_file($path)) { + $valid = true; + break; + } } - $twigLoader = new FilesystemLoader($templateDir); + if (!$valid) { + return $this->createError("Template file not found or not inside template directory"); + } + + $twigLoader = new FilesystemLoader(dirname($path)); $twigEnvironment = new Environment($twigLoader, [ 'cache' => $templateCache, 'auto_reload' => true diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index a78b335..5909e46 100644 --- a/Core/API/TfaAPI.class.php +++ b/Core/API/TfaAPI.class.php @@ -123,10 +123,11 @@ namespace Core\API\TFA { if ($this->success) { $body = $req->getResult()["html"]; $gpg = $currentUser->getGPG(); + $siteName = $settings->getSiteName(); $req = new \Core\API\Mail\Send($this->context); $this->success = $req->execute([ "to" => $currentUser->getEmail(), - "subject" => "[Security Lab] 2FA-Authentication removed", + "subject" => "[$siteName] 2FA-Authentication removed", "body" => $body, "gpgFingerprint" => $gpg?->getFingerprint() ]); diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 5f62faa..4051414 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -2,10 +2,11 @@ namespace Core\API { - use Cassandra\Date; use Core\Driver\SQL\Condition\Compare; use Core\Objects\Context; use Core\Objects\DatabaseEntity\Language; + use Core\Objects\DatabaseEntity\User; + use Core\Objects\DatabaseEntity\UserToken; abstract class UserAPI extends Request { @@ -74,10 +75,10 @@ namespace Core\API { $this->checkPasswordRequirements($password, $confirmPassword); } - protected function insertUser($username, $email, $password, $confirmed, $fullName = "") { + protected function insertUser($username, $email, $password, $confirmed, $fullName = ""): bool|User { $sql = $this->context->getSQL(); - $user = new \Core\Objects\DatabaseEntity\User(); + $user = new User(); $user->language = Language::DEFAULT_LANGUAGE(false); $user->registeredAt = new \DateTime(); $user->password = $this->hashPassword($password); @@ -89,57 +90,13 @@ namespace Core\API { $this->success = ($user->save($sql) !== FALSE); $this->lastError = $sql->getLastError(); - if ($this->success) { - return $user->getId(); - } - - return $this->success; + return $this->success ? $user : false; } protected function hashPassword($password): string { return password_hash($password, PASSWORD_BCRYPT); } - protected function getUser(int $id) { - $sql = $this->context->getSQL(); - $res = $sql->select("User.id as userId", "User.name", "User.full_name", "User.email", - "User.registered_at", "User.confirmed", "User.last_online", "User.profile_picture", - "User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint", - "GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm", - "Group.id as groupId", "Group.name as groupName", "Group.color as groupColor") - ->from("User") - ->leftJoin("UserGroup", "User.id", "UserGroup.user_id") - ->leftJoin("Group", "Group.id", "UserGroup.group_id") - ->leftJoin("GpgKey", "GpgKey.id", "User.gpg_id") - ->where(new Compare("User.id", $id)) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - - return ($this->success && !empty($res) ? $res : array()); - } - - protected function invalidateToken($token) { - $this->context->getSQL() - ->update("UserToken") - ->set("used", true) - ->where(new Compare("token", $token)) - ->execute(); - } - - protected function insertToken(int $userId, string $token, string $tokenType, int $duration): bool { - $validUntil = (new \DateTime())->modify("+$duration hour"); - $sql = $this->context->getSQL(); - $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until")) - ->addRow($userId, $token, $tokenType, $validUntil) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - return $this->success; - } - protected function formatDuration(int $count, string $string): string { if ($count === 1) { return $string; @@ -147,6 +104,24 @@ namespace Core\API { return "the next $count ${string}s"; } } + + protected function checkToken(string $token) : UserToken|bool { + $sql = $this->context->getSQL(); + $userToken = UserToken::findBuilder($sql) + ->where(new Compare("UserToken.token", $token)) + ->where(new Compare("UserToken.valid_until", $sql->now(), ">")) + ->where(new Compare("UserToken.used", 0)) + ->fetchEntities() + ->execute(); + + if ($userToken === false) { + return $this->createError("Error verifying token: " . $sql->getLastError()); + } else if ($userToken === null) { + return $this->createError("This token does not exist or is no longer valid"); + } else { + return $userToken; + } + } } } @@ -158,18 +133,14 @@ namespace Core\API\User { use Core\API\Template\Render; use Core\API\UserAPI; use Core\API\VerifyCaptcha; - use DateTime; + use Core\Objects\DatabaseEntity\UserToken; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; - use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondIn; - use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Expression\JsonArrayAgg; use ImagickException; use Core\Objects\Context; - use Core\Objects\DatabaseEntity\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\GpgKey; - use Core\Objects\DatabaseEntity\TwoFactorToken; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use Core\Objects\DatabaseEntity\User; @@ -192,7 +163,6 @@ namespace Core\API\User { $email = $this->getParam('email'); $password = $this->getParam('password'); $confirmPassword = $this->getParam('confirmPassword'); - if (!$this->checkRequirements($username, $password, $confirmPassword)) { return false; } @@ -203,10 +173,9 @@ namespace Core\API\User { // prevent duplicate keys $email = (!is_null($email) && empty($email)) ? null : $email; - - $id = $this->insertUser($username, $email, $password, true); - if ($id !== false) { - $this->result["userId"] = $id; + $user = $this->insertUser($username, $email, $password, true); + if ($user !== false) { + $this->result["userId"] = $user->getId(); } return $this->success; @@ -357,80 +326,60 @@ namespace Core\API\User { $sql = $this->context->getSQL(); $userId = $this->getParam("id"); - $user = $this->getUser($userId); - if ($this->success) { - if (empty($user)) { - return $this->createError("User not found"); - } else { + $user = User::find($sql, $userId, true); + if ($user === false) { + return $this->createError("Error querying user: " . $sql->getLastError()); + } else if ($user === null) { + return $this->createError("User not found"); + } else { - $gpgFingerprint = null; - if ($user[0]["gpg_id"] && $sql->parseBool($user[0]["gpg_confirmed"])) { - $gpgFingerprint = $user[0]["gpg_fingerprint"]; + $queriedUser = $user->jsonSerialize(); + + // either we are querying own info or we are support / admin + $currentUser = $this->context->getUser(); + $canView = ($userId === $currentUser->getId() || + $currentUser->hasGroup(USER_GROUP_ADMIN) || + $currentUser->hasGroup(USER_GROUP_SUPPORT)); + + // full info only when we have administrative privileges, or we are querying ourselves + $fullInfo = ($userId === $currentUser->getId() || + $currentUser->hasGroup(USER_GROUP_ADMIN) || + $currentUser->hasGroup(USER_GROUP_SUPPORT)); + + if (!$canView) { + + // check if user posted something publicly + $res = $sql->select(new JsonArrayAgg(new Column("publishedBy"), "publisherIds")) + ->from("News") + ->execute(); + $this->success = ($res !== false); + $this->lastError = $sql->getLastError(); + if (!$this->success) { + return false; + } else { + $canView = in_array($userId, json_decode($res[0]["publisherIds"], true)); } + } - $queriedUser = array( - "id" => $userId, - "name" => $user[0]["name"], - "fullName" => $user[0]["full_name"], - "email" => $user[0]["email"], - "registered_at" => $user[0]["registered_at"], - "last_online" => $user[0]["last_online"], - "profilePicture" => $user[0]["profile_picture"], - "confirmed" => $sql->parseBool($user["0"]["confirmed"]), - "groups" => array(), - "gpgFingerprint" => $gpgFingerprint, - ); + if (!$canView) { + return $this->createError("No permissions to access this user"); + } - foreach ($user as $row) { - if (!is_null($row["groupId"])) { - $queriedUser["groups"][$row["groupId"]] = array( - "name" => $row["groupName"], - "color" => $row["groupColor"], - ); - } - } - - // either we are querying own info or we are support / admin - $currentUser = $this->context->getUser(); - $canView = ($userId === $currentUser->getId() || - $currentUser->hasGroup(USER_GROUP_ADMIN) || - $currentUser->hasGroup(USER_GROUP_SUPPORT)); - - // full info only when we have administrative privileges, or we are querying ourselves - $fullInfo = ($userId === $currentUser->getId() || - $currentUser->hasGroup(USER_GROUP_ADMIN) || - $currentUser->hasGroup(USER_GROUP_SUPPORT)); - - if (!$canView) { - - // check if user posted something publicly - $res = $sql->select(new JsonArrayAgg(new Column("publishedBy"), "publisherIds")) - ->from("News") - ->execute(); - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; - } else { - $canView = in_array($userId, json_decode($res[0]["publisherIds"], true)); - } - } - - if (!$canView) { + if (!$fullInfo) { + if (!$queriedUser["confirmed"]) { return $this->createError("No permissions to access this user"); } - if (!$fullInfo) { - if (!$queriedUser["confirmed"]) { - return $this->createError("No permissions to access this user"); + $publicAttributes = ["id", "name", "fullName", "profilePicture", "email", "groups"]; + foreach (array_keys($queriedUser) as $attr) { + if (!in_array($attr, $publicAttributes)) { + unset($queriedUser[$attr]); } - unset($queriedUser["registered_at"]); - unset($queriedUser["confirmed"]); - unset($queriedUser["last_online"]); } - - $this->result["user"] = $queriedUser; } + + unset($queriedUser["session"]); // strip session information + $this->result["user"] = $queriedUser; } return $this->success; @@ -469,7 +418,6 @@ namespace Core\API\User { $this->result["permissions"] = $permissions; $this->result["user"] = $currentUser->jsonSerialize(); - $this->result["user"]["session"] = $this->context->getSession()?->jsonSerialize(); } return $this->success; @@ -496,25 +444,19 @@ namespace Core\API\User { } // Create user - $id = $this->insertUser($username, $email, "", false); - if (!$this->success) { + $user = $this->insertUser($username, $email, "", false); + if ($user === false) { return false; } // Create Token $token = generateRandomString(36); $validDays = 7; - $valid_until = (new DateTime())->modify("+$validDays day"); $sql = $this->context->getSQL(); - $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until")) - ->addRow($id, $token, "invite", $valid_until) - ->execute(); - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - - //send validation mail - if ($this->success) { + $userToken = new UserToken($user, $token, UserToken::TYPE_INVITE, $validDays * 24); + if ($userToken->save($sql)) { + //send validation mail $settings = $this->context->getSettings(); $baseUrl = $settings->getBaseUrl(); $siteName = $settings->getSiteName(); @@ -551,7 +493,7 @@ namespace Core\API\User { } } - $this->logger->info("Created new user with id=$id"); + $this->logger->info("Created new user with id=" . $user->getId()); return $this->success; } } @@ -566,56 +508,37 @@ namespace Core\API\User { $this->csrfTokenRequired = false; } - private function updateUser($uid, $password): bool { - $sql = $this->context->getSQL(); - $res = $sql->update("User") - ->set("password", $this->hashPassword($password)) - ->set("confirmed", true) - ->where(new Compare("id", $uid)) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - return $this->success; - } - public function _execute(): bool { if ($this->context->getUser()) { return $this->createError("You are already logged in."); } + $sql = $this->context->getSQL(); $token = $this->getParam("token"); $password = $this->getParam("password"); $confirmPassword = $this->getParam("confirmPassword"); - - $req = new CheckToken($this->context); - $this->success = $req->execute(array("token" => $token)); - $this->lastError = $req->getLastError(); - - if (!$this->success) { + $userToken = $this->checkToken($token); + if ($userToken === false) { return false; + } else if ($userToken->getType() !== UserToken::TYPE_INVITE) { + return $this->createError("Invalid token type"); } - $result = $req->getResult(); - if (strcasecmp($result["token"]["type"], "invite") !== 0) { - return $this->createError("Invalid token type"); - } else if ($result["user"]["confirmed"]) { + $user = $userToken->getUser(); + if ($user->confirmed) { return $this->createError("Your email address is already confirmed."); } else if (!$this->checkPasswordRequirements($password, $confirmPassword)) { return false; - } else if (!$this->updateUser($result["user"]["id"], $password)) { - return false; } else { - - // Invalidate token - $this->context->getSQL() - ->update("UserToken") - ->set("used", true) - ->where(new Compare("token", $token)) - ->execute(); - - return true; + $user->password = $this->hashPassword($password); + $user->confirmed = true; + if ($user->save($sql)) { + $userToken->invalidate($sql); + return true; + } else { + return $this->createError("Unable to update user details: " . $sql->getLastError()); + } } } } @@ -629,44 +552,33 @@ namespace Core\API\User { $this->csrfTokenRequired = false; } - private function updateUser($uid): bool { - $sql = $this->context->getSQL(); - $res = $sql->update("User") - ->set("confirmed", true) - ->where(new Compare("id", $uid)) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - return $this->success; - } - public function _execute(): bool { if ($this->context->getUser()) { return $this->createError("You are already logged in."); } + $sql = $this->context->getSQL(); $token = $this->getParam("token"); - $req = new CheckToken($this->context); - $this->success = $req->execute(array("token" => $token)); - $this->lastError = $req->getLastError(); - - if ($this->success) { - $result = $req->getResult(); - if (strcasecmp($result["token"]["type"], "email_confirm") !== 0) { - return $this->createError("Invalid token type"); - } else if ($result["user"]["confirmed"]) { - return $this->createError("Your email address is already confirmed."); - } else if (!$this->updateUser($result["user"]["id"])) { - return false; - } else { - $this->invalidateToken($token); - return true; - } + $userToken = $this->checkToken($token); + if ($userToken === false) { + return false; + } else if ($userToken->getType() !== UserToken::TYPE_EMAIL_CONFIRM) { + return $this->createError("Invalid token type"); } - return $this->success; + $user = $userToken->getUser(); + if ($user->confirmed) { + return $this->createError("Your email address is already confirmed."); + } else { + $user->confirmed = true; + if ($user->save($sql)) { + $userToken->invalidate($sql); + return true; + } else { + return $this->createError("Unable to update user details: " . $sql->getLastError()); + } + } } } @@ -705,39 +617,29 @@ namespace Core\API\User { $stayLoggedIn = $this->getParam('stayLoggedIn'); $sql = $this->context->getSQL(); - $res = $sql->select("User.id", "User.password", "User.confirmed", - "TwoFactorToken.id as 2fa_id", "TwoFactorToken.type as 2fa_type", - "TwoFactorToken.confirmed as 2fa_confirmed", "TwoFactorToken.data as 2fa_data") - ->from("User") + $user = User::findBuilder($sql) ->where(new Compare("User.name", $username), new Compare("User.email", $username)) - ->leftJoin("TwoFactorToken", "TwoFactorToken.id", "User.two_factor_token_id") - ->first() + ->fetchEntities() ->execute(); - $session = null; - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - - if ($this->success) { - if ($res === null) { + if ($user !== false) { + if ($user === null) { return $this->wrongCredentials(); } else { - $userId = $res['id']; - $confirmed = $sql->parseBool($res["confirmed"]); - $token = $res["2fa_id"] ? TwoFactorToken::fromRow($sql, DatabaseEntityHandler::getPrefixedRow($res, "2fa_")) : null; - if (password_verify($password, $res['password'])) { - if (!$confirmed) { + if (password_verify($password, $user->password)) { + if (!$user->confirmed) { $this->result["emailConfirmed"] = false; return $this->createError("Your email address has not been confirmed yet."); - } else if (!($session = $this->context->createSession($userId, $stayLoggedIn))) { + } else if (!($session = $this->context->createSession($user, $stayLoggedIn))) { return $this->createError("Error creating Session: " . $sql->getLastError()); } else { + $tfaToken = $user->getTwoFactorToken(); $this->result["loggedIn"] = true; $this->result["logoutIn"] = $session->getExpiresSeconds(); $this->result["csrf_token"] = $session->getCsrfToken(); - if ($token && $token->isConfirmed()) { - $this->result["2fa"] = ["type" => $token->getType()]; - if ($token instanceof KeyBasedTwoFactorToken) { + if ($tfaToken && $tfaToken->isConfirmed()) { + $this->result["2fa"] = ["type" => $tfaToken->getType()]; + if ($tfaToken instanceof KeyBasedTwoFactorToken) { $challenge = base64_encode(generateRandomString(32, "raw")); $this->result["2fa"]["challenge"] = $challenge; $_SESSION["challenge"] = $challenge; @@ -749,6 +651,8 @@ namespace Core\API\User { return $this->wrongCredentials(); } } + } else { + return $this->createError("Error fetching user details: " . $sql->getLastError()); } return $this->success; @@ -779,9 +683,6 @@ namespace Core\API\User { class Register extends UserAPI { - private ?int $userId; - private string $token; - public function __construct(Context $context, bool $externalCall = false) { $parameters = array( "username" => new StringType("username", 32), @@ -837,14 +738,17 @@ namespace Core\API\User { }, explode(".", $fullName)) ); - $this->userId = $this->insertUser($username, $email, $password, false, $fullName); - if (!$this->success) { + $sql = $this->context->getSQL(); + $user = $this->insertUser($username, $email, $password, false, $fullName); + if ($user === false) { return false; } $validHours = 48; - $this->token = generateRandomString(36); - if ($this->insertToken($this->userId, $this->token, "email_confirm", $validHours)) { + $token = generateRandomString(36); + $userToken = new UserToken($user, $token, UserToken::TYPE_EMAIL_CONFIRM, $validHours); + + if ($userToken->save($sql)) { $baseUrl = $settings->getBaseUrl(); $siteName = $settings->getSiteName(); @@ -852,7 +756,7 @@ namespace Core\API\User { $this->success = $req->execute([ "file" => "mail/confirm_email.twig", "parameters" => [ - "link" => "$baseUrl/confirmEmail?token=$this->token", + "link" => "$baseUrl/confirmEmail?token=$token", "site_name" => $siteName, "base_url" => $baseUrl, "username" => $username, @@ -868,10 +772,12 @@ namespace Core\API\User { "to" => $email, "subject" => "[$siteName] E-Mail Confirmation", "body" => $messageBody, - "async" => true, )); $this->lastError = $request->getLastError(); } + } else { + $this->lastError = "Could create user token: " . $sql->getLastError(); + $this->success = false; } if (!$this->success) { @@ -880,59 +786,7 @@ namespace Core\API\User { "Please contact the server administration. This issue has been automatically logged. Reason: " . $this->lastError; } - $this->logger->info("Registered new user with id=" . $this->userId); - return $this->success; - } - } - - class CheckToken extends UserAPI { - - public function __construct(Context $context, $externalCall = false) { - parent::__construct($context, $externalCall, array( - 'token' => new StringType('token', 36), - )); - } - - private function checkToken($token) { - $sql = $this->context->getSQL(); - $res = $sql->select("UserToken.token_type", "User.id", "User.name", "User.email", "User.confirmed") - ->from("UserToken") - ->innerJoin("User", "UserToken.user_id", "User.id") - ->where(new Compare("UserToken.token", $token)) - ->where(new Compare("UserToken.valid_until", $sql->now(), ">")) - ->where(new Compare("UserToken.used", 0)) - ->execute(); - $this->lastError = $sql->getLastError(); - $this->success = ($res !== FALSE); - - if ($this->success && !empty($res)) { - return $res[0]; - } - - return array(); - } - - public function _execute(): bool { - - $token = $this->getParam('token'); - $tokenEntry = $this->checkToken($token); - - if ($this->success) { - if (!empty($tokenEntry)) { - $this->result["token"] = array( - "type" => $tokenEntry["token_type"] - ); - - $this->result["user"] = array( - "name" => $tokenEntry["name"], - "email" => $tokenEntry["email"], - "id" => $tokenEntry["id"], - "confirmed" => $tokenEntry["confirmed"] - ); - } else { - return $this->createError("This token does not exist or is no longer valid"); - } - } + $this->logger->info("Registered new user with id=" . $user->getId()); return $this->success; } } @@ -956,11 +810,13 @@ namespace Core\API\User { public function _execute(): bool { + $sql = $this->context->getSQL(); + $currentUser = $this->context->getUser(); $id = $this->getParam("id"); - $user = $this->getUser($id); + $user = User::find($sql, $id, true); - if ($this->success) { - if (empty($user)) { + if ($user !== false) { + if ($user === null) { return $this->createError("User not found"); } @@ -986,45 +842,35 @@ namespace Core\API\User { $groupIds[] = $param->value; } - if ($id === $this->context->getUser()->getId() && !in_array(USER_GROUP_ADMIN, $groupIds)) { + if ($id === $currentUser->getId() && !in_array(USER_GROUP_ADMIN, $groupIds)) { return $this->createError("Cannot remove Administrator group from own user."); } } // Check for duplicate username, email - $usernameChanged = !is_null($username) && strcasecmp($username, $user[0]["name"]) !== 0; - $fullNameChanged = !is_null($fullName) && strcasecmp($fullName, $user[0]["full_name"]) !== 0; - $emailChanged = !is_null($email) && strcasecmp($email, $user[0]["email"]) !== 0; + $usernameChanged = !is_null($username) && strcasecmp($username, $user->name) !== 0; + $fullNameChanged = !is_null($fullName) && strcasecmp($fullName, $user->fullName) !== 0; + $emailChanged = !is_null($email) && strcasecmp($email, $user->email) !== 0; if ($usernameChanged || $emailChanged) { if (!$this->checkUserExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) { return false; } } - $sql = $this->context->getSQL(); - $query = $sql->update("User"); - - if ($usernameChanged) $query->set("name", $username); - if ($fullNameChanged) $query->set("full_name", $fullName); - if ($emailChanged) $query->set("email", $email); - if (!is_null($password)) $query->set("password", $this->hashPassword($password)); + if ($usernameChanged) $user->name = $username; + if ($fullNameChanged) $user->fullName = $fullName; + if ($emailChanged) $user->email = $email; + if (!is_null($password)) $user->password = $this->hashPassword($password); if (!is_null($confirmed)) { - if ($id === $this->context->getUser()->getId() && $confirmed === false) { + if ($id === $currentUser->getId() && $confirmed === false) { return $this->createError("Cannot make own account unconfirmed."); } else { - $query->set("confirmed", $confirmed); + $user->confirmed = $confirmed; } } - if (!empty($query->getValues())) { - $query->where(new Compare("User.id", $id)); - $res = $query->execute(); - $this->lastError = $sql->getLastError(); - $this->success = ($res !== FALSE); - } - - if ($this->success) { + if ($user->save($sql)) { $deleteQuery = $sql->delete("UserGroup")->where(new Compare("user_id", $id)); $insertQuery = $sql->insert("UserGroup", array("user_id", "group_id")); @@ -1036,6 +882,8 @@ namespace Core\API\User { $this->success = ($deleteQuery->execute() !== FALSE) && (empty($groupIds) || $insertQuery->execute() !== FALSE); $this->lastError = $sql->getLastError(); } + } else { + return $this->createError("Error fetching user details: " . $sql->getLastError()); } return $this->success; @@ -1054,19 +902,18 @@ namespace Core\API\User { public function _execute(): bool { + $currentUser = $this->context->getUser(); $id = $this->getParam("id"); - if ($id === $this->context->getUser()->getId()) { + if ($id === $currentUser->getId()) { return $this->createError("You cannot delete your own user."); } - $user = $this->getUser($id); - if ($this->success) { - if (empty($user)) { + $sql = $this->context->getSQL(); + $user = User::find($sql, $id); + if ($user !== false) { + if ($user === null) { return $this->createError("User not found"); } else { - - $sql = $this->context->getSQL(); - $user = new User($id); $this->success = ($user->delete($sql) !== FALSE); $this->lastError = $sql->getLastError(); } @@ -1109,17 +956,20 @@ namespace Core\API\User { } } + $sql = $this->context->getSQL(); $email = $this->getParam("email"); - $user = $this->findUser($email); - if ($this->success === false) { - return false; - } - - if ($user !== null) { + $user = User::findBuilder($sql) + ->where(new Compare("email", $email)) + ->fetchEntities() + ->execute(); + if ($user === false) { + return $this->createError("Could not fetch user details: " . $sql->getLastError()); + } else if ($user !== null) { $validHours = 1; $token = generateRandomString(36); - if (!$this->insertToken($user["id"], $token, "password_reset", $validHours)) { - return false; + $userToken = new UserToken($user, $token, UserToken::TYPE_PASSWORD_RESET, $validHours); + if (!$userToken->save($sql)) { + return $this->createError("Could not create user token: " . $sql->getLastError()); } $baseUrl = $settings->getBaseUrl(); @@ -1132,7 +982,7 @@ namespace Core\API\User { "link" => "$baseUrl/resetPassword?token=$token", "site_name" => $siteName, "base_url" => $baseUrl, - "username" => $user["name"], + "username" => $user->name, "valid_time" => $this->formatDuration($validHours, "hour") ] ]); @@ -1141,11 +991,8 @@ namespace Core\API\User { if ($this->success) { $messageBody = $req->getResult()["html"]; - $gpgFingerprint = null; - if ($user["gpg_id"] && $user["gpg_confirmed"]) { - $gpgFingerprint = $user["gpg_fingerprint"]; - } - + $gpgKey = $user->getGPG(); + $gpgFingerprint = ($gpgKey && $gpgKey->isConfirmed()) ? $gpgKey->getFingerprint() : null; $request = new \Core\API\Mail\Send($this->context); $this->success = $request->execute(array( "to" => $email, @@ -1154,33 +1001,12 @@ namespace Core\API\User { "gpgFingerprint" => $gpgFingerprint )); $this->lastError = $request->getLastError(); - $this->logger->info("Requested password reset for user id=" . $user["id"] . " by ip_address=" . $_SERVER["REMOTE_ADDR"]); + $this->logger->info("Requested password reset for user id=" . $user->getId() . " by ip_address=" . $_SERVER["REMOTE_ADDR"]); } } return $this->success; } - - private function findUser($email): ?array { - $sql = $this->context->getSQL(); - $res = $sql->select("User.id", "User.name", - "User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint") - ->from("User") - ->leftJoin("GpgKey", "GpgKey.id", "User.gpg_id") - ->where(new Compare("User.email", $email)) - ->where(new CondBool("User.confirmed")) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - if ($this->success) { - if (!empty($res)) { - return $res[0]; - } - } - - return null; - } } class ResendConfirmEmail extends UserAPI { @@ -1214,46 +1040,39 @@ namespace Core\API\User { $email = $this->getParam("email"); $sql = $this->context->getSQL(); - $res = $sql->select("User.id", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used") - ->from("User") - ->leftJoin("UserToken", "User.id", "UserToken.user_id") + $user = User::findBuilder($sql) ->where(new Compare("User.email", $email)) ->where(new Compare("User.confirmed", false)) ->execute(); - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return $this->createError($sql->getLastError()); - } else if (!is_array($res) || empty($res)) { - // user does not exist + if ($user === false) { + return $this->createError("Error retrieving user details: " . $sql->getLastError()); + } else if ($user === null) { + // token does not exist: ignore! return true; } - $userId = $res[0]["id"]; - $token = current( - array_map(function ($row) { - return $row["token"]; - }, array_filter($res, function ($row) use ($sql) { - return !$sql->parseBool($row["used"]) && $row["token_type"] === "email_confirm"; - })) - ); + $userToken = UserToken::findBuilder($sql) + ->where(new Compare("used", false)) + ->where(new Compare("tokenType", UserToken::TYPE_EMAIL_CONFIRM)) + ->where(new Compare("user_id", $user->getId())) + ->execute(); $validHours = 48; - if (!$token) { + 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); - if (!$this->insertToken($userId, $token, "email_confirm", $validHours)) { - return false; + $userToken = new UserToken($user, $token, UserToken::TYPE_EMAIL_CONFIRM, $validHours); + if (!$userToken->save($sql)) { + return $this->createError("Error generating new token: " . $sql->getLastError()); } } else { - $sql->update("UserToken") - ->set("valid_until", (new DateTime())->modify("+$validHours hour")) - ->where(new Compare("token", $token)) - ->execute(); + $userToken->updateDurability($sql, $validHours); } - $username = $res[0]["name"]; + $username = $user->name; $baseUrl = $settings->getBaseUrl(); $siteName = $settings->getSiteName(); @@ -1261,7 +1080,7 @@ namespace Core\API\User { $this->success = $req->execute([ "file" => "mail/confirm_email.twig", "parameters" => [ - "link" => "$baseUrl/confirmEmail?token=$token", + "link" => "$baseUrl/confirmEmail?token=" . $userToken->getToken(), "site_name" => $siteName, "base_url" => $baseUrl, "username" => $username, @@ -1298,46 +1117,35 @@ namespace Core\API\User { $this->csrfTokenRequired = false; } - private function updateUser($uid, $password): bool { - $sql = $this->context->getSQL(); - $res = $sql->update("User") - ->set("password", $this->hashPassword($password)) - ->where(new Compare("id", $uid)) - ->execute(); - - $this->success = ($res !== FALSE); - $this->lastError = $sql->getLastError(); - return $this->success; - } - public function _execute(): bool { if ($this->context->getUser()) { return $this->createError("You are already logged in."); } + $sql = $this->context->getSQL(); $token = $this->getParam("token"); $password = $this->getParam("password"); $confirmPassword = $this->getParam("confirmPassword"); - - $req = new CheckToken($this->context); - $this->success = $req->execute(array("token" => $token)); - $this->lastError = $req->getLastError(); - if (!$this->success) { + $userToken = $this->checkToken($token); + if ($userToken === false) { return false; + } else if ($userToken->getType() !== UserToken::TYPE_PASSWORD_RESET) { + return $this->createError("Invalid token type"); } - $result = $req->getResult(); - if (strcasecmp($result["token"]["type"], "password_reset") !== 0) { - return $this->createError("Invalid token type"); - } else if (!$this->checkPasswordRequirements($password, $confirmPassword)) { - return false; - } else if (!$this->updateUser($result["user"]["id"], $password)) { + $user = $token->getUser(); + if (!$this->checkPasswordRequirements($password, $confirmPassword)) { return false; } else { - $this->logger->info("Issued password reset for user id=" . $result["user"]["id"]); - $this->invalidateToken($token); - return true; + $user->password = $this->hashPassword($password); + if ($user->save($sql)) { + $this->logger->info("Issued password reset for user id=" . $user->getId()); + $userToken->invalidate($sql); + return true; + } else { + return $this->createError("Error updating user details: " . $sql->getLastError()); + } } } } @@ -1370,43 +1178,33 @@ namespace Core\API\User { } $sql = $this->context->getSQL(); - $query = $sql->update("User")->where(new Compare("id", $this->context->getUser()->getId())); + + $currentUser = $this->context->getUser(); if ($newUsername !== null) { if (!$this->checkUsernameRequirements($newUsername) || !$this->checkUserExists($newUsername)) { return false; } else { - $query->set("name", $newUsername); + $currentUser->name = $newUsername; } } if ($newFullName !== null) { - $query->set("full_name", $newFullName); + $currentUser->fullName = $newFullName; } if ($newPassword !== null || $newPasswordConfirm !== null) { if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) { return false; } else { - $res = $sql->select("password") - ->from("User") - ->where(new Compare("id", $this->context->getUser()->getId())) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; - } - - if (!password_verify($oldPassword, $res[0]["password"])) { + if (!password_verify($oldPassword, $currentUser->password)) { return $this->createError("Wrong password"); } - $query->set("password", $this->hashPassword($newPassword)); + $currentUser->password = $this->hashPassword($newPassword); } } - $this->success = $query->execute(); + $this->success = $currentUser->save($sql) !== false; $this->lastError = $sql->getLastError(); return $this->success; } @@ -1425,7 +1223,7 @@ namespace Core\API\User { private function testKey(string $keyString) { $res = GpgKey::getKeyInfo($keyString); if (!$res["success"]) { - return $this->createError($res["error"]); + return $this->createError($res["error"] ?? $res["msg"]); } $keyData = $res["data"]; @@ -1466,38 +1264,15 @@ namespace Core\API\User { } $sql = $this->context->getSQL(); - $res = $sql->insert("GpgKey", ["fingerprint", "algorithm", "expires"]) - ->addRow($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"]) - ->returning("id") - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; - } - - $gpgKeyId = $sql->getLastInsertId(); - $res = $sql->update("User") - ->set("gpg_id", $gpgKeyId) - ->where(new Compare("id", $currentUser->getId())) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; + $gpgKey = new GpgKey($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"]); + if (!$gpgKey->save($sql)) { + return $this->createError("Error creating gpg key: " . $sql->getLastError()); } $token = generateRandomString(36); - $res = $sql->insert("UserToken", ["user_id", "token", "token_type", "valid_until"]) - ->addRow($currentUser->getId(), $token, "gpg_confirm", (new DateTime())->modify("+1 hour")) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; + $userToken = new UserToken($currentUser, $token, UserToken::TYPE_GPG_CONFIRM, 1); + if (!$userToken->save($sql)) { + return $this->createError("Error saving user token: " . $sql->getLastError()); } $name = htmlspecialchars($currentUser->getFullName()); @@ -1508,32 +1283,32 @@ namespace Core\API\User { $settings = $this->context->getSettings(); $baseUrl = htmlspecialchars($settings->getBaseUrl()); $token = htmlspecialchars(urlencode($token)); - $url = "$baseUrl/settings?confirmGPG&token=$token"; + $url = "$baseUrl/settings?confirmGPG&token=$token"; // TODO: fix this url $mailBody = "Hello $name,

" . "you imported a GPG public key for end-to-end encrypted mail communication. " . "To confirm the key and verify, you own the corresponding private key, please click on the following link. " . "The link is active for one hour.

" . "$url
- Best Regards
- ilum:e Security Lab"; + Best Regards
" . + $settings->getSiteName() . " Administration"; $sendMail = new \Core\API\Mail\Send($this->context); $this->success = $sendMail->execute(array( "to" => $currentUser->getEmail(), - "subject" => "Security Lab - Confirm GPG-Key", + "subject" => $settings->getSiteName() . " - Confirm GPG-Key", "body" => $mailBody, - "gpgFingerprint" => $keyData["fingerprint"] + "gpgFingerprint" => $gpgKey->getFingerprint() )); $this->lastError = $sendMail->getLastError(); if ($this->success) { - $this->result["gpg"] = array( - "fingerprint" => $keyData["fingerprint"], - "confirmed" => false, - "algorithm" => $keyData["algorithm"], - "expires" => $keyData["expires"]->getTimestamp() - ); + $currentUser->gpgKey = $gpgKey; + if ($currentUser->save($sql)) { + $this->result["gpg"] = $gpgKey->jsonSerialize(); + } else { + return $this->createError("Error updating user details: " . $sql->getLastError()); + } } return $this->success; @@ -1558,29 +1333,11 @@ namespace Core\API\User { } $sql = $this->context->getSQL(); - $res = $sql->select("password") - ->from("User") - ->where(new Compare("User.id", $currentUser->getId())) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - - if ($this->success && is_array($res)) { - $hash = $res[0]["password"]; - $password = $this->getParam("password"); - if (!password_verify($password, $hash)) { - return $this->createError("Incorrect password."); - } else { - $res = $sql->delete("GpgKey") - ->where(new Compare("id", - $sql->select("User.gpg_id") - ->from("User") - ->where(new Compare("User.id", $currentUser->getId())) - ))->execute(); - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - } + $password = $this->getParam("password"); + if (!password_verify($password, $currentUser->password)) { + return $this->createError("Incorrect password."); + } else if (!$gpgKey->delete($sql)) { + return $this->createError("Error deleting gpg key: " . $sql->getLastError()); } return $this->success; @@ -1608,41 +1365,26 @@ namespace Core\API\User { $token = $this->getParam("token"); $sql = $this->context->getSQL(); - $res = $sql->select($sql->count()) - ->from("UserToken") + + $userToken = UserToken::findBuilder($sql) ->where(new Compare("token", $token)) ->where(new Compare("valid_until", $sql->now(), ">=")) ->where(new Compare("user_id", $currentUser->getId())) - ->where(new Compare("token_type", "gpg_confirm")) - ->where(new CondNot(new CondBool("used"))) + ->where(new Compare("token_type", UserToken::TYPE_GPG_CONFIRM)) ->execute(); - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - - if ($this->success && is_array($res)) { - if ($res[0]["count"] === 0) { + if ($userToken !== false) { + if ($userToken === null) { return $this->createError("Invalid token"); } else { - $res = $sql->update("GpgKey") - ->set("confirmed", 1) - ->where(new Compare("id", $gpgKey->getId())) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); - if (!$this->success) { - return false; + if (!$gpgKey->confirm($sql)) { + return $this->createError("Error updating gpg key: " . $sql->getLastError()); } - $res = $sql->update("UserToken") - ->set("used", 1) - ->where(new Compare("token", $token)) - ->execute(); - - $this->success = ($res !== false); - $this->lastError = $sql->getLastError(); + $userToken->invalidate($sql); } + } else { + return $this->createError("Error validating token: " . $sql->getLastError()); } return $this->success; @@ -1676,24 +1418,23 @@ namespace Core\API\User { } $email = $currentUser->getEmail(); - $gpgFingerprint = $gpgKey->getFingerprint(); } else { - $req = new Get($this->context); - $this->success = $req->execute(["id" => $userId]); - $this->lastError = $req->getLastError(); - if (!$this->success) { - return false; + $sql = $this->context->getSQL(); + $user = User::find($sql, $userId, true); + if ($user === false) { + return $this->createError("Error fetching user details: " . $sql->getLastError()); + } else if ($user === null) { + return $this->createError("User not found"); } - $res = $req->getResult()["user"]; - $email = $res["email"]; - $gpgFingerprint = $res["gpgFingerprint"]; - if (!$gpgFingerprint) { - return $this->createError("This user has not added a gpg key yet"); + $email = $user->getEmail(); + $gpgKey = $user->getGPG(); + if (!$gpgKey || !$gpgKey->isConfirmed()) { + return $this->createError("This user has not added a gpg key yet or has not confirmed it yet."); } } - $res = GpgKey::export($gpgFingerprint, $format !== "gpg"); + $res = GpgKey::export($gpgKey->getFingerprint(), $format !== "gpg"); if (!$res["success"]) { return $this->createError($res["error"]); } @@ -1817,14 +1558,11 @@ namespace Core\API\User { } $sql = $this->context->getSQL(); - $this->success = $sql->update("User") - ->set("profile_picture", $fileName) - ->where(new Compare("id", $userId)) - ->execute(); - - $this->lastError = $sql->getLastError(); - if ($this->success) { + $currentUser->profilePicture = $fileName; + if ($currentUser->save($sql)) { $this->result["profilePicture"] = $fileName; + } else { + return $this->createError("Error updating user details: " . $sql->getLastError()); } return $this->success; @@ -1839,25 +1577,22 @@ namespace Core\API\User { public function _execute(): bool { + $sql = $this->context->getSQL(); $currentUser = $this->context->getUser(); + $userId = $currentUser->getId(); $pfp = $currentUser->getProfilePicture(); if (!$pfp) { return $this->createError("You did not upload a profile picture yet"); } - $userId = $currentUser->getId(); - $sql = $this->context->getSQL(); - $this->success = $sql->update("User") - ->set("profile_picture", NULL) - ->where(new Compare("id", $userId)) - ->execute(); - $this->lastError = $sql->getLastError(); + $currentUser->profilePicture = null; + if (!$currentUser->save($sql)) { + return $this->createError("Error updating user details: " . $sql->getLastError()); + } - if ($this->success) { - $path = WEBROOT . "/img/uploads/user/$userId/$pfp"; - if (is_file($path)) { - @unlink($path); - } + $path = WEBROOT . "/img/uploads/user/$userId/$pfp"; + if (is_file($path)) { + @unlink($path); } return $this->success; diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php index 5e8ff85..d91390b 100644 --- a/Core/Configuration/CreateDatabase.class.php +++ b/Core/Configuration/CreateDatabase.class.php @@ -19,14 +19,6 @@ class CreateDatabase extends DatabaseScript { ->addRow("en_US", 'American English') ->addRow("de_DE", 'Deutsch Standard'); - $queries[] = $sql->createTable("UserToken") - ->addInt("user_id") - ->addString("token", 36) - ->addEnum("token_type", array("password_reset", "email_confirm", "invite", "gpg_confirm")) - ->addDateTime("valid_until") - ->addBool("used", false) - ->foreignKey("user_id", "User", "id", new CascadeStrategy()); - $queries[] = $sql->insert("Group", array("name", "color")) ->addRow(USER_GROUP_MODERATOR_NAME, "#007bff") ->addRow(USER_GROUP_SUPPORT_NAME, "#28a745") diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index ec3b42c..7d42e53 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -15,20 +15,28 @@ class Settings { // private bool $installationComplete; - // settings + // general settings private string $siteName; private string $baseUrl; + private bool $registrationAllowed; + private array $allowedExtensions; + private string $timeZone; + + // jwt private ?string $jwtPublicKey; private ?string $jwtSecretKey; private string $jwtAlgorithm; - private bool $registrationAllowed; + + // recaptcha private bool $recaptchaEnabled; - private bool $mailEnabled; private string $recaptchaPublicKey; private string $recaptchaPrivateKey; + + // mail + private bool $mailEnabled; private string $mailSender; private string $mailFooter; - private array $allowedExtensions; + private bool $mailAsync; // private Logger $logger; @@ -55,7 +63,11 @@ class Settings { } public static function loadDefaults(): Settings { - $hostname = $_SERVER["SERVER_NAME"] ?? "localhost"; + $hostname = $_SERVER["SERVER_NAME"]; + if (empty($hostname)) { + $hostname = "localhost"; + } + $protocol = getProtocol(); $settings = new Settings(); @@ -65,6 +77,7 @@ class Settings { $settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html']; $settings->installationComplete = false; $settings->registrationAllowed = false; + $settings->timeZone = date_default_timezone_get(); // JWT $settings->jwtSecretKey = null; @@ -80,7 +93,7 @@ class Settings { $settings->mailEnabled = false; $settings->mailSender = "webmaster@localhost"; $settings->mailFooter = ""; - + $settings->mailAsync = false; return $settings; } @@ -118,7 +131,7 @@ class Settings { return in_array(strtoupper($algorithm), ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "EDDSA"]); } - public function saveJwtKey(Context $context) { + public function saveJwtKey(Context $context): \Core\API\Settings\Set { $req = new \Core\API\Settings\Set($context); $req->execute(array("settings" => array( "jwt_secret_key" => $this->jwtSecretKey, @@ -140,6 +153,7 @@ class Settings { $this->baseUrl = $result["base_url"] ?? $this->baseUrl; $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; + $this->timeZone = $result["time_zone"] ?? $this->timeZone; $this->jwtSecretKey = $result["jwt_secret_key"] ?? $this->jwtSecretKey; $this->jwtPublicKey = $result["jwt_public_key"] ?? $this->jwtPublicKey; $this->jwtAlgorithm = $result["jwt_algorithm"] ?? $this->jwtAlgorithm; @@ -149,6 +163,7 @@ class Settings { $this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled; $this->mailSender = $result["mail_from"] ?? $this->mailSender; $this->mailFooter = $result["mail_footer"] ?? $this->mailFooter; + $this->mailAsync = $result["mail_async"] ?? $this->mailAsync; $this->allowedExtensions = explode(",", $result["allowed_extensions"] ?? strtolower(implode(",", $this->allowedExtensions))); if (!isset($result["jwt_secret_key"])) { @@ -156,16 +171,19 @@ class Settings { $this->saveJwtKey($context); } } + + date_default_timezone_set($this->timeZone); } return false; } - public function addRows(Insert $query) { + public function addRows(Insert $query): void { $query->addRow("site_name", $this->siteName, false, false) ->addRow("base_url", $this->baseUrl, false, false) ->addRow("user_registration_enabled", $this->registrationAllowed ? "1" : "0", false, false) ->addRow("installation_completed", $this->installationComplete ? "1" : "0", true, true) + ->addRow("time_zone", $this->timeZone, false, false) ->addRow("jwt_secret_key", $this->jwtSecretKey, true, false) ->addRow("jwt_public_key", $this->jwtPublicKey, false, false) ->addRow("jwt_algorithm", $this->jwtAlgorithm, false, false) @@ -179,6 +197,14 @@ class Settings { return $this->siteName; } + public function getTimeZone(): string { + return $this->timeZone; + } + + public function setTimeZone(string $tz) { + $this->timeZone = $tz; + } + public function getBaseUrl(): string { return $this->baseUrl; } @@ -203,6 +229,10 @@ class Settings { return $this->mailEnabled; } + public function isMailAsync(): bool { + return $this->mailAsync; + } + public function getMailSender(): string { return $this->mailSender; } diff --git a/Core/Documents/Install.class.php b/Core/Documents/Install.class.php index 7d0b76b..47925c9 100644 --- a/Core/Documents/Install.class.php +++ b/Core/Documents/Install.class.php @@ -235,7 +235,7 @@ namespace Documents\Install { $username = posix_getpwuid($userId)['name']; $failedRequirements[] = sprintf("%s is not owned by current user: $username ($userId). " . "Try running chown -R $userId %s or give the required directories write permissions: " . - "core/Configuration, core/Cache, core/External", + "Site/Configuration, Core/Cache, Core/External", WEBROOT, WEBROOT); $success = false; } diff --git a/Core/Driver/Logger/Logger.class.php b/Core/Driver/Logger/Logger.class.php index 2062a46..239cb65 100644 --- a/Core/Driver/Logger/Logger.class.php +++ b/Core/Driver/Logger/Logger.class.php @@ -72,7 +72,7 @@ class Logger { $module = preg_replace("/[^a-zA-Z0-9-]/", "-", $this->module); $date = (\DateTime::createFromFormat('U.u', microtime(true)))->format(self::LOG_FILE_DATE_FORMAT); $logFile = implode("_", [$module, $severity, $date]) . ".log"; - $logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "core", "Logs", $logFile]); + $logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Core", "Logs", $logFile]); @file_put_contents($logPath, $message); } diff --git a/Core/Elements/TemplateDocument.class.php b/Core/Elements/TemplateDocument.class.php index e7ae1d5..fc40c20 100644 --- a/Core/Elements/TemplateDocument.class.php +++ b/Core/Elements/TemplateDocument.class.php @@ -31,7 +31,7 @@ class TemplateDocument extends Document { $this->parameters = $params; $this->twigLoader = new FilesystemLoader(self::TEMPLATE_PATH); $this->twigEnvironment = new Environment($this->twigLoader, [ - 'cache' => WEBROOT . '/core/Cache/Templates/', + 'cache' => WEBROOT . '/Core/Cache/Templates/', 'auto_reload' => true ]); $this->twigEnvironment->addExtension(new CustomTwigFunctions()); diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 4a38e5c..a3c415d 100644 --- a/Core/Objects/Context.class.php +++ b/Core/Objects/Context.class.php @@ -7,6 +7,7 @@ use Core\Configuration\Settings; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Condition\CondOr; +use Core\Driver\SQL\Join; use Core\Driver\SQL\SQL; use Firebase\JWT\JWT; use Core\Objects\DatabaseEntity\Language; @@ -92,6 +93,9 @@ class Context { private function loadSession(int $userId, int $sessionId) { $this->session = Session::init($this, $userId, $sessionId); $this->user = $this->session?->getUser(); + if ($this->user) { + $this->user->session = $this->session; + } } public function parseCookies() { @@ -173,7 +177,7 @@ class Context { public function loadApiKey(string $apiKey): bool { $this->user = User::findBuilder($this->sql) - ->addJoin(new \Driver\SQL\Join("INNER","ApiKey", "ApiKey.user_id", "User.id")) + ->addJoin(new Join("INNER","ApiKey", "ApiKey.user_id", "User.id")) ->where(new Compare("ApiKey.api_key", $apiKey)) ->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">")) ->where(new Compare("ApiKey.active", true)) @@ -184,19 +188,18 @@ class Context { return $this->user !== null; } - public function createSession(int $userId, bool $stayLoggedIn): ?Session { - $this->user = User::find($this->sql, $userId); - if ($this->user) { - $this->session = new Session($this, $this->user); - $this->session->stayLoggedIn = $stayLoggedIn; - if ($this->session->update()) { - return $this->session; - } + public function createSession(User $user, bool $stayLoggedIn): ?Session { + $this->user = $user; + $this->session = new Session($this, $this->user); + $this->session->stayLoggedIn = $stayLoggedIn; + if ($this->session->update()) { + $user->session = $this->session; + return $this->session; + } else { + $this->user = null; + $this->session = null; + return null; } - - $this->user = null; - $this->session = null; - return null; } public function getLanguage(): Language { diff --git a/Core/Objects/DatabaseEntity/Attribute/EnumArr.class.php b/Core/Objects/DatabaseEntity/Attribute/EnumArr.class.php new file mode 100644 index 0000000..4fd1fb4 --- /dev/null +++ b/Core/Objects/DatabaseEntity/Attribute/EnumArr.class.php @@ -0,0 +1,12 @@ +isInitialized($entity)) { $value = $property->getValue($entity); if (isset($this->relations[$propertyName])) { - $value = $value->getId(); + $value = $value?->getId(); } } else if (!$this->columns[$propertyName]->notNull()) { $value = null; @@ -411,4 +411,8 @@ class DatabaseEntityHandler { $this->logger->error($message); throw new Exception($message); } + + public function getSQL(): SQL { + return $this->sql; + } } \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/DatabaseEntityQuery.class.php b/Core/Objects/DatabaseEntity/DatabaseEntityQuery.class.php index 53c5178..842372c 100644 --- a/Core/Objects/DatabaseEntity/DatabaseEntityQuery.class.php +++ b/Core/Objects/DatabaseEntity/DatabaseEntityQuery.class.php @@ -2,6 +2,7 @@ namespace Core\Objects\DatabaseEntity; +use Core\Driver\Logger\Logger; use Core\Driver\SQL\Condition\Condition; use Core\Driver\SQL\Join; use Core\Driver\SQL\Query\Select; @@ -13,20 +14,29 @@ use Core\Driver\SQL\SQL; */ class DatabaseEntityQuery { + private Logger $logger; private DatabaseEntityHandler $handler; private Select $selectQuery; private int $resultType; + private bool $logVerbose; private function __construct(DatabaseEntityHandler $handler, int $resultType) { $this->handler = $handler; $this->selectQuery = $handler->getSelectQuery(); + $this->logger = new Logger("DB-EntityQuery", $handler->getSQL()); $this->resultType = $resultType; + $this->logVerbose = false; if ($this->resultType === SQL::FETCH_ONE) { $this->selectQuery->first(); } } + public function debug(): DatabaseEntityQuery { + $this->logVerbose = true; + return $this; + } + public static function fetchAll(DatabaseEntityHandler $handler): DatabaseEntityQuery { return new DatabaseEntityQuery($handler, SQL::FETCH_ALL); } @@ -106,6 +116,13 @@ class DatabaseEntityQuery { } public function execute(): DatabaseEntity|array|null { + + if ($this->logVerbose) { + $params = []; + $query = $this->selectQuery->build($params); + $this->logger->debug("QUERY: $query\nARGS: " . print_r($params, true)); + } + $res = $this->selectQuery->execute(); if ($res === null || $res === false) { return null; diff --git a/Core/Objects/DatabaseEntity/GpgKey.class.php b/Core/Objects/DatabaseEntity/GpgKey.class.php index c32139a..6a41b85 100644 --- a/Core/Objects/DatabaseEntity/GpgKey.class.php +++ b/Core/Objects/DatabaseEntity/GpgKey.class.php @@ -3,6 +3,7 @@ namespace Core\Objects\DatabaseEntity; use Core\Driver\SQL\Expression\CurrentTimeStamp; +use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\DefaultValue; @@ -16,12 +17,13 @@ class GpgKey extends DatabaseEntity { private \DateTime $expires; #[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added; - public function __construct(int $id, bool $confirmed, string $fingerprint, string $algorithm, string $expires) { - parent::__construct($id); - $this->confirmed = $confirmed; + public function __construct(string $fingerprint, string $algorithm, \DateTime $expires) { + parent::__construct(); + $this->confirmed = false; $this->fingerprint = $fingerprint; $this->algorithm = $algorithm; - $this->expires = new \DateTime($expires); + $this->expires = $expires; + $this->added = new \DateTime(); } public static function encrypt(string $body, string $gpgFingerprint): array { @@ -130,4 +132,9 @@ class GpgKey extends DatabaseEntity { "confirmed" => $this->confirmed ]; } + + public function confirm(SQL $sql): bool { + $this->confirmed = true; + return $this->save($sql); + } } \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/Session.class.php b/Core/Objects/DatabaseEntity/Session.class.php index 43534b9..4fae092 100644 --- a/Core/Objects/DatabaseEntity/Session.class.php +++ b/Core/Objects/DatabaseEntity/Session.class.php @@ -96,7 +96,7 @@ class Session extends DatabaseEntity { return array( 'id' => $this->getId(), 'active' => $this->active, - 'expires' => $this->expires, + 'expires' => $this->expires->getTimestamp(), 'ipAddress' => $this->ipAddress, 'os' => $this->os, 'browser' => $this->browser, diff --git a/Core/Objects/DatabaseEntity/User.class.php b/Core/Objects/DatabaseEntity/User.class.php index 2b4c3ec..4b21130 100644 --- a/Core/Objects/DatabaseEntity/User.class.php +++ b/Core/Objects/DatabaseEntity/User.class.php @@ -17,12 +17,12 @@ class User extends DatabaseEntity { #[MaxLength(128)] public string $password; #[MaxLength(64)] public string $fullName; #[MaxLength(64)] #[Unique] public ?string $email; - #[MaxLength(64)] private ?string $profilePicture; + #[MaxLength(64)] public ?string $profilePicture; private ?\DateTime $lastOnline; #[DefaultValue(CurrentTimeStamp::class)] public \DateTime $registeredAt; public bool $confirmed; #[DefaultValue(1)] public Language $language; - private ?GpgKey $gpgKey; + public ?GpgKey $gpgKey; private ?TwoFactorToken $twoFactorToken; #[Transient] private array $groups; @@ -37,7 +37,6 @@ class User extends DatabaseEntity { $this->groups = []; $groups = Group::findAllBuilder($sql) - ->fetchEntities() ->addJoin(new Join("INNER", "UserGroup", "UserGroup.group_id", "Group.id")) ->where(new Compare("UserGroup.user_id", $this->id)) ->execute(); @@ -99,6 +98,9 @@ class User extends DatabaseEntity { 'session' => (isset($this->session) ? $this->session->jsonSerialize() : null), "gpg" => (isset($this->gpgKey) ? $this->gpgKey->jsonSerialize() : null), "2fa" => (isset($this->twoFactorToken) ? $this->twoFactorToken->jsonSerialize() : null), + "reqisteredAt" => $this->registeredAt->getTimestamp(), + "lastOnline" => $this->lastOnline->getTimestamp(), + "confirmed" => $this->confirmed ]; } diff --git a/Core/Objects/DatabaseEntity/UserToken.class.php b/Core/Objects/DatabaseEntity/UserToken.class.php new file mode 100644 index 0000000..0c41fff --- /dev/null +++ b/Core/Objects/DatabaseEntity/UserToken.class.php @@ -0,0 +1,72 @@ +user = $user; + $this->token = $token; + $this->tokenType = $type; + $this->validUntil = (new \DateTime())->modify("+$validHours HOUR"); + $this->used = false; + } + + public function jsonSerialize(): array { + return [ + "id" => $this->getId(), + "token" => $this->token, + "tokenType" => $this->tokenType + ]; + } + + public function getType(): string { + return $this->tokenType; + } + + public function invalidate(SQL $sql): bool { + $this->used = true; + return $this->save($sql); + } + + 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); + } + + public function getToken(): string { + return $this->token; + } +} \ No newline at end of file diff --git a/Site/Templates/.gitkeep b/Site/Templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli.php b/cli.php index d8ca069..fb5cc70 100644 --- a/cli.php +++ b/cli.php @@ -48,17 +48,6 @@ if ($database !== null && $database->getProperty("isDocker", false) && !is_file( } } -/*function getUser(): ?User { - global $config; - $user = new User($config); - if (!$user->getSQL() || !$user->getSQL()->isConnected()) { - printLine("Could not establish database connection"); - return null; - } - - return $user; -}*/ - function connectSQL(): ?SQL { global $context; $sql = $context->initSQL(); @@ -76,7 +65,7 @@ function printHelp() { function applyPatch(\Core\Driver\SQL\SQL $sql, string $patchName): bool { $class = str_replace('/', '\\', $patchName); - $className = "\\Configuration\\$class"; + $className = "\\Core\\Configuration\\$class"; $classPath = getClassPath($className); if (!file_exists($classPath) || !is_readable($classPath)) { printLine("Database script file does not exist or is not readable"); @@ -282,7 +271,7 @@ function onMaintenance(array $argv) { _exit("Maintenance disabled"); } else if ($action === "update") { - $oldPatchFiles = glob('core/Configuration/Patch/*.php'); + $oldPatchFiles = glob('Core/Configuration/Patch/*.php'); printLine("$ git remote -v"); exec("git remote -v", $gitRemote, $ret); if ($ret !== 0) { @@ -339,14 +328,15 @@ function onMaintenance(array $argv) { die(); } - $newPatchFiles = glob('core/Configuration/Patch/*.php'); + // TODO: also collect patches from Site/Configuration/Patch ... and what about database entities? + $newPatchFiles = glob('Core/Configuration/Patch/*.php'); $newPatchFiles = array_diff($newPatchFiles, $oldPatchFiles); if (count($newPatchFiles) > 0) { printLine("Applying new database patches"); $sql = connectSQL(); if ($sql) { foreach ($newPatchFiles as $patchFile) { - if (preg_match("/core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) { + if (preg_match("/Core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) { $patchName = $match[1]; applyPatch($sql, $patchName); } @@ -415,7 +405,7 @@ function printTable(array $head, array $body) { function onSettings(array $argv) { global $context; - $sql = connectSQL() or die(); + connectSQL() or die(); $action = $argv[2] ?? "list"; if ($action === "list" || $action === "get") { @@ -461,7 +451,7 @@ function onSettings(array $argv) { function onRoutes(array $argv) { global $context; - $sql = connectSQL() or die(); + connectSQL() or die(); $action = $argv[2] ?? "list"; if ($action === "list") { @@ -607,6 +597,7 @@ function onMail($argv) { global $context; $action = $argv[2] ?? null; if ($action === "send_queue") { + connectSQL() or die(); $req = new \Core\API\Mail\SendQueue($context); $debug = in_array("debug", $argv); if (!$req->execute(["debug" => $debug])) { diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index e4385ad..4782d5b 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,11 +1,14 @@ FROM composer:latest AS composer FROM php:8.0-fpm WORKDIR "/application" -RUN mkdir -p /application/core/Configuration -RUN chown -R www-data:www-data /application +RUN mkdir -p /application/core/Configuration /var/www/.gnupg && \ + chown -R www-data:www-data /application /var/www/ && \ + chmod 700 /var/www/.gnupg # YAML + dev dependencies -RUN apt-get update -y && apt-get install libyaml-dev libzip-dev libgmp-dev -y && apt-get clean && \ +RUN apt-get update -y && \ + apt-get install -y libyaml-dev libzip-dev libgmp-dev gnupg2 && \ + apt-get clean && \ pecl install yaml && docker-php-ext-enable yaml # Runkit (no stable release available)