Dav: implements JWT extraction from the URL, and add the access_token in dav urls

This commit is contained in:
Julien Fastré 2023-09-14 21:54:30 +02:00
parent 146e0090fb
commit 6f6683f549
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
10 changed files with 193 additions and 39 deletions

View File

@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use DateTimeInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -30,41 +31,44 @@ final readonly class WebdavController
public function __construct( public function __construct(
private \Twig\Environment $engine, private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager, private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private ?JWTTokenManagerInterface $JWTTokenManager = null,
) { ) {
$this->requestAnalyzer = new PropfindRequestAnalyzer(); $this->requestAnalyzer = new PropfindRequestAnalyzer();
} }
/** /**
* @Route("/dav/open/{uuid}") * @Route("/chdoc/open/{uuid}")
*/ */
public function open(StoredObject $storedObject): Response public function open(StoredObject $storedObject): Response
{ {
/*$accessToken = $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ $accessToken = $this->JWTTokenManager?->createFromPayload($this->security->getUser(), [
'UserCanWrite' => true, 'UserCanWrite' => true,
'UserCanAttend' => true, 'UserCanAttend' => true,
'UserCanPresent' => true, 'UserCanPresent' => true,
'fileId' => $storedObject->getUuid(), 'fileId' => $storedObject->getUuid(),
]);*/ ]);
return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [
'stored_object' => $storedObject, 'access_token' => '', 'stored_object' => $storedObject, 'access_token' => $accessToken,
])); ]));
} }
/** /**
* @Route("/dav/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
*/ */
public function getDirectory(StoredObject $storedObject): Response public function getDirectory(StoredObject $storedObject, string $access_token): Response
{ {
return new DavResponse( return new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
'stored_object' => $storedObject 'stored_object' => $storedObject,
'access_token' => $access_token,
]) ])
); );
} }
/** /**
* @Route("/dav/get/{uuid}/", methods={"OPTIONS"}) * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
*/ */
public function optionsDirectory(StoredObject $storedObject): Response public function optionsDirectory(StoredObject $storedObject): Response
{ {
@ -78,9 +82,9 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/", methods={"PROPFIND"}) * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
*/ */
public function propfindDirectory(StoredObject $storedObject, Request $request): Response public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
{ {
$depth = $request->headers->get('depth'); $depth = $request->headers->get('depth');
@ -111,10 +115,11 @@ final readonly class WebdavController
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
'stored_object' => $storedObject, 'stored_object' => $storedObject,
'properties' => $properties, 'properties' => $properties,
'last_modified' => $lastModified ?? null, 'last_modified' => $lastModified ,
'etag' => $etag ?? null, 'etag' => $etag,
'content_length' => $length ?? null, 'content_length' => $length,
'depth' => (int) $depth 'depth' => (int) $depth,
'access_token' => $access_token,
]), ]),
207 207
); );
@ -127,7 +132,7 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
*/ */
public function getDocument(StoredObject $storedObject): Response public function getDocument(StoredObject $storedObject): Response
{ {
@ -136,7 +141,7 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/d", methods={"HEAD"}) * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
*/ */
public function headDocument(StoredObject $storedObject): Response public function headDocument(StoredObject $storedObject): Response
{ {
@ -154,7 +159,7 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/d", methods={"OPTIONS"}) * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
*/ */
public function optionsDocument(StoredObject $storedObject): Response public function optionsDocument(StoredObject $storedObject): Response
{ {
@ -176,9 +181,9 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/d", methods={"PROPFIND"}) * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
*/ */
public function propfindDocument(StoredObject $storedObject, Request $request): Response public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
{ {
$content = $request->getContent(); $content = $request->getContent();
$xml = new \DOMDocument(); $xml = new \DOMDocument();
@ -204,9 +209,10 @@ final readonly class WebdavController
[ [
'stored_object' => $storedObject, 'stored_object' => $storedObject,
'properties' => $properties, 'properties' => $properties,
'etag' => $etag ?? null, 'etag' => $etag,
'last_modified' => $lastModified ?? null, 'last_modified' => $lastModified,
'content_length' => $length ?? null, 'content_length' => $length,
'access_token' => $access_token,
] ]
), ),
207 207
@ -221,7 +227,7 @@ final readonly class WebdavController
} }
/** /**
* @Route("/dav/get/{uuid}/d", methods={"PUT"}) * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
*/ */
public function putDocument(StoredObject $storedObject, Request $request): Response public function putDocument(StoredObject $storedObject, Request $request): Response
{ {

View File

@ -6,7 +6,7 @@
</head> </head>
<body> <body>
<ul> <ul>
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid })) }}">d</a></li> <li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
</ul> </ul>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid } ) }}</d:href> <d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
{% if properties.resourceType or properties.contentType %} {% if properties.resourceType or properties.contentType %}
<d:propstat> <d:propstat>
<d:prop> <d:prop>
@ -28,7 +28,7 @@
</d:response> </d:response>
{% if depth == 1 %} {% if depth == 1 %}
<d:response> <d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }}</d:href> <d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat> <d:propstat>
<d:prop> <d:prop>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }}</d:href> <d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat> <d:propstat>
<d:prop> <d:prop>

