Skip to content

Commit 5f09dd0

Browse files
committed
Add token-based security for cart loading
- Require valid token or logged-in cart owner to load a cart via load-cart action - Add cartLinkExpiry setting (default 24 hours) for token expiration - Add getLoadCartUrl() method to Carts service that generates secure token URLs - Update Order::getLoadCartUrl() to return secure token URL - Add email challenge flow for cart recovery when token is missing/expired - Register commerce_cart_recovery system message for recovery emails - Add _cart/email-challenge.twig and email-sent.twig templates
1 parent 053db18 commit 5f09dd0

File tree

7 files changed

+351
-14
lines changed

7 files changed

+351
-14
lines changed

src/Plugin.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,12 @@ function(RegisterEmailMessagesEvent $event) {
862862
'subject' => Craft::t('commerce', 'Your Order PDF Download Link'),
863863
'body' => $this->_getDefaultPdfDownloadMessage(),
864864
],
865+
[
866+
'key' => 'commerce_cart_recovery',
867+
'heading' => Craft::t('commerce', 'Cart Recovery Link'),
868+
'subject' => Craft::t('commerce', 'Your Cart Recovery Link'),
869+
'body' => $this->_getDefaultCartRecoveryMessage(),
870+
],
865871
]);
866872
}
867873
);
@@ -1308,4 +1314,18 @@ private function _getDefaultPdfDownloadMessage(): string
13081314
"**Please note:** This link will expire for security purposes.\n\n" .
13091315
"Thank you!";
13101316
}
1317+
1318+
/**
1319+
* Returns the default message body for the cart recovery email.
1320+
*
1321+
* @return string
1322+
*/
1323+
private function _getDefaultCartRecoveryMessage(): string
1324+
{
1325+
return "Hello,\n\n" .
1326+
"You requested a link to recover your shopping cart. Click the link below to continue shopping:\n\n" .
1327+
"[Recover My Cart]({{ link }})\n\n" .
1328+
"**Please note:** This link will expire for security purposes.\n\n" .
1329+
"Thank you!";
1330+
}
13111331
}

src/controllers/CartController.php

Lines changed: 148 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use yii\web\HttpException;
3232
use yii\web\NotFoundHttpException;
3333
use yii\web\Response;
34+
use craft\web\View;
3435

