Skip to content

Commit

Permalink
Authenticate changes requests
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasbestle committed Dec 2, 2024
1 parent 4acdd2b commit ef86d92
Show file tree
Hide file tree
Showing 8 changed files with 342 additions and 145 deletions.
2 changes: 1 addition & 1 deletion src/Cms/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -1277,7 +1277,7 @@ public function resolve(string|null $path = null, string|null $language = null):
if (!$page && $draft = $site->draft($path)) {
if (
$this->user() ||
$draft->isVerified($this->request()->get('_token'))
$draft->renderVersionFromRequest() !== null
) {
$page = $draft;
}
Expand Down
79 changes: 42 additions & 37 deletions src/Cms/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -776,27 +776,6 @@ public function isUnlisted(): bool
return $this->isPublished() && $this->num() === null;
}

/**
* Checks if the page access is verified.
* This is only used for drafts so far.
* @internal
*/
public function isVerified(string|null $token = null): bool
{
if (
$this->isPublished() === true &&
$this->parents()->findBy('status', 'draft') === null
) {
return true;
}

if ($token === null) {
return false;
}

return hash_equals($this->token(), $token);
}

/**
* Returns the root to the media folder for the page
* @internal
Expand Down Expand Up @@ -932,7 +911,7 @@ public function permissions(): PagePermissions
}

/**
* Returns the preview URL with authentication for drafts
* Returns the preview URL with authentication for drafts and versions
* @internal
*/
public function previewUrl(VersionId|string $versionId = 'latest'): string|null
Expand Down Expand Up @@ -961,10 +940,12 @@ public function render(

// if not manually overridden, first use a globally set
// version ID (e.g. when rendering within another render),
// otherwise auto-detect from the request;
// make sure to convert it to an object
// otherwise auto-detect from the request and fall back to
// the latest version if request is unauthenticated (no valid token);
// make sure to convert it to an object no matter what happened
$versionId ??= VersionId::$render;
$versionId ??= $this->kirby()->request()->get('_version') === 'changes' ? VersionId::changes() : VersionId::latest();
$versionId ??= $this->renderVersionFromRequest();
$versionId ??= VersionId::latest();
$versionId = VersionId::from($versionId);

// try to get the page from cache
Expand Down Expand Up @@ -1040,6 +1021,42 @@ public function render(
return $html;
}

/**
* Determines which version (if any) can be rendered
* based on the token authentication in the current request
* @internal
*/
public function renderVersionFromRequest(): VersionId|null
{
$request = $this->kirby()->request();
$token = $request->get('_token', '');

try {
$versionId = VersionId::from($request->get('_version', ''));
} catch (InvalidArgumentException) {
// ignore invalid enum values in the request
$versionId = VersionId::latest();
}

// authenticated requests can always be trusted
$expectedToken = $this->version($versionId)->previewToken();
if ($token !== '' && hash_equals($expectedToken, $token) === true) {
return $versionId;
}

// published pages with published parents can render
// the latest version without (valid) token
if (
$this->isPublished() === true &&
$this->parents()->findBy('status', 'draft') === null
) {
return VersionId::latest();
}

// drafts cannot be accessed without authentication
return null;
}

/**
* @internal
* @throws \Kirby\Exception\NotFoundException If the content representation cannot be found
Expand Down Expand Up @@ -1216,18 +1233,6 @@ public function toArray(): array
];
}

/**
* Returns a verification token, which
* is used for the draft authentication
*/
protected function token(): string
{
return $this->kirby()->contentToken(
$this,
$this->id() . $this->template()
);
}

/**
* Returns the UID of the page.
* The UID is basically the same as the
Expand Down
2 changes: 1 addition & 1 deletion src/Cms/Site.php
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ public function permissions(): SitePermissions
}

/**
* Returns the preview URL with authentication for drafts
* Returns the preview URL with authentication for drafts and versions
* @internal
*/
public function previewUrl(VersionId|string $versionId = 'latest'): string|null
Expand Down
7 changes: 5 additions & 2 deletions src/Content/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ public function update(
}

/**
* Returns the preview URL with authentication for drafts
* Returns the preview URL with authentication for drafts and versions
* @internal
*/
public function url(): string|null
Expand All @@ -576,7 +576,10 @@ public function url(): string|null

$uri = new Uri($url);

if ($this->model instanceof Page && $this->model->isDraft() === true) {
if (
($this->model instanceof Page && $this->model->isDraft() === true) ||
$this->id->is('changes') === true
) {
$uri->query->_token = $this->previewToken();
}

Expand Down
202 changes: 201 additions & 1 deletion tests/Cms/Pages/PageRenderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ public function setUp(): void
'slug' => 'version-recursive',
'template' => 'version-recursive'
]
],
'drafts' => [
[
'slug' => 'version-draft',
'template' => 'version',
'children' => [
[
'slug' => 'a-child',
'template' => 'version'
]
]
],
]
],
'options' => [
Expand Down Expand Up @@ -613,7 +625,36 @@ public function testRenderVersionDetectedFromRequest()

$this->app = $this->app->clone([
'request' => [
'query' => ['_version' => 'changes']
'query' => [
'_token' => $page->version('changes')->previewToken(),
'_version' => 'changes'
]
]
]);

$this->assertSame("Version: changes\nContent: Changes Title", $page->render());
}