View File

@ -2,6 +2,6 @@
{% block content %} {% block content %}
<p>document uuid: {{ stored_object.uuid }}</p> <p>document uuid: {{ stored_object.uuid }}</p>
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid })) }}</p> <p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid })) }}">Open document</a> <a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
{
public function __construct(
private LoggerInterface $logger,
) {}
public function extract(Request $request): string|false
{
$uri = $request->getRequestUri();
$segments = array_values(
array_filter(
explode('/', $uri),
fn ($item) => '' !== trim($item)
)
);
if (2 > count($segments)) {
$this->logger->info("not enough segment for parsing URL");
return false;
}
if ('dav' !== $segments[0]) {
$this->logger->info("the first segment of the url must be DAV");
return false;
}
return $segments[1];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
{
public function __construct(
JWTTokenManagerInterface $jwtManager,
EventDispatcherInterface $dispatcher,
TokenExtractorInterface $tokenExtractor,
TokenStorageInterface $preAuthenticationTokenStorage,
TranslatorInterface $translator = null,
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
) {
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
}
protected function getTokenExtractor()
{
return $this->davOnUrlTokenExtractor;
}
}

View File

@ -21,6 +21,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Templating\EngineInterface; use Symfony\Component\Templating\EngineInterface;
/** /**
@ -43,8 +44,9 @@ class WebdavControllerTest extends KernelTestCase
private function buildController(): WebdavController private function buildController(): WebdavController
{ {
$storedObjectManager = new MockedStoredObjectManager(); $storedObjectManager = new MockedStoredObjectManager();
$security = $this->prophesize(Security::class);
return new WebdavController($this->engine, $storedObjectManager); return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
} }
private function buildDocument(): StoredObject private function buildDocument(): StoredObject
@ -115,7 +117,7 @@ class WebdavControllerTest extends KernelTestCase
$request = new Request([], [], [], [], [], [], $requestContent); $request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND'); $request->setMethod('PROPFIND');
$response = $controller->propfindDocument($this->buildDocument(), $request); $response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys()); self::assertContains('content-type', $response->headers->keys());
@ -134,7 +136,7 @@ class WebdavControllerTest extends KernelTestCase
$request = new Request([], [], [], [], [], [], $requestContent); $request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND'); $request->setMethod('PROPFIND');
$request->headers->add(["Depth" => "0"]); $request->headers->add(["Depth" => "0"]);
$response = $controller->propfindDirectory($this->buildDocument(), $request); $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys()); self::assertContains('content-type', $response->headers->keys());
@ -170,7 +172,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" > <d:multistatus xmlns:d="DAV:" >
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat> <d:propstat>
<d:prop> <d:prop>
<d:resourcetype/> <d:resourcetype/>
@ -205,7 +207,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat> <d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/> <ns0:IsReadOnly/>
@ -232,7 +234,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat> <d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/"> <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:BaseURI/> <ns0:BaseURI/>
@ -259,7 +261,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat> <d:propstat>
<d:prop> <d:prop>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT --> <!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
@ -285,7 +287,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat> <d:propstat>
<d:prop> <d:prop>
<d:resourcetype/> <d:resourcetype/>
@ -323,7 +325,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat> <d:propstat>
<d:prop> <d:prop>
<d:resourcetype><d:collection/></d:resourcetype> <d:resourcetype><d:collection/></d:resourcetype>
@ -365,7 +367,7 @@ class WebdavControllerTest extends KernelTestCase
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"> <d:multistatus xmlns:d="DAV:">
<d:response> <d:response>
<d:href>/dav/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href> <d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat> <d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" > <d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
<ns0:CreatableContentsInfo/> <ns0:CreatableContentsInfo/>

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
* @coversNothing
*/
class DavOnUrlTokenExtractorTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataUri
*/
public function testExtract(string $uri, string|false $expected): void
{
$request = $this->prophesize(Request::class);
$request->getRequestUri()->willReturn($uri);
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
$actual = $extractor->extract($request->reveal());
self::assertEquals($expected, $actual);
}
/**
* @phpstan-pure
*/
public static function provideDataUri(): iterable
{
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
yield ['/dav/123456789', '123456789'];
yield ['/not-dav/123456978', false];
yield ['/dav', false];
yield ['/', false];
}
}

View File

@ -34,6 +34,11 @@ services:
autoconfigure: true autoconfigure: true
autowire: true autowire: true
Chill\DocStoreBundle\Security\:
resource: './../Security'
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Serializer\Normalizer\: Chill\DocStoreBundle\Serializer\Normalizer\:
autowire: true autowire: true
resource: '../Serializer/Normalizer/' resource: '../Serializer/Normalizer/'