3536
/**
3637
* Class Cart Controller
@@ -372,16 +373,15 @@ public function actionLoadCart(): ?Response
372373
{
373374
$carts = Plugin::getInstance()->getCarts();
374375
$number = $this->request->getParam('number');
376+
$token = $this->request->getParam('token');
375377
$loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? '';
376378
$redirect = UrlHelper::siteUrl($loadCartRedirectUrl);
377379

378380
if (!$number) {
379381
$error = Craft::t('commerce', 'A cart number must be specified.');
380-
381382
if ($this->request->getAcceptsJson()) {
382383
return $this->asFailure($error);
383384
}
384-
385385
$this->setFailFlash($error);
386386
return $this->request->getIsGet() ? $this->redirect($redirect) : null;
387387
}
@@ -390,18 +390,59 @@ public function actionLoadCart(): ?Response
390390

391391
if (!$cart) {
392392
$error = Craft::t('commerce', 'Unable to retrieve cart.');
393-
394393
if ($this->request->getAcceptsJson()) {
395394
return $this->asFailure($error);
396395
}
396+
$this->setFailFlash($error);
397+
return $this->request->getIsGet() ? $this->redirect($redirect) : null;
398+
}
397399

400+
// Carts without email cannot be recovered
401+
if (!$cart->getEmail()) {
402+
$error = Craft::t('commerce', 'Unable to retrieve cart.');
403+
if ($this->request->getAcceptsJson()) {
404+
return $this->asFailure($error);
405+
}
398406
$this->setFailFlash($error);
399407
return $this->request->getIsGet() ? $this->redirect($redirect) : null;
400408
}
401409

402-
// If we have a cart, use the site for that cart for the URL redirect.
403-
$redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId);
410+
$currentUser = Craft::$app->getUser()->getIdentity();
411+
$hasValidToken = false;
412+
413+
// Check token if provided
414+
if ($token) {
415+
$tokenData = Craft::$app->getTokens()->getTokenRoute($token);
416+
417+
if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) {
418+
Craft::$app->getSession()->setError(Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.'));
419+
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
420+
}
421+
422+
if (isset($tokenData[1]['expiresAt'])) {
423+
$now = (new \DateTime())->getTimestamp();
424+
if ($now > $tokenData[1]['expiresAt']) {
425+
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
426+
}
427+
}
428+
429+
$hasValidToken = true;
430+
}
404431

432+
// Check permissions if no valid token
433+
if (!$hasValidToken) {
434+
if ($currentUser) {
435+
$isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id;
436+
if (!$isCartCustomer) {
437+
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
438+
}
439+
} else {
440+
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
441+
}
442+
}
443+
444+
// Load the cart (existing logic)
445+
$redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId);
405446
$carts->forgetCart();
406447
$carts->setSessionCartNumber($number);
407448

@@ -802,4 +843,106 @@ private function _setAddresses(): void
802843
}
803844
}
804845
}
846+
847+
/**
848+
* Renders the cart email challenge template.
849+
*/
850+
private function renderCartEmailChallenge(Order $cart, string $cartNumber): Response
851+
{
852+
return $this->renderTemplate('commerce/_cart/email-challenge', [
853+
'cart' => $cart,
854+
'cartNumber' => $cartNumber,
855+
], View::TEMPLATE_MODE_CP);
856+
}
857+
858+
/**
859+
* Displays the email challenge form for cart recovery.
860+
* @since 5.x
861+
*/
862+
public function actionEmailChallenge(): Response
863+
{
864+
$number = $this->request->getQueryParam('number');
865+
866+
if (!$number) {
867+
throw new BadRequestHttpException('Cart number required');
868+
}
869+
870+
$cart = Order::find()->number($number)->isCompleted(false)->one();
871+
872+
if (!$cart || !$cart->getEmail()) {
873+
throw new HttpException(404, 'Cart not found');
874+
}
875+
876+
return $this->renderCartEmailChallenge($cart, $number);
877+
}
878+
879+
/**
880+
* Handles the email challenge form submission for cart recovery.
881+
* @since 5.x
882+
*/
883+
public function actionCartChallenge(): Response
884+
{
885+
$this->requirePostRequest();
886+
887+
$cartNumberHash = $this->request->getBodyParam('cartNumberHash');
888+
889+
if (!$cartNumberHash) {
890+
throw new BadRequestHttpException('Cart number hash is required');
891+
}
892+
893+
$cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash);
894+
895+
if ($cartNumber === false) {
896+
throw new BadRequestHttpException('Invalid cart number hash');
897+
}
898+
899+
$cart = Order::find()->number($cartNumber)->isCompleted(false)->one();
900+
901+
if (!$cart) {
902+
throw new HttpException(404, 'Cart not found');
903+
}
904+
905+
$loadCartUrl = Plugin::getInstance()->getCarts()->getLoadCartUrl($cart);
906+
907+
if (!Craft::$app->getMailer()->composeFromKey('commerce_cart_recovery', [
908+
'link' => $loadCartUrl,
909+
'cart' => $cart,
910+
])->setTo($cart->email)->send()) {
911+
Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.'));
912+
return $this->renderCartEmailChallenge($cart, $cartNumber);
913+
}
914+
915+
Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A cart recovery link has been sent to {email}', ['email' => $cart->getMaskedEmail()]));
916+
917+
return $this->redirect(UrlHelper::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash]));
918+
}
919+
920+
/**
921+
* Displays success page after cart recovery email is sent.
922+
* @since 5.x
923+
*/
924+
public function actionCartSent(): Response
925+
{
926+
$cartNumberHash = $this->request->getQueryParam('hash');
927+
928+
if (!$cartNumberHash) {
929+
throw new BadRequestHttpException('Hash parameter required');
930+
}
931+
932+
$cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash);
933+
934+
if ($cartNumber === false) {
935+
throw new HttpException(400, 'Invalid hash parameter');
936+
}
937+
938+
$cart = Order::find()->number($cartNumber)->isCompleted(false)->one();
939+
940+
if (!$cart) {
941+
throw new HttpException(404, 'Cart not found');
942+
}
943+
944+
return $this->renderTemplate('commerce/_cart/email-sent', [
945+
'email' => $cart->getMaskedEmail(),
946+
], View::TEMPLATE_MODE_CP);
947+
}
805948
}

