From 906841d26e914aa2f2c7e890cd86eb42d1298739 Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Fri, 6 Aug 2021 15:20:54 -0400 Subject: [PATCH 1/5] Support Non-Embedded apps again Bring back ShopSession service and Shopify Auth from v16 1 Middleware alias "verify.shopify" that will now points to 2 different Middlewares (VerifyShopifyExternal or VerifyShopifyEmbedded) depending on the "appbridge_enabled" config. --- src/Actions/AuthorizeShop.php | 115 +++++ ...yShopify.php => VerifyShopifyEmbedded.php} | 0 src/Http/Middleware/VerifyShopifyExternal.php | 402 ++++++++++++++++++ src/Services/ShopSession.php | 389 +++++++++++++++++ src/ShopifyAppProvider.php | 10 +- src/Traits/AuthController.php | 41 +- src/resources/config/shopify-app.php | 1 + src/resources/routes/shopify.php | 20 + 8 files changed, 975 insertions(+), 3 deletions(-) create mode 100644 src/Actions/AuthorizeShop.php rename src/Http/Middleware/{VerifyShopify.php => VerifyShopifyEmbedded.php} (100%) create mode 100644 src/Http/Middleware/VerifyShopifyExternal.php create mode 100644 src/Services/ShopSession.php diff --git a/src/Actions/AuthorizeShop.php b/src/Actions/AuthorizeShop.php new file mode 100644 index 00000000..68100578 --- /dev/null +++ b/src/Actions/AuthorizeShop.php @@ -0,0 +1,115 @@ +shopQuery = $shopQuery; + $this->shopCommand = $shopCommand; + $this->shopSession = $shopSession; + } + + /** + * Execution. + * TODO: Rethrow an API exception. + * + * @param ShopDomain $shopDomain The shop ID. + * @param string|null $code The code from Shopify. + * + * @return stdClass + */ + public function __invoke(ShopDomain $shopDomain, ?string $code): stdClass + { + // Get the shop + $shop = $this->shopQuery->getByDomain($shopDomain, [], true); + if ($shop === null) { + // Shop does not exist, make them and re-get + $this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null)); + $shop = $this->shopQuery->getByDomain($shopDomain); + } + + // Return data + $return = [ + 'completed' => false, + 'url' => null, + ]; + + $apiHelper = $shop->apiHelper(); + + // Access/grant mode + $grantMode = $shop->hasOfflineAccess() ? + AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) : + AuthMode::OFFLINE(); + + $return['url'] = $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)); + + // If there's no code + if (empty($code)) { + return (object) $return; + } + + // if the store has been deleted, restore the store to set the access token + if ($shop->trashed()) { + $shop->restore(); + } + + // We have a good code, get the access details + $this->shopSession->make($shop->getDomain()); + + try { + $this->shopSession->setAccess($apiHelper->getAccessData($code)); + $return['url'] = null; + $return['completed'] = true; + } catch (\Exception $e) { + // Just return the default setting + } + + return (object) $return; + } +} diff --git a/src/Http/Middleware/VerifyShopify.php b/src/Http/Middleware/VerifyShopifyEmbedded.php similarity index 100% rename from src/Http/Middleware/VerifyShopify.php rename to src/Http/Middleware/VerifyShopifyEmbedded.php diff --git a/src/Http/Middleware/VerifyShopifyExternal.php b/src/Http/Middleware/VerifyShopifyExternal.php new file mode 100644 index 00000000..8b2a814b --- /dev/null +++ b/src/Http/Middleware/VerifyShopifyExternal.php @@ -0,0 +1,402 @@ +shopSession = $shopSession; + $this->apiHelper = $apiHelper; + $this->apiHelper->make(); + } + + /** + * Handle an incoming request. + * If HMAC is present, it will try to valiate it. + * If shop is not logged in, redirect to authenticate will happen. + * + * @param Request $request The request object. + * @param \Closure $next The next action. + * + * @throws SignatureVerificationException + * + * @return mixed + */ + public function handle(Request $request, Closure $next) + { + // Grab the domain and check the HMAC (if present) + $domain = $this->getShopDomainFromRequest($request); + $hmac = $this->verifyHmac($request); + + $checks = []; + if ($this->shopSession->guest()) { + if ($hmac === null) { + // Auth flow required if not yet logged in + return $this->handleBadVerification($request, $domain); + } + + // Login the shop and verify their data + $checks[] = 'loginShop'; + } + + // Verify the Shopify session token and verify the shop data + array_push($checks, 'verifyShopifySessionToken', 'verifyShop'); + + // Loop all checks needing to be done, if we get a false, handle it + foreach ($checks as $check) { + $result = call_user_func([$this, $check], $request, $domain); + if ($result === false) { + return $this->handleBadVerification($request, $domain); + } + } + + return $next($request); + } + + /** + * Verify HMAC data, if present. + * + * @param Request $request The request object. + * + * @throws SignatureVerificationException + * + * @return bool|null + */ + private function verifyHmac(Request $request): ?bool + { + $hmac = $this->getHmac($request); + if ($hmac === null) { + // No HMAC, move on... + return null; + } + + // We have HMAC, validate it + $data = $this->getData($request, $hmac[1]); + if ($this->apiHelper->verifyRequest($data)) { + return true; + } + + // Something didn't match + throw new SignatureVerificationException('Unable to verify signature.'); + } + + /** + * Login and verify the shop and it's data. + * + * @param Request $request The request object. + * @param ShopDomainValue $domain The shop domain. + * + * @return bool + */ + private function loginShop(Request $request, ShopDomainValue $domain): bool + { + // Log the shop in + $status = $this->shopSession->make($domain); + if (! $status || ! $this->shopSession->isValid()) { + // Somethings not right... missing token? + return false; + } + + return true; + } + + /** + * Verify the shop is alright, if theres a current session, it will compare. + * + * @param Request $request The request object. + * @param ShopDomainValue $domain The shop domain. + * + * @return bool + */ + private function verifyShop(Request $request, ShopDomainValue $domain): bool + { + // Grab the domain + if (! $domain->isNull() && ! $this->shopSession->isValidCompare($domain)) { + // Somethings not right with the validation + return false; + } + + return true; + } + + /** + * Check the Shopify session token. + * + * @param Request $request The request object. + * @param ShopDomainValue $domain The shop domain. + * + * @return bool + */ + private function verifyShopifySessionToken(Request $request, ShopDomainValue $domain): bool + { + // Ensure Shopify session token is OK + $incomingToken = $request->query('session'); + if ($incomingToken) { + if (! $this->shopSession->isSessionTokenValid($incomingToken)) { + // Tokens do not match + return false; + } + + // Save the session token + $this->shopSession->setSessionToken($incomingToken); + } + + return true; + } + + /** + * Grab the HMAC value, if present, and how it was found. + * Order of precedence is:. + * + * - GET/POST Variable + * - Headers + * - Referer + * + * @param Request $request The request object. + * + * @return null|array + */ + private function getHmac(Request $request): ?array + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => $request->input('hmac'), + // Headers + DataSource::HEADER()->toNative() => $request->header('X-Shop-Signature'), + // Headers: Referer + DataSource::REFERER()->toNative() => function () use ($request): ?string { + $url = parse_url($request->header('referer'), PHP_URL_QUERY); + parse_str($url, $refererQueryParams); + if (! $refererQueryParams || ! isset($refererQueryParams['hmac'])) { + return null; + } + + return $refererQueryParams['hmac']; + }, + ]; + + // Loop through each until we find the HMAC + foreach ($options as $method => $value) { + $result = is_callable($value) ? $value() : $value; + if ($result !== null) { + return [$result, $method]; + } + } + + return null; + } + + /** + * Grab the shop, if present, and how it was found. + * Order of precedence is:. + * + * - GET/POST Variable + * - Headers + * - Referer + * + * @param Request $request The request object. + * + * @return ShopDomainValue + */ + private function getShopDomainFromRequest(Request $request): ShopDomainValue + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => $request->input('shop'), + // Headers + DataSource::HEADER()->toNative() => $request->header('X-Shop-Domain'), + // Headers: Referer + DataSource::REFERER()->toNative() => function () use ($request): ?string { + $url = parse_url($request->header('referer'), PHP_URL_QUERY); + parse_str($url, $refererQueryParams); + if (! $refererQueryParams || ! isset($refererQueryParams['shop'])) { + return null; + } + + return $refererQueryParams['shop']; + }, + ]; + + // Loop through each until we find the HMAC + foreach ($options as $method => $value) { + $result = is_callable($value) ? $value() : $value; + if ($result !== null) { + // Found a shop + return ShopDomain::fromNative($result); + } + } + + // No shop domain found in any source + return NullShopDomain::fromNative(null); + } + + /** + * Grab the data. + * + * @param Request $request The request object. + * @param string $source The source of the data. + * + * @return array + */ + private function getData(Request $request, string $source): array + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => function () use ($request): array { + // Verify + $verify = []; + foreach ($request->query() as $key => $value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + + return $verify; + }, + // Headers + DataSource::HEADER()->toNative() => function () use ($request): array { + // Always present + $shop = $request->header('X-Shop-Domain'); + $signature = $request->header('X-Shop-Signature'); + $timestamp = $request->header('X-Shop-Time'); + + $verify = [ + 'shop' => $shop, + 'hmac' => $signature, + 'timestamp' => $timestamp, + ]; + + // Sometimes present + $code = $request->header('X-Shop-Code') ?? null; + $locale = $request->header('X-Shop-Locale') ?? null; + $state = $request->header('X-Shop-State') ?? null; + $id = $request->header('X-Shop-ID') ?? null; + $ids = $request->header('X-Shop-IDs') ?? null; + + foreach (compact('code', 'locale', 'state', 'id', 'ids') as $key => $value) { + if ($value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + } + + return $verify; + }, + // Headers: Referer + DataSource::REFERER()->toNative() => function () use ($request): array { + $url = parse_url($request->header('referer'), PHP_URL_QUERY); + parse_str($url, $refererQueryParams); + + // Verify + $verify = []; + foreach ($refererQueryParams as $key => $value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + + return $verify; + }, + ]; + + return $options[$source](); + } + + /** + * Handle bad verification by killing the session and redirecting to auth. + * + * @param Request $request The request object. + * @param ShopDomainValue $domain The shop domain. + * + * @throws MissingShopDomainException + * + * @return RedirectResponse + */ + private function handleBadVerification(Request $request, ShopDomainValue $domain) + { + if ($domain->isNull()) { + // We have no idea of knowing who this is, this should not happen + throw new MissingShopDomainException(); + } + + // Set the return-to path so we can redirect after successful authentication + Session::put('return_to', $request->fullUrl()); + + // Kill off anything to do with the session + $this->shopSession->forget(); + + // Mis-match of shops + return Redirect::route( + Util::getShopifyConfig('route_names.authenticate.oauth'), + ['shop' => $domain->toNative()] + ); + } + + /** + * Parse the data source value. + * Handle simple key/values, arrays, and nested arrays. + * + * @param mixed $value + * + * @return string + */ + private function parseDataSourceValue($value): string + { + /** + * Format the value. + * + * @param mixed $val + * + * @return string + */ + $formatValue = function ($val): string { + return is_array($val) ? '["'.implode('", "', $val).'"]' : $val; + }; + + // Nested array + if (is_array($value) && is_array(current($value))) { + return implode(', ', array_map($formatValue, $value)); + } + + // Array or basic value + return $formatValue($value); + } +} diff --git a/src/Services/ShopSession.php b/src/Services/ShopSession.php new file mode 100644 index 00000000..39bb587e --- /dev/null +++ b/src/Services/ShopSession.php @@ -0,0 +1,389 @@ +auth = $auth; + $this->apiHelper = $apiHelper; + $this->cookieHelper = $cookieHelper; + $this->shopCommand = $shopCommand; + $this->shopQuery = $shopQuery; + } + + /** + * Login a shop. + * + * @return bool + */ + public function make(ShopDomainValue $domain): bool + { + // Get the shop + $shop = $this->shopQuery->getByDomain($domain, [], true); + if (! $shop) { + return false; + } + + // Log them in with the guard + $this->cookieHelper->setCookiePolicy(); + $this->auth->guard()->login($shop); + + return true; + } + + /** + * Wrapper for auth->guard()->guest(). + * + * @return bool + */ + public function guest(): bool + { + return $this->auth->guard()->guest(); + } + + /** + * Determines the type of access. + * + * @return string + */ + public function getType(): AuthMode + { + return AuthMode::fromNative(strtoupper(Util::getShopifyConfig('api_grant_mode', $this->getShop()))); + } + + /** + * Determines if the type of access matches. + * + * @param AuthMode $type The type of access to check. + * + * @return bool + */ + public function isType(AuthMode $type): bool + { + return $this->getType()->isSame($type); + } + + /** + * Stores the access token and user (if any). + * Uses database for acess token if it was an offline authentication. + * + * @param ResponseAccess $access + * + * @return self + */ + public function setAccess(ResponseAccess $access): self + { + // Grab the token + $token = AccessToken::fromNative($access['access_token']); + + // Per-User + if (isset($access['associated_user'])) { + // Modify the expire time to a timestamp + $now = Carbon::now(); + $expires = $now->addSeconds($access['expires_in'] - 10); + + // We have a user, so access will live only in session + $this->sessionSet(self::USER, $access['associated_user']); + $this->sessionSet(self::USER_TOKEN, $token->toNative()); + $this->sessionSet(self::USER_EXPIRES, $expires->toDateTimeString()); + } else { + // Update the token in database + $this->shopCommand->setAccessToken($this->getShop()->getId(), $token); + + // Refresh the model + $this->getShop()->refresh(); + } + + return $this; + } + + /** + * Sets the session token from Shopify. + * + * @param string $token The session token from Shopify. + * + * @return self + */ + public function setSessionToken(string $token): self + { + $this->sessionSet(self::SESSION_TOKEN, $token); + + return $this; + } + + /** + * Get the Shopify session token. + * + * @return string|null + */ + public function getSessionToken(): ?string + { + return Session::get(self::SESSION_TOKEN); + } + + /** + * Compare session tokens from Shopify. + * + * @param string|null $incomingToken The session token from Shopify, from the request. + * + * @return bool + */ + public function isSessionTokenValid(?string $incomingToken): bool + { + $currentToken = $this->getSessionToken(); + if ($incomingToken === null || $currentToken === null) { + return true; + } + + return $incomingToken === $currentToken; + } + + /** + * Gets the access token in use. + * + * @param bool $strict Return the token matching the grant type (default: use either). + * + * @return AccessTokenValue + */ + public function getToken(bool $strict = false): AccessTokenValue + { + // Keys as strings + $peruser = AuthMode::PERUSER()->toNative(); + $offline = AuthMode::OFFLINE()->toNative(); + + // Token mapping + $tokens = [ + $peruser => NullableAccessToken::fromNative(Session::get(self::USER_TOKEN)), + $offline => $this->getShop()->getAccessToken(), + ]; + + if ($strict) { + // We need the token matching the type + return $tokens[$this->getType()->toNative()]; + } + + // We need a token either way... + return $tokens[$peruser]->isNull() ? $tokens[$offline] : $tokens[$peruser]; + } + + /** + * Gets the associated user (if any). + * + * @return ResponseAccess|null + */ + public function getUser(): ?ResponseAccess + { + return Session::get(self::USER); + } + + /** + * Determines if there is an associated user. + * + * @return bool + */ + public function hasUser(): bool + { + return $this->getUser() !== null; + } + + /** + * Check if the user has expired. + * + * @return bool + */ + public function isUserExpired(): bool + { + $now = Carbon::now(); + $expires = new Carbon(Session::get(self::USER_EXPIRES)); + + return $now->greaterThanOrEqualTo($expires); + } + + /** + * Forgets anything in session. + * Log out a shop via auth()->guard()->logout(). + * + * @return self + */ + public function forget(): self + { + // Forget session values + $keys = [self::USER, self::USER_TOKEN, self::USER_EXPIRES, self::SESSION_TOKEN]; + foreach ($keys as $key) { + Session::forget($key); + } + + // Logout the shop if logged in + $this->auth->guard()->logout(); + + return $this; + } + + /** + * Checks if the package has everything it needs in session. + * + * @return bool + */ + public function isValid(): bool + { + $currentShop = $this->getShop(); + $currentToken = $this->getToken(true); + $currentDomain = $currentShop->getDomain(); + + $baseValid = ! $currentToken->isEmpty() && ! $currentDomain->isNull(); + if ($this->getUser() !== null) { + // Handle validation of per-user + return $baseValid && ! $this->isUserExpired(); + } + + // Handle validation of standard + return $baseValid; + } + + /** + * Checks if the package has everything it needs in session (compare). + * + * @param ShopDomain $shopDomain The shop to compare validity to. + * + * @return bool + */ + public function isValidCompare(ShopDomain $shopDomain): bool + { + // Ensure domains match + return $this->isValid() && $shopDomain->isSame($this->getShop()->getDomain()); + } + + /** + * Wrapper for auth->guard()->user(). + * + * @return IShopModel|null + */ + public function getShop(): ?IShopModel + { + return $this->auth->guard()->user(); + } + + /** + * Set a session key/value and fix cookie issues. + * + * @param string $key The key. + * @param mixed $value The value. + * + * @return self + */ + protected function sessionSet(string $key, $value): self + { + $this->cookieHelper->setCookiePolicy(); + Session::put($key, $value); + + return $this; + } +} diff --git a/src/ShopifyAppProvider.php b/src/ShopifyAppProvider.php index f78960f2..55a55419 100644 --- a/src/ShopifyAppProvider.php +++ b/src/ShopifyAppProvider.php @@ -30,7 +30,8 @@ use Osiset\ShopifyApp\Http\Middleware\AuthProxy; use Osiset\ShopifyApp\Http\Middleware\AuthWebhook; use Osiset\ShopifyApp\Http\Middleware\Billable; -use Osiset\ShopifyApp\Http\Middleware\VerifyShopify; +use Osiset\ShopifyApp\Http\Middleware\VerifyShopifyEmbedded; +use Osiset\ShopifyApp\Http\Middleware\VerifyShopifyExternal; use Osiset\ShopifyApp\Macros\TokenRedirect; use Osiset\ShopifyApp\Macros\TokenRoute; use Osiset\ShopifyApp\Messaging\Jobs\ScripttagInstaller; @@ -304,7 +305,12 @@ private function bootMiddlewares(): void $this->app['router']->aliasMiddleware('auth.proxy', AuthProxy::class); $this->app['router']->aliasMiddleware('auth.webhook', AuthWebhook::class); $this->app['router']->aliasMiddleware('billable', Billable::class); - $this->app['router']->aliasMiddleware('verify.shopify', VerifyShopify::class); + + if(Util::getShopifyConfig('appbridge_enabled')) { + $this->app['router']->aliasMiddleware('verify.shopify', VerifyShopifyEmbedded::class); + }else{ + $this->app['router']->aliasMiddleware('verify.shopify', VerifyShopifyExternal::class); + } } /** diff --git a/src/Traits/AuthController.php b/src/Traits/AuthController.php index 38868796..31e01726 100644 --- a/src/Traits/AuthController.php +++ b/src/Traits/AuthController.php @@ -6,9 +6,9 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\View; use Osiset\ShopifyApp\Actions\AuthenticateShop; +use Osiset\ShopifyApp\Actions\AuthorizeShop; use Osiset\ShopifyApp\Exceptions\MissingAuthUrlException; use Osiset\ShopifyApp\Exceptions\SignatureVerificationException; use Osiset\ShopifyApp\Objects\Values\ShopDomain; @@ -52,6 +52,7 @@ public function authenticate(Request $request, AuthenticateShop $authShop) [ 'authUrl' => $result['url'], 'shopDomain' => $shopDomain->toNative(), + 'appUrl' => config('app.url') ] ); } else { @@ -95,4 +96,42 @@ public function token(Request $request) ] ); } + + + /** + * Simply redirects to Shopify's Oauth screen. + * + * @param Request $request The request object. + * @param AuthorizeShop $authShop The action for authenticating a shop. + * + * @return ViewView + */ + public function oauth(Request $request, AuthorizeShop $authShop): ViewView + { + // Setup + $shopDomain = ShopDomain::fromNative($request->get('shop')); + $result = $authShop($shopDomain, null); + + // Redirect + return $this->oauthFailure($result->url, $shopDomain); + } + + /** + * Handles when authentication is unsuccessful or new. + * + * @param string $authUrl The auth URl to redirect the user to get the code. + * @param ShopDomain $shopDomain The shop's domain. + * + * @return ViewView + */ + private function oauthFailure(string $authUrl, ShopDomain $shopDomain): ViewView + { + return View::make( + 'shopify-app::auth.fullpage_redirect', + [ + 'authUrl' => $authUrl, + 'shopDomain' => $shopDomain->toNative(), + ] + ); + } } diff --git a/src/resources/config/shopify-app.php b/src/resources/config/shopify-app.php index 7186666f..59f25235 100644 --- a/src/resources/config/shopify-app.php +++ b/src/resources/config/shopify-app.php @@ -58,6 +58,7 @@ 'route_names' => [ 'home' => env('SHOPIFY_ROUTE_NAME_HOME', 'home'), 'authenticate' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE', 'authenticate'), + 'authenticate.oauth' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE_OAUTH', 'authenticate.oauth'), 'authenticate.token' => env('SHOPIFY_ROUTE_NAME_AUTHENTICATE_TOKEN', 'authenticate.token'), 'billing' => env('SHOPIFY_ROUTE_NAME_BILLING', 'billing'), 'billing.process' => env('SHOPIFY_ROUTE_NAME_BILLING_PROCESS', 'billing.process'), diff --git a/src/resources/routes/shopify.php b/src/resources/routes/shopify.php index 28ee6118..8d04dab1 100644 --- a/src/resources/routes/shopify.php +++ b/src/resources/routes/shopify.php @@ -61,6 +61,26 @@ ->name(Util::getShopifyConfig('route_names.authenticate')); } + /* + |-------------------------------------------------------------------------- + | Authenticate: Auth + |-------------------------------------------------------------------------- + | + | This route is hit when a shop comes to the app without a session token + | yet. A token will be grabbed from Shopify AppBridge Javascript + | and then forwarded back to the home route. + | + */ + + if (Util::registerPackageRoute('authenticate.oauth', $manualRoutes)) { + Route::get( + '/authenticate/oauth', + AuthController::class.'@oauth' + ) + ->middleware(['verify.shopify']) + ->name(Util::getShopifyConfig('route_names.authenticate.oauth')); + } + /* |-------------------------------------------------------------------------- | Authenticate: Token From f2f401eefa012da68bbedc6c698f712b9b8eb87d Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Fri, 6 Aug 2021 15:34:52 -0400 Subject: [PATCH 2/5] App url not needed --- src/Traits/AuthController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Traits/AuthController.php b/src/Traits/AuthController.php index 31e01726..0b197e62 100644 --- a/src/Traits/AuthController.php +++ b/src/Traits/AuthController.php @@ -51,8 +51,7 @@ public function authenticate(Request $request, AuthenticateShop $authShop) 'shopify-app::auth.fullpage_redirect', [ 'authUrl' => $result['url'], - 'shopDomain' => $shopDomain->toNative(), - 'appUrl' => config('app.url') + 'shopDomain' => $shopDomain->toNative() ] ); } else { From d6f90260ea787a883ccbc04ffb8b1113b5ca3e80 Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Fri, 6 Aug 2021 15:46:15 -0400 Subject: [PATCH 3/5] Match class name --- src/Http/Middleware/VerifyShopifyEmbedded.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Middleware/VerifyShopifyEmbedded.php b/src/Http/Middleware/VerifyShopifyEmbedded.php index 3f12cf67..2de94931 100644 --- a/src/Http/Middleware/VerifyShopifyEmbedded.php +++ b/src/Http/Middleware/VerifyShopifyEmbedded.php @@ -27,7 +27,7 @@ /** * Responsible for validating the request. */ -class VerifyShopify +class VerifyShopifyEmbedded { /** * The auth manager. From 4a4c7bd3e67f42fa69721894897e18ecb7e5db39 Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Fri, 6 Aug 2021 15:56:17 -0400 Subject: [PATCH 4/5] Missing Util --- src/Http/Middleware/VerifyShopifyExternal.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/Middleware/VerifyShopifyExternal.php b/src/Http/Middleware/VerifyShopifyExternal.php index 8b2a814b..b19b9310 100644 --- a/src/Http/Middleware/VerifyShopifyExternal.php +++ b/src/Http/Middleware/VerifyShopifyExternal.php @@ -15,6 +15,7 @@ use Osiset\ShopifyApp\Objects\Values\NullShopDomain; use Osiset\ShopifyApp\Objects\Values\ShopDomain; use Osiset\ShopifyApp\Services\ShopSession; +use Osiset\ShopifyApp\Util; /** * Response for ensuring an authenticated request. From a69e0a10df13dc42bb8f7c1fc8ad350b976d0aa6 Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Fri, 6 Aug 2021 16:40:44 -0400 Subject: [PATCH 5/5] Create VerifyShopifyEmbedded and VerifyShopifyExternal middlewares that extend the base VerifyShopify middleware --- src/Http/Middleware/VerifyShopify.php | 201 ++++++++++++++++++ src/Http/Middleware/VerifyShopifyEmbedded.php | 181 +--------------- src/Http/Middleware/VerifyShopifyExternal.php | 181 +--------------- 3 files changed, 207 insertions(+), 356 deletions(-) create mode 100644 src/Http/Middleware/VerifyShopify.php diff --git a/src/Http/Middleware/VerifyShopify.php b/src/Http/Middleware/VerifyShopify.php new file mode 100644 index 00000000..05d3eb9c --- /dev/null +++ b/src/Http/Middleware/VerifyShopify.php @@ -0,0 +1,201 @@ +verifyHmac($request); + if ($hmacResult === false) { + // Invalid HMAC + throw new SignatureVerificationException('Unable to verify signature.'); + } + + return $next($request); + } + + /** + * Verify HMAC data, if present. + * + * @param Request $request The request object. + * + * @throws SignatureVerificationException + * + * @return bool|null + */ + protected function verifyHmac(Request $request): ?bool + { + $hmac = $this->getHmacFromRequest($request); + if ($hmac['source'] === null) { + // No HMAC, skip + return null; + } + + // We have HMAC, validate it + $data = $this->getRequestData($request, $hmac['source']); + + return $this->apiHelper->verifyRequest($data); + } + + /** + * Grab the HMAC value, if present, and how it was found. + * Order of precedence is:. + * + * - GET/POST Variable + * - Headers + * - Referer + * + * @param Request $request The request object. + * + * @return array + */ + protected function getHmacFromRequest(Request $request): array + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => $request->input('hmac'), + // Headers + DataSource::HEADER()->toNative() => $request->header('X-Shop-Signature'), + // Headers: Referer + DataSource::REFERER()->toNative() => function () use ($request): ?string { + $url = parse_url($request->header('referer'), PHP_URL_QUERY); + parse_str($url, $refererQueryParams); + if (! $refererQueryParams || ! isset($refererQueryParams['hmac'])) { + return null; + } + + return $refererQueryParams['hmac']; + }, + ]; + + // Loop through each until we find the HMAC + foreach ($options as $method => $value) { + $result = is_callable($value) ? $value() : $value; + if ($result !== null) { + return ['source' => $method, 'value' => $value]; + } + } + + return ['source' => null, 'value' => null]; + } + + /** + * Grab the request data. + * + * @param Request $request The request object. + * @param string $source The source of the data. + * + * @return array + */ + protected function getRequestData(Request $request, string $source): array + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => function () use ($request): array { + // Verify + $verify = []; + foreach ($request->query() as $key => $value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + + return $verify; + }, + // Headers + DataSource::HEADER()->toNative() => function () use ($request): array { + // Always present + $shop = $request->header('X-Shop-Domain'); + $signature = $request->header('X-Shop-Signature'); + $timestamp = $request->header('X-Shop-Time'); + + $verify = [ + 'shop' => $shop, + 'hmac' => $signature, + 'timestamp' => $timestamp, + ]; + + // Sometimes present + $code = $request->header('X-Shop-Code') ?? null; + $locale = $request->header('X-Shop-Locale') ?? null; + $state = $request->header('X-Shop-State') ?? null; + $id = $request->header('X-Shop-ID') ?? null; + $ids = $request->header('X-Shop-IDs') ?? null; + + foreach (compact('code', 'locale', 'state', 'id', 'ids') as $key => $value) { + if ($value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + } + + return $verify; + }, + // Headers: Referer + DataSource::REFERER()->toNative() => function () use ($request): array { + $url = parse_url($request->header('referer'), PHP_URL_QUERY); + parse_str($url, $refererQueryParams); + + // Verify + $verify = []; + foreach ($refererQueryParams as $key => $value) { + $verify[$key] = $this->parseDataSourceValue($value); + } + + return $verify; + }, + ]; + + return $options[$source](); + } + + + /** + * Parse the data source value. + * Handle simple key/values, arrays, and nested arrays. + * + * @param mixed $value + * + * @return string + */ + protected function parseDataSourceValue($value): string + { + /** + * Format the value. + * + * @param mixed $val + * + * @return string + */ + $formatValue = function ($val): string { + return is_array($val) ? '["'.implode('", "', $val).'"]' : $val; + }; + + // Nested array + if (is_array($value) && is_array(current($value))) { + return implode(', ', array_map($formatValue, $value)); + } + + // Array or basic value + return $formatValue($value); + } + +} diff --git a/src/Http/Middleware/VerifyShopifyEmbedded.php b/src/Http/Middleware/VerifyShopifyEmbedded.php index 2de94931..5bc6a344 100644 --- a/src/Http/Middleware/VerifyShopifyEmbedded.php +++ b/src/Http/Middleware/VerifyShopifyEmbedded.php @@ -22,13 +22,15 @@ use Osiset\ShopifyApp\Objects\Values\SessionContext; use Osiset\ShopifyApp\Objects\Values\SessionToken; use Osiset\ShopifyApp\Objects\Values\ShopDomain; +use Osiset\ShopifyApp\Traits\VerifyShopifyMiddleware; use Osiset\ShopifyApp\Util; /** * Responsible for validating the request. */ -class VerifyShopifyEmbedded +class VerifyShopifyEmbedded extends VerifyShopify { + /** * The auth manager. * @@ -36,13 +38,6 @@ class VerifyShopifyEmbedded */ protected $auth; - /** - * The API helper. - * - * @var IApiHelper - */ - protected $apiHelper; - /** * The shop querier. * @@ -89,12 +84,6 @@ public function __construct( */ public function handle(Request $request, Closure $next) { - // Verify the HMAC (if available) - $hmacResult = $this->verifyHmac($request); - if ($hmacResult === false) { - // Invalid HMAC - throw new SignatureVerificationException('Unable to verify signature.'); - } // Continue if current route is an auth or billing route if (Str::contains($request->getRequestUri(), ['/authenticate', '/billing'])) { @@ -199,29 +188,6 @@ protected function handleInvalidShop(Request $request) return $this->installRedirect(ShopDomain::fromRequest($request)); } - /** - * Verify HMAC data, if present. - * - * @param Request $request The request object. - * - * @throws SignatureVerificationException - * - * @return bool|null - */ - protected function verifyHmac(Request $request): ?bool - { - $hmac = $this->getHmacFromRequest($request); - if ($hmac['source'] === null) { - // No HMAC, skip - return null; - } - - // We have HMAC, validate it - $data = $this->getRequestData($request, $hmac['source']); - - return $this->apiHelper->verifyRequest($data); - } - /** * Login and verify the shop and it's data. * @@ -306,49 +272,6 @@ protected function installRedirect(ShopDomainValue $shopDomain): RedirectRespons ); } - /** - * Grab the HMAC value, if present, and how it was found. - * Order of precedence is:. - * - * - GET/POST Variable - * - Headers - * - Referer - * - * @param Request $request The request object. - * - * @return array - */ - protected function getHmacFromRequest(Request $request): array - { - // All possible methods - $options = [ - // GET/POST - DataSource::INPUT()->toNative() => $request->input('hmac'), - // Headers - DataSource::HEADER()->toNative() => $request->header('X-Shop-Signature'), - // Headers: Referer - DataSource::REFERER()->toNative() => function () use ($request): ?string { - $url = parse_url($request->header('referer'), PHP_URL_QUERY); - parse_str($url, $refererQueryParams); - if (! $refererQueryParams || ! isset($refererQueryParams['hmac'])) { - return null; - } - - return $refererQueryParams['hmac']; - }, - ]; - - // Loop through each until we find the HMAC - foreach ($options as $method => $value) { - $result = is_callable($value) ? $value() : $value; - if ($result !== null) { - return ['source' => $method, 'value' => $value]; - } - } - - return ['source' => null, 'value' => null]; - } - /** * Get the token from request (if available). * @@ -374,104 +297,6 @@ protected function getAccessTokenFromRequest(Request $request): ?string return $this->isApiRequest($request) ? $request->bearerToken() : $request->get('token'); } - /** - * Grab the request data. - * - * @param Request $request The request object. - * @param string $source The source of the data. - * - * @return array - */ - protected function getRequestData(Request $request, string $source): array - { - // All possible methods - $options = [ - // GET/POST - DataSource::INPUT()->toNative() => function () use ($request): array { - // Verify - $verify = []; - foreach ($request->query() as $key => $value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - - return $verify; - }, - // Headers - DataSource::HEADER()->toNative() => function () use ($request): array { - // Always present - $shop = $request->header('X-Shop-Domain'); - $signature = $request->header('X-Shop-Signature'); - $timestamp = $request->header('X-Shop-Time'); - - $verify = [ - 'shop' => $shop, - 'hmac' => $signature, - 'timestamp' => $timestamp, - ]; - - // Sometimes present - $code = $request->header('X-Shop-Code') ?? null; - $locale = $request->header('X-Shop-Locale') ?? null; - $state = $request->header('X-Shop-State') ?? null; - $id = $request->header('X-Shop-ID') ?? null; - $ids = $request->header('X-Shop-IDs') ?? null; - - foreach (compact('code', 'locale', 'state', 'id', 'ids') as $key => $value) { - if ($value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - } - - return $verify; - }, - // Headers: Referer - DataSource::REFERER()->toNative() => function () use ($request): array { - $url = parse_url($request->header('referer'), PHP_URL_QUERY); - parse_str($url, $refererQueryParams); - - // Verify - $verify = []; - foreach ($refererQueryParams as $key => $value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - - return $verify; - }, - ]; - - return $options[$source](); - } - - /** - * Parse the data source value. - * Handle simple key/values, arrays, and nested arrays. - * - * @param mixed $value - * - * @return string - */ - protected function parseDataSourceValue($value): string - { - /** - * Format the value. - * - * @param mixed $val - * - * @return string - */ - $formatValue = function ($val): string { - return is_array($val) ? '["'.implode('", "', $val).'"]' : $val; - }; - - // Nested array - if (is_array($value) && is_array(current($value))) { - return implode(', ', array_map($formatValue, $value)); - } - - // Array or basic value - return $formatValue($value); - } - /** * Determine if the request is AJAX or expects JSON. * diff --git a/src/Http/Middleware/VerifyShopifyExternal.php b/src/Http/Middleware/VerifyShopifyExternal.php index b19b9310..ed11729c 100644 --- a/src/Http/Middleware/VerifyShopifyExternal.php +++ b/src/Http/Middleware/VerifyShopifyExternal.php @@ -16,18 +16,14 @@ use Osiset\ShopifyApp\Objects\Values\ShopDomain; use Osiset\ShopifyApp\Services\ShopSession; use Osiset\ShopifyApp\Util; +use Osiset\ShopifyApp\Traits\VerifyShopifyMiddleware; + /** * Response for ensuring an authenticated request. */ -class VerifyShopifyExternal +class VerifyShopifyExternal extends VerifyShopify { - /** - * The API helper. - * - * @var IApiHelper - */ - protected $apiHelper; /** * The shop session helper. @@ -67,14 +63,9 @@ public function handle(Request $request, Closure $next) { // Grab the domain and check the HMAC (if present) $domain = $this->getShopDomainFromRequest($request); - $hmac = $this->verifyHmac($request); $checks = []; if ($this->shopSession->guest()) { - if ($hmac === null) { - // Auth flow required if not yet logged in - return $this->handleBadVerification($request, $domain); - } // Login the shop and verify their data $checks[] = 'loginShop'; @@ -94,33 +85,6 @@ public function handle(Request $request, Closure $next) return $next($request); } - /** - * Verify HMAC data, if present. - * - * @param Request $request The request object. - * - * @throws SignatureVerificationException - * - * @return bool|null - */ - private function verifyHmac(Request $request): ?bool - { - $hmac = $this->getHmac($request); - if ($hmac === null) { - // No HMAC, move on... - return null; - } - - // We have HMAC, validate it - $data = $this->getData($request, $hmac[1]); - if ($this->apiHelper->verifyRequest($data)) { - return true; - } - - // Something didn't match - throw new SignatureVerificationException('Unable to verify signature.'); - } - /** * Login and verify the shop and it's data. * @@ -185,49 +149,6 @@ private function verifyShopifySessionToken(Request $request, ShopDomainValue $do return true; } - /** - * Grab the HMAC value, if present, and how it was found. - * Order of precedence is:. - * - * - GET/POST Variable - * - Headers - * - Referer - * - * @param Request $request The request object. - * - * @return null|array - */ - private function getHmac(Request $request): ?array - { - // All possible methods - $options = [ - // GET/POST - DataSource::INPUT()->toNative() => $request->input('hmac'), - // Headers - DataSource::HEADER()->toNative() => $request->header('X-Shop-Signature'), - // Headers: Referer - DataSource::REFERER()->toNative() => function () use ($request): ?string { - $url = parse_url($request->header('referer'), PHP_URL_QUERY); - parse_str($url, $refererQueryParams); - if (! $refererQueryParams || ! isset($refererQueryParams['hmac'])) { - return null; - } - - return $refererQueryParams['hmac']; - }, - ]; - - // Loop through each until we find the HMAC - foreach ($options as $method => $value) { - $result = is_callable($value) ? $value() : $value; - if ($result !== null) { - return [$result, $method]; - } - } - - return null; - } - /** * Grab the shop, if present, and how it was found. * Order of precedence is:. @@ -273,73 +194,6 @@ private function getShopDomainFromRequest(Request $request): ShopDomainValue return NullShopDomain::fromNative(null); } - /** - * Grab the data. - * - * @param Request $request The request object. - * @param string $source The source of the data. - * - * @return array - */ - private function getData(Request $request, string $source): array - { - // All possible methods - $options = [ - // GET/POST - DataSource::INPUT()->toNative() => function () use ($request): array { - // Verify - $verify = []; - foreach ($request->query() as $key => $value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - - return $verify; - }, - // Headers - DataSource::HEADER()->toNative() => function () use ($request): array { - // Always present - $shop = $request->header('X-Shop-Domain'); - $signature = $request->header('X-Shop-Signature'); - $timestamp = $request->header('X-Shop-Time'); - - $verify = [ - 'shop' => $shop, - 'hmac' => $signature, - 'timestamp' => $timestamp, - ]; - - // Sometimes present - $code = $request->header('X-Shop-Code') ?? null; - $locale = $request->header('X-Shop-Locale') ?? null; - $state = $request->header('X-Shop-State') ?? null; - $id = $request->header('X-Shop-ID') ?? null; - $ids = $request->header('X-Shop-IDs') ?? null; - - foreach (compact('code', 'locale', 'state', 'id', 'ids') as $key => $value) { - if ($value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - } - - return $verify; - }, - // Headers: Referer - DataSource::REFERER()->toNative() => function () use ($request): array { - $url = parse_url($request->header('referer'), PHP_URL_QUERY); - parse_str($url, $refererQueryParams); - - // Verify - $verify = []; - foreach ($refererQueryParams as $key => $value) { - $verify[$key] = $this->parseDataSourceValue($value); - } - - return $verify; - }, - ]; - - return $options[$source](); - } /** * Handle bad verification by killing the session and redirecting to auth. @@ -371,33 +225,4 @@ private function handleBadVerification(Request $request, ShopDomainValue $domain ); } - /** - * Parse the data source value. - * Handle simple key/values, arrays, and nested arrays. - * - * @param mixed $value - * - * @return string - */ - private function parseDataSourceValue($value): string - { - /** - * Format the value. - * - * @param mixed $val - * - * @return string - */ - $formatValue = function ($val): string { - return is_array($val) ? '["'.implode('", "', $val).'"]' : $val; - }; - - // Nested array - if (is_array($value) && is_array(current($value))) { - return implode(', ', array_map($formatValue, $value)); - } - - // Array or basic value - return $formatValue($value); - } }