diff --git a/composer.json b/composer.json index 45d7ca832..7a6b5cf7a 100644 --- a/composer.json +++ b/composer.json @@ -9,18 +9,22 @@ ], "require": { "php": "^7.4", + "ext-json": "*", + "ext-openssl": "*", + "ext-redis": "*", "champs-libres/async-uploader-bundle": "dev-sf4#d57134aee8e504a83c902ff0cf9f8d36ac418290", - "champs-libres/wopi-bundle": "dev-master#6dd8e0a14e00131eb4b889ecc30270ee4a0e5224", - "champs-libres/wopi-lib": "dev-master#8615f4a45a39fc2b6a98765ea835fcfd39618787", + "champs-libres/wopi-bundle": "dev-master@dev", + "champs-libres/wopi-lib": "dev-master@dev", "doctrine/doctrine-bundle": "^2.1", "doctrine/doctrine-migrations-bundle": "^3.0", - "doctrine/orm": "^2.7", + "doctrine/orm": "^2.13.0", "erusev/parsedown": "^1.7", "graylog2/gelf-php": "^1.5", "knplabs/knp-menu-bundle": "^3.0", "knplabs/knp-time-bundle": "^1.12", "knpuniversity/oauth2-client-bundle": "^2.10", "league/csv": "^9.7.1", + "lexik/jwt-authentication-bundle": "^2.16", "nyholm/psr7": "^1.4", "ocramius/package-versions": "^1.10 || ^2", "odolbeau/phone-number-bundle": "^3.6", @@ -29,12 +33,12 @@ "ramsey/uuid-doctrine": "^1.7", "sensio/framework-extra-bundle": "^5.5", "spomky-labs/base64url": "^2.0", - "symfony/asset": "^4.4", "symfony/browser-kit": "^4.4", "symfony/css-selector": "^4.4", "symfony/expression-language": "^4.4", "symfony/form": "^4.4", "symfony/framework-bundle": "^4.4", + "symfony/http-client": "^4.4 || ^5", "symfony/http-foundation": "^4.4", "symfony/intl": "^4.4", "symfony/mailer": "^5.4", @@ -72,8 +76,7 @@ "symfony/maker-bundle": "^1.20", "symfony/phpunit-bridge": "^4.4", "symfony/stopwatch": "^4.4", - "symfony/var-dumper": "^4.4", - "symfony/web-profiler-bundle": "^4.4" + "symfony/var-dumper": "^4.4" }, "conflict": { "symfony/symfony": "*" diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index daed12b84..a1ef42ff1 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -60,7 +60,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface $this ->tempUrlGenerator ->generate( - Request::METHOD_PUT, + Request::METHOD_HEAD, $document->getFilename() ) ->url diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 80a0b5b2a..1317edf83 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -15,7 +15,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use RuntimeException; -use Symfony\Component\Security\Core\User\AdvancedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -31,7 +31,7 @@ use function in_array; * "user": User::class * }) */ -class User implements AdvancedUserInterface +class User implements UserInterface { /** * @ORM\Id @@ -58,8 +58,6 @@ class User implements AdvancedUserInterface private ?Location $currentLocation = null; /** - * @var string - * * @ORM\Column(type="string", length=150, nullable=true) */ private ?string $email = null; @@ -216,7 +214,7 @@ class User implements AdvancedUserInterface } /** - * @return GroupCenter + * @return Collection */ public function getGroupCenters() { @@ -225,10 +223,8 @@ class User implements AdvancedUserInterface /** * Get id. - * - * @return int */ - public function getId() + public function getId(): int { return $this->id; } @@ -487,7 +483,7 @@ class User implements AdvancedUserInterface * * @param string $name * - * @return Agent + * @return User */ public function setUsername($name) { diff --git a/src/Bundle/ChillWopiBundle/src/Controller/Editor.php b/src/Bundle/ChillWopiBundle/src/Controller/Editor.php index 486a63eb2..4be848489 100644 --- a/src/Bundle/ChillWopiBundle/src/Controller/Editor.php +++ b/src/Bundle/ChillWopiBundle/src/Controller/Editor.php @@ -15,11 +15,13 @@ use ChampsLibres\WopiLib\Contract\Service\Configuration\ConfigurationInterface; use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; use ChampsLibres\WopiLib\Contract\Service\DocumentManagerInterface; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\MainBundle\Entity\User; use Chill\WopiBundle\Service\Controller\ResponderInterface; use Exception; +use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use loophp\psr17\Psr17Interface; -use Symfony\Component\Finder\Exception\AccessDeniedException; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; @@ -33,6 +35,8 @@ final class Editor { private DocumentManagerInterface $documentManager; + private JWTTokenManagerInterface $JWTTokenManager; + private Psr17Interface $psr17; private ResponderInterface $responder; @@ -49,12 +53,14 @@ final class Editor ConfigurationInterface $wopiConfiguration, DiscoveryInterface $wopiDiscovery, DocumentManagerInterface $documentManager, + JWTTokenManagerInterface $JWTTokenManager, ResponderInterface $responder, Security $security, Psr17Interface $psr17, RouterInterface $router ) { $this->documentManager = $documentManager; + $this->JWTTokenManager = $JWTTokenManager; $this->wopiConfiguration = $wopiConfiguration; $this->wopiDiscovery = $wopiDiscovery; $this->responder = $responder; @@ -65,8 +71,12 @@ final class Editor public function __invoke(string $fileId): Response { - if (null === $user = $this->security->getUser()->getUsername()) { - throw new AccessDeniedException('You must be logged in to access to this resource.'); + if (null === $user = $this->security->getUser()) { + throw new AccessDeniedHttpException('Please authenticate to access this feature'); + } + + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Please authenticate as a user to access this feature'); } $configuration = $this->wopiConfiguration->jsonSerialize(); @@ -82,7 +92,19 @@ final class Editor } $configuration['favIconUrl'] = ''; - $configuration['access_token'] = $user; + $configuration['access_token'] = $this->JWTTokenManager->createFromPayload($user, [ + 'UserCanWrite' => true, + 'UserCanAttend' => true, + 'UserCanPresent' => true, + 'fileId' => $fileId, + ]); + + // we parse the token back to get the access_token_ttl + // reminder: access_token_ttl is a javascript epoch, not a number of seconds; it is the + // time when the token will expire, not the time to live: + // https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#the-access_token_ttl-property + $jwt = $this->JWTTokenManager->parse($configuration['access_token']); + $configuration['access_token_ttl'] = $jwt['exp'] * 1000; $configuration['server'] = $this ->psr17 diff --git a/src/Bundle/ChillWopiBundle/src/Resources/config/services.php b/src/Bundle/ChillWopiBundle/src/Resources/config/services.php index ffd1a7947..e7ca90dbc 100644 --- a/src/Bundle/ChillWopiBundle/src/Resources/config/services.php +++ b/src/Bundle/ChillWopiBundle/src/Resources/config/services.php @@ -12,12 +12,15 @@ declare(strict_types=1); namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface; +use ChampsLibres\WopiBundle\Contracts\AuthorizationManagerInterface; +use ChampsLibres\WopiBundle\Contracts\UserManagerInterface; use ChampsLibres\WopiBundle\Service\Wopi as CLWopi; -use ChampsLibres\WopiLib\Contract\Service\DocumentLockManagerInterface; use ChampsLibres\WopiLib\Contract\Service\DocumentManagerInterface; +use Chill\WopiBundle\Service\Wopi\AuthorizationManager; use Chill\WopiBundle\Service\Wopi\ChillDocumentLockManager; use Chill\WopiBundle\Service\Wopi\ChillDocumentManager; use Chill\WopiBundle\Service\Wopi\ChillWopi; +use Chill\WopiBundle\Service\Wopi\UserManager; return static function (ContainerConfigurator $container) { $services = $container @@ -44,8 +47,17 @@ return static function (ContainerConfigurator $container) { ->alias(DocumentManagerInterface::class, ChillDocumentManager::class); $services - ->set(ChillDocumentLockManager::class) - ->decorate(DocumentLockManagerInterface::class); + ->set(ChillDocumentLockManager::class); + + $services + ->set(AuthorizationManager::class); + + $services->alias(AuthorizationManagerInterface::class, AuthorizationManager::class); + + $services + ->set(UserManager::class); + + $services->alias(UserManagerInterface::class, UserManager::class); // TODO: Move this into the async bundle (low priority) $services diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php new file mode 100644 index 000000000..24ea00982 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php @@ -0,0 +1,88 @@ +tokenManager = $tokenManager; + $this->security = $security; + } + + public function isRestrictedWebViewOnly(string $accessToken, Document $document, RequestInterface $request): bool + { + return false; + } + + public function isTokenValid(string $accessToken, Document $document, RequestInterface $request): bool + { + $metadata = $this->tokenManager->parse($accessToken); + + if (false === $metadata) { + return false; + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return false; + } + + return $document->getWopiDocId() === $metadata['fileId']; + } + + public function userCanAttend(string $accessToken, Document $document, RequestInterface $request): bool + { + return $this->isTokenValid($accessToken, $document, $request); + } + + public function userCanDelete(string $accessToken, Document $document, RequestInterface $request): bool + { + return false; + } + + public function userCannotWriteRelative(string $accessToken, Document $document, RequestInterface $request): bool + { + return true; + } + + public function userCanPresent(string $accessToken, Document $document, RequestInterface $request): bool + { + return $this->isTokenValid($accessToken, $document, $request); + } + + public function userCanRead(string $accessToken, Document $document, RequestInterface $request): bool + { + return $this->isTokenValid($accessToken, $document, $request); + } + + public function userCanRename(string $accessToken, Document $document, RequestInterface $request): bool + { + return false; + } + + public function userCanWrite(string $accessToken, Document $document, RequestInterface $request): bool + { + return $this->isTokenValid($accessToken, $document, $request); + } +} diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillDocumentManager.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillDocumentManager.php index 1ae6b395a..8b6e3f846 100644 --- a/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillDocumentManager.php +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillDocumentManager.php @@ -24,11 +24,12 @@ use loophp\psr17\Psr17Interface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; use Ramsey\Uuid\Uuid; +use RuntimeException; use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mime\MimeTypes; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use function strlen; final class ChillDocumentManager implements DocumentManagerInterface @@ -190,7 +191,12 @@ final class ChillDocumentManager implements DocumentManagerInterface public function remove(Document $document): void { - // TODO: To implement when we have a clearer view and API. + throw new RuntimeException('this is not implemented and should not happens'); + } + + public function rename(Document $document, string $requestedName): void + { + throw new RuntimeException('this is not implemented and should not happens'); } public function write(Document $document, array $properties = []): void diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php index 3cafc4c5d..e0e57e078 100644 --- a/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/ChillWopi.php @@ -13,39 +13,20 @@ namespace Chill\WopiBundle\Service\Wopi; use ChampsLibres\WopiLib\Contract\Service\DocumentManagerInterface; use ChampsLibres\WopiLib\Contract\Service\WopiInterface; -use DateTimeImmutable; -use DateTimeInterface; use loophp\psr17\Psr17Interface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\UserProviderInterface; -use function strlen; final class ChillWopi implements WopiInterface { - private CacheItemPoolInterface $cache; - - private DocumentManagerInterface $documentManager; - - private Psr17Interface $psr17; - - private UserProviderInterface $userProvider; - private WopiInterface $wopi; public function __construct( - CacheItemPoolInterface $cache, - DocumentManagerInterface $documentManager, - Psr17Interface $psr17, - UserProviderInterface $userProvider, WopiInterface $wopi ) { - $this->cache = $cache; - $this->documentManager = $documentManager; - $this->psr17 = $psr17; - $this->userProvider = $userProvider; $this->wopi = $wopi; } @@ -54,55 +35,9 @@ final class ChillWopi implements WopiInterface ?string $accessToken, RequestInterface $request ): ResponseInterface { - try { - $user = $this->userProvider->loadUserByUsername($accessToken); - } catch (UsernameNotFoundException $e) { - return $this - ->psr17 - ->createResponse(401); - } - - // @ TODO : Replace this with a call to decorated object once authentication is done. - $document = $this->documentManager->findByDocumentId($fileId); - $userIdentifier = $user->getUsername(); - $userCacheKey = sprintf('wopi_putUserInfo_%s', $userIdentifier); - - return $this - ->psr17 - ->createResponse() - ->withHeader('Content-Type', 'application/json') - ->withBody($this->psr17->createStream((string) json_encode( - [ - 'BaseFileName' => $this->documentManager->getBasename($document), - 'OwnerId' => 'Symfony', - 'Size' => $this->documentManager->getSize($document), - 'UserId' => $userIdentifier, - 'ReadOnly' => false, - 'UserCanAttend' => true, - 'UserCanPresent' => true, - 'UserCanRename' => true, - 'UserCanWrite' => true, - 'UserCanNotWriteRelative' => false, - 'SupportsUserInfo' => true, - 'SupportsDeleteFile' => true, - 'SupportsLocks' => true, - 'SupportsGetLock' => true, - 'SupportsExtendedLockLength' => true, - 'UserFriendlyName' => $userIdentifier, - 'SupportsUpdate' => true, - 'SupportsRename' => true, - 'SupportsFolder' => false, - 'DisablePrint' => false, - 'AllowExternalMarketplace' => true, - 'SupportedShareUrlTypes' => [ - 'ReadOnly', - ], - 'LastModifiedTime' => $this->documentManager->getLastModifiedDate($document) - ->format(DateTimeInterface::ATOM), - 'SHA256' => $this->documentManager->getSha256($document), - 'UserInfo' => (string) $this->cache->getItem($userCacheKey)->get(), - ] - ))); + return $this->wopi->checkFileInfo($fileId, $accessToken, $request, [ + 'SupportsRename' => false, + ]); } public function deleteFile(string $fileId, ?string $accessToken, RequestInterface $request): ResponseInterface @@ -152,97 +87,7 @@ final class ChillWopi implements WopiInterface string $xWopiEditors, RequestInterface $request ): ResponseInterface { - $document = $this->documentManager->findByDocumentId($fileId); - $version = $this->documentManager->getVersion($document); - - // File is unlocked, we must reject the document, except if collabora is autosaving - if (false === $this->documentManager->hasLock($document) && 'true' !== ($request->getHeader('x-lool-wopi-isexitsave') ?? ['false'])[0]) { - if (0 !== $this->documentManager->getSize($document)) { - return $this - ->psr17 - ->createResponse(409) - ->withHeader( - WopiInterface::HEADER_ITEM_VERSION, - sprintf('v%s', $version) - ); - } - } - - // File is locked, we check for the lock - if ($this->documentManager->hasLock($document)) { - if ($xWopiLock !== $currentLock = $this->documentManager->getLock($document)) { - return $this - ->psr17 - ->createResponse(409) - ->withHeader( - WopiInterface::HEADER_LOCK, - $currentLock - ) - ->withHeader( - WopiInterface::HEADER_ITEM_VERSION, - sprintf('v%s', $version) - ); - } - } - - // for collabora online editor, check timestamp if present - /* delete because it seems that collabora send always the first wopi-timestamp, not the last known one - // example: - // load the doc: the last-wopi is 12:00 in FileInfo - // save the doc: x-cool-wopi-timestamp is 12:00, but we replace the ts with the time of save (12:05) - // save the doc again: x-cool-wopi-timestamp is still 12:00... - if ($request->hasHeader('x-cool-wopi-timestamp')) { - $date = DateTimeImmutable::createFromFormat( - DateTimeImmutable::ATOM, - $request->getHeader('x-cool-wopi-timestamp')[0] - ); - - if (false === $date) { - throw new RuntimeException('Error parsing date: ' . implode('', DateTimeImmutable::getLastErrors())); - } - - if ($this->documentManager->getLastModifiedDate($document)->getTimestamp() < $date->getTimestamp()) { - return $this - ->psr17 - ->createResponse(409) - ->withHeader( - WopiInterface::HEADER_LOCK, - $currentLock - ) - ->withHeader( - WopiInterface::HEADER_ITEM_VERSION, - sprintf('v%s', $version) - ) - ->withBody( - $this->psr17->createStream( - json_encode(['COOLStatusCode' => 1010]) - ) - ); - } - } - */ - - $body = (string) $request->getBody(); - $this->documentManager->write( - $document, - [ - 'content' => $body, - 'size' => (string) strlen($body), - ] - ); - $version = $this->documentManager->getVersion($document); - - return $this - ->psr17 - ->createResponse() - ->withHeader( - WopiInterface::HEADER_LOCK, - $xWopiLock - ) - ->withHeader( - WopiInterface::HEADER_ITEM_VERSION, - sprintf('v%s', $version) - ); + return $this->wopi->putFile($fileId, $accessToken, $xWopiLock, $xWopiEditors, $request); } public function putRelativeFile(string $fileId, string $accessToken, ?string $suggestedTarget, ?string $relativeTarget, bool $overwriteRelativeTarget, int $size, RequestInterface $request): ResponseInterface diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php new file mode 100644 index 000000000..dc030192b --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php @@ -0,0 +1,53 @@ +security = $security; + } + + public function getUserFriendlyName(string $accessToken, string $fileId, RequestInterface $request): ?string + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return null; + } + + return (string) $user->getLabel(); + } + + public function getUserId(string $accessToken, string $fileId, RequestInterface $request): ?string + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return null; + } + + return (string) $user->getId(); + } + + public function isAnonymousUser(string $accessToken, string $fileId, RequestInterface $request): bool + { + return false; + } +}