magento / magento2

Prior to making any Submission(s), you must sign an Adobe Contributor License Agreement, available here at: https://opensource.adobe.com/cla.html. All Submissions you make to Adobe Inc. and its affiliates, assigns and subsidiaries (collectively “Adobe”) are subject to the terms of the Adobe Contributor License Agreement.
http://www.magento.com
Open Software License 3.0
11.57k stars 9.32k forks source link

What's the proper way to redirect from external payment Gateway with POST redirect and still use SameSite: Lax? #38889

Open ioweb-gr opened 4 months ago

ioweb-gr commented 4 months ago

Summary

I've read many of the SameSite issues and possible solutions / workarounds on the repo issues, however so far the only real way of making this work is setting SameSite to None.

My question is about how to make it work with Lax mode and how Paypal integration works.

I've been checking the paypal module and it seems to preserve the session for the logged in user, it uses this plugin

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare(strict_types=1);

namespace Magento\Paypal\Plugin;

use Magento\Framework\App\Request\Http;
use Magento\Framework\Session\SessionStartChecker;

/**
 * Intended to preserve session cookie after submitting POST form from PayPal to Magento controller.
 */
class TransparentSessionChecker
{
    /**
     * @var string[]
     */
    private $disableSessionUrls = [
        'paypal/transparent/redirect',
        'paypal/payflowadvanced/returnUrl',
        'paypal/payflow/returnUrl',
        'paypal/hostedpro/return',
    ];

    /**
     * @var Http
     */
    private $request;

    /**
     * @param Http $request
     */
    public function __construct(
        Http $request
    ) {
        $this->request = $request;
    }

    /**
     * Prevents session starting while instantiating PayPal transparent redirect controller.
     *
     * @param SessionStartChecker $subject
     * @param bool $result
     * @return bool
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function afterCheck(SessionStartChecker $subject, bool $result): bool
    {
        if ($result === false) {
            return false;
        }

        foreach ($this->disableSessionUrls as $url) {
            if (strpos((string)$this->request->getPathInfo(), $url) !== false) {
                return false;
            }
        }

        return true;
    }
}

This effectively makes the session not be destroyed and the user stays logged in.

However on the initial controller which handles the response in my case

cardlink_checkout/payment/response

When fetching the CheckoutSession via Factory or via Proxy or Plain with the class through DI, it's an empty session.

I've read further into the paypal integration and saw that it uses an intermediate redirect by intercepting the initial POST request

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
namespace Magento\Paypal\Controller\Transparent;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\View\Result\LayoutFactory;
use Magento\Payment\Model\Method\Logger;
use Magento\Paypal\Model\Payflow\Transparent;

/**
 * Class for redirecting the Paypal response result to Magento controller.
 */
class Redirect extends Action implements CsrfAwareActionInterface, HttpPostActionInterface
{
    /**
     * @var LayoutFactory
     */
    private $resultLayoutFactory;

    /**
     * @var Transparent
     */
    private $transparent;

    /**
     * @var Logger
     */
    private $logger;

    /**
     * @param Context $context
     * @param LayoutFactory $resultLayoutFactory
     * @param Transparent $transparent
     * @param Logger $logger
     */
    public function __construct(
        Context $context,
        LayoutFactory $resultLayoutFactory,
        Transparent $transparent,
        Logger $logger
    ) {
        $this->resultLayoutFactory = $resultLayoutFactory;
        $this->transparent = $transparent;
        $this->logger = $logger;

        parent::__construct($context);
    }

    /**
     * @inheritdoc
     */
    public function createCsrfValidationException(
        RequestInterface $request
    ): ?InvalidRequestException {
        return null;
    }

