From 7877293bd34a1af8517995fe2fa74df8fcd1bad7 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 10:08:18 +0100 Subject: [PATCH 01/14] Added an endpoint for longer valid JWT tokens to be used as API keys --- src/api/v2/index.php | 2 + src/dba/Factory.php | 12 ++ src/dba/models/JwtApiKey.php | 110 ++++++++++++++++++ src/dba/models/JwtApiKeyFactory.php | 92 +++++++++++++++ src/dba/models/generator.php | 9 ++ src/inc/apiv2/auth/token.routes.php | 47 +++++--- src/inc/apiv2/common/AbstractBaseAPI.php | 51 +++----- src/inc/apiv2/model/AgentAPI.php | 2 +- src/inc/apiv2/model/ApiTokenAPI.php | 100 ++++++++++++++++ src/inc/apiv2/model/PreTaskAPI.php | 2 +- src/inc/apiv2/model/TaskAPI.php | 2 +- src/inc/utils/JwtTokenUtils.php | 21 ++++ .../mysql/20260309164000_api-key.sql | 6 + .../postgres/20260309164000_api-key.sql | 6 + 14 files changed, 407 insertions(+), 55 deletions(-) create mode 100644 src/dba/models/JwtApiKey.php create mode 100644 src/dba/models/JwtApiKeyFactory.php create mode 100644 src/inc/apiv2/model/ApiTokenAPI.php create mode 100644 src/inc/utils/JwtTokenUtils.php create mode 100644 src/migrations/mysql/20260309164000_api-key.sql create mode 100644 src/migrations/postgres/20260309164000_api-key.sql diff --git a/src/api/v2/index.php b/src/api/v2/index.php index 245d28f4d..50f06de1f 100644 --- a/src/api/v2/index.php +++ b/src/api/v2/index.php @@ -63,6 +63,7 @@ use Hashtopolis\inc\apiv2\model\AgentBinaryAPI; use Hashtopolis\inc\apiv2\model\AgentErrorAPI; use Hashtopolis\inc\apiv2\model\AgentStatAPI; +use Hashtopolis\inc\apiv2\model\ApiTokenAPI; use Hashtopolis\inc\apiv2\model\ChunkAPI; use Hashtopolis\inc\apiv2\model\ConfigAPI; use Hashtopolis\inc\apiv2\model\ConfigSectionAPI; @@ -234,6 +235,7 @@ AgentBinaryAPI::register($app); AgentErrorAPI::register($app); AgentStatAPI::register($app); +ApiTokenAPI::register($app); ChunkAPI::register($app); ConfigAPI::register($app); ConfigSectionAPI::register($app); diff --git a/src/dba/Factory.php b/src/dba/Factory.php index 1debfb909..b495cbdb3 100644 --- a/src/dba/Factory.php +++ b/src/dba/Factory.php @@ -25,6 +25,7 @@ use Hashtopolis\dba\models\HashTypeFactory; use Hashtopolis\dba\models\HealthCheckFactory; use Hashtopolis\dba\models\HealthCheckAgentFactory; +use Hashtopolis\dba\models\JwtApiKeyFactory; use Hashtopolis\dba\models\LogEntryFactory; use Hashtopolis\dba\models\NotificationSettingFactory; use Hashtopolis\dba\models\PreprocessorFactory; @@ -71,6 +72,7 @@ class Factory { private static ?HashTypeFactory $hashTypeFactory = null; private static ?HealthCheckFactory $healthCheckFactory = null; private static ?HealthCheckAgentFactory $healthCheckAgentFactory = null; + private static ?JwtApiKeyFactory $jwtApiKeyFactory = null; private static ?LogEntryFactory $logEntryFactory = null; private static ?NotificationSettingFactory $notificationSettingFactory = null; private static ?PreprocessorFactory $preprocessorFactory = null; @@ -323,6 +325,16 @@ public static function getHealthCheckAgentFactory(): HealthCheckAgentFactory { } } + public static function getJwtApiKeyFactory(): JwtApiKeyFactory { + if (self::$jwtApiKeyFactory == null) { + $f = new JwtApiKeyFactory(); + self::$jwtApiKeyFactory = $f; + return $f; + } else { + return self::$jwtApiKeyFactory; + } + } + public static function getLogEntryFactory(): LogEntryFactory { if (self::$logEntryFactory == null) { $f = new LogEntryFactory(); diff --git a/src/dba/models/JwtApiKey.php b/src/dba/models/JwtApiKey.php new file mode 100644 index 000000000..a3d822a47 --- /dev/null +++ b/src/dba/models/JwtApiKey.php @@ -0,0 +1,110 @@ +JwtApiKeyId = $JwtApiKeyId; + $this->startValid = $startValid; + $this->endValid = $endValid; + $this->userId = $userId; + $this->isRevoked = $isRevoked; + } + + function getKeyValueDict(): array { + $dict = array(); + $dict['JwtApiKeyId'] = $this->JwtApiKeyId; + $dict['startValid'] = $this->startValid; + $dict['endValid'] = $this->endValid; + $dict['userId'] = $this->userId; + $dict['isRevoked'] = $this->isRevoked; + + return $dict; + } + + static function getFeatures(): array { + $dict = array(); + $dict['JwtApiKeyId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => True, "protected" => True, "private" => False, "alias" => "JwtApiKeyId", "public" => False, "dba_mapping" => False]; + $dict['startValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "startValid", "public" => False, "dba_mapping" => False]; + $dict['endValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "endValid", "public" => False, "dba_mapping" => False]; + $dict['userId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => True, "private" => False, "alias" => "userId", "public" => False, "dba_mapping" => False]; + $dict['isRevoked'] = ['read_only' => False, "type" => "bool", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => True, "private" => False, "alias" => "isRevoked", "public" => False, "dba_mapping" => False]; + + return $dict; + } + + function getPrimaryKey(): string { + return "JwtApiKeyId"; + } + + function getPrimaryKeyValue(): ?int { + return $this->JwtApiKeyId; + } + + function getId(): ?int { + return $this->JwtApiKeyId; + } + + function setId($id): void { + $this->JwtApiKeyId = $id; + } + + /** + * Used to serialize the data contained in the model + * @return array + */ + public function expose(): array { + return get_object_vars($this); + } + + function getStartValid(): ?int { + return $this->startValid; + } + + function setStartValid(?int $startValid): void { + $this->startValid = $startValid; + } + + function getEndValid(): ?int { + return $this->endValid; + } + + function setEndValid(?int $endValid): void { + $this->endValid = $endValid; + } + + function getUserId(): ?int { + return $this->userId; + } + + function setUserId(?int $userId): void { + $this->userId = $userId; + } + + function getIsRevoked(): ?int { + return $this->isRevoked; + } + + function setIsRevoked(?int $isRevoked): void { + $this->isRevoked = $isRevoked; + } + + const _JWT_API_KEY_ID = "JwtApiKeyId"; + const START_VALID = "startValid"; + const END_VALID = "endValid"; + const USER_ID = "userId"; + const IS_REVOKED = "isRevoked"; + + const PERM_CREATE = "permJwtApiKeyCreate"; + const PERM_READ = "permJwtApiKeyRead"; + const PERM_UPDATE = "permJwtApiKeyUpdate"; + const PERM_DELETE = "permJwtApiKeyDelete"; +} diff --git a/src/dba/models/JwtApiKeyFactory.php b/src/dba/models/JwtApiKeyFactory.php new file mode 100644 index 000000000..8ba88d68e --- /dev/null +++ b/src/dba/models/JwtApiKeyFactory.php @@ -0,0 +1,92 @@ + $val) { + $conv[strtolower($key)] = $val; + } + $dict = $conv; + return new JwtApiKey($dict['jwtapikeyid'], $dict['startvalid'], $dict['endvalid'], $dict['userid'], $dict['isrevoked']); + } + + /** + * @param array $options + * @param bool $single + * @return JwtApiKey|JwtApiKey[] + */ + function filter(array $options, bool $single = false): JwtApiKey|array|null { + $join = false; + if (array_key_exists('join', $options)) { + $join = true; + } + if ($single) { + if ($join) { + return parent::filter($options, $single); + } + return Util::cast(parent::filter($options, $single), JwtApiKey::class); + } + $objects = parent::filter($options, $single); + if ($join) { + return $objects; + } + $models = array(); + foreach ($objects as $object) { + $models[] = Util::cast($object, JwtApiKey::class); + } + return $models; + } + + /** + * @param string $pk + * @return ?JwtApiKey + */ + function get($pk): ?JwtApiKey { + return Util::cast(parent::get($pk), JwtApiKey::class); + } + + /** + * @param JwtApiKey $model + * @return JwtApiKey + */ + function save($model): JwtApiKey { + return Util::cast(parent::save($model), JwtApiKey::class); + } +} diff --git a/src/dba/models/generator.php b/src/dba/models/generator.php index 104055066..941448b93 100644 --- a/src/dba/models/generator.php +++ b/src/dba/models/generator.php @@ -287,6 +287,15 @@ ['name' => 'errors', 'read_only' => True, 'type' => 'str(65535)', 'protected' => True], ], ]; +$CONF['JwtApiKey'] = [ + 'columns' => [ + ['name' => 'JwtApiKeyId', 'read_only' => True, 'type' => 'int', 'protected' => True], + ['name' => 'startValid', 'read_only' => True, 'type' => 'int64'], + ['name' => 'endValid', 'read_only' => True, 'type' => 'int64'], + ['name' => 'userId', 'read_only' => True, 'type' => 'int', 'relation' => 'User'], + ['name' => 'isRevoked', 'read_only' => False, 'type' => 'bool'], + ], +]; $CONF['LogEntry'] = [ 'columns' => [ ['name' => 'logEntryId', 'read_only' => True, 'type' => 'int', 'protected' => True], diff --git a/src/inc/apiv2/auth/token.routes.php b/src/inc/apiv2/auth/token.routes.php index 11078b066..509a4e568 100644 --- a/src/inc/apiv2/auth/token.routes.php +++ b/src/inc/apiv2/auth/token.routes.php @@ -13,34 +13,45 @@ use Hashtopolis\dba\models\User; use Hashtopolis\dba\Factory; use Firebase\JWT\JWK; +use Hashtopolis\dba\JoinFilter; +use Hashtopolis\dba\models\RightGroup; require_once(dirname(__FILE__) . "/../../startup/include.php"); function generateTokenForUser(Request $request, string $userName, int $expires) { $jti = bin2hex(random_bytes(16)); - $requested_scopes = $request->getParsedBody() ?: ["todo.all"]; - - $valid_scopes = [ - "todo.create", - "todo.read", - "todo.update", - "todo.delete", - "todo.list", - "todo.all" - ]; - - $scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) { - return in_array($needle, $valid_scopes); - }); - // FIXME: This is duplicated and should be passed by HttpBasicMiddleware $filter = new QueryFilter(User::USERNAME, $userName, "="); - $check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]); + $jF = new JoinFilter(Factory::getRightGroupFactory(), User::RIGHT_GROUP_ID, RightGroup::RIGHT_GROUP_ID); + $joined = Factory::getUserFactory()->filter([Factory::FILTER => $filter, Factory::JOIN => $jF]); + /** @var $check User[] */ + $check = $joined[Factory::getUserFactory()->getModelName()]; + if (count($check) === 0) { + throw new HttpError("No user with this userName in the database"); + } $user = $check[0]; - if (empty($user)) { - throw new HttpError("No user with this userName in the database"); + /** @var $groupArray RightGroup[] */ + $groupArray = $joined[Factory::getRightGroupFactory()->getModelName()]; + if (count($groupArray) === 0) { + throw new HttpError("No rightgroup found for this user"); } + $group = $groupArray[0]; + $scopes = $group->getPermissions(); + + // $requested_scopes = $request->getParsedBody() ?: ["todo.all"]; + // $valid_scopes = [ + // "todo.create", + // "todo.read", + // "todo.update", + // "todo.delete", + // "todo.list", + // "todo.all" + // ]; + + // $scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) { + // return in_array($needle, $valid_scopes); + // }); $secret = StartupConfig::getInstance()->getPepper(0); $now = new DateTime(); diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index b2f4ab45a..4d8824899 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -59,6 +59,8 @@ use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\LikeFilter; use Hashtopolis\dba\LikeFilterInsensitive; +use Hashtopolis\dba\models\ApiKey; +use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\dba\models\LogEntry; use Hashtopolis\dba\models\Preprocessor; use Hashtopolis\dba\QueryFilter; @@ -182,7 +184,7 @@ protected function getUpdateHandlers($id, $current_user): array { * Implementations should use $includedData to collect related resources that should be included * in the API response, such as related entities or additional data. */ - public static function aggregateData(object $object, array &$includedData = [], ?array $aggregateFieldsets = null): array { + public function aggregateData(object $object, array &$includedData = [], ?array $aggregateFieldsets = null): array { return []; } @@ -307,6 +309,8 @@ protected static function getModelFactory(string $model): AbstractModelFactory { return Factory::getTaskWrapperFactory(); case User::class: return Factory::getUserFactory(); + case JwtApiKey::class: + return Factory::getJwtApiKeyFactory(); } throw new HttpError("Model '$model' cannot be mapped to Factory"); } @@ -546,6 +550,7 @@ protected static function getExpandPermissions(string $expand): array { // src/inc/defines/notifications.php DAccessControl::LOGIN_ACCESS => array(NotificationSetting::PERM_CREATE, NotificationSetting::PERM_READ, NotificationSetting::PERM_UPDATE, NotificationSetting::PERM_DELETE, LogEntry::PERM_CREATE, LogEntry::PERM_DELETE, LogEntry::PERM_UPDATE), + "ApiTokenAccess" => array(JwtApiKey::PERM_CREATE, JwtApiKey::PERM_DELETE, JwtApiKey::PERM_READ, JwtApiKey::PERM_UPDATE), ); /** @@ -679,7 +684,7 @@ protected function obj2Resource(object $obj, array &$expandResult = [], ?array $ $attributes[$feature['alias']] = $apiClass::db2json($feature, $kv[$name]); } - $aggregatedData = $apiClass::aggregateData($obj, $expandResult, $aggregateFieldsets); + $aggregatedData = $this->aggregateData($obj, $expandResult, $aggregateFieldsets); $attributes = array_merge($attributes, $aggregatedData); /* Build JSON::API relationship resource */ @@ -1063,7 +1068,7 @@ protected function makeExpandables(Request $request, array $validExpandables): a } array_push($required_perms, ...$expandedPerms); } - $permissionResponse = $this->validatePermissions($required_perms, $request->getMethod(), $permsExpandMatching); + $permissionResponse = $this->validatePermissions($request->getAttribute("scope"), $required_perms, $request->getMethod(), $permsExpandMatching); $expands_to_remove = []; // remove expands with missing permissions @@ -1372,44 +1377,19 @@ protected function processExpands( return $includedResources; } - /** - * Validate if user is allowed to access hashlist - * @throws HttpForbidden - * @throws ResourceNotFoundError - */ - protected function validateHashlistAccess(Request $request, User $user, string $hashlistId): Hashlist { - // TODO: Fix permissions - if (!AccessControl::getInstance($user)->hasPermission(DAccessControl::MANAGE_HASHLIST_ACCESS)) { - throw new HttpForbidden("No '" . DAccessControl::getDescription(DAccessControl::MANAGE_HASHLIST_ACCESS) . "' permission"); - } - - try { - $hashlist = HashlistUtils::getHashlist($hashlistId); - } - catch (HTException $ex) { - throw new ResourceNotFoundError($ex->getMessage()); - } - if (!AccessUtils::userCanAccessHashlists($hashlist, $user)) { - throw new HttpForbidden("No access to hashlist!"); - } - - return $hashlist; - } - /** * Validate permissions */ - protected function validatePermissions(array $required_perms, string $method, array $permsExpandMatching = []): bool|array { + protected function validatePermissions(string $permissions, array $required_perms, string $method, array $permsExpandMatching = []): bool|array { // Retrieve permissions from RightGroup part of the User - $group = Factory::getRightGroupFactory()->get($this->user->getRightGroupId()); - if ($group->getPermissions() == 'ALL') { + if ($permissions == 'ALL') { // Special (legacy) case for administrative access, enable all available permissions $all_perms = array_keys(self::$acl_mapping); $rightgroup_perms = array_combine($all_perms, array_fill(0, count($all_perms), true)); } else { - $rightgroup_perms = json_decode($group->getPermissions(), true); + $rightgroup_perms = json_decode($permissions, true); } // Validate if no undefined permissions are set in $acl_mapping @@ -1508,8 +1488,11 @@ protected function addPublicAttributeClass($class): void { */ protected function preCommon(Request $request): void { $userId = $request->getAttribute(('userId')); - $this->user = UserUtils::getUser($userId); - + $user = UserUtils::getUser($userId); + if ($user->getIsValid() != 1) { + throw new HttpForbidden("User is set to invalid"); + } + $this->user = $user; # 'Initiate' AccessControl class, by requesting instance with parameter of logged-in user. # This will cause the AccessControl class to initiate it's static 'instance' parameter, # which is in turn used at later stages (e.g. src/inc/utils/NotificationUtils.class.php) to @@ -1532,7 +1515,7 @@ protected function preCommon(Request $request): void { ); } - if ($this->validatePermissions($required_perms, $request->getMethod()) === FALSE) { + if ($this->validatePermissions($request->getAttribute("scope"), $required_perms, $request->getMethod()) === FALSE) { throw new HttpForbidden(join('||', $this->permissionErrors)); } } diff --git a/src/inc/apiv2/model/AgentAPI.php b/src/inc/apiv2/model/AgentAPI.php index 67888868f..917eeac53 100644 --- a/src/inc/apiv2/model/AgentAPI.php +++ b/src/inc/apiv2/model/AgentAPI.php @@ -54,7 +54,7 @@ protected function getUpdateHandlers($id, $current_user): array { * @param array|null $aggregateFieldsets * @return array not used here */ - static function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { + function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { $agentId = $object->getId(); $qFs = []; $qFs[] = new QueryFilter(Chunk::AGENT_ID, $agentId, "="); diff --git a/src/inc/apiv2/model/ApiTokenAPI.php b/src/inc/apiv2/model/ApiTokenAPI.php new file mode 100644 index 000000000..950b083d0 --- /dev/null +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -0,0 +1,100 @@ +jwtToken = $token; + } + + private function getJwtToken(): ?string { + return $this->jwtToken; + } + + public static function getBaseUri(): string { + return "/api/v2/ui/apiTokens"; + } + + public static function getAvailableMethods(): array { + return ['GET', 'POST', 'PATCH']; + } + + public static function getDBAclass(): string { + return JwtApiKey::class; + } + + public static function getToOneRelationships(): array { + return [ + 'user' => [ + 'key' => JwtApiKey::USER_ID, + + 'relationType' => User::class, + 'relationKey' => User::USER_ID, + ] + ]; + } + + /** + * @throws HttpError + */ + protected function createObject(array $data): int { + $rightGroup = $this->getRightGroup($this->getCurrentUser()->getRightGroupId()); + + $secret = StartupConfig::getInstance()->getPepper(0); + $iat = $data[JwtApiKey::START_VALID]; + $expires = $data[JwtApiKey::END_VALID]; + $token = JwtTokenUtils::createKey($this->getCurrentUser()->getId(), $iat, $expires); + $jti = $token->getId(); + + $payload = [ + "iat" => $iat, + "exp" => $expires, + "jti" => $jti, + "userId" => $this->getCurrentUser()->getId(), + "scope" => $rightGroup->getPermissions(), + "iss" => "Hashtopolis", + "kid" => hash("sha256", $secret) + ]; + + $tokenEncoded = JWT::encode($payload, $secret, "HS256"); + $this->setJwtToken($tokenEncoded); + + return $token->getId(); + } + + function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { + // $token is only set in POST, this way the actual token is only returned after creation. + $aggregatedData = []; + $token = $this->getJwtToken(); + if ($token !== null) { + $aggregatedData["Token"] = $token; + } + + return $aggregatedData; + } + + /** + * @throws HttpError + */ + public function updateObject(int $objectId, array $data): void { + throw new HttpError("ApiToken cannot be updated via API"); + } + + /** + * @throws HttpError + */ + protected function deleteObject(object $object): void { + throw new HttpError("ApiToken cannot be deleted via API"); + } +} \ No newline at end of file diff --git a/src/inc/apiv2/model/PreTaskAPI.php b/src/inc/apiv2/model/PreTaskAPI.php index 9ae5fa327..17b87cc72 100644 --- a/src/inc/apiv2/model/PreTaskAPI.php +++ b/src/inc/apiv2/model/PreTaskAPI.php @@ -69,7 +69,7 @@ protected function createObject(array $data): int { } //TODO make aggregate data queryable and not included by default - static function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { + function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { $aggregatedData = []; if (is_null($aggregateFieldsets) || array_key_exists('pretask', $aggregateFieldsets)) { diff --git a/src/inc/apiv2/model/TaskAPI.php b/src/inc/apiv2/model/TaskAPI.php index 93a61c25d..7753ea0b2 100644 --- a/src/inc/apiv2/model/TaskAPI.php +++ b/src/inc/apiv2/model/TaskAPI.php @@ -170,7 +170,7 @@ protected function createObject(array $data): int { } //TODO make aggregate data queryable and not included by default - static function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { + function aggregateData(object $object, array &$included_data = [], ?array $aggregateFieldsets = null): array { $aggregatedData = []; if (is_null($aggregateFieldsets) || array_key_exists('task', $aggregateFieldsets)) { diff --git a/src/inc/utils/JwtTokenUtils.php b/src/inc/utils/JwtTokenUtils.php new file mode 100644 index 000000000..b9b336433 --- /dev/null +++ b/src/inc/utils/JwtTokenUtils.php @@ -0,0 +1,21 @@ +get($userId); + if ($user == null) { + throw new HttpError("Invalid user ID"); + } + + $key = new JwtApiKey(null, $startValid, $endValid, $userId, 0); + Factory::getJwtApiKeyFactory()->save($key); + return $key; + } +} \ No newline at end of file diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql new file mode 100644 index 000000000..a9a82aa2c --- /dev/null +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -0,0 +1,6 @@ +CREATE TABLE JwtApiKey ( + JwtApiKeyId SERIAL PRIMARY KEY, + userId INTEGER REFERENCES htp_User(id), + startValid bigint, + endValid bigint, + isRevoked BOOLEAN DEFAULT FALSE); \ No newline at end of file diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql new file mode 100644 index 000000000..a9a82aa2c --- /dev/null +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -0,0 +1,6 @@ +CREATE TABLE JwtApiKey ( + JwtApiKeyId SERIAL PRIMARY KEY, + userId INTEGER REFERENCES htp_User(id), + startValid bigint, + endValid bigint, + isRevoked BOOLEAN DEFAULT FALSE); \ No newline at end of file From 49f69187d7d278774a47682bc9c010fb116c7bc6 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 13:05:34 +0100 Subject: [PATCH 02/14] Added DELETE to api key and a way to select specific scopes for the token --- src/dba/models/JwtApiKey.php | 2 +- src/inc/apiv2/common/AbstractBaseAPI.php | 2 +- src/inc/apiv2/model/ApiTokenAPI.php | 37 +++++++++++++++++------- src/inc/utils/JwtTokenUtils.php | 12 +++++++- src/inc/utils/UserUtils.php | 5 ++++ 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/dba/models/JwtApiKey.php b/src/dba/models/JwtApiKey.php index a3d822a47..12cdeae41 100644 --- a/src/dba/models/JwtApiKey.php +++ b/src/dba/models/JwtApiKey.php @@ -36,7 +36,7 @@ static function getFeatures(): array { $dict['startValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "startValid", "public" => False, "dba_mapping" => False]; $dict['endValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "endValid", "public" => False, "dba_mapping" => False]; $dict['userId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => True, "private" => False, "alias" => "userId", "public" => False, "dba_mapping" => False]; - $dict['isRevoked'] = ['read_only' => False, "type" => "bool", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => True, "private" => False, "alias" => "isRevoked", "public" => False, "dba_mapping" => False]; + $dict['isRevoked'] = ['read_only' => False, "type" => "bool", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "isRevoked", "public" => False, "dba_mapping" => False]; return $dict; } diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index 4d8824899..70b5962d5 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -599,7 +599,7 @@ protected static function json2db(array $feature, mixed $obj): ?string { elseif (str_starts_with($feature['type'], 'str')) { $val = htmlentities($obj, ENT_QUOTES, "UTF-8"); } - elseif ($feature['type'] == 'array' && $feature['subtype'] == 'int') { + elseif ($feature['type'] == 'array' && ($feature['subtype'] == 'int' || $feature['subtype'] == 'string')) { $val = implode(",", $obj); } elseif ($feature['type'] == 'dict' && $feature['subtype'] == 'bool') { diff --git a/src/inc/apiv2/model/ApiTokenAPI.php b/src/inc/apiv2/model/ApiTokenAPI.php index 950b083d0..2ad662b2c 100644 --- a/src/inc/apiv2/model/ApiTokenAPI.php +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -27,7 +27,7 @@ public static function getBaseUri(): string { } public static function getAvailableMethods(): array { - return ['GET', 'POST', 'PATCH']; + return ['GET', 'POST', 'PATCH', 'DELETE']; } public static function getDBAclass(): string { @@ -44,12 +44,34 @@ public static function getToOneRelationships(): array { ] ]; } + + public function getFormFields(): array { + // TODO Form declarations in more generic class to allow auto-generated OpenAPI specifications + return [ + "scopes" => ['type' => 'array', 'subtype' => 'string'] + ]; +} /** * @throws HttpError */ protected function createObject(array $data): int { - $rightGroup = $this->getRightGroup($this->getCurrentUser()->getRightGroupId()); + //Scopes is an array of permissions in format [permFileTaskUpdate, permAgentDelete] + $scopes = explode(",", $data["scopes"]); + + $allPermissions = $this->getRightGroup($this->getCurrentUser()->getRightGroupId())->getPermissions(); + if ($allPermissions == 'ALL') { + // Special (legacy) case for administrative access, enable all available permissions + $all_perms = array_keys(self::$acl_mapping); + $rightgroup_perms = array_combine($all_perms, array_fill(0, count($all_perms), true)); + } + else { + $rightgroup_perms = json_decode($allPermissions, true); + } + $NotAllowedPerms = array_filter($rightgroup_perms, fn($v) => $v === false); + $allowedPerms = array_intersect_key($rightgroup_perms, array_flip($scopes)); + + $requestedScopes = $allowedPerms + $NotAllowedPerms; $secret = StartupConfig::getInstance()->getPepper(0); $iat = $data[JwtApiKey::START_VALID]; @@ -62,7 +84,7 @@ protected function createObject(array $data): int { "exp" => $expires, "jti" => $jti, "userId" => $this->getCurrentUser()->getId(), - "scope" => $rightGroup->getPermissions(), + "scope" => $requestedScopes, "iss" => "Hashtopolis", "kid" => hash("sha256", $secret) ]; @@ -84,17 +106,10 @@ function aggregateData(object $object, array &$included_data = [], ?array $aggre return $aggregatedData; } - /** - * @throws HttpError - */ - public function updateObject(int $objectId, array $data): void { - throw new HttpError("ApiToken cannot be updated via API"); - } - /** * @throws HttpError */ protected function deleteObject(object $object): void { - throw new HttpError("ApiToken cannot be deleted via API"); + JwtTokenUtils::deleteKey($object); } } \ No newline at end of file diff --git a/src/inc/utils/JwtTokenUtils.php b/src/inc/utils/JwtTokenUtils.php index b9b336433..c13c88708 100644 --- a/src/inc/utils/JwtTokenUtils.php +++ b/src/inc/utils/JwtTokenUtils.php @@ -5,10 +5,11 @@ use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\inc\apiv2\error\HttpError; +use Hashtopolis\inc\apiv2\error\HttpForbidden; class JwtTokenUtils { - public static function createKey($userId, $startValid, $endValid) { + public static function createKey(int $userId, int $startValid, int $endValid) { $user = Factory::getUserFactory()->get($userId); if ($user == null) { throw new HttpError("Invalid user ID"); @@ -18,4 +19,13 @@ public static function createKey($userId, $startValid, $endValid) { Factory::getJwtApiKeyFactory()->save($key); return $key; } + + public static function deleteKey(JwtApiKey $JwtToken) { + $expireTime = $JwtToken->getEndValid(); + if (time() < $expireTime) { + throw new HttpForbidden("Not possible to delete Api key when it has not expired yet. revoke it instead"); + } + Factory::getJwtApiKeyFactory()->delete($JwtToken); + + } } \ No newline at end of file diff --git a/src/inc/utils/UserUtils.php b/src/inc/utils/UserUtils.php index 0bdb7230b..8ad4f4a99 100644 --- a/src/inc/utils/UserUtils.php +++ b/src/inc/utils/UserUtils.php @@ -12,6 +12,7 @@ use Hashtopolis\dba\models\NotificationSetting; use Hashtopolis\dba\models\Agent; use Hashtopolis\dba\Factory; +use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\inc\apiv2\error\HttpConflict; use Hashtopolis\inc\apiv2\error\HttpError; use Hashtopolis\inc\defines\DConfig; @@ -62,6 +63,10 @@ public static function deleteUser($userId, $adminUser) { Factory::getSessionFactory()->massDeletion([Factory::FILTER => $qF]); $qF = new QueryFilter(AccessGroupUser::USER_ID, $user->getId(), "="); Factory::getAccessGroupUserFactory()->massDeletion([Factory::FILTER => $qF]); + $qF = new QueryFilter(Session::USER_ID, $user->getId(), "="); + $uS = new UpdateSet(JwtApiKey::IS_REVOKED, 1); + // Revoke all of the API keys of the user + Factory::getJwtApiKeyFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => $uS]); Factory::getUserFactory()->delete($user); } From 72fab9e12b48c995fb26ad58e54ba2f947a1d72f Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 13:49:37 +0100 Subject: [PATCH 03/14] Fixed bug where wrong apiclass was used to aggregatedata --- src/inc/apiv2/common/AbstractBaseAPI.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index 70b5962d5..d62dc5d14 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -684,7 +684,8 @@ protected function obj2Resource(object $obj, array &$expandResult = [], ?array $ $attributes[$feature['alias']] = $apiClass::db2json($feature, $kv[$name]); } - $aggregatedData = $this->aggregateData($obj, $expandResult, $aggregateFieldsets); + $apiClassObject = new $apiClass($this->container); + $aggregatedData = $apiClassObject->aggregateData($obj, $expandResult, $aggregateFieldsets); $attributes = array_merge($attributes, $aggregatedData); /* Build JSON::API relationship resource */ From 6f737d57efd2261f08337f03eb68244a7132475e Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 14:21:15 +0100 Subject: [PATCH 04/14] Fixed sql migrations --- src/migrations/mysql/20260309164000_api-key.sql | 9 +++++++-- src/migrations/postgres/20260309164000_api-key.sql | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql index a9a82aa2c..c04b42170 100644 --- a/src/migrations/mysql/20260309164000_api-key.sql +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -1,6 +1,11 @@ CREATE TABLE JwtApiKey ( - JwtApiKeyId SERIAL PRIMARY KEY, + JwtApiKeyId INT NOT NULL, userId INTEGER REFERENCES htp_User(id), startValid bigint, endValid bigint, - isRevoked BOOLEAN DEFAULT FALSE); \ No newline at end of file + isRevoked BOOLEAN DEFAULT FALSE, + PRIMARY KEY (`JwtApiKeyId`), + KEY `idx_JwtApiKey_userId` (`userId`), + CONSTRAINT `fk_JwtApiKey_user` + FOREIGN KEY (`userId`) REFERENCES `htp_User`(`userId`) +); \ No newline at end of file diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index a9a82aa2c..8a2b41e18 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -1,6 +1,11 @@ CREATE TABLE JwtApiKey ( - JwtApiKeyId SERIAL PRIMARY KEY, + JwtApiKeyId SERIAL NOT NULL PRIMARY KEY, userId INTEGER REFERENCES htp_User(id), startValid bigint, endValid bigint, - isRevoked BOOLEAN DEFAULT FALSE); \ No newline at end of file + isRevoked BOOLEAN DEFAULT FALSE, + CONSTRAINT fk_JwtApiKey_user + FOREIGN KEY (userId) REFERENCES htp_User(id), + CONSTRAINT idx_JwtApiKey_userId + INDEX (userId) +); \ No newline at end of file From 9cd4fe80ec3a14700e63d5c7c746719cd29436e6 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 14:31:53 +0100 Subject: [PATCH 05/14] Removed unecesary index statement --- src/migrations/mysql/20260309164000_api-key.sql | 2 +- src/migrations/postgres/20260309164000_api-key.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql index c04b42170..28005d70a 100644 --- a/src/migrations/mysql/20260309164000_api-key.sql +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -1,6 +1,6 @@ CREATE TABLE JwtApiKey ( JwtApiKeyId INT NOT NULL, - userId INTEGER REFERENCES htp_User(id), + userId INTEGER, startValid bigint, endValid bigint, isRevoked BOOLEAN DEFAULT FALSE, diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index 8a2b41e18..3ff5912b5 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -1,6 +1,6 @@ CREATE TABLE JwtApiKey ( JwtApiKeyId SERIAL NOT NULL PRIMARY KEY, - userId INTEGER REFERENCES htp_User(id), + userId INTEGER, startValid bigint, endValid bigint, isRevoked BOOLEAN DEFAULT FALSE, From 3b5d5c0f85df5cff91c092670b75cf4d03aa1924 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 14:38:11 +0100 Subject: [PATCH 06/14] Fixed index postgres --- src/migrations/postgres/20260309164000_api-key.sql | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index 3ff5912b5..80d74d373 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -5,7 +5,6 @@ CREATE TABLE JwtApiKey ( endValid bigint, isRevoked BOOLEAN DEFAULT FALSE, CONSTRAINT fk_JwtApiKey_user - FOREIGN KEY (userId) REFERENCES htp_User(id), - CONSTRAINT idx_JwtApiKey_userId - INDEX (userId) -); \ No newline at end of file + FOREIGN KEY (userId) REFERENCES htp_User(id) +); +CREATE INDEX idx_JwtApiKey_userId ON JwtApiKey (userId); \ No newline at end of file From 9f624a6c6413ed27d50e19ee13b90c2a99fba5d8 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 14:44:34 +0100 Subject: [PATCH 07/14] Another sql fix --- src/migrations/postgres/20260309164000_api-key.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index 80d74d373..2ce510f1d 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -5,6 +5,6 @@ CREATE TABLE JwtApiKey ( endValid bigint, isRevoked BOOLEAN DEFAULT FALSE, CONSTRAINT fk_JwtApiKey_user - FOREIGN KEY (userId) REFERENCES htp_User(id) + FOREIGN KEY (userId) REFERENCES htp_User(userId) ); CREATE INDEX idx_JwtApiKey_userId ON JwtApiKey (userId); \ No newline at end of file From 58a555c43944ef77ffb0d9b3d7b4c077b3254c27 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 15:49:19 +0100 Subject: [PATCH 08/14] Added forgoten auto_increment to mysql --- src/migrations/mysql/20260309164000_api-key.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql index 28005d70a..8f8cc86b5 100644 --- a/src/migrations/mysql/20260309164000_api-key.sql +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -1,5 +1,5 @@ CREATE TABLE JwtApiKey ( - JwtApiKeyId INT NOT NULL, + JwtApiKeyId INT NOT NULL AUTO_INCREMENT, userId INTEGER, startValid bigint, endValid bigint, From fdb90bbc2361128af76f8756a7434a6d0c2a6457 Mon Sep 17 00:00:00 2001 From: jessevz Date: Wed, 11 Mar 2026 15:49:47 +0100 Subject: [PATCH 09/14] Added ACL to ApiToken --- src/inc/apiv2/model/ApiTokenAPI.php | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/inc/apiv2/model/ApiTokenAPI.php b/src/inc/apiv2/model/ApiTokenAPI.php index 2ad662b2c..cdc3b5960 100644 --- a/src/inc/apiv2/model/ApiTokenAPI.php +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -3,8 +3,10 @@ namespace Hashtopolis\inc\apiv2\model; use Firebase\JWT\JWT; +use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\dba\models\User; +use Hashtopolis\dba\QueryFilter; use Hashtopolis\inc\apiv2\common\AbstractModelAPI; use Hashtopolis\inc\apiv2\error\HttpError; use Hashtopolis\inc\StartupConfig; @@ -46,12 +48,24 @@ public static function getToOneRelationships(): array { } public function getFormFields(): array { - // TODO Form declarations in more generic class to allow auto-generated OpenAPI specifications - return [ - "scopes" => ['type' => 'array', 'subtype' => 'string'] - ]; -} + // TODO Form declarations in more generic class to allow auto-generated OpenAPI specifications + return [ + "scopes" => ['type' => 'array', 'subtype' => 'string'] + ]; + } + + protected function getSingleACL(User $user, object $object): bool { + return ($object->getUserId() === $user->getId()); + } + protected function getFilterACL(): array { + $userId = $this->getCurrentUser()->getId(); + return [ + Factory::FILTER => [ + new QueryFilter(User::USER_ID, $userId, "=") + ] + ]; + } /** * @throws HttpError */ From 25da3dc981c7af0cc15310398fc26061e5b38404 Mon Sep 17 00:00:00 2001 From: jessevz Date: Thu, 12 Mar 2026 08:47:12 +0100 Subject: [PATCH 10/14] Fixed copilot suggestions --- src/dba/models/JwtApiKey.php | 2 +- src/dba/models/generator.php | 2 +- src/inc/apiv2/auth/JWTBeforeHandler.php | 15 +++++++++++++++ src/inc/apiv2/auth/token.routes.php | 7 +++++-- src/inc/apiv2/common/AbstractBaseAPI.php | 9 +++++++-- src/inc/apiv2/model/ApiTokenAPI.php | 4 +++- src/inc/utils/UserUtils.php | 2 +- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/dba/models/JwtApiKey.php b/src/dba/models/JwtApiKey.php index 12cdeae41..3883b45d6 100644 --- a/src/dba/models/JwtApiKey.php +++ b/src/dba/models/JwtApiKey.php @@ -97,7 +97,7 @@ function setIsRevoked(?int $isRevoked): void { $this->isRevoked = $isRevoked; } - const _JWT_API_KEY_ID = "JwtApiKeyId"; + const JWT_API_KEY_ID = "JwtApiKeyId"; const START_VALID = "startValid"; const END_VALID = "endValid"; const USER_ID = "userId"; diff --git a/src/dba/models/generator.php b/src/dba/models/generator.php index 941448b93..726e57ba7 100644 --- a/src/dba/models/generator.php +++ b/src/dba/models/generator.php @@ -289,7 +289,7 @@ ]; $CONF['JwtApiKey'] = [ 'columns' => [ - ['name' => 'JwtApiKeyId', 'read_only' => True, 'type' => 'int', 'protected' => True], + ['name' => 'jwtApiKeyId', 'read_only' => True, 'type' => 'int', 'protected' => True], ['name' => 'startValid', 'read_only' => True, 'type' => 'int64'], ['name' => 'endValid', 'read_only' => True, 'type' => 'int64'], ['name' => 'userId', 'read_only' => True, 'type' => 'int', 'relation' => 'User'], diff --git a/src/inc/apiv2/auth/JWTBeforeHandler.php b/src/inc/apiv2/auth/JWTBeforeHandler.php index 2a4092338..eb884901b 100644 --- a/src/inc/apiv2/auth/JWTBeforeHandler.php +++ b/src/inc/apiv2/auth/JWTBeforeHandler.php @@ -2,6 +2,10 @@ namespace Hashtopolis\inc\apiv2\auth; +use Hashtopolis\dba\Factory; +use Hashtopolis\inc\apiv2\error\HttpError; +use Hashtopolis\inc\apiv2\error\HttpForbidden; +use Hashtopolis\inc\apiv2\model\ApiTokenAPI; use JimTools\JwtAuth\Handlers\BeforeHandlerInterface; use Psr\Http\Message\ServerRequestInterface; @@ -10,6 +14,17 @@ class JWTBeforeHandler implements BeforeHandlerInterface { * @param array{decoded: array, token: string} $arguments */ public function __invoke(ServerRequestInterface $request, array $arguments): ServerRequestInterface { + if (isset ($arguments["decoded"]["aud"]) && $arguments["decoded"]["aud"] == ApiTokenAPI::API_AUD) { + $apiTokenId = $arguments["decoded"]["jti"]; + $token = Factory::getJwtApiKeyFactory()->get($apiTokenId); + if ($token === null) { + // Should not happen + throw new HttpError("Token doesnt exists in database"); + } + if ($token->getIsRevoked() === 1) { + throw new HttpForbidden("Token is revoked"); + } + } // adds the decoded userId and scope to the request attributes return $request->withAttribute("userId", $arguments["decoded"]["userId"])->withAttribute("scope", $arguments["decoded"]["scope"]); } diff --git a/src/inc/apiv2/auth/token.routes.php b/src/inc/apiv2/auth/token.routes.php index 509a4e568..482817b6b 100644 --- a/src/inc/apiv2/auth/token.routes.php +++ b/src/inc/apiv2/auth/token.routes.php @@ -18,6 +18,7 @@ require_once(dirname(__FILE__) . "/../../startup/include.php"); +const USER_AUD = "user_hashtopolis"; function generateTokenForUser(Request $request, string $userName, int $expires) { $jti = bin2hex(random_bytes(16)); @@ -63,7 +64,8 @@ function generateTokenForUser(Request $request, string $userName, int $expires) "userId" => $user->getId(), "scope" => $scopes, "iss" => "Hashtopolis", - "kid" => hash("sha256", $secret) + "kid" => hash("sha256", $secret), + "aud" => USER_AUD ]; $token = JWT::encode($payload, $secret, "HS256"); @@ -170,7 +172,8 @@ function extractBearerToken(Request $request): ?string { "userId" => $request->getAttribute(('userId')), "scope" => $request->getAttribute("scope"), "iss" => "Hashtopolis", - "kid" => hash("sha256", $secret) + "kid" => hash("sha256", $secret), + "aud" => USER_AUD ]; $token = JWT::encode($payload, $secret, "HS256"); diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index d62dc5d14..532b1e212 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -59,7 +59,6 @@ use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\LikeFilter; use Hashtopolis\dba\LikeFilterInsensitive; -use Hashtopolis\dba\models\ApiKey; use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\dba\models\LogEntry; use Hashtopolis\dba\models\Preprocessor; @@ -684,7 +683,13 @@ protected function obj2Resource(object $obj, array &$expandResult = [], ?array $ $attributes[$feature['alias']] = $apiClass::db2json($feature, $kv[$name]); } - $apiClassObject = new $apiClass($this->container); + if ($this instanceof AbstractModelAPI && get_class($obj) !== $this->getDBAClass()) { + $apiClassObject = new $apiClass($this->container); + } else { + // use instance of this when the object is of the dba class of this api endpoint. + // This way its possible to set object attributes in the post to be used in the aggregateData function. + $apiClassObject = $this; + } $aggregatedData = $apiClassObject->aggregateData($obj, $expandResult, $aggregateFieldsets); $attributes = array_merge($attributes, $aggregatedData); diff --git a/src/inc/apiv2/model/ApiTokenAPI.php b/src/inc/apiv2/model/ApiTokenAPI.php index cdc3b5960..6f4e1897f 100644 --- a/src/inc/apiv2/model/ApiTokenAPI.php +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -14,6 +14,7 @@ use Hashtopolis\inc\utils\JwtTokenUtils; class ApiTokenAPI extends AbstractModelAPI { + const API_AUD = "api_hashtopolis"; private ?string $jwtToken = null; private function setJwtToken(string $token): void { @@ -98,8 +99,9 @@ protected function createObject(array $data): int { "exp" => $expires, "jti" => $jti, "userId" => $this->getCurrentUser()->getId(), - "scope" => $requestedScopes, + "scope" => json_encode($requestedScopes), "iss" => "Hashtopolis", + "aud" => $this::API_AUD, "kid" => hash("sha256", $secret) ]; diff --git a/src/inc/utils/UserUtils.php b/src/inc/utils/UserUtils.php index 8ad4f4a99..6fc7d8d53 100644 --- a/src/inc/utils/UserUtils.php +++ b/src/inc/utils/UserUtils.php @@ -63,7 +63,7 @@ public static function deleteUser($userId, $adminUser) { Factory::getSessionFactory()->massDeletion([Factory::FILTER => $qF]); $qF = new QueryFilter(AccessGroupUser::USER_ID, $user->getId(), "="); Factory::getAccessGroupUserFactory()->massDeletion([Factory::FILTER => $qF]); - $qF = new QueryFilter(Session::USER_ID, $user->getId(), "="); + $qF = new QueryFilter(JwtApiKey::USER_ID, $user->getId(), "="); $uS = new UpdateSet(JwtApiKey::IS_REVOKED, 1); // Revoke all of the API keys of the user Factory::getJwtApiKeyFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => $uS]); From dfc2b215eb23eb085d82468fffc4ee9539ab2410 Mon Sep 17 00:00:00 2001 From: jessevz Date: Thu, 12 Mar 2026 08:52:49 +0100 Subject: [PATCH 11/14] Return token data in lower case like all other data --- src/inc/apiv2/model/ApiTokenAPI.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inc/apiv2/model/ApiTokenAPI.php b/src/inc/apiv2/model/ApiTokenAPI.php index 6f4e1897f..601eaea13 100644 --- a/src/inc/apiv2/model/ApiTokenAPI.php +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -116,7 +116,7 @@ function aggregateData(object $object, array &$included_data = [], ?array $aggre $aggregatedData = []; $token = $this->getJwtToken(); if ($token !== null) { - $aggregatedData["Token"] = $token; + $aggregatedData["token"] = $token; } return $aggregatedData; From e12e54fa922214ea04ff4367113641886584d7c7 Mon Sep 17 00:00:00 2001 From: jessevz Date: Thu, 12 Mar 2026 10:27:39 +0100 Subject: [PATCH 12/14] Fixed phpstan errors --- src/inc/apiv2/auth/token.routes.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/inc/apiv2/auth/token.routes.php b/src/inc/apiv2/auth/token.routes.php index 482817b6b..956d0b1e2 100644 --- a/src/inc/apiv2/auth/token.routes.php +++ b/src/inc/apiv2/auth/token.routes.php @@ -15,6 +15,7 @@ use Firebase\JWT\JWK; use Hashtopolis\dba\JoinFilter; use Hashtopolis\dba\models\RightGroup; +use Hashtopolis\inc\apiv2\error\HttpForbidden; require_once(dirname(__FILE__) . "/../../startup/include.php"); @@ -25,14 +26,17 @@ function generateTokenForUser(Request $request, string $userName, int $expires) $filter = new QueryFilter(User::USERNAME, $userName, "="); $jF = new JoinFilter(Factory::getRightGroupFactory(), User::RIGHT_GROUP_ID, RightGroup::RIGHT_GROUP_ID); $joined = Factory::getUserFactory()->filter([Factory::FILTER => $filter, Factory::JOIN => $jF]); - /** @var $check User[] */ + /** @var User[] $check */ $check = $joined[Factory::getUserFactory()->getModelName()]; if (count($check) === 0) { throw new HttpError("No user with this userName in the database"); } $user = $check[0]; + if ($user->getIsValid() !== 1) { + throw new HttpForbidden("User is set to invalid"); + } - /** @var $groupArray RightGroup[] */ + /** @var RightGroup[] $groupArray */ $groupArray = $joined[Factory::getRightGroupFactory()->getModelName()]; if (count($groupArray) === 0) { throw new HttpError("No rightgroup found for this user"); @@ -88,8 +92,7 @@ function extractBearerToken(Request $request): ?string { } // Exchanges an oauth token for a application JWT token -use Slim\App; -/** @var App $app */ +/** @var \Slim\App $app */ $app->group("/api/v2/auth/oauth-token", function (RouteCollectorProxy $group) { $group->post('', function (Request $request, Response $response, array $args): Response { From 04f22fa3b4056b661621c46391c14e48cbebcd41 Mon Sep 17 00:00:00 2001 From: jessevz Date: Thu, 12 Mar 2026 13:10:24 +0100 Subject: [PATCH 13/14] Made changes based on review --- src/dba/models/JwtApiKey.php | 22 +++++++++---------- src/inc/apiv2/auth/JWTBeforeHandler.php | 2 +- src/inc/utils/JwtTokenUtils.php | 2 +- src/inc/utils/UserUtils.php | 6 +++-- .../mysql/20260309164000_api-key.sql | 10 ++++----- .../postgres/20260309164000_api-key.sql | 8 +++---- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/dba/models/JwtApiKey.php b/src/dba/models/JwtApiKey.php index 3883b45d6..3beac6a98 100644 --- a/src/dba/models/JwtApiKey.php +++ b/src/dba/models/JwtApiKey.php @@ -5,14 +5,14 @@ use Hashtopolis\dba\AbstractModel; class JwtApiKey extends AbstractModel { - private ?int $JwtApiKeyId; + private ?int $jwtApiKeyId; private ?int $startValid; private ?int $endValid; private ?int $userId; private ?int $isRevoked; - function __construct(?int $JwtApiKeyId, ?int $startValid, ?int $endValid, ?int $userId, ?int $isRevoked) { - $this->JwtApiKeyId = $JwtApiKeyId; + function __construct(?int $jwtApiKeyId, ?int $startValid, ?int $endValid, ?int $userId, ?int $isRevoked) { + $this->jwtApiKeyId = $jwtApiKeyId; $this->startValid = $startValid; $this->endValid = $endValid; $this->userId = $userId; @@ -21,7 +21,7 @@ function __construct(?int $JwtApiKeyId, ?int $startValid, ?int $endValid, ?int $ function getKeyValueDict(): array { $dict = array(); - $dict['JwtApiKeyId'] = $this->JwtApiKeyId; + $dict['jwtApiKeyId'] = $this->jwtApiKeyId; $dict['startValid'] = $this->startValid; $dict['endValid'] = $this->endValid; $dict['userId'] = $this->userId; @@ -32,29 +32,29 @@ function getKeyValueDict(): array { static function getFeatures(): array { $dict = array(); - $dict['JwtApiKeyId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => True, "protected" => True, "private" => False, "alias" => "JwtApiKeyId", "public" => False, "dba_mapping" => False]; + $dict['jwtApiKeyId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => True, "protected" => True, "private" => False, "alias" => "jwtApiKeyId", "public" => False, "dba_mapping" => False]; $dict['startValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "startValid", "public" => False, "dba_mapping" => False]; $dict['endValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "endValid", "public" => False, "dba_mapping" => False]; - $dict['userId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => True, "private" => False, "alias" => "userId", "public" => False, "dba_mapping" => False]; + $dict['userId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "userId", "public" => False, "dba_mapping" => False]; $dict['isRevoked'] = ['read_only' => False, "type" => "bool", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "isRevoked", "public" => False, "dba_mapping" => False]; return $dict; } function getPrimaryKey(): string { - return "JwtApiKeyId"; + return "jwtApiKeyId"; } function getPrimaryKeyValue(): ?int { - return $this->JwtApiKeyId; + return $this->jwtApiKeyId; } function getId(): ?int { - return $this->JwtApiKeyId; + return $this->jwtApiKeyId; } function setId($id): void { - $this->JwtApiKeyId = $id; + $this->jwtApiKeyId = $id; } /** @@ -97,7 +97,7 @@ function setIsRevoked(?int $isRevoked): void { $this->isRevoked = $isRevoked; } - const JWT_API_KEY_ID = "JwtApiKeyId"; + const JWT_API_KEY_ID = "jwtApiKeyId"; const START_VALID = "startValid"; const END_VALID = "endValid"; const USER_ID = "userId"; diff --git a/src/inc/apiv2/auth/JWTBeforeHandler.php b/src/inc/apiv2/auth/JWTBeforeHandler.php index eb884901b..1eaf318d4 100644 --- a/src/inc/apiv2/auth/JWTBeforeHandler.php +++ b/src/inc/apiv2/auth/JWTBeforeHandler.php @@ -19,7 +19,7 @@ public function __invoke(ServerRequestInterface $request, array $arguments): Ser $token = Factory::getJwtApiKeyFactory()->get($apiTokenId); if ($token === null) { // Should not happen - throw new HttpError("Token doesnt exists in database"); + throw new HttpError("Token doesn't exists in the database"); } if ($token->getIsRevoked() === 1) { throw new HttpForbidden("Token is revoked"); diff --git a/src/inc/utils/JwtTokenUtils.php b/src/inc/utils/JwtTokenUtils.php index c13c88708..5e3b4d30d 100644 --- a/src/inc/utils/JwtTokenUtils.php +++ b/src/inc/utils/JwtTokenUtils.php @@ -23,7 +23,7 @@ public static function createKey(int $userId, int $startValid, int $endValid) { public static function deleteKey(JwtApiKey $JwtToken) { $expireTime = $JwtToken->getEndValid(); if (time() < $expireTime) { - throw new HttpForbidden("Not possible to delete Api key when it has not expired yet. revoke it instead"); + throw new HttpForbidden("Cannot delete API key before it expires; revoke it instead."); } Factory::getJwtApiKeyFactory()->delete($JwtToken); diff --git a/src/inc/utils/UserUtils.php b/src/inc/utils/UserUtils.php index 6fc7d8d53..28e455520 100644 --- a/src/inc/utils/UserUtils.php +++ b/src/inc/utils/UserUtils.php @@ -64,9 +64,11 @@ public static function deleteUser($userId, $adminUser) { $qF = new QueryFilter(AccessGroupUser::USER_ID, $user->getId(), "="); Factory::getAccessGroupUserFactory()->massDeletion([Factory::FILTER => $qF]); $qF = new QueryFilter(JwtApiKey::USER_ID, $user->getId(), "="); - $uS = new UpdateSet(JwtApiKey::IS_REVOKED, 1); + $uS1 = new UpdateSet(JwtApiKey::IS_REVOKED, 1); + $uS2 = new UpdateSet(JwtApiKey::USER_ID, null); + // Revoke all of the API keys of the user - Factory::getJwtApiKeyFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => $uS]); + Factory::getJwtApiKeyFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => [$uS1, $uS2]]); Factory::getUserFactory()->delete($user); } diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql index 8f8cc86b5..789fc739e 100644 --- a/src/migrations/mysql/20260309164000_api-key.sql +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -1,10 +1,10 @@ CREATE TABLE JwtApiKey ( - JwtApiKeyId INT NOT NULL AUTO_INCREMENT, + jwtApiKeyId INT NOT NULL AUTO_INCREMENT, userId INTEGER, - startValid bigint, - endValid bigint, - isRevoked BOOLEAN DEFAULT FALSE, - PRIMARY KEY (`JwtApiKeyId`), + startValid bigint NOT NULL, + endValid bigint NOT NULL, + isRevoked BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (`jwtApiKeyId`), KEY `idx_JwtApiKey_userId` (`userId`), CONSTRAINT `fk_JwtApiKey_user` FOREIGN KEY (`userId`) REFERENCES `htp_User`(`userId`) diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index 2ce510f1d..332659d93 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -1,9 +1,9 @@ CREATE TABLE JwtApiKey ( - JwtApiKeyId SERIAL NOT NULL PRIMARY KEY, + jwtApiKeyId SERIAL NOT NULL PRIMARY KEY, userId INTEGER, - startValid bigint, - endValid bigint, - isRevoked BOOLEAN DEFAULT FALSE, + startValid bigint NOT NULL, + endValid bigint NOT NULL, + isRevoked BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT fk_JwtApiKey_user FOREIGN KEY (userId) REFERENCES htp_User(userId) ); From 8715c3a90c2800d80671770a1c1a0f6a7859dffb Mon Sep 17 00:00:00 2001 From: jessevz Date: Thu, 12 Mar 2026 15:24:06 +0100 Subject: [PATCH 14/14] removed capital in foreignkey --- src/migrations/mysql/20260309164000_api-key.sql | 4 ++-- src/migrations/postgres/20260309164000_api-key.sql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql index 789fc739e..1e937f2c5 100644 --- a/src/migrations/mysql/20260309164000_api-key.sql +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -5,7 +5,7 @@ CREATE TABLE JwtApiKey ( endValid bigint NOT NULL, isRevoked BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (`jwtApiKeyId`), - KEY `idx_JwtApiKey_userId` (`userId`), - CONSTRAINT `fk_JwtApiKey_user` + KEY `idx_jwtApiKey_userId` (`userId`), + CONSTRAINT `fk_jwtApiKey_user` FOREIGN KEY (`userId`) REFERENCES `htp_User`(`userId`) ); \ No newline at end of file diff --git a/src/migrations/postgres/20260309164000_api-key.sql b/src/migrations/postgres/20260309164000_api-key.sql index 332659d93..40a5d2fd4 100644 --- a/src/migrations/postgres/20260309164000_api-key.sql +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -7,4 +7,4 @@ CREATE TABLE JwtApiKey ( CONSTRAINT fk_JwtApiKey_user FOREIGN KEY (userId) REFERENCES htp_User(userId) ); -CREATE INDEX idx_JwtApiKey_userId ON JwtApiKey (userId); \ No newline at end of file +CREATE INDEX idx_jwtApiKey_userId ON JwtApiKey (userId); \ No newline at end of file