src/elements/Order.php

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2460,9 +2460,9 @@ public function getPdfUrl(string $option = null, string $pdfHandle = null, bool
24602460
}
24612461

24622462
/**
2463-
* Returns the URL to the carts load action url
2463+
* Returns the URL to the cart's load action url with a secure token.
24642464
*
2465-
* @return string|null The URL to the orders load cart URL, or null if the cart is an order
2465+
* @return string|null The URL to the order's load cart URL, or null if the cart is an order
24662466
* @noinspection PhpUnused
24672467
*/
24682468
public function getLoadCartUrl(): ?string
@@ -2471,12 +2471,7 @@ public function getLoadCartUrl(): ?string
24712471
return null;
24722472
}
24732473

2474-
$originalCpRequest = Craft::$app->getRequest()->getIsCpRequest();
2475-
Craft::$app->getRequest()->setIsCpRequest(false);
2476-
$url = UrlHelper::actionUrl('commerce/cart/load-cart', ['number' => $this->number]);
2477-
Craft::$app->getRequest()->setIsCpRequest($originalCpRequest);
2478-
2479-
return $url;
2474+
return Plugin::getInstance()->getCarts()->getLoadCartUrl($this);
24802475
}
24812476

24822477
/**

src/models/Settings.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,22 @@ class Settings extends Model
141141
/**
142142
* @var string|null Default URL to be loaded after using the [load cart controller action](https://craftcms.com/docs/commerce/5.x/system/orders-carts.html#loading-a-cart).
143143
*
144-
* If `null` (default), Crafts default [`siteUrl`](config5:siteUrl) will be used.
144+
* If `null` (default), Craft's default [`siteUrl`](config5:siteUrl) will be used.
145145
*
146146
* @group Cart
147147
* @since 3.1
148148
*/
149149
public ?string $loadCartRedirectUrl = null;
150150

151+
/**
152+
* @var int How long (in seconds) a cart recovery link should remain valid before expiring.
153+
* Default is 86400 (24 hours).
154+
*
155+
* @group Cart
156+
* @since 5.x
157+
*/
158+
public int $cartLinkExpiry = 86400;
159+
151160
/**
152161
* @var array|null ISO codes for supported payment currencies.
153162
*

src/services/Carts.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use craft\helpers\ConfigHelper;
2222
use craft\helpers\DateTimeHelper;
2323
use craft\helpers\Db;
24+
use craft\helpers\UrlHelper;
2425
use DateTime;
2526
use Throwable;
2627
use yii\base\Component;
@@ -373,6 +374,32 @@ public function setSessionCartNumber(string $cartNumber): void
373374
}
374375
}
375376

377+
/**
378+
* Returns a URL to load a cart with a secure token.
379+
*
380+
* @param Order $cart The cart to generate the load URL for
381+
* @return string The URL with secure token
382+
* @since 5.x
383+
*/
384+
public function getLoadCartUrl(Order $cart): string
385+
{
386+
$linkExpiry = Plugin::getInstance()->getSettings()->cartLinkExpiry;
387+
$expiryTimestamp = (new \DateTime())->add(new \DateInterval('PT' . $linkExpiry . 'S'))->getTimestamp();
388+
389+
$token = Craft::$app->getTokens()->createToken([
390+
'commerce/cart/load-cart',
391+
[
392+
'cartNumber' => $cart->number,
393+
'expiresAt' => $expiryTimestamp,
394+
],
395+
]);
396+
397+
return UrlHelper::siteUrl('actions/commerce/cart/load-cart', [
398+
'number' => $cart->number,
399+
'token' => $token,
400+
]);
401+
}
402+
376403
/**
377404
* Restores previous cart for the current user if their current cart is empty.
378405
* Ideally this is only used when a user logs in.

0 commit comments

Comments
 (0)