    /**
     * @inheritdoc
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }

    /**
     * Saves the payment in quote
     *
     * @return ResultInterface
     * @throws LocalizedException
     */
    public function execute()
    {
        $gatewayResponse = (array)$this->getRequest()->getPostValue();
        $this->logger->debug(
            ['PayPal PayflowPro redirect:' => $gatewayResponse],
            $this->transparent->getDebugReplacePrivateDataKeys(),
            $this->transparent->getDebugFlag()
        );

        $resultLayout = $this->resultLayoutFactory->create();
        $resultLayout->addDefaultHandle();
        $resultLayout->getLayout()->getUpdate()->load(['transparent_payment_redirect']);

        return $resultLayout;
    }
}

Which effectively renders a form which submits again the same data but from the same domain

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

/** @var \Magento\Payment\Block\Transparent\Redirect $block */
$params = $block->getPostParams();
$redirectUrl = $block->getRedirectUrl();
?>
<html>
<head></head>
<body onload="document.forms['proxy_form'].submit()">
<form id="proxy_form" action="<?= $block->escapeUrl($redirectUrl) ?>"
      method="POST" hidden enctype="application/x-www-form-urlencoded" class="no-display">
    <?php foreach ($params as $name => $value):?>
        <input value="<?= $block->escapeHtmlAttr($value) ?>" name="<?= $block->escapeHtmlAttr($name) ?>" type="hidden"/>
    <?php endforeach?>
</form>
</body>
</html>

the URL is declared in di.xml

<?xml version="1.0"?>
<!--
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd">
    <container name="root" label="Root">
        <block class="Magento\Payment\Block\Transparent\Redirect" name="transparent_redirect" template="Magento_Payment::transparent/redirect.phtml">
            <arguments>
                <argument name="route_path" xsi:type="string">paypal/transparent/response</argument>
            </arguments>
        </block>
    </container>
</layout>

However when reaching the response controller equivalent in the module I'm trying to patch, I still can't get the CheckoutSession properly and it's entirely empty.

Furthermore, when setting the data to it for the LastSuccessQuoteId and redirecting to checkout/onepage/success controller, I notice that there, the checkoutSession is restored entirely to the previous state, before my modification. Thus because the LastSuccessQuoteId is not there in that state, I'm redirected to cart instead.

So I'm assuming here that something extra is being done in the paypal gateway to retain the actual CheckoutSession and modify that one instead of creating a new empty one.

However I'm unable to find any type of documentation regarding this. In the official docs, there's no mention of how to tackle SameSite and also it is left in the responsibility of each payment gateway to fix it. However this is a problem for Magento itself and us developers trying to fix the gateway modules without nuking the settings to make the cookies set Samesite to "None"

As far as I can see in the network tab, the PHPSESSID is there in all requests, but the cookie isn't.

On redirecting to payment provider

image image

On redirecting to the response handler which creates the "proxy" form as expected nothing is there image

On the actual processor everything is there except the PHPSESSID cookie for some reason in the response and in the request the PHPSESS ID is actually there image image

And on the checkout/onepage/success controller which can also see the original CheckoutSession I can see everything there as well.

image

So my question is what's the missing step to actually fetch the proper CheckoutSession after the "proxy" form redirects to the actual processor.

Assuming of course what paypal module shows in transparent/redirect classes / phtml files is the correct approach.

If not, what's the correct approach to achieve this while still using SameSite = Lax.

Examples

cardlink.zip

here's an example module provided by Cardlink a major payment gateway in Greece.

Their proposal was to change SameSite to "None" as well.

I'm particularly curious as to why the information for the session is transferred properly when redirecting from the responseProcessor to checkout/onepage/success controller on redirect 2 while not transferred properly when redirecting from the response controller to the responseProcessor on redirect 1 because if the information is there in redirect 2, why wouldn't it be available in redirect 1?

Any help is appreciated

Proposed solution

No response

Release note

No response

Triage and priority

m2-assistant[bot] commented 4 months ago

Hi @ioweb-gr. Thank you for your report. To speed up processing of this issue, make sure that the issue is reproducible on the vanilla Magento instance following Steps to reproduce. To deploy vanilla Magento instance on our environment, Add a comment to the issue:

m2-assistant[bot] commented 4 months ago

Hi @engcom-Hotel. Thank you for working on this issue. In order to make sure that issue has enough information and ready for development, please read and check the following instruction: :point_down:

engcom-Hotel commented 4 months ago

Hello @ioweb-gr,

Thanks for the report and collaboration!

We have gone through with your issue, in your case, you're trying to make it work with "Lax" mode. However, the issue is that the session cookie is not sent when the user is redirected back to your site from the payment gateway. This is because the browser sees this as a cross-site request and, due to the "Lax" setting, does not include the session cookie in the request. As a result, Magento can't associate the request with a session and creates a new, empty session.

The PayPal module gets around this by using an intermediate page that submits a form to the final destination. This form submission is seen as a same-site request by the browser, so the session cookie is included in the request.

But it seems to us an expected behavior and like PayPal handles the situation, Cardlink also handles similarly. Can you please raise the same issue with them?

Please let us know if we missed anything here.

Thanks

ioweb-gr commented 4 months ago

Hi @engcom-Hotel

first of all let me clarify that this module is the attempt I made to handle it like PayPal. So it's a modified version of the original. The original can be found here https://github.com/Cardlink-SA/cardlink-payment-gateway-magento2x and as you said is suffering from SameSite issue.

Basically:

In the original case

User is redirected to the payment gateway and pays The payment gateway redirects him to the magento site and due to Lax setting, the cookie is not there so the new session is created

In my modified version

I tried to replicate what Paypal does, by using the transparent redirect you mention that creates an internal page, a form with the orignal post data, which is automatically submitted to the processor.

I'm not sure if I've misinterpreted something from the Paypal module, but it seemed to me that's the way it handles it as you also mention

The PayPal module gets around this by using an intermediate page that submits a form to the final destination. This form submission is seen as a same-site request by the browser, so the session cookie is included in the request.

However in my module as you can see even if the process looks identical to paypal, again I'm facing the issue that on the actual ResponseProcessor controller which gets triggered after the second submission (from the same site) triggered by the ResponseController, still I don't have the proper session.

Basically the checkout session inside ResponseProcessor which is invoked after the automatic submission of the form rendered by the Response controller which processes the original request from Cardlink is still empty.

However after ResponseProcessor is done and redirects to the success page, the session is there properly.

To visualize this

cardlink

According to my understanding during the internal redirect from one magento controller to the other, the session should have been recovered but it isn't until the final GET request is done.

engcom-Hotel commented 4 months ago

Thanks @ioweb-gr for the clarification!

We are confirming this issue for further processing.

Thanks

github-jira-sync-bot commented 4 months ago

:white_check_mark: Jira issue https://jira.corp.adobe.com/browse/AC-12330 is successfully created for this GitHub issue.

m2-assistant[bot] commented 4 months ago

:white_check_mark: Confirmed by @engcom-Hotel. Thank you for verifying the issue.
Issue Available: @engcom-Hotel, You will be automatically unassigned. Contributors/Maintainers can claim this issue to continue. To reclaim and continue work, reassign the ticket to yourself.

ioweb-gr commented 4 months ago

Hi @engcom-Hotel thank you for confirming the issue.

Do you think you can ask the core developers who created the paypal module to provide some insight as to what extra does the PayPal module do to retain the session?

This is a very important issue since we are prevented from integrating with payment gateways.

ioweb-gr commented 1 month ago

Any update on this issue and what paypal does different to handle this use case?>

ioweb-gr commented 1 month ago

I've done some further digging by adding a log in each step on a similar integration suffering from the same issue. It seems that the session_id exists in $_COOKIE with the same ID

[2024-10-19T09:13:30.948621+00:00] ioweb_logger.INFO: Session id from cookie: gro6j5l5dgndtqc8g0vt0qm1dl [] []
[2024-10-19T09:13:30.949078+00:00] ioweb_logger.INFO: Redirecting to PiraeusBank Gateway for quote ID: 21753 [] []
[2024-10-19T09:14:01.669185+00:00] ioweb_logger.INFO: FAIL TRANSPARENT PROCESSOR: Session id from cookie: gro6j5l5dgndtqc8g0vt0qm1dl [] []

So basically even though the cookie exists, and the PHPSESSID is set, the magento session is empty when it reaches the final processing URL

image

But at the same time php's session_id function returns nothing after the redirect. So the session is indeed lost right after the redirect from the payment gateway to the magento url