Skip to content

[6.2] Implement secure article preview without requiring frontend login #3811

Description

@jgerman-bot

New language relevant PR in upstream repo: joomla/joomla-cms#48030 Here are the upstream changes:

Click to expand the diff!
diff --git a/administrator/components/com_content/config.xml b/administrator/components/com_content/config.xml
index 586c250a0feb3..8b21fceaf25d6 100644
--- a/administrator/components/com_content/config.xml
+++ b/administrator/components/com_content/config.xml
@@ -330,6 +330,17 @@
 			<option value="1">COM_CONTENT_FIELD_OPTION_BELOW</option>
 			<option value="-1">JHIDE</option>
 		</field>
+
+		<field
+			name="preview_token_expiration"
+			type="number"
+			label="COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_LABEL"
+			description="COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_DESC"
+			default="15"
+			min="1"
+			step="5"
+			filter="integer"
+		/>
 	</fieldset>
 	<fieldset
 		name="editinglayout"
diff --git a/administrator/components/com_content/src/Service/PreviewTokenService.php b/administrator/components/com_content/src/Service/PreviewTokenService.php
new file mode 100644
index 0000000000000..e890bbaa498dd
--- /dev/null
+++ b/administrator/components/com_content/src/Service/PreviewTokenService.php
@@ -0,0 +1,156 @@
+<?php
+
+/**
+ * @package         Joomla.Administrator
+ * @subpackage      com_content
+ *
+ * @copyright       (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license         GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Content\Administrator\Service;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Service class for generating and validating article preview tokens.
+ *
+ * Tokens are URL-safe, HMAC-SHA256 signed, and time-limited.
+ * They allow unauthenticated public access to article previews without
+ * requiring a frontend login or published state.
+ *
+ * Usage:
+ *   $helper = new PreviewTokenService($app->get('secret'));
+ *   $token  = $helper->createToken($articleId, 24);
+ *   $valid  = $helper->validateToken($token, $articleId);
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class PreviewTokenService
+{
+    /**
+     * The secret key used to sign and verify preview tokens.
+     *
+     * @var string
+     */
+    private string $secret;
+
+    /**
+     * @param   string  $secret  The secret key
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function __construct(string $secret)
+    {
+        $this->secret = $secret;
+    }
+
+    /**
+     * Generate a signed preview token for a given article.
+     *
+     * @param   int  $id                The article ID.
+     * @param   int  $expiresInMinutes  Number of minutes before the token expires.
+     *
+     * @return  string
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function createToken(int $id, int $expiresInMinutes): string
+    {
+        $payload = $this->encode(json_encode([
+            'id'  => $id,
+            'exp' => time() + ($expiresInMinutes * 60),
+        ]));
+
+        $signature = $this->encode(hash_hmac('sha256', $payload, $this->secret, true));
+
+        return $payload . '.' . $signature;
+    }
+
+    /**
+     * Validate a preview token against a given article ID.
+     *
+     * Checks that the token:
+     * - Has a valid structure
+     * - Has not been tampered with (HMAC signature check)
+     * - Has not expired
+     * - Belongs to the expected article
+     *
+     * @param   string  $token  The preview token from the URL.
+     * @param   int     $id     The expected article ID.
+     *
+     * @return  bool
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function validateToken(string $token, int $id): bool
+    {
+        $parts = explode('.', $token);
+
+        if (\count($parts) !== 2) {
+            return false;
+        }
+
+        [$payload, $signature] = $parts;
+
+        if (!hash_equals($this->encode(hash_hmac('sha256', $payload, $this->secret, true)), $signature)) {
+            return false;
+        }
+
+        $decoded = $this->decode($payload);
+
+        if ($decoded === false) {
+            return false;
+        }
+
+        $data = json_decode($decoded, true);
+
+        if (!\is_array($data) || !isset($data['id'], $data['exp'])) {
+            return false;
+        }
+
+        if (!\is_int($data['id']) || !\is_int($data['exp'])) {
+            return false;
+        }
+
+        if ($data['id'] !== $id) {
+            return false;
+        }
+
+        return time() < $data['exp'];
+    }
+
+    /**
+     * Encode binary data to URL-safe Base64 string.
+     *
+     * @param   string  $data
+     *
+     * @return  string
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    private function encode(string $data): string
+    {
+        return str_replace('=', '', strtr(base64_encode($data), '+/', '-_'));
+    }
+
+    /**
+     * Decode a URL-safe Base64 string to binary data.
+     *
+     * @param   string  $data
+     *
+     * @return  string|false
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    private function decode(string $data): string|false
+    {
+        if ($remainder = \strlen($data) % 4) {
+            $data .= str_repeat('=', 4 - $remainder);
+        }
+
+        return base64_decode(strtr($data, '-_', '+/'));
+    }
+}
diff --git a/administrator/components/com_content/src/View/Article/HtmlView.php b/administrator/components/com_content/src/View/Article/HtmlView.php
index cdebf91aacaf9..6ded1f8cbe799 100644
--- a/administrator/components/com_content/src/View/Article/HtmlView.php
+++ b/administrator/components/com_content/src/View/Article/HtmlView.php
@@ -10,11 +10,13 @@
 
 namespace Joomla\Component\Content\Administrator\View\Article;
 
+use Joomla\CMS\Component\ComponentHelper;
 use Joomla\CMS\Factory;
 use Joomla\CMS\Helper\ContentHelper;
 use Joomla\CMS\Language\Text;
 use Joomla\CMS\MVC\View\FormView;
 use Joomla\CMS\Toolbar\ToolbarHelper;
+use Joomla\Component\Content\Administrator\Service\PreviewTokenService;
 use Joomla\Component\Content\Site\Helper\RouteHelper;
 
 // phpcs:disable PSR1.Files.SideEffects
@@ -87,6 +89,18 @@ protected function initializeView()
 
         $url = RouteHelper::getArticleRoute($this->item->id . ':' . $this->item->alias, $this->item->catid, $this->item->language);
 
+        // Generate preview token if editing an existing article
+        if ($this->item->id > 0) {
+            $params     = ComponentHelper::getParams('com_content');
+            $expiration = (int) $params->get('preview_token_expiration', 15);
+
+            $tokenHelper = new PreviewTokenService(Factory::getApplication()->get('secret'));
+            $token       = $tokenHelper->createToken((int) $this->item->id, $expiration);
+
+            // Append token using proper URL query parameter handling
+            $url .= (!str_contains($url, '?') ? '?' : '&') . 'preview_token=' . $token;
+        }
+
         $this->previewLink = $url;
         $this->jooa11yLink = $url . '&jooa11y=1';
 
diff --git a/administrator/language/en-GB/com_content.ini b/administrator/language/en-GB/com_content.ini
index 72b75be7c2f00..972328dfdf72e 100644
--- a/administrator/language/en-GB/com_content.ini
+++ b/administrator/language/en-GB/com_content.ini
@@ -88,6 +88,8 @@ COM_CONTENT_FIELD_NOTE_LABEL="Note"
 COM_CONTENT_FIELD_OPTION_ABOVE="Above"
 COM_CONTENT_FIELD_OPTION_BELOW="Below"
 COM_CONTENT_FIELD_OPTION_SPLIT="Split"
+COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_DESC="Number of minutes before a preview token expires. Preview tokens allow unauthenticated users to view unpublished articles. Default is 15 minutes."
+COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_LABEL="Preview Token Expiration (minutes)"
 COM_CONTENT_FIELD_PUBLISH_DOWN_LABEL="Finish Publishing"
 COM_CONTENT_FIELD_PUBLISH_UP_LABEL="Start Publishing"
 COM_CONTENT_FIELD_SELECT_ARTICLE_LABEL="Select Article"
diff --git a/components/com_content/src/Model/ArticleModel.php b/components/com_content/src/Model/ArticleModel.php
index 6782dc4cbefdb..1c91f19f43229 100644
--- a/components/com_content/src/Model/ArticleModel.php
+++ b/components/com_content/src/Model/ArticleModel.php
@@ -17,6 +17,7 @@
 use Joomla\CMS\MVC\Model\ItemModel;
 use Joomla\CMS\Table\Content;
 use Joomla\Component\Content\Administrator\Extension\ContentComponent;
+use Joomla\Component\Content\Administrator\Service\PreviewTokenService;
 use Joomla\Database\ParameterType;
 use Joomla\Registry\Registry;
 use Joomla\Utilities\IpHelper;
@@ -68,6 +69,17 @@ protected function populateState()
         // If $pk is set then authorise on complete asset, else on component only
         $asset = empty($pk) ? 'com_content' : 'com_content.article.' . $pk;
 
+        // Validate preview token if present, before any permission checks.
+        $token = $app->getInput()->getString('preview_token', '');
+
+        if ($token !== '' && $pk > 0) {
+            $previewTokenHelper = new PreviewTokenService($app->get('secret'));
+
+            if ($previewTokenHelper->validateToken($token, $pk)) {
+                $this->setState('article.preview', true);
+            }
+        }
+
         if ((!$user->authorise('core.edit.state', $asset)) && (!$user->authorise('core.edit', $asset))) {
             $this->setState('filter.published', ContentComponent::CONDITION_PUBLISHED);
             $this->setState('filter.archived', ContentComponent::CONDITION_ARCHIVED);
@@ -177,8 +189,11 @@ public function getItem($pk = null)
                     $query->whereIn($db->quoteName('a.language'), [Factory::getLanguage()->getTag(), '*'], ParameterType::STRING);
                 }
 
+                $isPreview = $this->getState('article.preview', false);
+
                 if (
-                    !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
+                    !$isPreview
+                    && !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
                     && !$user->authorise('core.edit', 'com_content.article.' . $pk)
                 ) {
                     // Filter by start and end dates.
@@ -207,7 +222,7 @@ public function getItem($pk = null)
                 $published = $this->getState('filter.published');
                 $archived  = $this->getState('filter.archived');
 
-                if (is_numeric($published)) {
+                if (!$isPreview && is_numeric($published)) {
                     $query->whereIn($db->quoteName('a.state'), [(int) $published, (int) $archived]);
                 }
 
@@ -220,7 +235,7 @@ public function getItem($pk = null)
                 }
 
                 // Check for published state if filter set.
-                if ((is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
+                if (!$isPreview && (is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
                     throw new \Exception(Text::_('COM_CONTENT_ERROR_ARTICLE_NOT_FOUND'), 404);
                 }
 
@@ -262,8 +277,8 @@ public function getItem($pk = null)
                 }
 
                 // Compute view access permissions.
-                if ($this->getState('filter.access')) {
-                    // If the access filter has been set, we already know this user can view.
+                if ($isPreview || $this->getState('filter.access')) {
+                    // Valid preview token or access filter set — user can view.
                     $data->params->set('access-view', true);
                 } else {
                     // If no access filter is set, the layout takes some responsibility for display of limited information.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions