diff --git a/app/V1Module/presenters/EmailsPresenter.php b/app/V1Module/presenters/EmailsPresenter.php index ce958a02c..3736dcdd9 100644 --- a/app/V1Module/presenters/EmailsPresenter.php +++ b/app/V1Module/presenters/EmailsPresenter.php @@ -14,7 +14,6 @@ class EmailsPresenter extends BasePresenter { - /** * @var EmailLocalizationHelper * @inject @@ -57,7 +56,8 @@ public function checkDefault() * Sends an email with provided subject and message to all ReCodEx users. * @POST * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code") + * @Param(type="post", name="message", validation="string:1..", + * description="Message which will be sent, can be html code") */ public function actionDefault() { @@ -87,7 +87,8 @@ public function checkSendToSupervisors() * Sends an email with provided subject and message to all supervisors and superadmins. * @POST * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code") + * @Param(type="post", name="message", validation="string:1..", + * description="Message which will be sent, can be html code") */ public function actionSendToSupervisors() { @@ -123,7 +124,8 @@ public function checkSendToRegularUsers() * Sends an email with provided subject and message to all regular users. * @POST * @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email") - * @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code") + * @Param(type="post", name="message", validation="string:1..", + * description="Message which will be sent, can be html code") */ public function actionSendToRegularUsers() { diff --git a/app/V1Module/presenters/ExtensionsPresenter.php b/app/V1Module/presenters/ExtensionsPresenter.php new file mode 100644 index 000000000..72ccd3c1f --- /dev/null +++ b/app/V1Module/presenters/ExtensionsPresenter.php @@ -0,0 +1,138 @@ +getCurrentUser(); + $extension = $this->extensions->getExtension($extId); + $instance = $this->instances->findOrThrow($instanceId); + if (!$extension || !$extension->isAccessible($instance, $user)) { + throw new ForbiddenRequestException(); + } + } + + /** + * Return URL refering to the extension with properly injected temporary JWT token. + * @GET + * @Param(type="query", name="locale", required=false, validation="string:2") + * @Param(type="query", name="return", required=false, validation="string") + */ + public function actionUrl(string $extId, string $instanceId, ?string $locale, ?string $return) + { + $user = $this->getCurrentUser(); + $extension = $this->extensions->getExtension($extId); + + $token = $this->accessManager->issueToken( + $user, + null, + [TokenScope::EXTENSIONS], + $extension->getUrlTokenExpiration(), + ["instance" => $instanceId, "extension" => $extId] + ); + + if (!$locale) { + $locale = $this->getCurrentUserLocale(); + } + + $this->sendSuccessResponse($extension->getUrl($token, $locale, $return ?? '')); + } + + public function checkToken(string $extId) + { + /* + * This checker does not employ traditional ACLs for permission checks since it is trvial and it is better + * to keep everything here (in one place). However, this may change in the future should the presenter get + * more complex. + * This action expects to be authenticated by temporary token generated in 'url' action. + */ + + // All users within this scope are allowed the operation... + if (!$this->isInScope(TokenScope::EXTENSIONS)) { + throw new ForbiddenRequestException(); + } + + // ...but the token must be also valid... + $token = $this->getAccessToken(); + $instanceId = $token->getPayload('instance'); + if ($token->getPayload('extension') !== $extId || !$instanceId) { + throw new BadRequestException(); + } + + // ...and the extension must be accessible by the user. + $user = $this->getCurrentUser(); + $extension = $this->extensions->getExtension($extId); + $instance = $this->instances->findOrThrow($instanceId); + if (!$extension || !$extension->isAccessible($instance, $user)) { + throw new ForbiddenRequestException(); + } + } + + /** + * This endpoint is used by a backend of an extension to get a proper access token + * (from a temp token passed via URL). It also returns details about authenticated user. + * @POST + */ + public function actionToken(string $extId) + { + $user = $this->getCurrentUser(); + $extension = $this->extensions->getExtension($extId); + $authUser = $extension->getTokenUserId() ? $this->users->findOrThrow($extension->getTokenUserId()) : $user; + + $token = $this->accessManager->issueToken( + $authUser, + null, + $extension->getTokenScopes(), + $extension->getTokenExpiration(), + ); + + $this->sendSuccessResponse([ + "accessToken" => $token, + "user" => $this->userViewFactory->getFullUser($user, false /* do not show really everything */), + ]); + } +} diff --git a/app/V1Module/presenters/InstancesPresenter.php b/app/V1Module/presenters/InstancesPresenter.php index ebff03683..151c51587 100644 --- a/app/V1Module/presenters/InstancesPresenter.php +++ b/app/V1Module/presenters/InstancesPresenter.php @@ -4,9 +4,7 @@ use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; -use App\Model\Entity\Group; use App\Model\Entity\LocalizedGroup; -use App\Model\Entity\User; use App\Model\View\GroupViewFactory; use App\Model\View\InstanceViewFactory; use App\Model\View\UserViewFactory; @@ -25,7 +23,6 @@ */ class InstancesPresenter extends BasePresenter { - /** * @var Instances * @inject @@ -94,7 +91,9 @@ function (Instance $instance) { return $instance->isAllowed(); } ); - $this->sendSuccessResponse($this->instanceViewFactory->getInstances($instances)); + $this->sendSuccessResponse( + $this->instanceViewFactory->getInstances($instances, $this->getCurrentUserOrNull()) + ); } public function checkCreateInstance() @@ -109,7 +108,8 @@ public function checkCreateInstance() * @POST * @Param(type="post", name="name", validation="string:2..", description="Name of the instance") * @Param(type="post", name="description", required=false, description="Description of the instance") - * @Param(type="post", name="isOpen", validation="bool", description="Should the instance be open for registration?") + * @Param(type="post", name="isOpen", validation="bool", + * description="Should the instance be open for registration?") * @throws ForbiddenRequestException */ public function actionCreateInstance() @@ -129,7 +129,7 @@ public function actionCreateInstance() $this->instances->persist($instance->getRootGroup(), false); $this->instances->persist($localizedRootGroup, false); $this->instances->persist($instance); - $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance), IResponse::S201_CREATED); + $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance, $user), IResponse::S201_CREATED); } public function checkUpdateInstance(string $id) @@ -144,7 +144,8 @@ public function checkUpdateInstance(string $id) /** * Update an instance * @POST - * @Param(type="post", name="isOpen", validation="bool", required=false, description="Should the instance be open for registration?") + * @Param(type="post", name="isOpen", validation="bool", required=false, + * description="Should the instance be open for registration?") * @param string $id An identifier of the updated instance */ public function actionUpdateInstance(string $id) @@ -159,7 +160,7 @@ public function actionUpdateInstance(string $id) $instance->setIsOpen($isOpen); $this->instances->persist($instance); - $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance)); + $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance, $this->getCurrentUser())); } public function checkDeleteInstance(string $id) @@ -208,7 +209,7 @@ public function checkDetail(string $id) public function actionDetail(string $id) { $instance = $this->instances->findOrThrow($id); - $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance)); + $this->sendSuccessResponse($this->instanceViewFactory->getInstance($instance, $this->getCurrentUser())); } public function checkLicences(string $id) @@ -268,9 +269,12 @@ public function checkUpdateLicence(string $licenceId) /** * Update an existing license for an instance * @POST - * @Param(type="post", name="note", validation="string:2..255", required=false, description="A note for users or administrators") - * @Param(type="post", name="validUntil", validation="string", required=false, description="Expiration date of the license") - * @Param(type="post", name="isValid", validation="bool", required=false, description="Administrator switch to toggle licence validity") + * @Param(type="post", name="note", validation="string:2..255", required=false, + * description="A note for users or administrators") + * @Param(type="post", name="validUntil", validation="string", required=false, + * description="Expiration date of the license") + * @Param(type="post", name="isValid", validation="bool", required=false, + * description="Administrator switch to toggle licence validity") * @param string $licenceId Identifier of the licence * @throws NotFoundException */ diff --git a/app/V1Module/presenters/LoginPresenter.php b/app/V1Module/presenters/LoginPresenter.php index a82afdeb3..8559d138d 100644 --- a/app/V1Module/presenters/LoginPresenter.php +++ b/app/V1Module/presenters/LoginPresenter.php @@ -15,7 +15,6 @@ use App\Model\Repository\SecurityEvents; use App\Model\Repository\Users; use App\Model\View\UserViewFactory; -use App\Security\AccessToken; use App\Security\AccessManager; use App\Security\ACL\IUserPermissions; use App\Security\CredentialsAuthenticator; @@ -257,7 +256,7 @@ public function actionIssueRestrictedToken() $this->sendSuccessResponse( [ "accessToken" => $this->accessManager->issueToken($user, $effectiveRole, $scopes, $expiration), - "user" => $this->userViewFactory->getFullUser($user) + "user" => $this->userViewFactory->getFullUser($user), ] ); } diff --git a/app/V1Module/presenters/NotificationsPresenter.php b/app/V1Module/presenters/NotificationsPresenter.php index 824549267..5be144f33 100644 --- a/app/V1Module/presenters/NotificationsPresenter.php +++ b/app/V1Module/presenters/NotificationsPresenter.php @@ -103,10 +103,14 @@ public function checkCreate() /** * Create notification with given attributes - * @Param(type="post", name="groupsIds", validation="array", description="Identification of groups") - * @Param(type="post", name="visibleFrom", validation="timestamp", description="Date from which is notification visible") - * @Param(type="post", name="visibleTo", validation="timestamp", description="Date to which is notification visible") - * @Param(type="post", name="role", validation="string:1..", description="Users with this role and its children can see notification") + * @Param(type="post", name="groupsIds", validation="array", + * description="Identification of groups") + * @Param(type="post", name="visibleFrom", validation="timestamp", + * description="Date from which is notification visible") + * @Param(type="post", name="visibleTo", validation="timestamp", + * description="Date to which is notification visible") + * @Param(type="post", name="role", validation="string:1..", + * description="Users with this role and its children can see notification") * @Param(type="post", name="type", validation="string", description="Type of the notification (custom)") * @Param(type="post", name="localizedTexts", validation="array", description="Text of notification") * @POST @@ -218,9 +222,12 @@ public function checkUpdate(string $id) * @POST * @param string $id * @Param(type="post", name="groupsIds", validation="array", description="Identification of groups") - * @Param(type="post", name="visibleFrom", validation="timestamp", description="Date from which is notification visible") - * @Param(type="post", name="visibleTo", validation="timestamp", description="Date to which is notification visible") - * @Param(type="post", name="role", validation="string:1..", description="Users with this role and its children can see notification") + * @Param(type="post", name="visibleFrom", validation="timestamp", + * description="Date from which is notification visible") + * @Param(type="post", name="visibleTo", validation="timestamp", + * description="Date to which is notification visible") + * @Param(type="post", name="role", validation="string:1..", + * description="Users with this role and its children can see notification") * @Param(type="post", name="type", validation="string", description="Type of the notification (custom)") * @Param(type="post", name="localizedTexts", validation="array", description="Text of notification") * @throws NotFoundException diff --git a/app/V1Module/presenters/UsersPresenter.php b/app/V1Module/presenters/UsersPresenter.php index 173ebd78f..b15565040 100644 --- a/app/V1Module/presenters/UsersPresenter.php +++ b/app/V1Module/presenters/UsersPresenter.php @@ -655,7 +655,10 @@ public function actionInstances(string $id) { $user = $this->users->findOrThrow($id); - $this->sendSuccessResponse($this->instanceViewFactory->getInstances($user->getInstances()->toArray())); + $this->sendSuccessResponse($this->instanceViewFactory->getInstances( + $user->getInstances()->toArray(), + $this->getCurrentUser() + )); } public function checkSetRole(string $id) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 57f6fe0cc..893c20c19 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -137,19 +137,26 @@ protected function isRequestJson(): bool } /** - * @return User - * @throws ForbiddenRequestException + * @return User|null (null if no user is authenticated) */ - protected function getCurrentUser(): User + protected function getCurrentUserOrNull(): ?User { /** @var ?Identity $identity */ $identity = $this->getUser()->getIdentity(); + return $identity?->getUserData(); + } - if ($identity === null || $identity->getUserData() === null) { + /** + * @return User + * @throws ForbiddenRequestException + */ + protected function getCurrentUser(): User + { + $user = $this->getCurrentUserOrNull(); + if ($user === null) { throw new ForbiddenRequestException(); } - - return $identity->getUserData(); + return $user; } /** diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index 973be0ec8..c6357b495 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -58,6 +58,7 @@ public static function createRouter() $router[] = self::createWorkerFilesRoutes("$prefix/worker-files"); $router[] = self::createAsyncJobsRoutes("$prefix/async-jobs"); $router[] = self::createPlagiarismRoutes("$prefix/plagiarism"); + $router[] = self::createExtensionsRoutes("$prefix/extensions"); return $router; } @@ -664,4 +665,12 @@ private static function createPlagiarismRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix//", "Plagiarism:addSimilarities"); return $router; } + + private static function createExtensionsRoutes(string $prefix): RouteList + { + $router = new RouteList(); + $router[] = new GetRoute("$prefix//", "Extensions:url"); + $router[] = new PostRoute("$prefix/", "Extensions:token"); + return $router; + } } diff --git a/app/V1Module/security/TokenScope.php b/app/V1Module/security/TokenScope.php index 52d8d3d20..766c0848f 100644 --- a/app/V1Module/security/TokenScope.php +++ b/app/V1Module/security/TokenScope.php @@ -56,4 +56,9 @@ class TokenScope * Usually used in combination with other scopes. Allows refreshing the token. */ public const REFRESH = "refresh"; + + /** + * Scope for handling handshake and auth-exchange with 3rd party extensions. Temp tokens must use this scope. + */ + public const EXTENSIONS = "extensions"; } diff --git a/app/commands/BaseCommand.php b/app/commands/BaseCommand.php index e836582d7..9af24d219 100644 --- a/app/commands/BaseCommand.php +++ b/app/commands/BaseCommand.php @@ -2,27 +2,15 @@ namespace App\Console; -use App\Model\Repository\RuntimeEnvironments; -use App\Model\Repository\Pipelines; -use App\Model\Repository\UploadedFiles; -use App\Model\Entity\RuntimeEnvironment; -use App\Model\Entity\Pipeline; -use App\Model\Entity\SupplementaryExerciseFile; -use App\Helpers\TmpFilesHelper; -use App\Helpers\FileStorageManager; -use App\Helpers\FileStorage\ZipFileStorage; -use App\Helpers\ExerciseConfig\Loader; -use App\Helpers\ExerciseConfig\Validator as ConfigValidator; use DateTime; use Exception; use RuntimeException; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Helper\QuestionHelper; /** @@ -138,4 +126,23 @@ protected function select(string $text, array $options, ?callable $renderer = nu $selectedKey = $helper->ask($this->input, $this->output, $question); return array_key_exists($selectedKey, $translateBack) ? $translateBack[$selectedKey] : null; } + + /** + * Prompts the user for inputting specific text value. + * @param string $text question asked to the user before prompt + * @param string $default default value returned if the user does not input anything + * @param bool $hidden input (e.g., when password is prompted) + * @return string input (answer) from the user + */ + protected function prompt(string $text, string $default = '', bool $hidden = false): string + { + /** @var QuestionHelper */ + $helper = $this->getHelper('question'); + $question = new Question($text, $default); + if ($hidden) { + $question->setHidden(true); + $question->setHiddenFallback(false); + } + return $helper->ask($this->input, $this->output, $question); + } } diff --git a/app/commands/PlagiarismDetectionAccessToken.php b/app/commands/PlagiarismDetectionAccessToken.php index 7d735aa53..9a1002eb8 100644 --- a/app/commands/PlagiarismDetectionAccessToken.php +++ b/app/commands/PlagiarismDetectionAccessToken.php @@ -2,7 +2,6 @@ namespace App\Console; -use App\Model\Entity\User; use App\Model\Repository\Users; use App\Security\AccessManager; use App\Security\TokenScope; diff --git a/app/config/config.local.neon.example b/app/config/config.local.neon.example index 41a1aec70..3ffc62785 100644 --- a/app/config/config.local.neon.example +++ b/app/config/config.local.neon.example @@ -75,6 +75,23 @@ parameters: # Please note that the length of the auth. token expiration should be considered (readonly tokens may expire after 1 year). threshold: "2 years" + extensions: # 3rd party tools which are linked from UI and can cooperate with ReCodEx + - id: "ext-identifier" + caption: # to be displayed in UI; could be also single string (for all localizations) + cs: "Český popisek" + en: "English Caption" + # in URL, '{*}' are placeholders for auth token, locale (en/cs), and return URL + url: "https://extetrnal.domain.com/recodex/extension?token={token}&locale={locale}&return={return}" + urlTokenExpiration: 60 # [s] how long a temporary url token lasts + token: # generated from tmp tokens passed via URL so the ext. tool can access ReCodEx API + expiration: 86400 # [s] how long a full token lasts + scopes: [ 'master', 'refresh' ] # list of scopes for generated tokens (to be used by the extension) + user: null # user override (ID) for generating tokens (if null, the token will be generated for logged-in user) + instances: [] # array of instances where this extension is enabled (empty array = all) + user: # filters applied to determine, whether logged-in user can access the extension + roles: [] # array of enabled user roles (empty array = all) + externalLogins: [] # list of external_login.auth_service IDs (at least one is required, empty array = nothing is required) + # The most important part - a database system connection nettrine.dbal: diff --git a/app/config/config.neon b/app/config/config.neon index 3f9e704eb..984f0080d 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -205,6 +205,8 @@ parameters: roles: # restrict the cleanup to the following roles - "student" + extensions: [] + application: errorPresenter: V1:ApiError @@ -389,6 +391,7 @@ services: - App\Helpers\ExerciseConfig\Compilation\DirectoriesResolver - App\Helpers\ExerciseConfig\Helper - App\Helpers\ExerciseConfig\PipelinesCache + - App\Helpers\Extensions(%extensions%) - App\Helpers\SisHelper(%sis.apiBase%, %sis.faculty%, %sis.secret%) - App\Helpers\UserActions - App\Helpers\ExerciseConfig\ExerciseConfigChecker diff --git a/app/config/permissions.neon b/app/config/permissions.neon index fa2ccfb38..5cd506242 100644 --- a/app/config/permissions.neon +++ b/app/config/permissions.neon @@ -402,7 +402,6 @@ permissions: - setIsAllowed - createLocalAccount - invalidateTokens - - delete - allow: true role: student diff --git a/app/exceptions/ConfigException.php b/app/exceptions/ConfigException.php new file mode 100644 index 000000000..68acc4135 --- /dev/null +++ b/app/exceptions/ConfigException.php @@ -0,0 +1,34 @@ + caption). + * @var string|string[] + */ + private string|array $caption; + + /** + * URL template for the external service. The template may hold the following placeholders: + * - {token} - will be replaced with URL-encoded temporary token + * - {locale} - will be replaced with a language identifier ('en', 'cs', ...) based on currently selected language + * - {return} - will be replaced with a return URL (so the user can be returned back from the ext to the same page) + */ + private string $url; + + /** + * Expiration time (in seconds) for temporary tokens. + */ + private int $urlTokenExpiration; + + /** + * Expiration time (in seconds) for full tokens. + */ + private int $tokenExpiration; + + /** + * List of scopes that will be set to (full) access tokens generated after tmp-token verification. + * @var string[] + */ + private array $tokenScopes; + + /** + * User override for (full) access tokens. This user will be used instead of user ID passed in tmp token. + * This is a way how to safely provide more powerful full tokens (without compromising tmp tokens). + * If null, the (logged in) user from tmp token is passed to the full token. + */ + private string|null $tokenUserId = null; + + /** + * List of instances in which the extension should appear. + * Empty list = all instances. + * @var string[] + */ + private array $instances = []; + + /** + * List of user roles for which this extensions should appear. + * Empty list = all roles. + * @var string[] + */ + private array $userRoles = []; + + /** + * List of eligible user external login types. A user must hava at least one of these logins to see the extension. + * Empty list = no external logins are required. + */ + private array $userExternalLogins = []; + + public function __construct(array $config) + { + $this->id = (string)Arrays::get($config, "id"); + + $this->caption = Arrays::get($config, "caption"); + if (is_array($this->caption)) { + foreach ($this->caption as $locale => $caption) { + if (!is_string($locale) || !is_string($caption)) { + throw new ConfigException("Invalid extension caption format."); + } + } + } + + $this->url = Arrays::get($config, "url"); + $this->urlTokenExpiration = Arrays::get($config, "urlTokenExpiration", 60); + $this->tokenExpiration = Arrays::get($config, ["token", "expiration"], 86400 /* one day */); + $this->tokenScopes = Arrays::get( + $config, + ["token", "scopes"], + [ TokenScope::MASTER, TokenScope::REFRESH ] + ) ?? []; + $this->tokenUserId = Arrays::get($config, ["token", "user"], null); + $this->instances = Arrays::get($config, "instances", []) ?? []; + $this->userRoles = Arrays::get($config, ["user", "roles"], []) ?? []; + $this->userExternalLogins = Arrays::get($config, ["user", "externalLogins"], []) ?? []; + } + + public function getId(): string + { + return $this->id; + } + + /** + * @return string|array Either a universal caption or an array [ locale => localized-caption ] + */ + public function getCaption(): string|array + { + return $this->caption; + } + + /** + * Get formatted URL. A template is injected a token, current locale, and return URL. + * @param string $token already serialized JWT + * @param string $locale language identification ('en', 'cs', ...) + * @param string $return URL to which the user should return from the extension + * @return string an instantiated URL template + */ + public function getUrl(string $token, string $locale = '', string $return = ''): string + { + $url = $this->url; + $url = str_replace('{token}', urlencode($token), $url); + $url = str_replace('{locale}', urlencode($locale), $url); + $url = str_replace('{return}', urlencode($return), $url); + return $url; + } + + /** + * @return int expiration in seconds + */ + public function getUrlTokenExpiration(): int + { + return $this->urlTokenExpiration; + } + + /** + * @return int expiration in seconds + */ + public function getTokenExpiration(): int + { + return $this->tokenExpiration; + } + + /** + * @return string[] list of scopes assigned to a full token + */ + public function getTokenScopes(): array + { + return $this->tokenScopes; + } + + /** + * Get ID of a user who should be used as an authority for full tokens. + * @return string|null either user ID (for auth override) or null (current user) + */ + public function getTokenUserId(): ?string + { + return $this->tokenUserId; + } + + /** + * Check whether this extension is accessible by given user in given instance. + * @param Instance $instance + * @param User|null $user (if null, the extension must be accessible by all users) + * @return bool true if the extension is accessible + */ + public function isAccessible(Instance $instance, ?User $user): bool + { + if ($this->instances && !in_array($instance->getId(), $this->instances)) { + return false; + } + + if (!$user) { + // test accessibility for all users (no user filters must be present) + return !$this->userRoles && !$this->userExternalLogins; + } + + if ($this->userRoles && !in_array($user->getRole(), $this->userRoles)) { + return false; + } + + if ($this->userExternalLogins) { + $logins = $user->getConsolidatedExternalLogins(); + foreach ($this->userExternalLogins as $service) { + if (array_key_exists($service, $logins)) { + return true; + } + } + return false; + } + + return true; + } +} diff --git a/app/helpers/Extensions/Extensions.php b/app/helpers/Extensions/Extensions.php new file mode 100644 index 000000000..5cc0eb521 --- /dev/null +++ b/app/helpers/Extensions/Extensions.php @@ -0,0 +1,56 @@ +extensions[$extension->getId()] = $extension; + } + } + + /** + * Retrieve the extension by its ID. + * @param string $id + * @return ExtensionConfig|null null is returned if no such extension exists + */ + public function getExtension(string $id): ?ExtensionConfig + { + return $this->extensions[$id] ?? null; + } + + /** + * Filter out extensions that are accessible by given user in given instance. + * @param Instance $instance + * @param User|null $user (if null, only extensions available to all users are listed) + * @return ExtensionConfig[] array indexed by extension IDs + */ + public function getAccessibleExtensions(Instance $instance, ?User $user): array + { + $res = []; + foreach ($this->extensions as $id => $extension) { + if ($extension->isAccessible($instance, $user)) { + $res[$id] = $extension; + } + } + return $res; + } +} diff --git a/app/model/entity/ExternalLogin.php b/app/model/entity/ExternalLogin.php index 0839b0587..d07af0c71 100644 --- a/app/model/entity/ExternalLogin.php +++ b/app/model/entity/ExternalLogin.php @@ -3,8 +3,6 @@ namespace App\Model\Entity; use Doctrine\ORM\Mapping as ORM; -use App\Exceptions\InvalidArgumentException; -use Nette\Utils\Validators; /** * @ORM\Entity diff --git a/app/model/entity/User.php b/app/model/entity/User.php index 402b4218c..67e1d3045 100644 --- a/app/model/entity/User.php +++ b/app/model/entity/User.php @@ -9,7 +9,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; use Gravatar\Gravatar; -use App\Exceptions\ApiException; use InvalidArgumentException; use DateTime; use DateTimeInterface; diff --git a/app/model/view/InstanceViewFactory.php b/app/model/view/InstanceViewFactory.php index 3ece0d4d8..d7ba807f1 100644 --- a/app/model/view/InstanceViewFactory.php +++ b/app/model/view/InstanceViewFactory.php @@ -2,9 +2,11 @@ namespace App\Model\View; -use App\Helpers\Localizations; use App\Model\Entity\Instance; use App\Model\Entity\LocalizedGroup; +use App\Model\Entity\User; +use App\Helpers\Localizations; +use App\Helpers\Extensions; /** * Factory for instance views which somehow do not fit into json serialization @@ -12,25 +14,33 @@ */ class InstanceViewFactory { - /** @var GroupViewFactory */ private $groupViewFactory; - public function __construct(GroupViewFactory $groupViewFactory) + /** @var Extensions */ + private $extensions; + + public function __construct(GroupViewFactory $groupViewFactory, Extensions $extensions) { $this->groupViewFactory = $groupViewFactory; + $this->extensions = $extensions; } /** * Get as much instance detail info as your permissions grants you. * @param Instance $instance + * @param User|null $loggedUser (to better target available extensions) * @return array */ - public function getInstance(Instance $instance): array + public function getInstance(Instance $instance, ?User $loggedUser = null): array { /** @var LocalizedGroup|null $localizedRootGroup */ $localizedRootGroup = Localizations::getPrimaryLocalization($instance->getRootGroup()->getLocalizedTexts()); + $extensions = []; + foreach ($this->extensions->getAccessibleExtensions($instance, $loggedUser) as $ext) { + $extensions[$ext->getId()] = $ext->getCaption(); + } return [ "id" => $instance->getId(), @@ -44,21 +54,23 @@ public function getInstance(Instance $instance): array "deletedAt" => $instance->getDeletedAt() ? $instance->getDeletedAt()->getTimestamp() : null, "adminId" => $instance->getAdmin() ? $instance->getAdmin()->getId() : null, "rootGroup" => $this->groupViewFactory->getGroup($instance->getRootGroup()), - "rootGroupId" => $instance->getRootGroup()->getId() + "rootGroupId" => $instance->getRootGroup()->getId(), + "extensions" => $extensions, ]; } /** * Get instance data. * @param Instance[] $instances + * @param User|null $loggedUser (to better target available extensions) * @return array */ - public function getInstances(array $instances): array + public function getInstances(array $instances, ?User $loggedUser = null): array { $instances = array_values($instances); return array_map( - function (Instance $instance) { - return $this->getInstance($instance); + function (Instance $instance) use ($loggedUser) { + return $this->getInstance($instance, $loggedUser); }, $instances ); diff --git a/app/model/view/UserViewFactory.php b/app/model/view/UserViewFactory.php index 5d3ced526..f25d11c45 100644 --- a/app/model/view/UserViewFactory.php +++ b/app/model/view/UserViewFactory.php @@ -116,13 +116,14 @@ private function getUserData(User $user, bool $canViewPrivate, bool $reallyShowE } /** - * Get all information about user even private ones. + * Get all information about user (bypassing ACLs) even the private ones. * @param User $user + * @param bool $reallyShowEverything * @return array */ - public function getFullUser(User $user) + public function getFullUser(User $user, bool $reallyShowEverything = true) { - return $this->getUserData($user, true, true); // true, true = really show everyting + return $this->getUserData($user, true, $reallyShowEverything); } /** diff --git a/tests/Presenters/ExtensionsPresenter.phpt b/tests/Presenters/ExtensionsPresenter.phpt new file mode 100644 index 000000000..9e0f33e1f --- /dev/null +++ b/tests/Presenters/ExtensionsPresenter.phpt @@ -0,0 +1,402 @@ +container = $container; + $this->em = PresenterTestHelper::getEntityManager($container); + $this->user = $container->getByType(\Nette\Security\User::class); + $this->accessManager = $container->getByType(AccessManager::class); + } + + protected function setUp() + { + PresenterTestHelper::fillDatabase($this->container); + $this->presenter = PresenterTestHelper::createPresenter($this->container, ExtensionsPresenter::class); + } + + protected function tearDown() + { + if ($this->user->isLoggedIn()) { + $this->user->logout(true); + } + Mockery::close(); + } + + private function injectExtension( + string $id, + $caption, + string $url, + array $scopes = ['master', 'refresh'], + string $user = null, + array $instances = [], + array $roles = [], + array $externalLogins = [], + ) { + $this->extensionsConfig[] = [ + 'id' => $id, + 'caption' => $caption, + 'url' => $url, + 'urlTokenExpiration' => 42, + 'token' => [ 'expiration' => 54321, 'scopes' => $scopes, 'user' => $user ], + 'instances' => $instances, + 'user' => [ 'roles' => $roles, 'externalLogins' => $externalLogins ], + ]; + + $this->presenter->extensions = new Extensions($this->extensionsConfig); + } + + public function testUrl() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + + $this->injectExtension('test', 'Test', 'https://test.example.com/{token}/{locale}?return={return}'); + $returnUrl = 'http://back.com?foo=bar'; + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de', 'return' => $returnUrl] + ); + + Assert::type('string', $payload); + Assert::true(str_starts_with($payload, 'https://test.example.com/')); + Assert::true(str_ends_with($payload, '/de?return=' . urlencode($returnUrl))); + $tokens = explode('/', $payload); + Assert::count(5, $tokens); + $token = $this->accessManager->decodeToken($tokens[3]); + Assert::equal($currentUser->getId(), $token->getUserId()); + Assert::equal([TokenScope::EXTENSIONS], $token->getScopes()); + Assert::equal(42, $token->getExpirationTime()); + $data = $token->getPayloadData(); + Assert::equal($instanceId, $data['instance']); + Assert::equal('test', $data['extension']); + } + + public function testUrlInstanceFilter() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + + $this->injectExtension('test', 'Test', 'https://test.example.com/{token}/{locale}', [], null, [ $instanceId ]); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de'] + ); + + Assert::type('string', $payload); + Assert::true(str_starts_with($payload, 'https://test.example.com/')); + Assert::true(str_ends_with($payload, '/de')); + $tokens = explode('/', $payload); + Assert::count(5, $tokens); + $token = $this->accessManager->decodeToken($tokens[3]); + Assert::equal($currentUser->getId(), $token->getUserId()); + Assert::equal([TokenScope::EXTENSIONS], $token->getScopes()); + Assert::equal(42, $token->getExpirationTime()); + $data = $token->getPayloadData(); + Assert::equal($instanceId, $data['instance']); + Assert::equal('test', $data['extension']); + } + + public function testUrlExtensionUserFilters() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $login = new ExternalLogin($currentUser, "cas-uk", "12345678"); + $this->em->persist($login); + $this->em->flush(); + + $instanceId = $currentUser->getInstances()->first()->getId(); + + $this->injectExtension('test', 'Test', 'https://test.example.com/{token}/{locale}', [], null, [], ['student'], ['cas-uk']); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de'] + ); + + Assert::type('string', $payload); + Assert::true(str_starts_with($payload, 'https://test.example.com/')); + Assert::true(str_ends_with($payload, '/de')); + $tokens = explode('/', $payload); + Assert::count(5, $tokens); + $token = $this->accessManager->decodeToken($tokens[3]); + Assert::equal($currentUser->getId(), $token->getUserId()); + Assert::equal([TokenScope::EXTENSIONS], $token->getScopes()); + Assert::equal(42, $token->getExpirationTime()); + $data = $token->getPayloadData(); + Assert::equal($instanceId, $data['instance']); + Assert::equal('test', $data['extension']); + } + + public function testUrlNoExtension() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + + Assert::exception( + function () use ($instanceId) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de'] + ); + }, + App\Exceptions\ForbiddenRequestException::class + ); + } + + public function testUrlInvalidRole() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + + $this->injectExtension('test', 'Test', 'https://', [], null, [], [ 'supervisor' ]); + + Assert::exception( + function () use ($instanceId) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de'] + ); + }, + App\Exceptions\ForbiddenRequestException::class + ); + } + + public function testUrlInvalidExternalLogin() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com"); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $login = new ExternalLogin($currentUser, "cas-uk", "12345678"); + $this->em->persist($login); + $this->em->flush(); + + $instanceId = $currentUser->getInstances()->first()->getId(); + + $this->injectExtension('test', 'Test', 'https://', [], null, [], [], ['ext1']); + + Assert::exception( + function () use ($instanceId) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'url', 'extId' => 'test', 'instanceId' => $instanceId, 'locale' => 'de'] + ); + }, + App\Exceptions\ForbiddenRequestException::class + ); + } + + public function testToken() + { + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42, [ + "instance" => $instanceId, "extension" => "test" + ]); + + $this->injectExtension('test', 'Test', 'https://', [ TokenScope::USERS ]); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + + Assert::count(2, $payload); + Assert::equal($currentUser->getId(), $payload["user"]["id"]); + $token = $this->accessManager->decodeToken($payload["accessToken"]); + Assert::equal($currentUser->getId(), $token->getUserId()); + Assert::equal([TokenScope::USERS], $token->getScopes()); + Assert::equal(54321, $token->getExpirationTime()); + } + + public function testTokenUserOverride() + { + $admin = PresenterTestHelper::getUser($this->container, PresenterTestHelper::ADMIN_LOGIN); + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42, [ + "instance" => $instanceId, "extension" => "test" + ]); + + $this->injectExtension('test', 'Test', 'https://', [ TokenScope::USERS ], $admin->getId()); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + + Assert::count(2, $payload); + Assert::equal($currentUser->getId(), $payload["user"]["id"]); + $token = $this->accessManager->decodeToken($payload["accessToken"]); + Assert::equal($admin->getId(), $token->getUserId()); + Assert::equal([TokenScope::USERS], $token->getScopes()); + Assert::equal(54321, $token->getExpirationTime()); + } + + public function testTokenWrongScope() + { + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::MASTER], 42, [ + "instance" => $instanceId, "extension" => "test" + ]); + + $this->injectExtension('test', 'Test', 'https://'); + + Assert::exception( + function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + }, + App\Exceptions\ForbiddenRequestException::class + ); + } + + public function testTokenWrongPayload() + { + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42); + + $this->injectExtension('test', 'Test', 'https://'); + + Assert::exception( + function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + }, + App\Exceptions\InvalidArgumentException::class + ); + } + + public function testTokenWrongExtension() + { + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42, [ + "instance" => $instanceId, "extension" => "ext1" + ]); + + $this->injectExtension('test', 'Test', 'https://'); + + Assert::exception( + function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + }, + App\Exceptions\BadRequestException::class + ); + } + + public function testTokenWrongInstance() + { + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42, [ + "instance" => '', "extension" => "test" + ]); + + $this->injectExtension('test', 'Test', 'https://'); + + Assert::exception( + function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + }, + App\Exceptions\BadRequestException::class + ); + } + + public function testTokenNoExtension() + { + $currentUser = PresenterTestHelper::getUser($this->container, "submitUser1@example.com"); + $instanceId = $currentUser->getInstances()->first()->getId(); + PresenterTestHelper::login($this->container, "submitUser1@example.com", [TokenScope::EXTENSIONS], 42, [ + "instance" => $instanceId, "extension" => "test" + ]); + + Assert::exception( + function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Extensions', + 'GET', + ['action' => 'token', 'extId' => 'test'] + ); + }, + App\Exceptions\ForbiddenRequestException::class + ); + } +} + +$testCase = new TestExtensionsPresenter(); +$testCase->run(); diff --git a/tests/Presenters/NotificationsPresenter.phpt b/tests/Presenters/NotificationsPresenter.phpt index b23ca41ea..203ea487f 100644 --- a/tests/Presenters/NotificationsPresenter.phpt +++ b/tests/Presenters/NotificationsPresenter.phpt @@ -75,13 +75,13 @@ class TestNotificationsPresenter extends Tester\TestCase PresenterTestHelper::loginDefaultAdmin($this->container); // Demo group does not have notification, // so only one global should be returned - $group = current( - $this->presenter->groups->findFiltered( - null, - null, - "Demo group" - ) + $groups = $this->presenter->groups->findFiltered( + null, + null, + "Demo group" ); + Assert::count(1, $groups); + $group = current($groups); $request = new Nette\Application\Request( "V1:Notifications", diff --git a/tests/base/PresenterTestHelper.php b/tests/base/PresenterTestHelper.php index 545ff25bd..02af6c5f7 100644 --- a/tests/base/PresenterTestHelper.php +++ b/tests/base/PresenterTestHelper.php @@ -149,7 +149,9 @@ public static function createPresenter(Nette\DI\Container $container, string $cl public static function login( Container $container, string $login, - array $scopes = [TokenScope::MASTER, TokenScope::REFRESH] + array $scopes = [TokenScope::MASTER, TokenScope::REFRESH], + ?int $tokenExpiration = null, + array $tokenPayload = [] ): string { /** @var \Nette\Security\User $userSession */ $userSession = $container->getByType(\Nette\Security\User::class); @@ -157,7 +159,7 @@ public static function login( /** @var AccessManager $accessManager */ $accessManager = $container->getByType(AccessManager::class); - $tokenText = $accessManager->issueToken($user, null, $scopes); + $tokenText = $accessManager->issueToken($user, null, $scopes, $tokenExpiration, $tokenPayload); $token = $accessManager->decodeToken($tokenText); $userSession->login(new \App\Security\Identity($user, $token)); @@ -166,9 +168,11 @@ public static function login( public static function loginDefaultAdmin( Container $container, - array $scopes = [TokenScope::MASTER, TokenScope::REFRESH] + array $scopes = [TokenScope::MASTER, TokenScope::REFRESH], + ?int $tokenExpiration = null, + array $tokenPayload = [] ): string { - return self::login($container, self::ADMIN_LOGIN, $scopes); + return self::login($container, self::ADMIN_LOGIN, $scopes, $tokenExpiration, $tokenPayload); } public static function getUser(Container $container, $login = null): User @@ -190,7 +194,7 @@ public static function jsonResponse($payload) * @param array $params Parameters of the request. * @param array $post Body of the request (must be POST). * @param int $expectedCode Expected HTTP response code (200 by default). - * @return array|null Payload subtree of JSON request. + * @return mixed Payload subtree of JSON request. * @throws Exception */ public static function performPresenterRequest(