Fix PayPal Standard session loss in WordPress
authorChristian Wach <needle@haystack.co.uk>
Sat, 19 Sep 2020 11:47:26 +0000 (12:47 +0100)
committerChristian Wach <needle@haystack.co.uk>
Sat, 19 Sep 2020 11:47:26 +0000 (12:47 +0100)
CRM/Core/Payment/PayPalImpl.php
CRM/Utils/SameSite.php [new file with mode: 0644]
CRM/Utils/System.php
CRM/Utils/System/Base.php
CRM/Utils/System/WordPress.php

index b1833a8752ca0cb1ec08a2d5dda3b275f02eb989..a1002fe925e73d3c3c4a5947424574cfcde7dbb5 100644 (file)
@@ -971,6 +971,9 @@ class CRM_Core_Payment_PayPalImpl extends CRM_Core_Payment {
     $sub = empty($params['is_recur']) ? 'cgi-bin/webscr' : 'subscriptions';
     $paypalURL = "{$url}{$sub}?$uri";
 
+    // Allow each CMS to do a pre-flight check before redirecting to PayPal.
+    CRM_Utils_System::prePostRedirect();
+
     CRM_Utils_System::redirect($paypalURL);
   }
 
diff --git a/CRM/Utils/SameSite.php b/CRM/Utils/SameSite.php
new file mode 100644 (file)
index 0000000..0431761
--- /dev/null
@@ -0,0 +1,224 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * SameSite Utility Class.
+ *
+ * Determines if the current User Agent can handle the `SameSite=None` parameter
+ * by mapping against known incompatible clients.
+ *
+ * Sample code:
+ *
+ * // Get User Agent string.
+ * $rawUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
+ * $userAgent = mb_convert_encoding($rawUserAgent, 'UTF-8');
+ *
+ * // Get boolean representing User Agent compatibility.
+ * $shouldUseSameSite = CRM_Utils_SameSite::shouldSendSameSiteNone($userAgent);
+ *
+ * Based on code provided by "The Chromium Projects".
+ *
+ * @see https://www.chromium.org/updates/same-site/incompatible-clients
+ */
+class CRM_Utils_SameSite {
+
+  /**
+   * Determine if the current User Agent can handle the `SameSite=None` parameter.
+   *
+   * @param str $userAgent The User Agent.
+   * @return bool True if the User Agent is compatible, FALSE otherwise.
+   */
+  public static function shouldSendSameSiteNone($userAgent) {
+    return !self::isSameSiteNoneIncompatible($userAgent);
+  }
+
+  /**
+   * Detect classes of browsers known to be incompatible.
+   *
+   * @param str $userAgent The User Agent.
+   * @return bool True if the User Agent is determined to be incompatible, FALSE otherwise.
+   */
+  private static function isSameSiteNoneIncompatible($userAgent) {
+    return self::hasWebKitSameSiteBug($userAgent) ||
+           self::dropsUnrecognizedSameSiteCookies($userAgent);
+  }
+
+  /**
+   * Detect versions of Safari and embedded browsers on MacOS 10.14 and all
+   * browsers on iOS 12.
+   *
+   * These versions will erroneously treat cookies marked with `SameSite=None`
+   * as if they were marked `SameSite=Strict`.
+   *
+   * @param str $userAgent The User Agent.
+   * @return bool
+   */
+  private static function hasWebKitSameSiteBug($userAgent) {
+    return self::isIosVersion(12, $userAgent) || (self::isMacosxVersion(10, 14, $userAgent) &&
+           (self::isSafari($userAgent) || self::isMacEmbeddedBrowser($userAgent)));
+  }
+
+  /**
+   * Detect versions of UC Browser on Android prior to version 12.13.2.
+   *
+   * Older versions will reject a cookie with `SameSite=None`. This behavior was
+   * correct according to the version of the cookie specification at that time,
+   * but with the addition of the new "None" value to the specification, this
+   * behavior has been updated in newer versions of UC Browser.
+   *
+   * @param str $userAgent The User Agent.
+   * @return bool
+   */
+  private static function dropsUnrecognizedSameSiteCookies($userAgent) {
+    if (self::isUcBrowser($userAgent)) {
+      return !self::isUcBrowserVersionAtLeast(12, 13, 2, $userAgent);
+    }
+
+    return self::isChromiumBased($userAgent) &&
+           self::isChromiumVersionAtLeast(51, $userAgent, '>=') &&
+           self::isChromiumVersionAtLeast(67, $userAgent, '<=');
+  }
+
+  /**
+   * Detect iOS version.
+   *
+   * @param int $major The major version to test.
+   * @param str $userAgent The User Agent.
+   * @return bool
+   */
+  private static function isIosVersion($major, $userAgent) {
+    $regex = "/\(iP.+; CPU .*OS (\d+)[_\d]*.*\) AppleWebKit\//";
+    $matched = [];
+
+    if (preg_match($regex, $userAgent, $matched)) {
+      // Extract digits from first capturing group.
+      $version = (int) $matched[1];
+      return version_compare($version, $major, '<=');
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * Detect MacOS version.
+   *
+   * @param int $major The major version to test.
+   * @param int $minor The minor version to test.
+   * @param str $userAgent The User Agent.
+   * @return bool
+   */
+  private static function isMacosxVersion($major, $minor, $userAgent) {
+    $regex = "/\(Macintosh;.*Mac OS X (\d+)_(\d+)[_\d]*.*\) AppleWebKit\//";
+    $matched = [];
+
+    if (preg_match($regex, $userAgent, $matched)) {
+      // Extract digits from first and second capturing groups.
+      return version_compare((int) $matched[1], $major, '=') &&
+             version_compare((int) $matched[2], $minor, '<=');
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * Detect MacOS Safari.
+   *
+   * @param str $userAgent The User Agent.
+   * @return bool
+   */
+  private static function isSafari($userAgent) {
+    $regex = "/Version\/.* Safari\//";
+    return preg_match($regex, $userAgent) && !self::isChromiumBased($userAgent);
+  }
+
+  /**
+   * Detect MacOS embedded browser.
+   *
+   * @param str $userAgent The User Agent.
+   * @return FALSE|int
+   */
+  private static function isMacEmbeddedBrowser($userAgent) {
+    $regex = "/^Mozilla\/[\.\d]+ \(Macintosh;.*Mac OS X [_\d]+\) AppleWebKit\/[\.\d]+ \(KHTML, like Gecko\)$/";
+    return preg_match($regex, $userAgent);
+  }
+
+  /**
+   * Detect if browser is Chromium based.
+   *
+   * @param str $userAgent The User Agent.
+   * @return FALSE|int
+   */
+  private static function isChromiumBased($userAgent) {
+    $regex = "/Chrom(e|ium)/";
+    return preg_match($regex, $userAgent);
+  }
+
+  /**
+   * Detect if Chromium version meets requirements.
+   *
+   * @param int $major The major version to test.
+   * @param str $userAgent The User Agent.
+   * @param str $operator
+   * @return bool|int
+   */
+  private static function isChromiumVersionAtLeast($major, $userAgent, $operator) {
+    $regex = "/Chrom[^ \/]+\/(\d+)[\.\d]* /";
+    $matched = [];
+
+    if (preg_match($regex, $userAgent, $matched)) {
+      // Extract digits from first capturing group.
+      $version = (int) $matched[1];
+      return version_compare($version, $major, $operator);
+    }
+    return FALSE;
+  }
+
+  /**
+   * Detect UCBrowser.
+   *
+   * @param str $userAgent The User Agent.
+   * @return FALSE|int
+   */
+  private static function isUcBrowser($userAgent) {
+    $regex = "/UCBrowser\//";
+    return preg_match($regex, $userAgent);
+  }
+
+  /**
+   * Detect if UCBrowser version meets requirements.
+   *
+   * @param int $major The major version to test.
+   * @param int $minor The minor version to test.
+   * @param int $build The build version to test.
+   * @param str $userAgent The User Agent.
+   * @return bool|int
+   */
+  private static function isUcBrowserVersionAtLeast($major, $minor, $build, $userAgent) {
+    $regex = "/UCBrowser\/(\d+)\.(\d+)\.(\d+)[\.\d]* /";
+    $matched = [];
+
+    if (preg_match($regex, $userAgent, $matched)) {
+      // Extract digits from three capturing groups.
+      $majorVersion = (int) $matched[1];
+      $minorVersion = (int) $matched[2];
+      $buildVersion = (int) $matched[3];
+
+      if (version_compare($majorVersion, $major, '>=')) {
+        if (version_compare($minorVersion, $minor, '>=')) {
+          return version_compare($buildVersion, $build, '>=');
+        }
+      }
+    }
+
+    return FALSE;
+  }
+
+}
index 20b266d68046a5d9f2717b915c71ad54058531a8..6af92e60caff079b02c6a7b6a92b5dac6c161fb8 100644 (file)
@@ -1915,4 +1915,12 @@ class CRM_Utils_System {
     $config = CRM_Core_Config::singleton()->userSystem->sendResponse($response);
   }
 
+  /**
+   * Perform any necessary actions prior to redirecting via POST.
+   */
+  public static function prePostRedirect() {
+    $config = CRM_Core_Config::singleton();
+    $config->userSystem->paypalBeforeRedirect();
+  }
+
 }
index 5c4f1d58bb14f9de4cb275da75b166b15a45dca3..aabb9aa48b4d2b27e692c7954edbbf7935650220 100644 (file)
@@ -998,4 +998,10 @@ abstract class CRM_Utils_System_Base {
     return FALSE;
   }
 
+  /**
+   * Perform any necessary actions prior to redirecting via POST.
+   */
+  public function prePostRedirect() {
+  }
+
 }
index e82b2e93df01ce28365882ba9e22d1d4c43baa87..f13ad3883035fa22d9a7fb56bb4a62b560919673 100644 (file)
@@ -1087,4 +1087,161 @@ class CRM_Utils_System_WordPress extends CRM_Utils_System_Base {
     }
   }
 
+  /**
+   * Perform any necessary actions prior to redirecting via POST.
+   *
+   * Redirecting via POST means that cookies need to be sent with SameSite=None.
+   */
+  public function prePostRedirect() {
+    // Get User Agent string.
+    $rawUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
+    $userAgent = mb_convert_encoding($rawUserAgent, 'UTF-8');
+
+    // Bail early if User Agent does not support `SameSite=None`.
+    $shouldUseSameSite = CRM_Utils_SameSite::shouldSendSameSiteNone($userAgent);
+    if (!$shouldUseSameSite) {
+      return;
+    }
+
+    // Make sure session cookie is present in header.
+    $cookie_params = session_name() . '=' . session_id() . '; SameSite=None; Secure';
+    CRM_Utils_System::setHttpHeader('Set-Cookie', $cookie_params);
+
+    // Add WordPress auth cookies when user is logged in.
+    $user = wp_get_current_user();
+    if ($user->exists()) {
+      self::setAuthCookies($user->ID, TRUE, TRUE);
+    }
+  }
+
+  /**
+   * Explicitly set WordPress authentication cookies.
+   *
+   * Chrome 84 introduced a cookie policy change which prevents cookies for the
+   * session and for WordPress user authentication from being indentified when
+   * a purchaser returns to the site from PayPal using the "Back to Merchant"
+   * button.
+   *
+   * In order to comply with this policy, cookies need to be sent with their
+   * "SameSite" attribute set to "None" and with the "Secure" flag set, but this
+   * isn't possible to do via `wp_set_auth_cookie()` as it stands.
+   *
+   * This method is a modified clone of `wp_set_auth_cookie()` which satisfies
+   * the Chrome policy.
+   *
+   * @see wp_set_auth_cookie()
+   *
+   * The $remember parameter increases the time that the cookie will be kept. The
+   * default the cookie is kept without remembering is two days. When $remember is
+   * set, the cookies will be kept for 14 days or two weeks.
+   *
+   * @param int $user_id The WordPress User ID.
+   * @param bool $remember Whether to remember the user.
+   * @param bool|string $secure Whether the auth cookie should only be sent over
+   *                            HTTPS. Default is an empty string which means the
+   *                            value of `is_ssl()` will be used.
+   * @param string $token Optional. User's session token to use for this cookie.
+   */
+  private function setAuthCookies($user_id, $remember = FALSE, $secure = '', $token = '') {
+    if ($remember) {
+      /** This filter is documented in wp-includes/pluggable.php */
+      $expiration = time() + apply_filters('auth_cookie_expiration', 14 * DAY_IN_SECONDS, $user_id, $remember);
+
+      /*
+       * Ensure the browser will continue to send the cookie after the expiration time is reached.
+       * Needed for the login grace period in wp_validate_auth_cookie().
+       */
+      $expire = $expiration + (12 * HOUR_IN_SECONDS);
+    }
+    else {
+      /** This filter is documented in wp-includes/pluggable.php */
+      $expiration = time() + apply_filters('auth_cookie_expiration', 2 * DAY_IN_SECONDS, $user_id, $remember);
+      $expire = 0;
+    }
+
+    if ('' === $secure) {
+      $secure = is_ssl();
+    }
+
+    // Front-end cookie is secure when the auth cookie is secure and the site's home URL is forced HTTPS.
+    $secure_logged_in_cookie = $secure && 'https' === parse_url(get_option('home'), PHP_URL_SCHEME);
+
+    /** This filter is documented in wp-includes/pluggable.php */
+    $secure = apply_filters('secure_auth_cookie', $secure, $user_id);
+
+    /** This filter is documented in wp-includes/pluggable.php */
+    $secure_logged_in_cookie = apply_filters('secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure);
+
+    if ($secure) {
+      $auth_cookie_name = SECURE_AUTH_COOKIE;
+      $scheme = 'secure_auth';
+    }
+    else {
+      $auth_cookie_name = AUTH_COOKIE;
+      $scheme = 'auth';
+    }
+
+    if ('' === $token) {
+      $manager = WP_Session_Tokens::get_instance($user_id);
+      $token = $manager->create($expiration);
+    }
+
+    $auth_cookie = wp_generate_auth_cookie($user_id, $expiration, $scheme, $token);
+    $logged_in_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $token);
+
+    /** This filter is documented in wp-includes/pluggable.php */
+    do_action('set_auth_cookie', $auth_cookie, $expire, $expiration, $user_id, $scheme, $token);
+
+    /** This filter is documented in wp-includes/pluggable.php */
+    do_action('set_logged_in_cookie', $logged_in_cookie, $expire, $expiration, $user_id, 'logged_in', $token);
+
+    /** This filter is documented in wp-includes/pluggable.php */
+    if (!apply_filters('send_auth_cookies', TRUE)) {
+      return;
+    }
+
+    $base_options = [
+      'expires' => $expire,
+      'domain' => COOKIE_DOMAIN,
+      'httponly' => TRUE,
+      'samesite' => 'None',
+    ];
+
+    self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => PLUGINS_COOKIE_PATH]);
+    self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => ADMIN_COOKIE_PATH]);
+    self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => COOKIEPATH]);
+    if (COOKIEPATH != SITECOOKIEPATH) {
+      self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => SITECOOKIEPATH]);
+    }
+  }
+
+  /**
+   * Set cookie with "SameSite" flag.
+   *
+   * The method here is compatible with all versions of PHP. Needed because it
+   * is only as of PHP 7.3.0 that the setcookie() method supports the "SameSite"
+   * attribute in its options and will accept "None" as a valid value.
+   *
+   * @param $name The name of the cookie.
+   * @param $value The value of the cookie.
+   * @param array $options The header options for the cookie.
+   */
+  private function setAuthCookie($name, $value, $options) {
+    $header = 'Set-Cookie: ';
+    $header .= rawurlencode($name) . '=' . rawurlencode($value) . '; ';
+    $header .= 'expires=' . gmdate('D, d-M-Y H:i:s T', $options['expires']) . '; ';
+    $header .= 'Max-Age=' . max(0, (int) ($options['expires'] - time())) . '; ';
+    $header .= 'path=' . rawurlencode($options['path']) . '; ';
+    $header .= 'domain=' . rawurlencode($options['domain']) . '; ';
+
+    if (!empty($options['secure'])) {
+      $header .= 'secure; ';
+    }
+    $header .= 'httponly; ';
+    $header .= 'SameSite=' . rawurlencode($options['samesite']);
+
+    header($header, FALSE);
+    $_COOKIE[$name] = $value;
+  }
+
 }