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..3beac6a98 --- /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" => 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"; + } + + 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..726e57ba7 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/JWTBeforeHandler.php b/src/inc/apiv2/auth/JWTBeforeHandler.php index 2a4092338..1eaf318d4 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 doesn't exists in the 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 11078b066..956d0b1e2 100644 --- a/src/inc/apiv2/auth/token.routes.php +++ b/src/inc/apiv2/auth/token.routes.php @@ -13,34 +13,50 @@ use Hashtopolis\dba\models\User; use Hashtopolis\dba\Factory; 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"); +const USER_AUD = "user_hashtopolis"; 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 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"); + } - if (empty($user)) { - throw new HttpError("No user with this userName in the database"); + /** @var RightGroup[] $groupArray */ + $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(); @@ -52,7 +68,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"); @@ -75,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 { @@ -159,7 +175,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 b2f4ab45a..532b1e212 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -59,6 +59,7 @@ use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\LikeFilter; use Hashtopolis\dba\LikeFilterInsensitive; +use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\dba\models\LogEntry; use Hashtopolis\dba\models\Preprocessor; use Hashtopolis\dba\QueryFilter; @@ -182,7 +183,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 +308,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 +549,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), ); /** @@ -594,7 +598,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') { @@ -679,7 +683,14 @@ protected function obj2Resource(object $obj, array &$expandResult = [], ?array $ $attributes[$feature['alias']] = $apiClass::db2json($feature, $kv[$name]); } - $aggregatedData = $apiClass::aggregateData($obj, $expandResult, $aggregateFieldsets); + 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); /* Build JSON::API relationship resource */ @@ -1063,7 +1074,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 +1383,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 +1494,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 +1521,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..601eaea13 --- /dev/null +++ b/src/inc/apiv2/model/ApiTokenAPI.php @@ -0,0 +1,131 @@ +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', 'DELETE']; + } + + 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, + ] + ]; + } + + public function getFormFields(): array { + // 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 + */ + protected function createObject(array $data): int { + //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]; + $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" => json_encode($requestedScopes), + "iss" => "Hashtopolis", + "aud" => $this::API_AUD, + "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 + */ + protected function deleteObject(object $object): void { + JwtTokenUtils::deleteKey($object); + } +} \ 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..5e3b4d30d --- /dev/null +++ b/src/inc/utils/JwtTokenUtils.php @@ -0,0 +1,31 @@ +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; + } + + public static function deleteKey(JwtApiKey $JwtToken) { + $expireTime = $JwtToken->getEndValid(); + if (time() < $expireTime) { + throw new HttpForbidden("Cannot delete API key before it expires; 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..28e455520 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,12 @@ 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(JwtApiKey::USER_ID, $user->getId(), "="); + $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 => [$uS1, $uS2]]); Factory::getUserFactory()->delete($user); } diff --git a/src/migrations/mysql/20260309164000_api-key.sql b/src/migrations/mysql/20260309164000_api-key.sql new file mode 100644 index 000000000..1e937f2c5 --- /dev/null +++ b/src/migrations/mysql/20260309164000_api-key.sql @@ -0,0 +1,11 @@ +CREATE TABLE JwtApiKey ( + jwtApiKeyId INT NOT NULL AUTO_INCREMENT, + userId INTEGER, + 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`) +); \ 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..40a5d2fd4 --- /dev/null +++ b/src/migrations/postgres/20260309164000_api-key.sql @@ -0,0 +1,10 @@ +CREATE TABLE JwtApiKey ( + jwtApiKeyId SERIAL NOT NULL PRIMARY KEY, + userId INTEGER, + 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) +); +CREATE INDEX idx_jwtApiKey_userId ON JwtApiKey (userId); \ No newline at end of file