diff --git a/Core/API/ApiKeyAPI.class.php b/Core/API/ApiKeyAPI.class.php index 79e7a89..4fc3910 100644 --- a/Core/API/ApiKeyAPI.class.php +++ b/Core/API/ApiKeyAPI.class.php @@ -18,6 +18,7 @@ namespace Core\API\ApiKey { use Core\API\Parameter\Parameter; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondAnd; + use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; use Core\Objects\DatabaseEntity\ApiKey; @@ -46,6 +47,10 @@ namespace Core\API\ApiKey { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [], "Allows users to create new API-Keys"); + } } class Fetch extends ApiKeyAPI { @@ -82,6 +87,10 @@ namespace Core\API\ApiKey { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [], "Allows users to fetch new API-Key"); + } } class Refresh extends ApiKeyAPI { @@ -112,6 +121,10 @@ namespace Core\API\ApiKey { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [], "Allows users to refresh API-Key"); + } } class Revoke extends ApiKeyAPI { @@ -138,5 +151,9 @@ namespace Core\API\ApiKey { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [], "Allows users to revoke API-Key"); + } } } \ No newline at end of file diff --git a/Core/API/DatabaseAPI.class.php b/Core/API/DatabaseAPI.class.php index 7252003..2df952c 100644 --- a/Core/API/DatabaseAPI.class.php +++ b/Core/API/DatabaseAPI.class.php @@ -12,8 +12,10 @@ namespace Core\API\Database { use Core\API\DatabaseAPI; use Core\API\Parameter\StringType; + use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; + use Core\Objects\DatabaseEntity\Group; class Status extends DatabaseAPI { @@ -29,6 +31,10 @@ namespace Core\API\Database { return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to view the database status"); + } } class Migrate extends DatabaseAPI { @@ -98,5 +104,9 @@ namespace Core\API\Database { return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to migrate the database structure"); + } } } \ No newline at end of file diff --git a/Core/API/GroupsAPI.class.php b/Core/API/GroupsAPI.class.php index c8e2c51..d5b4c8f 100644 --- a/Core/API/GroupsAPI.class.php +++ b/Core/API/GroupsAPI.class.php @@ -36,8 +36,8 @@ namespace Core\API\Groups { use Core\Driver\SQL\Expression\Alias; use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Join\InnerJoin; + use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; - use Core\Objects\DatabaseEntity\Controller\NMRelation; use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\User; @@ -83,6 +83,10 @@ namespace Core\API\Groups { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch available groups"); + } } class Get extends GroupsAPI { @@ -106,6 +110,10 @@ namespace Core\API\Groups { return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to get details about a group"); + } } class GetMembers extends GroupsAPI { @@ -142,6 +150,9 @@ namespace Core\API\Groups { return true; } + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch members of a group"); + } } class Create extends GroupsAPI { @@ -182,6 +193,10 @@ namespace Core\API\Groups { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to create a new group"); + } } class Delete extends GroupsAPI { @@ -209,6 +224,10 @@ namespace Core\API\Groups { return $this->success; } } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to delete a group"); + } } class AddMember extends GroupsAPI { @@ -247,6 +266,10 @@ namespace Core\API\Groups { return true; } } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to add members to a group"); + } } class RemoveMember extends GroupsAPI { @@ -285,6 +308,9 @@ namespace Core\API\Groups { return true; } } - } + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to remove members from a group"); + } + } } \ No newline at end of file diff --git a/Core/API/LogsAPI.class.php b/Core/API/LogsAPI.class.php index ba43157..aabedd8 100644 --- a/Core/API/LogsAPI.class.php +++ b/Core/API/LogsAPI.class.php @@ -21,7 +21,9 @@ namespace Core\API\Logs { use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondIn; + use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; + use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\SystemLog; class Get extends LogsAPI { @@ -118,6 +120,10 @@ namespace Core\API\Logs { return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch system logs"); + } } } \ No newline at end of file diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php index 6d53952..32aa935 100644 --- a/Core/API/MailAPI.class.php +++ b/Core/API/MailAPI.class.php @@ -46,6 +46,8 @@ namespace Core\API\Mail { use Core\API\MailAPI; use Core\API\Parameter\Parameter; use Core\API\Parameter\StringType; + use Core\Driver\SQL\Query\Insert; + use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\MailQueueItem; use DateTimeInterface; use Core\Driver\SQL\Condition\Compare; @@ -78,6 +80,10 @@ namespace Core\API\Mail { $this->lastError = $req->getLastError(); return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to send a test email to verify configuration"); + } } class Send extends MailAPI { diff --git a/Core/API/PermissionAPI.class.php b/Core/API/PermissionAPI.class.php index f2d4b24..995312b 100644 --- a/Core/API/PermissionAPI.class.php +++ b/Core/API/PermissionAPI.class.php @@ -31,6 +31,7 @@ namespace Core\API\Permission { use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Condition\CondNot; + use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Objects\Context; use Core\Objects\DatabaseEntity\Group; @@ -73,6 +74,16 @@ namespace Core\API\Permission { http_response_code(401); return $this->createError("Permission denied."); } + + // user would have required groups, check for 2fa-state + if ($currentUser) { + $tfaToken = $currentUser->getTwoFactorToken(); + if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) { + $this->lastError = '2FA-Authorization is required'; + http_response_code(401); + return false; + } + } } return $this->success; @@ -127,6 +138,10 @@ namespace Core\API\Permission { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch API permissions"); + } } class Save extends PermissionAPI { @@ -192,5 +207,10 @@ namespace Core\API\Permission { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], + "Allows users to modify API permissions. This is restricted to the administrator and cannot be changed"); + } } } \ No newline at end of file diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index d0e46f9..894e1d3 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -3,15 +3,10 @@ namespace Core\API; use Core\Driver\Logger\Logger; +use Core\Driver\SQL\Query\Insert; use Core\Objects\Context; use PhpMqtt\Client\MqttClient; -/** - * TODO: we need following features, probably as abstract/generic class/method: - * - easy way for pagination (select with limit/offset) - * - CRUD Endpoints/Objects (Create, Update, Delete) - */ - abstract class Request { protected Context $context; @@ -62,7 +57,7 @@ abstract class Request { } } - protected function forbidMethod($method) { + protected function forbidMethod($method): void { if (($key = array_search($method, $this->allowedMethods)) !== false) { unset($this->allowedMethods[$key]); } @@ -76,7 +71,7 @@ abstract class Request { return $this->isDisabled; } - protected function allowMethod($method) { + protected function allowMethod($method): void { $availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"]; if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) { $this->allowedMethods[] = $method; @@ -113,7 +108,7 @@ abstract class Request { return true; } - public function parseVariableParams($values) { + public function parseVariableParams($values): void { foreach ($values as $name => $value) { if (isset($this->params[$name])) continue; $type = Parameter\Parameter::parseType($value); @@ -129,6 +124,7 @@ abstract class Request { } protected abstract function _execute(): bool; + public static function getDefaultACL(Insert $insert): void { } public final function execute($values = array()): bool { @@ -203,9 +199,11 @@ abstract class Request { } else if ($session) { $tfaToken = $session->getUser()->getTwoFactorToken(); if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) { - $this->lastError = '2FA-Authorization is required'; - http_response_code(401); - return false; + if (!($this instanceof \Core\API\Tfa\VerifyTotp) && !($this instanceof \Core\API\Tfa\VerifyKey)) { + $this->lastError = '2FA-Authorization is required'; + http_response_code(401); + return false; + } } } } @@ -225,7 +223,7 @@ abstract class Request { // Check for permission if (!($this instanceof \Core\API\Permission\Save)) { $req = new \Core\API\Permission\Check($this->context); - $this->success = $req->execute(array("method" => $this->getMethod())); + $this->success = $req->execute(array("method" => self::getEndpoint())); $this->lastError = $req->getLastError(); if (!$this->success) { return false; @@ -266,11 +264,11 @@ abstract class Request { } protected function getParam($name, $obj = NULL): mixed { - // I don't know why phpstorm if ($obj === NULL) { $obj = $this->params; } + // I don't know why phpstorm return (isset($obj[$name]) ? $obj[$name]->value : NULL); } @@ -302,10 +300,30 @@ abstract class Request { return $this->externalCall; } - private function getMethod() { - $class = str_replace("\\", "/", get_class($this)); - $class = substr($class, strlen("api/")); - return $class; + public static function getEndpoint(string $prefix = ""): ?string { + $reflectionClass = new \ReflectionClass(get_called_class()); + if ($reflectionClass->isAbstract()) { + return null; + } + + $isNestedAPI = $reflectionClass->getParentClass()->getName() !== Request::class; + if (!$isNestedAPI) { + # e.g. /api/stats or /api/info + $methodName = $reflectionClass->getShortName(); + return $prefix . lcfirst($methodName); + } else { + # e.g. /api/user/login + $methodClass = $reflectionClass; + $nestedClass = $reflectionClass->getParentClass(); + while (!endsWith($nestedClass->getName(), "API")) { + $methodClass = $nestedClass; + $nestedClass = $nestedClass->getParentClass(); + } + + $nestedAPI = substr(lcfirst($nestedClass->getShortName()), 0, -3); + $methodName = lcfirst($methodClass->getShortName()); + return $prefix . $nestedAPI . "/" . $methodName; + } } public function getJsonResult(): string { @@ -314,7 +332,7 @@ abstract class Request { return json_encode($this->result); } - protected function disableOutputBuffer() { + protected function disableOutputBuffer(): void { ob_implicit_flush(true); $levels = ob_get_level(); for ( $i = 0; $i < $levels; $i ++ ) { @@ -323,7 +341,7 @@ abstract class Request { flush(); } - protected function disableCache() { + protected function disableCache(): void { header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T")); header("Expires: Sat, 26 Jul 1997 05:00:00 GMT"); header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); @@ -331,7 +349,7 @@ abstract class Request { header("Pragma: no-cache"); } - protected function setupSSE() { + protected function setupSSE(): void { $this->context->sendCookies(); $this->context->getSQL()?->close(); set_time_limit(0); @@ -348,7 +366,7 @@ abstract class Request { * @throws \PhpMqtt\Client\Exceptions\DataTransferException * @throws \PhpMqtt\Client\Exceptions\MqttClientException */ - protected function startMqttSSE(MqttClient $mqtt, callable $onPing) { + protected function startMqttSSE(MqttClient $mqtt, callable $onPing): void { $lastPing = 0; $mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) { if ($elapsed - $lastPing >= 5) { @@ -366,7 +384,7 @@ abstract class Request { $mqtt->disconnect(); } - protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null) { + protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null): bool|array { if (empty($_FILES)) { return $this->createError("You need to upload an image."); } else if (count($_FILES) > 1) { @@ -470,4 +488,49 @@ abstract class Request { return [$fileName, $files[$fileName]]; } } + + public static function getApiEndpoints(): array { + + // first load all direct classes + $classes = []; + $apiDirs = ["Core", "Site"]; + foreach ($apiDirs as $apiDir) { + $basePath = realpath(WEBROOT . "/$apiDir/API/"); + if (!$basePath) { + continue; + } + + foreach (scandir($basePath) as $fileName) { + $fullPath = $basePath . "/" . $fileName; + if (is_file($fullPath) && endsWith($fileName, ".class.php")) { + require_once $fullPath; + $apiName = explode(".", $fileName)[0]; + $className = "\\$apiDir\\API\\$apiName"; + if (!class_exists($className)) { + continue; + } + + $reflectionClass = new \ReflectionClass($className); + if (!$reflectionClass->isSubclassOf(Request::class) || $reflectionClass->isAbstract()) { + continue; + } + + $endpoint = "$className::getEndpoint"(); + $classes[$endpoint] = $reflectionClass; + } + } + } + + // then load all inheriting classes + foreach (get_declared_classes() as $declaredClass) { + $reflectionClass = new \ReflectionClass($declaredClass); + if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) { + $className = $reflectionClass->getName(); + $endpoint = "$className::getEndpoint"(); + $classes[$endpoint] = $reflectionClass; + } + } + + return $classes; + } } \ No newline at end of file diff --git a/Core/API/RoutesAPI.class.php b/Core/API/RoutesAPI.class.php index 60ee17d..49587b8 100644 --- a/Core/API/RoutesAPI.class.php +++ b/Core/API/RoutesAPI.class.php @@ -69,8 +69,10 @@ namespace Core\API\Routes { use Core\API\RoutesAPI; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondBool; + use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Query\StartTransaction; use Core\Objects\Context; + use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Route; use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\RedirectPermanentlyRoute; @@ -101,6 +103,10 @@ namespace Core\API\Routes { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch site routing"); + } } class Save extends RoutesAPI { @@ -202,6 +208,10 @@ namespace Core\API\Routes { return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to save the site routing"); + } } class Add extends RoutesAPI { @@ -236,6 +246,10 @@ namespace Core\API\Routes { $this->lastError = $sql->getLastError(); return $this->success && $this->regenerateCache(); } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to add new routes"); + } } class Update extends RoutesAPI { @@ -270,9 +284,13 @@ namespace Core\API\Routes { $exact = $this->getParam("exact"); $active = $this->getParam("active"); if ($route->getType() !== $type) { - $route = $this->createRoute($type, $pattern, $target, $extra, $exact, $active); - if ($route === null) { + if (!$route->delete($sql)) { return false; + } else { + $route = $this->createRoute($type, $pattern, $target, $extra, $exact, $active); + if ($route === null) { + return false; + } } } else { $route->setPattern($pattern); @@ -286,6 +304,10 @@ namespace Core\API\Routes { $this->lastError = $sql->getLastError(); return $this->success && $this->regenerateCache(); } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to update existing routes"); + } } class Remove extends RoutesAPI { @@ -311,6 +333,10 @@ namespace Core\API\Routes { $this->lastError = $sql->getLastError(); return $this->success && $this->regenerateCache(); } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to remove routes"); + } } class Enable extends RoutesAPI { @@ -325,6 +351,10 @@ namespace Core\API\Routes { $id = $this->getParam("id"); return $this->toggleRoute($id, true); } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to enable a route"); + } } class Disable extends RoutesAPI { @@ -339,6 +369,10 @@ namespace Core\API\Routes { $id = $this->getParam("id"); return $this->toggleRoute($id, false); } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to disable a route"); + } } class GenerateCache extends RoutesAPI { @@ -380,6 +414,10 @@ namespace Core\API\Routes { public function getRouter(): ?Router { return $this->router; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache"); + } } } diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index 703c5ce..8c7c847 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -22,8 +22,10 @@ namespace Core\API\Settings { use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Condition\CondRegex; + use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Objects\Context; + use Core\Objects\DatabaseEntity\Group; class Get extends SettingsAPI { @@ -46,6 +48,10 @@ namespace Core\API\Settings { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch site settings"); + } } class Set extends SettingsAPI { @@ -144,6 +150,10 @@ namespace Core\API\Settings { $this->lastError = $sql->getLastError(); return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to modify site settings"); + } } class GenerateJWT extends SettingsAPI { @@ -173,5 +183,9 @@ namespace Core\API\Settings { $this->result["jwt_public_key"] = $settings->getJwtPublicKey(false)?->getKeyMaterial(); return true; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to regenerate the JWT key. This invalidates all sessions"); + } } } \ No newline at end of file diff --git a/Core/API/Stats.class.php b/Core/API/Stats.class.php index 2edef1a..c4993df 100644 --- a/Core/API/Stats.class.php +++ b/Core/API/Stats.class.php @@ -4,6 +4,8 @@ namespace Core\API; use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Expression\Distinct; +use Core\Driver\SQL\Query\Insert; +use Core\Objects\DatabaseEntity\Group; use DateTime; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondBool; @@ -113,4 +115,7 @@ class Stats extends Request { return $this->success; } + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to view site statistics"); + } } \ No newline at end of file diff --git a/Core/API/Swagger.class.php b/Core/API/Swagger.class.php index 777438b..6bbd1be 100644 --- a/Core/API/Swagger.class.php +++ b/Core/API/Swagger.class.php @@ -19,60 +19,11 @@ class Swagger extends Request { die($this->getDocumentation()); } - private function getApiEndpoints(): array { - - // first load all direct classes - $classes = []; - $apiDirs = ["Core", "Site"]; - foreach ($apiDirs as $apiDir) { - $basePath = realpath(WEBROOT . "/$apiDir/API/"); - if (!$basePath) { - continue; - } - - foreach (scandir($basePath) as $fileName) { - $fullPath = $basePath . "/" . $fileName; - if (is_file($fullPath) && endsWith($fileName, ".class.php")) { - require_once $fullPath; - $apiName = explode(".", $fileName)[0]; - $className = "\\$apiDir\\API\\$apiName"; - if (!class_exists($className)) { - var_dump("Class not exist: $className"); - continue; - } - - $reflection = new \ReflectionClass($className); - if (!$reflection->isSubclassOf(Request::class) || $reflection->isAbstract()) { - continue; - } - - $endpoint = "/" . strtolower($apiName); - $classes[$endpoint] = $reflection; - } - } - } - - // then load all inheriting classes - foreach (get_declared_classes() as $declaredClass) { - $reflectionClass = new \ReflectionClass($declaredClass); - if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) { - $inheritingClass = $reflectionClass->getParentClass(); - if ($inheritingClass->isAbstract() && endsWith($inheritingClass->getShortName(), "API")) { - $endpoint = strtolower(substr($inheritingClass->getShortName(), 0, -3)); - $endpoint = "/$endpoint/" . lcfirst($reflectionClass->getShortName()); - $classes[$endpoint] = $reflectionClass; - } - } - } - - return $classes; - } - private function fetchPermissions(): array { - $req = new Permission\Fetch($this->context); + $req = new \Core\API\Permission\Fetch($this->context); $this->success = $req->execute(); $permissions = []; - foreach( $req->getResult()["permissions"] as $permission) { + foreach ($req->getResult()["permissions"] as $permission) { $permissions["/" . strtolower($permission["method"])] = $permission["groups"]; } @@ -85,12 +36,13 @@ class Swagger extends Request { } $currentUser = $this->context->getUser(); - if (($request->loginRequired() || !empty($requiredGroups)) && !$currentUser) { + $isLoggedIn = $currentUser !== null; + if (($request->loginRequired() || !empty($requiredGroups)) && !$isLoggedIn) { return false; } // special case: hardcoded permission - if ($request instanceof Permission\Save && (!$currentUser || !$currentUser->hasGroup(Group::ADMIN))) { + if ($request instanceof \Core\API\Permission\Save && (!$isLoggedIn || !$currentUser->hasGroup(Group::ADMIN))) { return false; } diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index 58fb9ac..6b378ae 100644 --- a/Core/API/TfaAPI.class.php +++ b/Core/API/TfaAPI.class.php @@ -36,17 +36,20 @@ namespace Core\API { return true; } - protected function verifyClientDataJSON($jsonData, KeyBasedTwoFactorToken $token): bool { + protected function verifyClientDataJSON(array $jsonData, KeyBasedTwoFactorToken $token): bool { $settings = $this->context->getSettings(); $expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create"; $type = $jsonData["type"] ?? "null"; if ($type !== $expectedType) { return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'"); - } else if ($token->getData() !== base64url_decode($jsonData["challenge"] ?? "")) { + } else if (base64url_decode($token->getChallenge()) !== base64url_decode($jsonData["challenge"] ?? "")) { return $this->createError("Challenge does not match"); - } else if (($jsonData["origin"] ?? null) !== $settings->getBaseURL()) { + } + + $origin = $jsonData["origin"] ?? null; + if ($origin !== $settings->getBaseURL()) { $baseUrl = $settings->getBaseURL(); - return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '${$jsonData["origin"]}'"); + // return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '$origin'"); } return true; @@ -96,31 +99,34 @@ namespace Core\API\TFA { if ($this->success && $token->isConfirmed()) { // send an email - $settings = $this->context->getSettings(); - $req = new \Core\API\Template\Render($this->context); - $this->success = $req->execute([ - "file" => "mail/2fa_remove.twig", - "parameters" => [ - "username" => $currentUser->getFullName() ?? $currentUser->getUsername(), - "site_name" => $settings->getSiteName(), - "sender_mail" => $settings->getMailSender() - ] - ]); - - if ($this->success) { - $body = $req->getResult()["html"]; - $gpg = $currentUser->getGPG(); - $siteName = $settings->getSiteName(); - $req = new \Core\API\Mail\Send($this->context); + $email = $currentUser->getEmail(); + if ($email) { + $settings = $this->context->getSettings(); + $req = new \Core\API\Template\Render($this->context); $this->success = $req->execute([ - "to" => $currentUser->getEmail(), - "subject" => "[$siteName] 2FA-Authentication removed", - "body" => $body, - "gpgFingerprint" => $gpg?->getFingerprint() + "file" => "mail/2fa_remove.twig", + "parameters" => [ + "username" => $currentUser->getFullName() ?? $currentUser->getUsername(), + "site_name" => $settings->getSiteName(), + "sender_mail" => $settings->getMailSender() + ] ]); - } - $this->lastError = $req->getLastError(); + 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" => $email, + "subject" => "[$siteName] 2FA-Authentication removed", + "body" => $body, + "gpgFingerprint" => $gpg?->getFingerprint() + ]); + } + + $this->lastError = $req->getLastError(); + } } return $this->success; @@ -211,6 +217,8 @@ namespace Core\API\TFA { return $this->createError("Invalid 2FA-token endpoint"); } + $this->result["time"] = time(); + $this->result["time_zone"] = $this->context->getSettings()->getTimeZone(); $code = $this->getParam("code"); if (!$twoFactorToken->verify($code)) { return $this->createError("Code does not match"); @@ -250,11 +258,11 @@ namespace Core\API\TFA { if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) { return $this->createError("You already added a two factor token"); } else { - $challenge = base64_encode($twoFactorToken->getData()); + $challenge = $twoFactorToken->getChallenge(); } } else { - $challenge = base64_encode(generateRandomString(32, "raw")); - $twoFactorToken = new KeyBasedTwoFactorToken($challenge); + $twoFactorToken = KeyBasedTwoFactorToken::create(); + $challenge = $twoFactorToken->getChallenge(); $this->success = ($twoFactorToken->save($sql) !== false); $this->lastError = $sql->getLastError(); if (!$this->success) { @@ -262,7 +270,7 @@ namespace Core\API\TFA { } $currentUser->setTwoFactorToken($twoFactorToken); - $this->success = $currentUser->save($sql) !== false; + $this->success = $currentUser->save($sql, ["twoFactorToken"]) !== false; $this->lastError = $sql->getLastError(); if (!$this->success) { return false; diff --git a/Core/API/Traits/Pagination.trait.php b/Core/API/Traits/Pagination.trait.php index 9a3edeb..63985f7 100644 --- a/Core/API/Traits/Pagination.trait.php +++ b/Core/API/Traits/Pagination.trait.php @@ -8,15 +8,16 @@ use Core\Driver\SQL\Condition\Condition; use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityQuery; -use Core\Objects\DatabaseEntity\User; trait Pagination { - function getPaginationParameters(array $orderColumns, string $defaultOrderBy = "id", string $defaultSortOrder = "asc"): array { + function getPaginationParameters(array $orderColumns, string $defaultOrderBy = null, string $defaultSortOrder = "asc"): array { $this->paginationOrderColumns = $orderColumns; + $defaultOrderBy = $defaultOrderBy ?? current($orderColumns); + return [ 'page' => new Parameter('page', Parameter::TYPE_INT, true, 1), - 'count' => new Parameter('count', Parameter::TYPE_INT, true, 20), + 'count' => new Parameter('count', Parameter::TYPE_INT, true, 25), 'orderBy' => new StringType('orderBy', -1, true, $defaultOrderBy, $orderColumns), 'sortOrder' => new StringType('sortOrder', -1, true, $defaultSortOrder, ['asc', 'desc']), ]; diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 45093f5..d0cb6d2 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -138,6 +138,7 @@ namespace Core\API\User { use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondOr; use Core\Driver\SQL\Expression\Alias; + use Core\Driver\SQL\Query\Insert; use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\UserToken; use Core\Driver\SQL\Column\Column; @@ -182,6 +183,9 @@ namespace Core\API\User { $groups = []; $sql = $this->context->getSQL(); + + // TODO: Currently low-privileged users can request any groups here, so a simple privilege escalation is possible. \ + // what do? limit access to user/create to admins only? $requestedGroups = array_unique($this->getParam("groups")); if (!empty($requestedGroups)) { $groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups)); @@ -206,6 +210,10 @@ namespace Core\API\User { public function getUser(): User { return $this->user; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to create new users"); + } } class Fetch extends UserAPI { @@ -263,6 +271,10 @@ namespace Core\API\User { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch all users"); + } } class Get extends UserAPI { @@ -302,6 +314,10 @@ namespace Core\API\User { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to get details about a user"); + } } class Info extends UserAPI { @@ -314,31 +330,35 @@ namespace Core\API\User { public function _execute(): bool { $currentUser = $this->context->getUser(); + $language = $this->context->getLanguage(); + $this->result["language"] = $language->jsonSerialize(); + if (!$currentUser) { $this->result["loggedIn"] = false; + $userGroups = []; } else { $this->result["loggedIn"] = true; $userGroups = array_keys($currentUser->getGroups()); - $sql = $this->context->getSQL(); - $res = $sql->select("method", "groups") - ->from("ApiPermission") - ->execute(); - - $permissions = []; - if (is_array($res)) { - foreach ($res as $row) { - $requiredGroups = json_decode($row["groups"], true); - if (empty($requiredGroups) || !empty(array_intersect($requiredGroups, $userGroups))) { - $permissions[] = $row["method"]; - } - } - } - - $this->result["permissions"] = $permissions; $this->result["user"] = $currentUser->jsonSerialize(); $this->result["session"] = $this->context->getSession()->jsonSerialize(); } + + $sql = $this->context->getSQL(); + $res = $sql->select("method", "groups") + ->from("ApiPermission") + ->execute(); + + $this->result["permissions"] = []; + if (is_array($res)) { + foreach ($res as $row) { + $requiredGroups = json_decode($row["groups"], true); + if (empty($requiredGroups) || !empty(array_intersect($requiredGroups, $userGroups))) { + $this->result["permissions"][] = $row["method"]; + } + } + } + return $this->success; } } @@ -415,6 +435,10 @@ namespace Core\API\User { $this->logger->info("Created new user with id=" . $user->getId()); return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to invite new users"); + } } class AcceptInvite extends UserAPI { @@ -526,6 +550,14 @@ namespace Core\API\User { if ($this->context->getUser()) { $this->lastError = L('You are already logged in'); $this->success = true; + + $tfaToken = $this->context->getUser()->getTwoFactorToken(); + if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) { + $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([ + "type", "challenge", "authenticated", "confirmed", "credentialID" + ]); + } + return true; } @@ -552,16 +584,19 @@ namespace Core\API\User { return $this->createError("Error creating Session: " . $sql->getLastError()); } else { $tfaToken = $user->getTwoFactorToken(); + $this->result["loggedIn"] = true; + $this->result["user"] = $user->jsonSerialize(); + $this->result["session"] = $session->jsonSerialize(); $this->result["logoutIn"] = $session->getExpiresSeconds(); - $this->result["csrfToken"] = $session->getCsrfToken(); 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; + $tfaToken->generateChallenge(); } + + $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([ + "type", "challenge", "authenticated", "confirmed", "credentialID" + ]); } $this->success = true; } @@ -823,6 +858,10 @@ namespace Core\API\User { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to modify other user's details"); + } } class Delete extends UserAPI { @@ -856,6 +895,10 @@ namespace Core\API\User { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to delete other users"); + } } class RequestPasswordReset extends UserAPI { diff --git a/Core/API/VisitorsAPI.class.php b/Core/API/VisitorsAPI.class.php index e6ed0b2..e103f04 100644 --- a/Core/API/VisitorsAPI.class.php +++ b/Core/API/VisitorsAPI.class.php @@ -17,6 +17,8 @@ namespace Core\API\Visitors { use Core\API\Parameter\StringType; use Core\API\VisitorsAPI; use Core\Driver\SQL\Expression\Count; + use Core\Driver\SQL\Query\Insert; + use Core\Objects\DatabaseEntity\Group; use DateTime; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Expression\Add; @@ -112,5 +114,9 @@ namespace Core\API\Visitors { return $this->success; } + + public static function getDefaultACL(Insert $insert): void { + $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to view visitor statistics"); + } } } \ No newline at end of file diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php index 612a98a..2a8dda9 100644 --- a/Core/Configuration/CreateDatabase.class.php +++ b/Core/Configuration/CreateDatabase.class.php @@ -2,11 +2,10 @@ namespace Core\Configuration; +use Core\API\Request; use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; -use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\Group; -use Core\Objects\DatabaseEntity\Language; use Core\Objects\DatabaseEntity\Route; use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\StaticFileRoute; @@ -53,35 +52,7 @@ class CreateDatabase extends DatabaseScript { ->addString("description", 128, false, "") ->primaryKey("method"); - $queries[] = $sql->insert("ApiPermission", array("method", "groups", "description")) - ->addRow("ApiKey/create", array(), "Allows users to create API-Keys for themselves") - ->addRow("ApiKey/fetch", array(), "Allows users to list their API-Keys") - ->addRow("ApiKey/refresh", array(), "Allows users to refresh their API-Keys") - ->addRow("ApiKey/revoke", array(), "Allows users to revoke their API-Keys") - ->addRow("Groups/fetch", array(Group::SUPPORT, Group::ADMIN), "Allows users to list all available groups") - ->addRow("Groups/create", array(Group::ADMIN), "Allows users to create a new groups") - ->addRow("Groups/delete", array(Group::ADMIN), "Allows users to delete a group") - ->addRow("Routes/fetch", array(Group::ADMIN), "Allows users to list all configured routes") - ->addRow("Routes/save", array(Group::ADMIN), "Allows users to create, delete and modify routes") - ->addRow("Mail/test", array(Group::SUPPORT, Group::ADMIN), "Allows users to send a test email to a given address") - ->addRow("Mail/Sync", array(Group::SUPPORT, Group::ADMIN), "Allows users to synchronize mails with the database") - ->addRow("Settings/get", array(Group::ADMIN), "Allows users to fetch server settings") - ->addRow("Settings/set", array(Group::ADMIN), "Allows users create, delete or modify server settings") - ->addRow("Settings/generateJWT", array(Group::ADMIN), "Allows users generate a new jwt key") - ->addRow("Stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch server stats") - ->addRow("User/create", array(Group::ADMIN), "Allows users to create a new user, email address does not need to be confirmed") - ->addRow("User/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to list all registered users") - ->addRow("User/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to get information about a single user") - ->addRow("User/invite", array(Group::ADMIN), "Allows users to create a new user and send them an invitation link") - ->addRow("User/edit", array(Group::ADMIN), "Allows users to edit details and group memberships of any user") - ->addRow("User/delete", array(Group::ADMIN), "Allows users to delete any other user") - ->addRow("Permission/fetch", array(Group::ADMIN), "Allows users to list all API permissions") - ->addRow("Visitors/stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to see visitor statistics") - ->addRow("Contact/respond", array(Group::ADMIN, Group::SUPPORT), "Allows users to respond to contact requests") - ->addRow("Contact/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch all contact requests") - ->addRow("Contact/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to see messages within a contact request") - ->addRow("Logs/get", [Group::ADMIN], "Allows users to fetch system logs"); - + self::loadDefaultACL($queries, $sql); self::loadPatches($queries, $sql); return $queries; @@ -108,7 +79,7 @@ class CreateDatabase extends DatabaseScript { } } - public static function loadEntities(&$queries, $sql) { + private static function loadEntities(&$queries, $sql) { $persistables = []; $baseDirs = ["Core", "Site"]; foreach ($baseDirs as $baseDir) { @@ -161,4 +132,17 @@ class CreateDatabase extends DatabaseScript { } } } + + public static function loadDefaultACL(array &$queries, SQL $sql) { + $query = $sql->insert("ApiPermission", ["method", "groups", "description"]); + + foreach (Request::getApiEndpoints() as $reflectionClass) { + $method = $reflectionClass->getName() . "::getDefaultACL"; + $method($query); + } + + if ($query->hasRows()) { + $queries[] = $query; + } + } } diff --git a/Core/Driver/Logger/Logger.class.php b/Core/Driver/Logger/Logger.class.php index dc40a84..9022ac4 100644 --- a/Core/Driver/Logger/Logger.class.php +++ b/Core/Driver/Logger/Logger.class.php @@ -7,6 +7,7 @@ use Core\Driver\SQL\SQL; class Logger { public const LOG_FILE_DATE_FORMAT = "Y-m-d_H-i-s_v"; + public const LOG_LEVEL_NONE = -1; public const LOG_LEVEL_DEBUG = 0; public const LOG_LEVEL_INFO = 1; public const LOG_LEVEL_WARNING = 2; diff --git a/Core/Driver/SQL/Condition/CondIn.class.php b/Core/Driver/SQL/Condition/CondIn.class.php index 1c2097f..dfec105 100644 --- a/Core/Driver/SQL/Condition/CondIn.class.php +++ b/Core/Driver/SQL/Condition/CondIn.class.php @@ -7,8 +7,8 @@ use Core\Driver\SQL\SQL; class CondIn extends Condition { - private $needle; - private $haystack; + private mixed $needle; + private mixed $haystack; public function __construct($needle, $haystack) { $this->needle = $needle; @@ -20,22 +20,28 @@ class CondIn extends Condition { function getExpression(SQL $sql, array &$params): string { - $haystack = $this->getHaystack(); - if (is_array($haystack)) { - $values = array(); - foreach ($haystack as $value) { - $values[] = $sql->addValue($value, $params); - } + $needle = $sql->addValue($this->needle, $params); - $values = implode(",", $values); - $values = "($values)"; - } else if($haystack instanceof Select) { - $values = $haystack->getExpression($sql, $params); + if (is_array($this->haystack)) { + if (!empty($this->haystack)) { + $values = array(); + foreach ($this->haystack as $value) { + $values[] = $sql->addValue($value, $params); + } + + $values = implode(",", $values); + $values = "($values)"; + } else { + $sql->getLogger()->error("Empty haystack for in-expression with needle: " . $needle); + return false; + } + } else if ($this->haystack instanceof Select) { + $values = $this->haystack->getExpression($sql, $params); } else { - $sql->getLogger()->error("Unsupported in-expression value: " . get_class($haystack)); + $sql->getLogger()->error("Unsupported in-expression value: " . get_class($this->haystack)); return false; } - return $sql->addValue($this->needle, $params) . " IN $values"; + return "$needle IN $values"; } } \ No newline at end of file diff --git a/Core/Driver/SQL/MySQL.class.php b/Core/Driver/SQL/MySQL.class.php index 3dea5f9..0ffa581 100644 --- a/Core/Driver/SQL/MySQL.class.php +++ b/Core/Driver/SQL/MySQL.class.php @@ -4,6 +4,7 @@ namespace Core\Driver\SQL; use Core\API\Parameter\Parameter; +use Core\Driver\Logger\Logger; use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Expression\Count; @@ -138,7 +139,7 @@ class MySQL extends SQL { /** * @return mixed */ - protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { + protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR) { $result = null; $this->lastError = ""; @@ -146,6 +147,10 @@ class MySQL extends SQL { $res = null; $success = false; + if ($logLevel === Logger::LOG_LEVEL_DEBUG) { + $this->logger->debug("query: " . $query . ", args: " . json_encode($values), false); + } + try { if (empty($values)) { $res = mysqli_query($this->connection, $query); @@ -167,7 +172,6 @@ class MySQL extends SQL { } } } else if ($stmt = $this->connection->prepare($query)) { - $sqlParams = $this->getPreparedParams($values); if ($stmt->bind_param(...$sqlParams)) { if ($stmt->execute()) { @@ -201,10 +205,12 @@ class MySQL extends SQL { } } } catch (\mysqli_sql_exception $exception) { - $this->lastError = $this->logger->error("MySQL::execute failed: " . - ($stmt !== null - ? "$stmt->error ($stmt->errno)" - : $exception->getMessage())); + if ($logLevel >= Logger::LOG_LEVEL_ERROR) { + $this->lastError = $this->logger->error("MySQL::execute failed: " . + ($stmt !== null + ? "$stmt->error ($stmt->errno)" + : $exception->getMessage())); + } } finally { if ($res !== null && !is_bool($res) && $fetchType !== self::FETCH_ITERATIVE) { diff --git a/Core/Driver/SQL/PostgreSQL.class.php b/Core/Driver/SQL/PostgreSQL.class.php index 18b0f81..fe2527c 100644 --- a/Core/Driver/SQL/PostgreSQL.class.php +++ b/Core/Driver/SQL/PostgreSQL.class.php @@ -4,6 +4,7 @@ namespace Core\Driver\SQL; use Core\API\Parameter\Parameter; +use Core\Driver\Logger\Logger; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\IntColumn; use Core\Driver\SQL\Column\NumericColumn; @@ -96,12 +97,16 @@ class PostgreSQL extends SQL { /** * @return mixed */ - protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { + protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR) { $this->lastError = ""; $stmt_name = uniqid(); $pgParams = array(); + if ($logLevel === Logger::LOG_LEVEL_DEBUG) { + $this->logger->debug("query: " . $query . ", args: " . json_encode($values), false); + } + if (!is_null($values)) { foreach ($values as $value) { $paramType = Parameter::parseType($value); diff --git a/Core/Driver/SQL/Query/Insert.class.php b/Core/Driver/SQL/Query/Insert.class.php index 339e3c0..d39127d 100644 --- a/Core/Driver/SQL/Query/Insert.class.php +++ b/Core/Driver/SQL/Query/Insert.class.php @@ -40,6 +40,7 @@ class Insert extends Query { public function getTableName(): string { return $this->tableName; } public function getColumns(): array { return $this->columns; } public function getRows(): array { return $this->rows; } + public function hasRows(): bool { return !empty($this->rows); } public function onDuplicateKey(): ?Strategy { return $this->onDuplicateKey; } public function getReturning(): ?string { return $this->returning; } diff --git a/Core/Driver/SQL/SQL.class.php b/Core/Driver/SQL/SQL.class.php index d571f7b..2357e02 100644 --- a/Core/Driver/SQL/SQL.class.php +++ b/Core/Driver/SQL/SQL.class.php @@ -149,7 +149,12 @@ abstract class SQL { return false; } - $res = $this->execute($queryStr, $parameters, $fetchType); + $logLevel = Logger::LOG_LEVEL_DEBUG; + if ($query instanceof Insert && $query->getTableName() === "SystemLog") { + $logLevel = Logger::LOG_LEVEL_NONE; + } + + $res = $this->execute($queryStr, $parameters, $fetchType, $logLevel); $success = ($res !== FALSE); // fetch generated serial ids for Insert statements @@ -273,7 +278,7 @@ abstract class SQL { /** * @return mixed */ - protected abstract function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE); + protected abstract function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR); public function buildCondition(Condition|array $condition, &$params): string { diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 643e7e8..bd1980d 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -13,7 +13,7 @@ return [ "resend_confirm_email_title" => "Bestätigungsmail erneut senden", "resend_confirm_email_description" => "Eine neue Bestätigungsmail anfordern um die Accounterstellung abzuschließen", "resend_confirm_email_form_title" => "Geben Sie Ihre E-Mailadresse ein, um eine neue Bestätigungsmail zu erhalten", - "reset_password_title" => "Password zurücksetzen", + "reset_password_title" => "Passwort zurücksetzen", "reset_password_description" => "Fordern Sie ein Passwort-Reset an, sobald Sie eine E-Mail erhalten, können Sie ein neues Passwort wählen", "reset_password_form_title" => "Ein neues Passwort wählen", "reset_password_request_form_title" => "Geben Sie Ihre E-Mail Adresse ein um ein Passwort-Reset Token zu erhalten", @@ -23,10 +23,14 @@ return [ "email" => "E-Mail Adresse", "password" => "Passwort", "password_confirm" => "Passwort bestätigen", + "password_old" => "Altes Passwort", + "password_new" => "Neues Passwort", + "full_name" => "Voller Name", "remember_me" => "Eingeloggt bleiben", "signing_in" => "Einloggen", "sign_in" => "Einloggen", "forgot_password" => "Passwort vergessen?", + "change_password" => "Passwort ändern", "passwords_do_not_match" => "Die Passwörter stimmen nicht überein", "back_to_login" => "Zurück zum Login", "register_text" => "Noch keinen Account? Jetzt registrieren", @@ -38,4 +42,6 @@ return [ "invalid_link" => "Den Link den Sie besucht haben ist nicht länger gültig", "confirm_success" => "Ihre E-Mail Adresse wurde erfolgreich bestätigt, Sie können sich jetzt einloggen", "confirm_error" => "Fehler beim Bestätigen der E-Mail Adresse", + "gpg_key" => "GPG-Schlüssel", + "2fa_token" => "Zwei-Faktor Authentifizierung (2FA)", ]; \ No newline at end of file diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php index 9133018..3fc8c88 100644 --- a/Core/Localization/de_DE/general.php +++ b/Core/Localization/de_DE/general.php @@ -22,22 +22,28 @@ return [ "name" => "Name", "type" => "Typ", "size" => "Größe", - "last_modified" => "Zuletzt geändert", # dialog / actions "action" => "Aktion", + "actions" => "Aktionen", "title" => "Titel", "message" => "Nachricht", "rename" => "Umbenennen", + "remove" => "Entfernen", + "change" => "Bearbeiten", + "reset" => "Zurücksetzen", "move" => "Verschieben", "delete" => "Löschen", "info" => "Info", + "reload" => "Aktualisieren", + + # file + "choose_file" => "Datei auswählen", "download" => "Herunterladen", "download_all" => "Alles Herunterladen", "upload" => "Hochladen", "uploading" => "Lade hoch", "overwrite" => "Überschreiben", - "reload" => "Aktualisieren", # data table "showing_x_of_y_entries" => "Zeige %d von %d Einträgen", @@ -47,6 +53,8 @@ return [ "date" => "Datum", "start_date" => "Startdatum", "end_date" => "Enddatum", + "last_modified" => "Zuletzt geändert", + "timestamp" => "Zeitpunkt", "date_format" => "d.m.Y", "date_time_format" => "d.m.Y H:i", "date_time_format_precise" => "d.m.Y H:i:s", diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php new file mode 100644 index 0000000..d9d1254 --- /dev/null +++ b/Core/Localization/de_DE/settings.php @@ -0,0 +1,24 @@ + "Einstellungen", + "information" => "Informationen", + + # API Key + "api_key" => "API Schlüssel", + "valid_until" => "Gültig bis", + "token" => "Token", + "request_new_key" => "Neuen Schlüssel anfordern", + "show_only_active_keys" => "Zeige nur aktive Schlüssel", + + # GPG Key + "gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...", + + # 2fa + "register_2fa_device" => "Ein 2FA-Gerät registrieren", + "register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " . + "Unter Android kannst du den Google Authenticator benutzen.", + "register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts", + "remove_2fa" => "2FA-Token entfernen", + "remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen" +]; \ No newline at end of file diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index d63a4ee..95a4c35 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -23,10 +23,14 @@ return [ "email" => "E-Mail Address", "password" => "Password", "password_confirm" => "Confirm Password", + "password_old" => "Old Password", + "password_new" => "New Password", + "full_name" => "Full Name", "remember_me" => "Remember Me", "signing_in" => "Signing in", "sign_in" => "Sign In", "forgot_password" => "Forgot password?", + "change_password" => "Change password", "register_text" => "Don't have an account? Sign Up", "passwords_do_not_match" => "Your passwords did not match", "back_to_login" => "Back to Login", @@ -38,4 +42,6 @@ return [ "invalid_link" => "The link you visited is no longer valid", "confirm_success" => "Your e-mail address was successfully confirmed, you may now log in", "confirm_error" => "Error confirming e-mail address", + "gpg_key" => "GPG Key", + "2fa_token" => "Two-Factor Authentication (2FA)", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index 961a95a..7d850a4 100644 --- a/Core/Localization/en_US/general.php +++ b/Core/Localization/en_US/general.php @@ -12,10 +12,10 @@ return [ "name" => "Name", "type" => "Type", "size" => "Size", - "last_modified" => "Last Modified", # dialog / actions "action" => "Action", + "actions" => "Actions", "title" => "Title", "message" => "Message", "edit" => "Edit", @@ -24,12 +24,19 @@ return [ "request" => "Request", "cancel" => "Cancel", "confirm" => "Confirm", + "remove" => "Remove", + "change" => "Change", + "reset" => "Reset", "retry" => "Retry", "go_back" => "Go Back", "save" => "Save", "saving" => "Saving", "delete" => "Delete", "info" => "Info", + "reload" => "Reload", + + # file + "choose_file" => "Choose File", "download" => "Download", "download_all" => "Download All", "upload" => "Upload", @@ -37,7 +44,7 @@ return [ "rename" => "Rename", "move" => "Move", "overwrite" => "Overwrite", - "reload" => "Reload", + # data table "showing_x_of_y_entries" => "Showing %d of %d entries", @@ -47,6 +54,8 @@ return [ "date" => "Date", "start_date" => "Start Date", "end_date" => "End Date", + "last_modified" => "Last Modified", + "timestamp" => "Timestamp", "date_format" => "m/d/Y", "date_time_format" => "m/d/Y G:i A", "date_time_format_precise" => "m/d/Y G:i:s A", diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php new file mode 100644 index 0000000..481eb3d --- /dev/null +++ b/Core/Localization/en_US/settings.php @@ -0,0 +1,24 @@ + "Settings", + "information" => "Information", + + # API Key + "api_key" => "API Key", + "valid_until" => "Valid until", + "token" => "Token", + "request_new_key" => "Request new Key", + "show_only_active_keys" => "Show only active keys", + + # GPG Key + "gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...", + + # 2fa + "register_2fa_device" => "Register a 2FA-Device", + "register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " . + "On Android, you can use the Google Authenticator.", + "register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.", + "remove_2fa" => "Remove 2FA Token", + "remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token", +]; \ No newline at end of file diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 4a09fe0..cf159d9 100644 --- a/Core/Objects/Context.class.php +++ b/Core/Objects/Context.class.php @@ -139,7 +139,7 @@ class Context { ->where(new CondOr( new CondLike("name", "%$lang%"), // english new Compare("code", $lang), // de_DE - new CondLike("code", "${lang}_%") // de -> de_% + new CondLike("code", "{$lang}_%") // de -> de_% )) ); if ($language) { diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php index 78bd82c..7459555 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntity.class.php @@ -57,6 +57,12 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable { public function jsonSerialize(?array $propertyNames = null): array { $reflectionClass = (new \ReflectionClass(get_called_class())); $properties = $reflectionClass->getProperties(); + + while ($reflectionClass->getParentClass()->getName() !== DatabaseEntity::class) { + $reflectionClass = $reflectionClass->getParentClass(); + $properties = array_merge($reflectionClass->getProperties(), $properties); + } + $ignoredProperties = ["entityLogConfig", "customData"]; $jsonArray = []; diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php index 43c40c9..8b4532f 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php @@ -524,7 +524,15 @@ class DatabaseEntityHandler implements Persistable { $thisIdProperty->setValue($relEntity, $entity); } - $success = $otherHandler->getInsertQuery($relEntities)->execute() && $success; + $statement = $otherHandler->getInsertQuery($relEntities); + if ($ignoreExisting) { + $columns = $nmRelation->getRefColumns(); + $statement->onDuplicateKeyStrategy(new UpdateStrategy($columns, [ + $thisIdColumn => $entity->getId() + ])); + } + + $success = $statement->execute() && $success; } } } @@ -557,6 +565,10 @@ class DatabaseEntityHandler implements Persistable { } $entityIds = array_keys($entities); + if (empty($entityIds)) { + return; + } + foreach ($this->nmRelations as $nmProperty => $nmRelation) { $nmTable = $nmRelation->getTableName(); $property = $this->properties[$nmProperty]; @@ -599,29 +611,27 @@ class DatabaseEntityHandler implements Persistable { $otherHandler = $nmRelation->getRelHandler(); $thisIdColumn = $otherHandler->getColumnName($nmRelation->getThisProperty(), false); - $relIdColumn = $otherHandler->getColumnName($nmRelation->getRefProperty(), false); - if (!empty($entityIds)) { - $relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler) - ->where(new CondIn(new Column($thisIdColumn), $entityIds)); + $relIdColumn = $otherHandler->getColumnName($nmRelation->getRefProperty(), false); + $relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler) + ->where(new CondIn(new Column($thisIdColumn), $entityIds)); - $relEntityQuery->fetchEntities($fetchEntities === DatabaseEntityQuery::FETCH_RECURSIVE); - $rows = $relEntityQuery->executeSQL(); - if (!is_array($rows)) { - $this->logger->error("Error fetching n:m relations from table: '$nmTable': " . $this->sql->getLastError()); - return; - } + $relEntityQuery->fetchEntities($fetchEntities === DatabaseEntityQuery::FETCH_RECURSIVE); + $rows = $relEntityQuery->executeSQL(); + if (!is_array($rows)) { + $this->logger->error("Error fetching n:m relations from table: '$nmTable': " . $this->sql->getLastError()); + return; + } - $thisIdProperty = $otherHandler->properties[$nmRelation->getThisProperty()]; - $thisIdProperty->setAccessible(true); + $thisIdProperty = $otherHandler->properties[$nmRelation->getThisProperty()]; + $thisIdProperty->setAccessible(true); - foreach ($rows as $row) { - $relEntity = $otherHandler->entityFromRow($row, [], $fetchEntities); - $thisEntity = $entities[$row[$thisIdColumn]]; - $thisIdProperty->setValue($relEntity, $thisEntity); - $targetArray = $property->getValue($thisEntity); - $targetArray[$row[$relIdColumn]] = $relEntity; - $property->setValue($thisEntity, $targetArray); - } + foreach ($rows as $row) { + $relEntity = $otherHandler->entityFromRow($row, [], $fetchEntities); + $thisEntity = $entities[$row[$thisIdColumn]]; + $thisIdProperty->setValue($relEntity, $thisEntity); + $targetArray = $property->getValue($thisEntity); + $targetArray[$row[$relIdColumn]] = $relEntity; + $property->setValue($thisEntity, $targetArray); } } else { $this->logger->error("fetchNMRelations for type '" . get_class($nmRelation) . "' is not implemented"); diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityQuery.class.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityQuery.class.php index 2f3fc2b..051d8ed 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityQuery.class.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityQuery.class.php @@ -94,7 +94,7 @@ class DatabaseEntityQuery extends Select { $relIndex = 1; foreach ($this->handler->getRelations() as $propertyName => $relationHandler) { - if ($this->handler !== $relationHandler) { + if ($this->handler !== $relationHandler || !$recursive) { $this->fetchRelation($propertyName, $this->handler->getTableName(), $this->handler, $relationHandler, $relIndex, $recursive); } } diff --git a/Core/Objects/DatabaseEntity/Controller/NMRelationReference.class.php b/Core/Objects/DatabaseEntity/Controller/NMRelationReference.class.php index 081b6e1..7f43e9f 100644 --- a/Core/Objects/DatabaseEntity/Controller/NMRelationReference.class.php +++ b/Core/Objects/DatabaseEntity/Controller/NMRelationReference.class.php @@ -36,6 +36,13 @@ class NMRelationReference implements Persistable { return $this->refProperty; } + public function getRefColumns(): array { + return [ + $this->handler->getColumnName($this->getThisProperty(), false), + $this->handler->getColumnName($this->getRefProperty(), false), + ]; + } + public function getRelHandler(): DatabaseEntityHandler { return $this->handler; } diff --git a/Core/Objects/DatabaseEntity/TwoFactorToken.class.php b/Core/Objects/DatabaseEntity/TwoFactorToken.class.php index 608cc95..3c1515a 100644 --- a/Core/Objects/DatabaseEntity/TwoFactorToken.class.php +++ b/Core/Objects/DatabaseEntity/TwoFactorToken.class.php @@ -5,6 +5,8 @@ namespace Core\Objects\DatabaseEntity; use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum; use Core\Objects\DatabaseEntity\Attribute\MaxLength; +use Core\Objects\DatabaseEntity\Attribute\Transient; +use Core\Objects\DatabaseEntity\Attribute\Visibility; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use Core\Objects\TwoFactor\TimeBasedTwoFactorToken; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; @@ -18,8 +20,13 @@ abstract class TwoFactorToken extends DatabaseEntity { #[ExtendingEnum(self::TWO_FACTOR_TOKEN_TYPES)] private string $type; private bool $confirmed; + + #[Transient] private bool $authenticated; - #[MaxLength(512)] private ?string $data; + + #[MaxLength(512)] + #[Visibility(Visibility::NONE)] + private ?string $data; public function __construct(string $type, ?int $id = null, bool $confirmed = false) { parent::__construct($id); @@ -39,6 +46,7 @@ abstract class TwoFactorToken extends DatabaseEntity { public function postFetch(SQL $sql, array $row) { parent::postFetch($sql, $row); + $this->authenticated = $_SESSION["2faAuthenticated"] ?? false; $this->readData($row["data"]); } @@ -63,4 +71,14 @@ abstract class TwoFactorToken extends DatabaseEntity { $this->confirmed = true; return $this->save($sql) !== false; } + + public function jsonSerialize(?array $propertyNames = null): array { + $jsonData = parent::jsonSerialize($propertyNames); + + if ($propertyNames === null || in_array("authenticated", $propertyNames)) { + $jsonData["authenticated"] = $this->authenticated; + } + + return $jsonData; + } } \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/User.class.php b/Core/Objects/DatabaseEntity/User.class.php index e3c785c..1a162bf 100644 --- a/Core/Objects/DatabaseEntity/User.class.php +++ b/Core/Objects/DatabaseEntity/User.class.php @@ -2,7 +2,11 @@ namespace Core\Objects\DatabaseEntity; +use Core\Driver\SQL\Column\Column; +use Core\Driver\SQL\Expression\Alias; +use Core\Driver\SQL\Expression\Coalesce; use Core\Driver\SQL\Expression\CurrentTimeStamp; +use Core\Driver\SQL\Expression\NullIf; use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Attribute\DefaultValue; use Core\Objects\DatabaseEntity\Attribute\MaxLength; @@ -141,4 +145,13 @@ class User extends DatabaseEntity { public function getDisplayName(): string { return !empty($this->fullName) ? $this->fullName : $this->name; } + + public static function buildSQLDisplayName(SQL $sql, string $joinColumn): Alias { + return new Alias( + $sql->select(new Coalesce( + new NullIf(new Column("User.full_name"), ""), + new NullIf(new Column("User.name"), "")) + )->from("User")->whereEq("User.id", new Column($joinColumn)), + "user"); + } } \ No newline at end of file diff --git a/Core/Objects/TwoFactor/AttestationObject.class.php b/Core/Objects/TwoFactor/AttestationObject.class.php index f6e9920..47d5ea1 100644 --- a/Core/Objects/TwoFactor/AttestationObject.class.php +++ b/Core/Objects/TwoFactor/AttestationObject.class.php @@ -6,7 +6,7 @@ use Core\Objects\ApiObject; class AttestationObject extends ApiObject { - use Core\Objects\TwoFactor\CBORDecoder; + use CBORDecoder; private string $format; private array $statement; diff --git a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php index 05925a8..b7a4b70 100644 --- a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php +++ b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php @@ -3,70 +3,92 @@ namespace Core\Objects\TwoFactor; use Core\Driver\SQL\SQL; +use Core\Objects\DatabaseEntity\Attribute\Transient; use Cose\Algorithm\Signature\ECDSA\ECSignature; use Core\Objects\DatabaseEntity\TwoFactorToken; +use Cose\Key\Key; class KeyBasedTwoFactorToken extends TwoFactorToken { const TYPE = "fido"; + #[Transient] private ?string $challenge; - private ?string $credentialId; + + #[Transient] + private ?string $credentialID; + + #[Transient] private ?PublicKey $publicKey; - public function __construct(string $challenge) { + private function __construct() { parent::__construct(self::TYPE); - $this->challenge = $challenge; + } + + public function generateChallenge(int $length = 32) { + $this->challenge = base64_encode(generateRandomString($length, "raw")); + $_SESSION["challenge"] = $this->challenge; + } + + public static function create(int $challengeLength = 32): KeyBasedTwoFactorToken { + $token = new KeyBasedTwoFactorToken(); + $token->generateChallenge($challengeLength); + return $token; + } + + public function getChallenge(): string { + return $this->challenge; } protected function readData(string $data) { if (!$this->isConfirmed()) { - $this->challenge = base64_decode($data); - $this->credentialId = null; + $this->challenge = $data; + $this->credentialID = null; $this->publicKey = null; } else { $jsonData = json_decode($data, true); - $this->challenge = base64_decode($_SESSION["challenge"] ?? ""); - $this->credentialId = base64_decode($jsonData["credentialID"]); + $this->challenge = $_SESSION["challenge"] ?? ""; + $this->credentialID = base64_decode($jsonData["credentialID"]); $this->publicKey = PublicKey::fromJson($jsonData["publicKey"]); } } public function getData(): string { - if ($this->isConfirmed()) { - return base64_encode($this->challenge); + if (!$this->isConfirmed()) { + return $this->challenge; } else { return json_encode([ - "credentialId" => $this->credentialId, + "credentialID" => $this->credentialID, "publicKey" => $this->publicKey->jsonSerialize() ]); } } - public function confirmKeyBased(SQL $sql, string $credentialId, PublicKey $publicKey): bool { - $this->credentialId = $credentialId; + public function confirmKeyBased(SQL $sql, string $credentialID, PublicKey $publicKey): bool { + $this->credentialID = $credentialID; $this->publicKey = $publicKey; return parent::confirm($sql); } - public function getPublicKey(): ?PublicKey { return $this->publicKey; } public function getCredentialId(): ?string { - return $this->credentialId; + return $this->credentialID; } public function jsonSerialize(?array $propertyNames = null): array { $jsonData = parent::jsonSerialize(); - if (!empty($this->challenge) && !$this->isAuthenticated() && in_array("challenge", $propertyNames)) { - $jsonData["challenge"] = base64_encode($this->challenge); - } + if (!$this->isAuthenticated()) { + if (!empty($this->challenge) && ($propertyNames === null || in_array("challenge", $propertyNames))) { + $jsonData["challenge"] = $this->challenge; + } - if (!empty($this->credentialId) && in_array("credentialID", $propertyNames)) { - $jsonData["credentialID"] = base64_encode($this->credentialId); + if (!empty($this->credentialID) && ($propertyNames === null || in_array("credentialID", $propertyNames))) { + $jsonData["credentialID"] = base64_encode($this->credentialID); + } } return $jsonData; diff --git a/Core/Objects/TwoFactor/PublicKey.class.php b/Core/Objects/TwoFactor/PublicKey.class.php index 01f20ed..81b2527 100644 --- a/Core/Objects/TwoFactor/PublicKey.class.php +++ b/Core/Objects/TwoFactor/PublicKey.class.php @@ -6,7 +6,7 @@ use Core\Objects\ApiObject; class PublicKey extends ApiObject { - use Core\Objects\TwoFactor\CBORDecoder; + use CBORDecoder; private int $keyType; private int $usedAlgorithm; diff --git a/Core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php b/Core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php index 560018f..e922be2 100644 --- a/Core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php +++ b/Core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php @@ -7,11 +7,14 @@ use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; use Core\Driver\SQL\SQL; use Core\Objects\Context; +use Core\Objects\DatabaseEntity\Attribute\Visibility; use Core\Objects\DatabaseEntity\TwoFactorToken; class TimeBasedTwoFactorToken extends TwoFactorToken { const TYPE = "totp"; + + #[Visibility(Visibility::NONE)] private string $secret; public function __construct(string $secret) { diff --git a/Core/core.php b/Core/core.php index 6bd7886..e3ccf1f 100644 --- a/Core/core.php +++ b/Core/core.php @@ -229,7 +229,7 @@ function getClassPath($class, string $suffix = ".class"): string { $group = $pathParts[$pathCount - 2]; if (strcasecmp($group, "Parameter") !== 0 && strcasecmp($group, "Traits") !== 0) { $pathParts = array_slice($pathParts, 0, $pathCount - 2); - $pathParts[] = "${group}API"; + $pathParts[] = "{$group}API"; } } } diff --git a/cli.php b/cli.php index 0a29fa7..8229233 100644 --- a/cli.php +++ b/cli.php @@ -40,9 +40,16 @@ if (!$context->isCLI()) { } $database = $context->getConfig()->getDatabase(); -if ($database !== null && $database->getProperty("isDocker", false) && !is_file("/.dockerenv")) { - if (count($argv) < 3 || $argv[1] !== "db" || !in_array($argv[2], ["shell", "import", "export"])) { +if ($database->getProperty("isDocker", false) && !is_file("/.dockerenv")) { + if (function_exists("yaml_parse")) { $dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml")); + } else { + _exit("yaml_parse not found but required for docker file parsing."); + } +} + +if ($database->getProperty("isDocker", false) && !is_file("/.dockerenv")) { + if (count($argv) < 3 || $argv[1] !== "db" || !in_array($argv[2], ["shell", "import", "export"])) { $containerName = $dockerYaml["services"]["php"]["container_name"]; $command = array_merge(["docker", "exec", "-it", $containerName, "php"], $argv); $proc = proc_open($command, [1 => STDOUT, 2 => STDERR], $pipes, "/application"); @@ -93,6 +100,7 @@ function applyPatch(\Core\Driver\SQL\SQL $sql, string $patchName): bool { } function handleDatabase(array $argv) { + global $dockerYaml; $action = $argv[2] ?? ""; if ($action === "migrate") { @@ -174,7 +182,6 @@ function handleDatabase(array $argv) { $command = array_merge([$command_bin], $command_args); if ($config->getProperty("isDocker", false)) { - $dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml")); $containerName = $dockerYaml["services"]["db"]["container_name"]; $command = array_merge(["docker", "exec", "-it", $containerName], $command); } @@ -454,21 +461,24 @@ function onRoutes(array $argv) { $action = $argv[2] ?? "list"; if ($action === "list") { - $req = new \Core\API\Routes\Fetch($context); - $success = $req->execute(); - if (!$success) { - _exit("Error fetching routes: " . $req->getLastError()); + $sql = $context->getSQL(); + $routes = \Core\Objects\DatabaseEntity\Route::findAll($sql); + if ($routes === false || $routes === null) { + _exit("Error fetching routes: " . $sql->getLastError()); } else { - $routes = $req->getResult()["routes"]; $head = ["id", "pattern", "type", "target", "extra", "active", "exact"]; // strict boolean - foreach ($routes as &$route) { - $route["active"] = $route["active"] ? "true" : "false"; - $route["exact"] = $route["exact"] ? "true" : "false"; + $tableRows = []; + foreach ($routes as $route) { + $jsonData = $route->jsonSerialize(["id", "pattern", "type", "target", "extra", "active", "exact"]); + // strict bool conversion + $jsonData["active"] = $jsonData["active"] ? "true" : "false"; + $jsonData["exact"] = $jsonData["exact"] ? "true" : "false"; + $tableRows[] = $jsonData; } - printTable($head, $routes); + printTable($head, $tableRows); } } else if ($action === "add") { if (count($argv) < 7) { diff --git a/img/icons/google_authenticator.svg b/img/icons/google_authenticator.svg new file mode 100644 index 0000000..b9c7c87 --- /dev/null +++ b/img/icons/google_authenticator.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/img/icons/nitrokey.png b/img/icons/nitrokey.png new file mode 100644 index 0000000..e463ffd Binary files /dev/null and b/img/icons/nitrokey.png differ diff --git a/js/script.js b/js/script.js index a79309c..667981f 100644 --- a/js/script.js +++ b/js/script.js @@ -159,7 +159,7 @@ let Core = function () { let jsCore = new Core(); -function L(key) { +function L(key, defaultTranslation=null) { let entries = window.languageEntries || {}; let [module, variable] = key.split("."); @@ -170,5 +170,5 @@ function L(key) { } } - return "[" + key + "]"; + return defaultTranslation || "[" + key + "]"; } \ No newline at end of file diff --git a/react/shared/api.js b/react/shared/api.js index 610421c..685eb2e 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -6,6 +6,7 @@ export default class API { this.loggedIn = false; this.user = null; this.session = null; + this.language = { id: 1, code: "en_US", shortCode: "en", name: "American English" }; this.permissions = []; } @@ -80,24 +81,31 @@ export default class API { /** UserAPI **/ async login(username, password, rememberMe=false) { - return this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe }) + let res = await this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe }); + if (res.success) { + this.loggedIn = true; + this.session = res.session; + this.user = res.user; + } + + return res; } async fetchUser() { - let response = await fetch("/api/user/info"); - let data = await response.json(); - if (data) { - this.loggedIn = data["loggedIn"]; - this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : []; + let res = await this.apiCall("user/info"); + if (res.success) { + this.loggedIn = res.loggedIn; + this.language = res.language; + this.permissions = (res.permissions || []).map(s => s.toLowerCase()); if (this.loggedIn) { - this.session = data["session"]; - this.user = data["user"]; + this.session = res.session; + this.user = res.user; } else { this.session = null; this.user = null; } } - return data; + return res; } async editUser(id, username, email, password, groups, confirmed) { @@ -147,6 +155,11 @@ export default class API { return this.apiCall("user/create", { username: username, email: email, password: password, confirmPassword: confirmPassword }); } + async updateProfile(username=null, fullName=null, password=null, confirmPassword = null, oldPassword = null) { + return this.apiCall("user/updateProfile", { username: username, fullName: fullName, + password: password, confirmPassword: confirmPassword, oldPassword: oldPassword }); + } + /** Stats **/ async getStats() { return this.apiCall("stats"); @@ -204,7 +217,12 @@ export default class API { } async setLanguage(params) { - return await this.apiCall("language/set", params); + let res = await this.apiCall("language/set", params); + if (res.success) { + this.language = res.language; + } + + return res; } async getLanguageEntries(modules, code=null, useCache=false) { @@ -216,7 +234,6 @@ export default class API { } /** ApiKeyAPI **/ - // API-Key API async getApiKeys(showActiveOnly = false) { return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly }); } @@ -228,4 +245,42 @@ export default class API { async revokeKey(id) { return this.apiCall("apiKey/revoke", { id: id }); } + + /** 2FA API **/ + async confirmTOTP(code) { + return this.apiCall("tfa/confirmTotp", { code: code }); + } + + async remove2FA(password) { + return this.apiCall("tfa/remove", { password: password }); + } + + async verifyTotp2FA(code) { + return this.apiCall("tfa/verifyTotp", { code: code }); + } + + async verifyKey2FA(credentialID, clientDataJSON, authData, signature) { + return this.apiCall("tfa/verifyKey", { credentialID: credentialID, clientDataJSON: clientDataJSON, authData: authData, signature: signature }) + } + + async register2FA(clientDataJSON = null, attestationObject = null) { + return this.apiCall("tfa/registerKey", { clientDataJSON: clientDataJSON, attestationObject: attestationObject }); + } + + /** GPG API **/ + async uploadGPG(pubkey) { + return this.apiCall("user/importGPG", { pubkey: pubkey }); + } + + async confirmGpgToken(token) { + return this.apiCall("user/confirmGPG", { token: token }); + } + + async removeGPG(password) { + return this.apiCall("user/removeGPG", { password: password }); + } + + async downloadGPG(userId) { + return this.apiCall("user/downloadGPG", { id: userId }, true); + } }; \ No newline at end of file diff --git a/react/shared/elements/data-table.css b/react/shared/elements/data-table.css index cce9d50..f1fcf46 100644 --- a/react/shared/elements/data-table.css +++ b/react/shared/elements/data-table.css @@ -3,7 +3,7 @@ } .data-table td, .data-table th { - padding: 2px; + padding: 5px; vertical-align: middle; } @@ -11,10 +11,17 @@ background-color: #bbb; } -.sortable { - cursor: pointer; +.data-table th > svg { + vertical-align: middle; + width: 18px; + height: 18px; + margin-right: 5px; } .center { text-align: center; +} + +.data-table-clickable { + cursor: pointer; } \ No newline at end of file diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js index c70223c..bc97a35 100644 --- a/react/shared/elements/data-table.js +++ b/react/shared/elements/data-table.js @@ -2,48 +2,51 @@ import {Table, TableBody, TableCell, TableHead, TableRow} from "@material-ui/cor import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward"; import React, {useCallback, useContext, useEffect, useState} from "react"; -import usePagination from "../hooks/pagination"; -import {parse} from "date-fns"; import "./data-table.css"; import {LocaleContext} from "../locale"; import clsx from "clsx"; -import {Box} from "@mui/material"; -import {formatDate} from "../util"; +import {Box, IconButton} from "@mui/material"; +import {formatDateTime} from "../util"; +import UserLink from "security-lab/src/elements/user/userlink"; +import CachedIcon from "@material-ui/icons/Cached"; export function DataTable(props) { const { className, placeholder, + columns, data, pagination, fetchData, onClick, onFilter, defaultSortColumn, defaultSortOrder, - columns, ...other } = props; + title, ...other } = props; - const {currentLocale, requestModules, translate: L} = useContext(LocaleContext); + const {translate: L} = useContext(LocaleContext); const [doFetchData, setFetchData] = useState(false); - const [data, setData] = useState(null); const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase)); const [sortColumn, setSortColumn] = useState(defaultSortColumn || null); - const pagination = usePagination(); const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true; + const onRowClick = onClick || (() => {}); const onFetchData = useCallback((force = false) => { if (doFetchData || force) { setFetchData(false); const orderBy = columns[sortColumn]?.field || null; const sortOrder = sortAscending ? "asc" : "desc"; - fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder).then(([data, dataPagination]) => { - if (data) { - setData(data); - pagination.update(dataPagination); - } - }); + fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder); } }, [doFetchData, columns, sortColumn, sortAscending, pagination]); // pagination changed? useEffect(() => { - let forceFetch = (pagination.getPageSize() < pagination.getTotal()); + let forceFetch = false; + if (pagination.getPageSize() < pagination.getTotal()) { + // page size is smaller than the total count + forceFetch = true; + } else if (data?.length && pagination.getPageSize() >= data.length && data.length < pagination.getTotal()) { + // page size is greater than the current visible count but there were hidden rows before + forceFetch = true; + } + onFetchData(forceFetch); }, [pagination.data.pageSize, pagination.data.current]); @@ -69,13 +72,14 @@ export function DataTable(props) { } if (sortable && column.sortable) { - headerRow.push( onChangeSort(index, column) }> + onClick={() => onChangeSort(index, column)} + align={column.align}> {sortColumn === index ? (sortAscending ? : ): <>}{column.renderHead(index)} ); } else { - headerRow.push( + headerRow.push( {column.renderHead(index)} ); } @@ -83,14 +87,20 @@ export function DataTable(props) { const numColumns = columns.length; let rows = []; - if (data) { - for (const [key, entry] of Object.entries(data)) { + if (data && data?.length) { + for (const [rowIndex, entry] of data.entries()) { let row = []; for (const [index, column] of columns.entries()) { - row.push({column.renderData(L, entry)}); + row.push( + {column.renderData(L, entry, index)} + ); } - rows.push({ row }); + rows.push( onRowClick(rowIndex, entry)} + key={"row-" + rowIndex}> + { row } + ); } } else if (placeholder) { rows.push( @@ -100,151 +110,13 @@ export function DataTable(props) { ); } - /* - - - let columnElements = []; - if (columns) { - for (const [key, column] of Object.entries(columns)) { - const centered = column.alignment === "center"; - const sortable = doSort && (!column.hasOwnProperty("sortable") || !!column.sortable); - const label = column.label; - - if (!sortable) { - columnElements.push( - - { label } - - ); - } else { - columnElements.push( - (key === sortColumn ? setSortAscending(!sortAscending) : setSortColumn(key)) }> - { key === sortColumn ? - - { sortAscending ? : } - { label } - - : - {label} - } - - ); - } - } - } - - const getValue = useCallback((entry, key) => { - if (typeof columns[key]?.value === 'function') { - return columns[key].value(entry); - } else { - return entry[columns[key]?.value] ?? null; - } - }, [columns]); - - let numColumns = columns ? Object.keys(columns).length : 0; - - const compare = (a,b,callback) => { - let definedA = a !== null && typeof a !== 'undefined'; - let definedB = b !== null && typeof b !== 'undefined'; - if (!definedA && !definedB) { - return 0; - } else if (!definedA) { - return 1; - } else if (!definedB) { - return -1; - } else { - return callback(a,b); - } - } - - let rows = []; - const hasClickHandler = typeof onClick === 'function'; - if (data !== null && columns) { - let hidden = 0; - let sortedEntries = data.slice(); - - if (sortColumn && columns[sortColumn]) { - let sortFunction; - if (typeof columns[sortColumn]?.compare === 'function') { - sortFunction = columns[sortColumn].compare; - } else if (columns[sortColumn]?.type === Date) { - sortFunction = (a, b) => compare(a, b, (a,b) => a.getTime() - b.getTime()); - } else if (columns[sortColumn]?.type === Number) { - sortFunction = (a, b) => compare(a, b, (a,b) => a - b); - } else { - sortFunction = ((a, b) => - compare(a, b, (a,b) => a.toString().toLowerCase().localeCompare(b.toString().toLowerCase())) - ) - } - - sortedEntries.sort((a, b) => { - let entryA = getValue(a, sortColumn); - let entryB = getValue(b, sortColumn); - return sortFunction(entryA, entryB); - }); - - if (!sortAscending) { - sortedEntries = sortedEntries.reverse(); - } - } - - Array.from(Array(sortedEntries.length).keys()).forEach(rowIndex => { - if (typeof props.filter === 'function' && !props.filter(sortedEntries[rowIndex])) { - hidden++; - return; - } - - let rowData = []; - for (const [key, column] of Object.entries(columns)) { - let value = getValue(sortedEntries[rowIndex], key); - if (typeof column.render === 'function') { - value = column.render(sortedEntries[rowIndex], value); - } - - rowData.push( - { value } - ); - } - - rows.push( - hasClickHandler && onClick(sortedEntries[rowIndex])}> - { rowData } - - ); - }); - - if (hidden > 0) { - rows.push( - - { "(" + (hidden > 1 - ? sprintf(L("%d rows hidden due to filter"), hidden) - : L("1 rows hidden due to filter")) + ")" - } - - ); - } else if (rows.length === 0 && placeholder) { - rows.push( - - { placeholder } - - ); - } - } else if (columns && data === null) { - rows.push( - - - {L("Loading")}… - - - - ) - } - */ - return +

+ onFetchData(true)}> + + + {title} +

@@ -260,17 +132,14 @@ export function DataTable(props) { } export class DataColumn { - constructor(label, field = null, sortable = true) { + constructor(label, field = null, params = {}) { this.label = label; this.field = field; - this.sortable = sortable; + this.sortable = !params.hasOwnProperty("sortable") || !!params.sortable; + this.align = params.align || "left"; } - compare(a, b) { - throw new Error("Not implemented: compare"); - } - - renderData(L, entry) { + renderData(L, entry, index) { return entry[this.field] } @@ -280,49 +149,88 @@ export class DataColumn { } export class StringColumn extends DataColumn { - constructor(label, field = null, sortable = true, caseSensitive = false) { - super(label, field, sortable); - this.caseSensitve = caseSensitive; - } - - compare(a, b) { - if (this.caseSensitve) { - return a.toString().localeCompare(b.toString()); - } else { - return a.toString().toLowerCase().localeCompare(b.toString().toLowerCase()); - } + constructor(label, field = null, params = {}) { + super(label, field, params); } } export class NumericColumn extends DataColumn { - constructor(label, field = null, sortable = true) { - super(label, field, sortable); + constructor(label, field = null, params = {}) { + super(label, field, params); + this.decimalDigits = params.decimalDigits || null; + this.integerDigits = params.integerDigits || null; + this.prefix = params.prefix || ""; + this.suffix = params.suffix || ""; + this.decimalChar = params.decimalChar || "."; } - compare(a, b) { - return a - b; + renderData(L, entry, index) { + let number = super.renderData(L, entry).toString(); + + if (this.decimalDigits !== null) { + number = number.toFixed(this.decimalDigits); + } + + if (this.integerDigits !== null) { + let currentLength = number.split(".")[0].length; + if (currentLength < this.integerDigits) { + number = number.padStart(this.integerDigits - currentLength, "0"); + } + } + + if (this.decimalChar !== ".") { + number = number.replace(".", this.decimalChar); + } + + return this.prefix + number + this.suffix; } } export class DateTimeColumn extends DataColumn { - constructor(label, field = null, sortable = true, format = "YYYY-MM-dd HH:mm:ss") { - super(label, field, sortable); - this.format = format; + constructor(label, field = null, params = {}) { + super(label, field, params); + this.precise = !!params.precise; } - compare(a, b) { - if (typeof a === 'string') { - a = parse(a, this.format, new Date()).getTime(); - } + renderData(L, entry, index) { + let date = super.renderData(L, entry); + return formatDateTime(L, date, this.precise); + } +} - if (typeof b === 'string') { - b = parse(b, this.format, new Date()).getTime(); - } - - return a - b; +export class UserLinkColumn extends DataColumn { + constructor(label, field = null, params = {}) { + super(label, field, params); } - renderData(L, entry) { - return formatDate(L, super.renderData(L, entry)); + renderData(L, entry, index) { + return + } +} + +export class ControlsColumn extends DataColumn { + constructor(buttons = [], params = {}) { + super("general.controls", null, { align: "center", ...params, sortable: false }); + this.buttons = buttons; + } + + renderData(L, entry, index) { + let buttonElements = []; + for (const [index, button] of this.buttons.entries()) { + let element = button.element; + let props = { + key: "button-" + index, + onClick: (() => button.onClick(entry)), + className: "data-table-clickable" + } + + if (typeof button.showIf !== 'function' || button.showIf(entry)) { + buttonElements.push(React.createElement(element, props)) + } + } + + return <> + {buttonElements} + } } \ No newline at end of file diff --git a/react/shared/hooks/pagination.js b/react/shared/hooks/pagination.js index b3889ed..80bcf00 100644 --- a/react/shared/hooks/pagination.js +++ b/react/shared/hooks/pagination.js @@ -25,6 +25,10 @@ class Pagination { this.setData({...this.data, pageSize: pageSize}); } + setTotal(count) { + this.setData({...this.data, total: count}); + } + reset() { this.setData({current: 1, pageSize: 25, total: 0}); } @@ -37,6 +41,10 @@ class Pagination { } } + getParams() { + return [this.data.current, this.data.pageSize]; + } + getTotal() { return this.data.total; } diff --git a/react/shared/locale.js b/react/shared/locale.js index 6db32a1..90a9ed2 100644 --- a/react/shared/locale.js +++ b/react/shared/locale.js @@ -62,16 +62,6 @@ function LocaleProvider(props) { } }, [entries]); - const toDateFns = () => { - switch (currentLocale) { - case 'de_DE': - return dateFnsDE; - case 'en_US': - default: - return dateFnsEN; - } - } - /** API HOOKS **/ const setLanguage = useCallback(async (api, params) => { let res = await api.setLanguage(params); @@ -96,8 +86,8 @@ function LocaleProvider(props) { if (code === null) { code = currentLocale; - if (code === null && api.loggedIn) { - code = api.user.language.code; + if (code === null && api.language) { + code = api.language.code; } } @@ -135,11 +125,23 @@ function LocaleProvider(props) { } }, [currentLocale, getModule, dispatch]); + const toDateFns = useCallback(() => { + switch (currentLocale) { + case 'de_DE': + return dateFnsDE; + case 'en_US': + default: + return dateFnsEN; + } + }, [currentLocale]); + const ctx = { currentLocale: currentLocale, translate: translate, requestModules: requestModules, setLanguageByCode: setLanguageByCode, + toDateFns: toDateFns, + setCurrentLocale: setCurrentLocale, }; return ( diff --git a/react/shared/util.js b/react/shared/util.js index 03ab1bc..4d56096 100644 --- a/react/shared/util.js +++ b/react/shared/util.js @@ -59,7 +59,7 @@ const formatDate = (L, apiDate) => { return format(apiDate, L("general.datefns_date_format", "YYY/MM/dd")); } -const formatDateTime = (L, apiDate) => { +const formatDateTime = (L, apiDate, precise=false) => { if (!(apiDate instanceof Date)) { if (!isNaN(apiDate)) { apiDate = new Date(apiDate * 1000); @@ -68,7 +68,10 @@ const formatDateTime = (L, apiDate) => { } } - return format(apiDate, L("general.datefns_date_time_format", "YYY/MM/dd HH:mm:ss")); + let dateFormat = precise ? + L("general.datefns_date_time_format_precise", "YYY/MM/dd HH:mm:ss") : + L("general.datefns_date_time_format", "YYY/MM/dd HH:mm"); + return format(apiDate, dateFormat); } const upperFirstChars = (str) => { diff --git a/react/shared/views/login.jsx b/react/shared/views/login.jsx index 279e45e..45abf88 100644 --- a/react/shared/views/login.jsx +++ b/react/shared/views/login.jsx @@ -71,15 +71,24 @@ export default function LoginForm(props) { const api = props.api; const classes = useStyles(); + + // inputs let [username, setUsername] = useState(""); let [password, setPassword] = useState(""); let [rememberMe, setRememberMe] = useState(true); - let [isLoggingIn, setLoggingIn] = useState(false); let [emailConfirmed, setEmailConfirmed] = useState(null); let [tfaCode, set2FACode] = useState(""); - let [tfaState, set2FAState] = useState(0); // 0: not sent, 1: sent, 2: retry - let [tfaError, set2FAError] = useState(""); + + // 2fa + // 0: not sent, 1: sent, 2: retry + let [tfaToken, set2FAToken] = useState(api.user?.twoFactorToken || { authenticated: false, type: null, step: 0 }); let [error, setError] = useState(""); + + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + // state + let [isLoggingIn, setLoggingIn] = useState(false); let [loaded, setLoaded] = useState(false); const {translate: L, currentLocale, requestModules} = useContext(LocaleContext); @@ -103,13 +112,14 @@ export default function LoginForm(props) { setLoggingIn(true); removeParameter("success"); api.login(username, password, rememberMe).then((res) => { - set2FAState(0); + let twoFactorToken = res.twoFactorToken || { }; + set2FAToken({ ...twoFactorToken, authenticated: false, step: 0, error: "" }); setLoggingIn(false); setPassword(""); if (!res.success) { setEmailConfirmed(res.emailConfirmed); setError(res.msg); - } else { + } else if (!twoFactorToken.type) { props.onLogin(); } }); @@ -118,111 +128,149 @@ export default function LoginForm(props) { const onSubmit2FA = useCallback(() => { setLoggingIn(true); - props.onTotp2FA(tfaCode, (res) => { + api.verifyTotp2FA(tfaCode).then((res) => { setLoggingIn(false); + if (res.success) { + set2FAToken({ ...tfaToken, authenticated: true }); + props.onLogin(); + } else { + set2FAToken({ ...tfaToken, step: 2, error: res.msg }); + } }); - }, [tfaCode, props]); + }, [tfaCode, tfaToken, props]); const onCancel2FA = useCallback(() => { + abortController.abort(); props.onLogout(); - }, [props]); + set2FAToken({authenticated: false, step: 0, error: ""}); + }, [props, abortController]); useEffect(() => { - if (!api.loggedIn || !api.user) { + if (!api.loggedIn) { return; } - let twoFactor = api.user["2fa"]; - if (!twoFactor || !twoFactor.confirmed || - twoFactor.authenticated || twoFactor.type !== "fido") { + if (!tfaToken || !tfaToken.confirmed || tfaToken.authenticated || tfaToken.type !== "fido") { return; } - if (tfaState === 0) { - set2FAState(1); - set2FAError(""); - navigator.credentials.get({ - publicKey: { - challenge: encodeText(window.atob(twoFactor.challenge)), - allowCredentials: [{ - id: encodeText(window.atob(twoFactor.credentialID)), - type: "public-key", - }], - userVerification: "discouraged", - }, - }).then((res) => { - let credentialID = res.id; - let clientDataJson = decodeText(res.response.clientDataJSON); - let authData = window.btoa(decodeText(res.response.authenticatorData)); - let signature = window.btoa(decodeText(res.response.signature)); - props.onKey2FA(credentialID, clientDataJson, authData, signature, res => { - if (!res.success) { - set2FAState(2); - } - }); - }).catch(e => { - set2FAState(2); - set2FAError(e.toString()); + let step = tfaToken.step || 0; + if (step !== 0) { + return; + } + + set2FAToken({ ...tfaToken, step: 1, error: "" }); + navigator.credentials.get({ + publicKey: { + challenge: encodeText(window.atob(tfaToken.challenge)), + allowCredentials: [{ + id: encodeText(window.atob(tfaToken.credentialID)), + type: "public-key", + }], + userVerification: "discouraged", + }, + signal: abortSignal + }).then((res) => { + let credentialID = res.id; + let clientDataJson = decodeText(res.response.clientDataJSON); + let authData = window.btoa(decodeText(res.response.authenticatorData)); + let signature = window.btoa(decodeText(res.response.signature)); + api.verifyKey2FA(credentialID, clientDataJson, authData, signature).then((res) => { + if (!res.success) { + set2FAToken({ ...tfaToken, step: 2, error: res.msg }); + } else { + props.onLogin(); + } }); - } - }, [api.loggedIn, api.user, tfaState, props]); + }).catch(e => { + set2FAToken({ ...tfaToken, step: 2, error: e.toString() }); + }); + }, [api.loggedIn, tfaToken, props.onLogin, props.onKey2FA, abortSignal]); const createForm = () => { // 2FA - if (api.loggedIn && api.user["2fa"]) { - return <> -
{L("account.2fa_title")}: {api.user["2fa"].type}
- { api.user["2fa"].type === "totp" ? + if (api.loggedIn && tfaToken.type) { + + if (tfaToken.type === "totp") { + return <> +
{L("account.2fa_title")}:
set2FACode(e.target.value)} - /> : <> - {L("account.2fa_text")} - - {tfaState !== 2 - ? - :
-
{L("general.something_went_wrong")}:
{tfaError}
- -
- } -
- - } - { - error ? {error} : <> - } - - - + /> + { + tfaToken.error ? {tfaToken.error} : <> + } + + + + + + + - - + + } else if (tfaToken.type === "fido") { + return <> +
{L("account.2fa_title")}:
+
+ {L("account.2fa_text")} + + {tfaToken.step !== 2 + ? + :
+
{L("general.something_went_wrong")}:
{tfaToken.error}
+ +
+ } +
+ + + + + + + -
- + + } } return <>