/**
* @covers ::cacheId
* @covers ::render
*/
public function testRenderVersionDetectedFromRequestDraft()
{
$page = $this->app->page('version-draft');
$page->version('latest')->save(['title' => 'Latest Title']);
$page->version('changes')->save(['title' => 'Changes Title']);

// manual renders of drafts falls back to the latest version even if
// the draft couldn't be rendered "publicly" by `$kirby->resolve()`
$this->assertSame("Version: latest\nContent: Latest Title", $page->render());

$this->app = $this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('changes')->previewToken(),
'_version' => 'changes'
]
]
]);

Expand Down Expand Up @@ -683,4 +724,163 @@ public function testRenderVersionException()
// global state always needs to be reset after rendering
$this->assertNull(VersionId::$render);
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestAuthenticated()
{
$page = $this->app->page('default');

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('latest')->previewToken(),
'_version' => 'latest'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('changes')->previewToken(),
'_version' => 'changes'
]
]
]);

$this->assertSame('changes', $page->renderVersionFromRequest()->value());
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestAuthenticatedDraft()
{
$page = $this->app->page('version-draft');

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('latest')->previewToken(),
'_version' => 'latest'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('changes')->previewToken(),
'_version' => 'changes'
]
]
]);

$this->assertSame('changes', $page->renderVersionFromRequest()->value());
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestInvalidId()
{
$page = $this->app->page('default');
$draft = $this->app->page('version-draft');
$draftChild = $this->app->page('version-draft/a-child');

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('changes')->previewToken(),
'_version' => 'some-gibberish'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());
$this->assertNull($draft->renderVersionFromRequest());
$this->assertNull($draftChild->renderVersionFromRequest());
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestMissingId()
{
$page = $this->app->page('default');

$this->app->clone([
'request' => [
'query' => [
'_token' => $page->version('changes')->previewToken()
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestMissingToken()
{
$page = $this->app->page('default');
$draft = $this->app->page('version-draft');
$draftChild = $this->app->page('version-draft/a-child');

$this->app->clone([
'request' => [
'query' => [
'_version' => 'changes'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());
$this->assertNull($draft->renderVersionFromRequest());
$this->assertNull($draftChild->renderVersionFromRequest());

$this->app->clone([
'request' => [
'query' => [
'_token' => '',
'_version' => 'changes'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());
$this->assertNull($draft->renderVersionFromRequest());
$this->assertNull($draftChild->renderVersionFromRequest());
}

/**
* @covers ::renderVersionFromRequest
*/
public function testRenderVersionFromRequestInvalidToken()
{
$page = $this->app->page('default');
$draft = $this->app->page('version-draft');
$draftChild = $this->app->page('version-draft/a-child');

$this->app->clone([
'request' => [
'query' => [
'_token' => 'some-gibberish',
'_version' => 'changes'
]
]
]);

$this->assertSame('latest', $page->renderVersionFromRequest()->value());
$this->assertNull($draft->renderVersionFromRequest());
$this->assertNull($draftChild->renderVersionFromRequest());
}
}
Loading

0 comments on commit ef86d92

Please sign in to comment.