diff --git a/.gitignore b/.gitignore index 612c6016..6b320585 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/bin +/build /vendor composer.lock .phpunit.result.cache diff --git a/phpstan.neon b/phpstan.neon index d9d69d11..7539d708 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,5 +7,7 @@ parameters: - '#Access to an undefined property Osiset\\ShopifyApp\\Storage\\Models\\.*::\$.*\.#' - '#Access to an undefined property Osiset\\ShopifyApp\\Test\\Stubs\\.*::\$.*\.#' - '#Variable \$factory might not be defined\.#' + - '#Call to an undefined static method Illuminate\\Routing\\Redirector::tokenRedirect\(\).#' + - '#Call to an undefined static method Illuminate\\Routing\\UrlGenerator::tokenRoute\(\).#' parallel: maximumNumberOfProcesses: 2 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 87ae076f..c96be1a3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,7 +23,6 @@ src/ShopifyApp/Contracts/ src/ShopifyApp/Exceptions/ src/ShopifyApp/Objects/Enums/ - src/ShopifyApp/Objects/Values/ src/ShopifyApp/resources/ src/ShopifyApp/Messaging/Events/AppLoggedIn.php src/ShopifyApp/ShopifyAppProvider.php diff --git a/src/ShopifyApp/Actions/AfterAuthorize.php b/src/ShopifyApp/Actions/AfterAuthorize.php index 4ab09544..16616537 100644 --- a/src/ShopifyApp/Actions/AfterAuthorize.php +++ b/src/ShopifyApp/Actions/AfterAuthorize.php @@ -2,6 +2,7 @@ namespace Osiset\ShopifyApp\Actions; +use Illuminate\Support\Arr; use Osiset\ShopifyApp\Contracts\Objects\Values\ShopId as ShopIdValue; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; use Osiset\ShopifyApp\Contracts\ShopModel as IShopModel; @@ -50,8 +51,8 @@ public function __invoke(ShopIdValue $shopId): bool * @return bool */ $fireJob = function (array $config, IShopModel $shop): bool { - $job = $config['job']; - if (isset($config['inline']) && $config['inline'] === true) { + $job = Arr::get($config, 'job'); + if (Arr::get($config, 'inline', false)) { // Run this job immediately $job::dispatchNow($shop); } else { @@ -68,8 +69,7 @@ public function __invoke(ShopIdValue $shopId): bool // Grab the jobs config $jobsConfig = Util::getShopifyConfig('after_authenticate_job'); - - if (isset($jobsConfig[0])) { + if (Arr::has($jobsConfig, 0)) { // We have multi-jobs foreach ($jobsConfig as $jobConfig) { // We have a job, pass the shop object to the contructor @@ -77,9 +77,7 @@ public function __invoke(ShopIdValue $shopId): bool } return true; - } - - if (isset($jobsConfig['job'])) { + } elseif (Arr::has($jobsConfig, 'job')) { // We have a single job return $fireJob($jobsConfig, $shop); } diff --git a/src/ShopifyApp/Actions/AuthenticateShop.php b/src/ShopifyApp/Actions/AuthenticateShop.php index c91c7e06..248bcfde 100644 --- a/src/ShopifyApp/Actions/AuthenticateShop.php +++ b/src/ShopifyApp/Actions/AuthenticateShop.php @@ -5,20 +5,12 @@ use Illuminate\Http\Request; use Osiset\ShopifyApp\Contracts\ApiHelper as IApiHelper; use Osiset\ShopifyApp\Objects\Values\ShopDomain; -use Osiset\ShopifyApp\Services\ShopSession; /** * Authenticates a shop and fires post authentication actions. */ class AuthenticateShop { - /** - * The shop session handler. - * - * @var ShopSession - */ - protected $shopSession; - /** * The API helper. * @@ -27,11 +19,11 @@ class AuthenticateShop protected $apiHelper; /** - * The action for authorizing a shop. + * The action for installing a shop. * - * @var AuthorizeShop + * @var InstallShop */ - protected $authorizeShopAction; + protected $installShopAction; /** * The action for dispatching scripts. @@ -57,9 +49,8 @@ class AuthenticateShop /** * Setup. * - * @param ShopSession $shopSession The shop session handler. * @param IApiHelper $apiHelper The API helper. - * @param AuthorizeShop $authorizeShopAction The action for authorizing a shop. + * @param InstallShop $installShopAction The action for installing a shop. * @param DispatchScripts $dispatchScriptsAction The action for dispatching scripts. * @param DispatchWebhooks $dispatchWebhooksAction The action for dispatching webhooks. * @param AfterAuthorize $afterAuthorizeAction The action for after authorize actions. @@ -67,16 +58,14 @@ class AuthenticateShop * @return void */ public function __construct( - ShopSession $shopSession, IApiHelper $apiHelper, - AuthorizeShop $authorizeShopAction, + InstallShop $installShopAction, DispatchScripts $dispatchScriptsAction, DispatchWebhooks $dispatchWebhooksAction, AfterAuthorize $afterAuthorizeAction ) { - $this->shopSession = $shopSession; $this->apiHelper = $apiHelper; - $this->authorizeShopAction = $authorizeShopAction; + $this->installShopAction = $installShopAction; $this->dispatchScriptsAction = $dispatchScriptsAction; $this->dispatchWebhooksAction = $dispatchWebhooksAction; $this->afterAuthorizeAction = $afterAuthorizeAction; @@ -91,13 +80,15 @@ public function __construct( */ public function __invoke(Request $request): array { - // Setup - $shopDomain = ShopDomain::fromNative($request->get('shop')); - $code = $request->get('code'); - // Run the check - $result = call_user_func($this->authorizeShopAction, $shopDomain, $code); - if (! $result->completed) { + /** @var $result array */ + $result = call_user_func( + $this->installShopAction, + ShopDomain::fromNative($request->get('shop')), + $request->query('code') + ); + + if (! $result['completed']) { // No code, redirect to auth URL return [$result, false]; } @@ -110,10 +101,9 @@ public function __invoke(Request $request): array } // Fire the post processing jobs - $shopId = $this->shopSession->getShop()->getId(); - call_user_func($this->dispatchScriptsAction, $shopId, false); - call_user_func($this->dispatchWebhooksAction, $shopId, false); - call_user_func($this->afterAuthorizeAction, $shopId); + call_user_func($this->dispatchScriptsAction, $result['shop_id'], false); + call_user_func($this->dispatchWebhooksAction, $result['shop_id'], false); + call_user_func($this->afterAuthorizeAction, $result['shop_id']); return [$result, true]; } diff --git a/src/ShopifyApp/Actions/AuthorizeShop.php b/src/ShopifyApp/Actions/InstallShop.php similarity index 60% rename from src/ShopifyApp/Actions/AuthorizeShop.php rename to src/ShopifyApp/Actions/InstallShop.php index c3350eef..32ad4134 100644 --- a/src/ShopifyApp/Actions/AuthorizeShop.php +++ b/src/ShopifyApp/Actions/InstallShop.php @@ -6,16 +6,15 @@ use Osiset\ShopifyApp\Contracts\Commands\Shop as IShopCommand; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; use Osiset\ShopifyApp\Objects\Enums\AuthMode; +use Osiset\ShopifyApp\Objects\Values\AccessToken; use Osiset\ShopifyApp\Objects\Values\NullAccessToken; use Osiset\ShopifyApp\Objects\Values\ShopDomain; -use Osiset\ShopifyApp\Services\ShopSession; use Osiset\ShopifyApp\Util; -use stdClass; /** - * Authenticates a shop via HTTP request. + * Install steps for a shop. */ -class AuthorizeShop +class InstallShop { /** * Querier for shops. @@ -31,41 +30,30 @@ class AuthorizeShop */ protected $shopCommand; - /** - * The shop session handler. - * - * @var ShopSession - */ - protected $shopSession; - /** * Setup. * * @param IShopQuery $shopQuery The querier for the shop. - * @param ShopSession $shopSession The shop session handler. * * @return void */ public function __construct( IShopQuery $shopQuery, - IShopCommand $shopCommand, - ShopSession $shopSession + IShopCommand $shopCommand ) { $this->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 + * @return array */ - public function __invoke(ShopDomain $shopDomain, ?string $code): stdClass + public function __invoke(ShopDomain $shopDomain, ?string $code): array { // Get the shop $shop = $this->shopQuery->getByDomain($shopDomain, [], true); @@ -75,42 +63,43 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): stdClass $shop = $this->shopQuery->getByDomain($shopDomain); } - // Return data - $return = [ - 'completed' => false, - 'url' => null, - ]; - - $apiHelper = $shop->apiHelper(); - // Access/grant mode + $apiHelper = $shop->apiHelper(); $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(); + return [ + 'completed' => false, + 'url' => $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)), + 'shop_id' => $shop->getId(), + ]; } - // 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; + // if the store has been deleted, restore the store to set the access token + if ($shop->trashed()) { + $shop->restore(); + } + + // Get the data and set the access token + $data = $apiHelper->getAccessData($code); + $this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token'])); + + return [ + 'completed' => true, + 'url' => null, + 'shop_id' => $shop->getId(), + ]; } catch (Exception $e) { // Just return the default setting + return [ + 'completed' => false, + 'url' => null, + 'shop_id' => null, + ]; } - - return (object) $return; } } diff --git a/src/ShopifyApp/Contracts/Objects/Values/SessionId.php b/src/ShopifyApp/Contracts/Objects/Values/SessionId.php new file mode 100644 index 00000000..a65c0d92 --- /dev/null +++ b/src/ShopifyApp/Contracts/Objects/Values/SessionId.php @@ -0,0 +1,12 @@ +'; + } +} diff --git a/src/ShopifyApp/Exceptions/MissingAuthUrlException.php b/src/ShopifyApp/Exceptions/MissingAuthUrlException.php new file mode 100644 index 00000000..2da82af6 --- /dev/null +++ b/src/ShopifyApp/Exceptions/MissingAuthUrlException.php @@ -0,0 +1,10 @@ +middleware('auth.token'); - } } diff --git a/src/ShopifyApp/Http/Controllers/ItpController.php b/src/ShopifyApp/Http/Controllers/ItpController.php deleted file mode 100644 index fe84ff48..00000000 --- a/src/ShopifyApp/Http/Controllers/ItpController.php +++ /dev/null @@ -1,14 +0,0 @@ -shopSession = $shopSession; + $this->auth = $auth; + $this->shopQuery = $shopQuery; } /** * Handle an incoming request to ensure it is valid. * - * @param Request $request The request object. - * @param \Closure $next The next action. + * @param Request $request The request object. + * @param Closure $next The next action. * * @return mixed */ public function handle(Request $request, Closure $next) { // Grab the query parameters we need - $query = $this->getQueryStringParameters($request); - $signature = $query['signature'] ?? null; - $shop = NullableShopDomain::fromNative($query['shop'] ?? null); + $query = Util::parseQueryString($request->server->get('QUERY_STRING')); + $signature = Arr::get($query, 'signature', ''); + $shop = NullableShopDomain::fromNative(Arr::get($query, 'shop')); - if (isset($query['signature'])) { + if (! empty($signature)) { // Remove signature since its not part of the signature calculation - unset($query['signature']); + Arr::forget($query, 'signature'); } // Build a local signature - $signatureLocal = \Osiset\ShopifyApp\Util::createHmac( + $signatureLocal = Util::createHmac( [ 'data' => $query, 'buildQuery' => true, ], Util::getShopifyConfig('api_secret', $shop) ); - if (hash_equals($signature, $signatureLocal) === false || $shop->isNull()) { + if (! Hmac::fromNative($signature)->isSame($signatureLocal) || $shop->isNull()) { // Issue with HMAC or missing shop header - return Response::make('Invalid proxy signature.', 401); + return Response::make('Invalid proxy signature.', HttpResponse::HTTP_UNAUTHORIZED); } // Login the shop - $this->shopSession->make($shop); + $shop = $this->shopQuery->getByDomain($shop); + if ($shop) { + $this->auth->login($shop); + } // All good, process proxy request return $next($request); } - - /** - * Parse query strings the same way Shopify does. - * - * @param Request $request The request object. - * - * @return array - */ - protected function getQueryStringParameters(Request $request): array - { - return Util::parseQueryString($request->server->get('QUERY_STRING')); - } } diff --git a/src/ShopifyApp/Http/Middleware/AuthShopify.php b/src/ShopifyApp/Http/Middleware/AuthShopify.php deleted file mode 100644 index b61c27a9..00000000 --- a/src/ShopifyApp/Http/Middleware/AuthShopify.php +++ /dev/null @@ -1,401 +0,0 @@ -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); - - return Arr::get($refererQueryParams, 'shop'); - }, - ]; - - // Loop through each until we find the HMAC - foreach ($options as $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/ShopifyApp/Http/Middleware/AuthToken.php b/src/ShopifyApp/Http/Middleware/AuthToken.php deleted file mode 100644 index fd1177b2..00000000 --- a/src/ShopifyApp/Http/Middleware/AuthToken.php +++ /dev/null @@ -1,130 +0,0 @@ -shopSession = $shopSession; - } - - /** - * Handle an incoming request. - * - * Get the bearer token, validate and verify, and create a - * session based on the contents. - * - * The token is "url safe" (`+` is `-` and `/` is `_`) base64. - * - * @param Request $request The request object. - * @param \Closure $next The next action. - * - * @return mixed - */ - public function handle(Request $request, Closure $next) - { - $now = time(); - - $token = $request->bearerToken(); - - if (! $token) { - throw new HttpException('Missing authentication token', 401); - } - - // The header is fixed so include it here - if (! preg_match('/^eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9\-\_=]+\.[A-Za-z0-9\-\_\=]*$/', $token)) { - throw new HttpException('Malformed token', 400); - } - - if (! $this->checkSignature($token)) { - throw new HttpException('Unable to verify signature', 400); - } - - $parts = explode('.', $token); - $body = json_decode(Util::base64UrlDecode($parts[1])); - - if (! $body || - ! isset($body->iss) || - ! isset($body->dest) || - ! isset($body->aud) || - ! isset($body->sub) || - ! isset($body->exp) || - ! isset($body->nbf) || - ! isset($body->iat) || - ! isset($body->jti) || - ! isset($body->sid)) { - throw new HttpException('Malformed token', 400); - } - - if ($now > $body->exp || $now < $body->nbf || $now < $body->iat) { - throw new HttpException('Expired token', 403); - } - - if (! stristr($body->iss, $body->dest)) { - throw new HttpException('Invalid token', 400); - } - - if ($body->aud !== Util::getShopifyConfig('api_key')) { - throw new HttpException('Invalid token', 400); - } - - // All is well, login - $url = parse_url($body->dest); - - $this->shopSession->make(ShopDomain::fromNative($url['host'])); - $this->shopSession->setSessionToken($body->sid); - - return $next($request); - } - - /** - * Checks the validity of the signature sent with the token. - * - * @param string $token The token to check. - * - * @return bool - */ - private function checkSignature($token) - { - // Get the signature data - $parts = explode('.', $token); - $signature = array_pop($parts); - $check = implode('.', $parts); - - // Get the shop - $shop = null; - $body = json_decode(Util::base64UrlDecode($parts[1])); - if (isset($body->dest)) { - $url = parse_url($body->dest); - $shop = $url['host'] ?? null; - } - - $secret = Util::getShopifyConfig('api_secret', $shop); - $hmac = hash_hmac('sha256', $check, $secret, true); - $encoded = Util::base64UrlEncode($hmac); - - return hash_equals($encoded, $signature); - } -} diff --git a/src/ShopifyApp/Http/Middleware/AuthWebhook.php b/src/ShopifyApp/Http/Middleware/AuthWebhook.php index 8b33ebf8..0c15cfbf 100644 --- a/src/ShopifyApp/Http/Middleware/AuthWebhook.php +++ b/src/ShopifyApp/Http/Middleware/AuthWebhook.php @@ -4,7 +4,10 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Http\Response as HttpResponse; use Illuminate\Support\Facades\Response; +use Osiset\ShopifyApp\Objects\Values\Hmac; +use Osiset\ShopifyApp\Objects\Values\NullableShopDomain; use Osiset\ShopifyApp\Util; /** @@ -22,8 +25,8 @@ class AuthWebhook */ public function handle(Request $request, Closure $next) { - $hmac = $request->header('x-shopify-hmac-sha256') ?: ''; - $shop = $request->header('x-shopify-shop-domain'); + $hmac = Hmac::fromNative($request->header('x-shopify-hmac-sha256', '')); + $shop = NullableShopDomain::fromNative($request->header('x-shopify-shop-domain')); $data = $request->getContent(); $hmacLocal = Util::createHmac( [ @@ -34,9 +37,9 @@ public function handle(Request $request, Closure $next) Util::getShopifyConfig('api_secret', $shop) ); - if (hash_equals($hmac, $hmacLocal) === false || empty($shop)) { + if (! $hmac->isSame($hmacLocal) || $shop->isNull()) { // Issue with HMAC or missing shop header - return Response::make('Invalid webhook signature.', 401); + return Response::make('Invalid webhook signature.', HttpResponse::HTTP_UNAUTHORIZED); } // All good, process webhook diff --git a/src/ShopifyApp/Http/Middleware/Billable.php b/src/ShopifyApp/Http/Middleware/Billable.php index 1ec9f03a..ca8df789 100644 --- a/src/ShopifyApp/Http/Middleware/Billable.php +++ b/src/ShopifyApp/Http/Middleware/Billable.php @@ -5,7 +5,7 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Redirect; -use Osiset\ShopifyApp\Services\ShopSession; +use Osiset\ShopifyApp\Contracts\ShopModel as IShopModel; use Osiset\ShopifyApp\Util; /** @@ -13,25 +13,6 @@ */ class Billable { - /** - * The shop session helper. - * - * @var ShopSession - */ - protected $shopSession; - - /** - * Setup. - * - * @param ShopSession $shopSession The shop session helper. - * - * @return void - */ - public function __construct(ShopSession $shopSession) - { - $this->shopSession = $shopSession; - } - /** * Checks if a shop has paid for access. * @@ -43,10 +24,14 @@ public function __construct(ShopSession $shopSession) public function handle(Request $request, Closure $next) { if (Util::getShopifyConfig('billing_enabled') === true) { - $shop = $this->shopSession->getShop(); + /** @var $shop IShopModel */ + $shop = auth()->user(); if (! $shop->isFreemium() && ! $shop->isGrandfathered() && ! $shop->plan) { // They're not grandfathered in, and there is no charge or charge was declined... redirect to billing - return Redirect::route(Util::getShopifyConfig('route_names.billing'), $request->input()); + return Redirect::route( + Util::getShopifyConfig('route_names.billing'), + array_merge($request->input(), ['shop' => $shop->getDomain()->toNative()]) + ); } } diff --git a/src/ShopifyApp/Http/Middleware/ITP.php b/src/ShopifyApp/Http/Middleware/ITP.php deleted file mode 100644 index fc072850..00000000 --- a/src/ShopifyApp/Http/Middleware/ITP.php +++ /dev/null @@ -1,87 +0,0 @@ -path(), 'itp'); - $itpCookie = $request->cookie('itp'); - $needItpCookie = ! $itpCookie && ! $isItpPath; - $needItpPermission = $request->query('itp', false); - - if ($needItpCookie && $needItpPermission) { - // ITP cookie was attempted to be set but it failed - return $this->ask(); - } - - if ($needItpCookie) { - // Attempt to set ITP cookie - return $this->redirect($request); - } - - return $next($request); - } - - /** - * Do a full-page redirect to set attempt to set the ITP cookie. - * - * @param Request $request The request object. - * - * @return HttpResponse - */ - protected function redirect(Request $request): HttpResponse - { - $authUrl = URL::secure( - URL::route( - Util::getShopifyConfig('route_names.itp'), - ['shop' => $request->get('shop')], - false - ) - ); - - return Response::make( - View::make( - 'shopify-app::auth.fullpage_redirect', - [ - 'authUrl' => $authUrl, - 'shopDomain' => $request->get('shop'), - ] - ) - ); - } - - /** - * Redirect to the ask permission page. - * - * @return RedirectResponse - */ - protected function ask(): RedirectResponse - { - return Redirect::route(Util::getShopifyConfig('route_names.itp.ask')); - } -} diff --git a/src/ShopifyApp/Http/Middleware/VerifyShopify.php b/src/ShopifyApp/Http/Middleware/VerifyShopify.php new file mode 100644 index 00000000..b8236b7b --- /dev/null +++ b/src/ShopifyApp/Http/Middleware/VerifyShopify.php @@ -0,0 +1,500 @@ +auth = $auth; + $this->shopQuery = $shopQuery; + $this->apiHelper = $apiHelper; + $this->apiHelper->make(); + } + + /** + * Undocumented function. + * + * @param Request $request The request object. + * @param Closure $next The next action. + * + * @throws SignatureVerificationException If HMAC verification fails. + * + * @return mixed + */ + 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'])) { + return $next($request); + } + + $tokenSource = $this->getAccessTokenFromRequest($request); + if ($tokenSource === null) { + //Check if there is a store record in the database + return $this->checkPreviousInstallation($request) + // Shop exists, token not available, we need to get one + ? $this->handleMissingToken($request) + // Shop does not exist + : $this->handleInvalidShop($request); + } + + try { + // Try and process the token + $token = SessionToken::fromNative($tokenSource); + } catch (AssertionFailedException $e) { + // Invalid or expired token, we need a new one + return $this->handleInvalidToken($request, $e); + } + + // Set the previous shop (if available) + if ($request->user()) { + $this->previousShop = $request->user(); + } + + // Login the shop + $loginResult = $this->loginShopFromToken( + $token, + NullableSessionId::fromNative($request->query('session')) + ); + if (! $loginResult) { + // Shop is not installed or something is missing from it's data + return $this->handleInvalidShop($request); + } + + return $next($request); + } + + /** + * Handle missing token. + * + * @param Request $request The request object. + * + * @throws HttpException If an AJAX/JSON request. + * + * @return mixed + */ + protected function handleMissingToken(Request $request) + { + if ($this->isApiRequest($request)) { + // AJAX, return HTTP exception + throw new HttpException(SessionToken::EXCEPTION_INVALID, Response::HTTP_BAD_REQUEST); + } + + return $this->tokenRedirect($request); + } + + /** + * Handle an invalid or expired token. + * + * @param Request $request The request object. + * @param AssertionFailedException $e The assertion failure exception. + * + * @throws HttpException If an AJAX/JSON request. + * + * @return mixed + */ + protected function handleInvalidToken(Request $request, AssertionFailedException $e) + { + $isExpired = $e->getMessage() === SessionToken::EXCEPTION_EXPIRED; + if ($this->isApiRequest($request)) { + // AJAX, return HTTP exception + throw new HttpException( + $e->getMessage(), + $isExpired ? Response::HTTP_FORBIDDEN : Response::HTTP_BAD_REQUEST + ); + } + + return $this->tokenRedirect($request); + } + + /** + * Handle a shop that is not installed or it's data is invalid. + * + * @param Request $request The request object. + * + * @throws HttpException If an AJAX/JSON request. + * + * @return mixed + */ + protected function handleInvalidShop(Request $request) + { + if ($this->isApiRequest($request)) { + // AJAX, return HTTP exception + throw new HttpException('Shop is not installed or missing data.', Response::HTTP_FORBIDDEN); + } + + 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. + * + * @param SessionToken $token The session token. + * @param NullableSessionId $sessionId Incoming session ID (if available). + * + * @return bool + */ + protected function loginShopFromToken(SessionToken $token, NullableSessionId $sessionId): bool + { + // Get the shop + $shop = $this->shopQuery->getByDomain($token->getShopDomain(), [], true); + if (! $shop) { + return false; + } + + // Set the session details for the token, session ID, and access token + $context = new SessionContext($token, $sessionId, $shop->getAccessToken()); + $shop->setSessionContext($context); + + $previousContext = $this->previousShop ? $this->previousShop->getSessionContext() : null; + if (! $shop->getSessionContext()->isValid($previousContext)) { + // Something is invalid + return false; + } + + // All is well, login the shop + $this->auth->login($shop); + + return true; + } + + /** + * Redirect to token route. + * + * @param Request $request The request object. + * + * @return RedirectResponse + */ + protected function tokenRedirect(Request $request): RedirectResponse + { + // At this point the HMAC and other details are verified already, filter it out + $path = $request->path(); + $target = Str::start($path, '/'); + + if ($request->query()) { + $filteredQuery = Collection::make($request->query())->except([ + 'hmac', + 'locale', + 'new_design_language', + 'timestamp', + 'session', + 'shop', + ]); + + if ($filteredQuery->isNotEmpty()) { + $target .= '?'.http_build_query($filteredQuery->toArray()); + } + } + + return Redirect::route( + Util::getShopifyConfig('route_names.authenticate.token'), + [ + 'shop' => ShopDomain::fromRequest($request)->toNative(), + 'target' => $target, + ] + ); + } + + /** + * Redirect to install route. + * + * @param ShopDomainValue $shopDomain The shop domain. + * + * @return RedirectResponse + */ + protected function installRedirect(ShopDomainValue $shopDomain): RedirectResponse + { + return Redirect::route( + Util::getShopifyConfig('route_names.authenticate'), + ['shop' => $shopDomain->toNative()] + ); + } + + /** + * 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). + * + * @param Request $request The request object. + * + * @return string + */ + protected function getAccessTokenFromRequest(Request $request): ?string + { + if (Util::getShopifyConfig('turbo_enabled')) { + if ($request->bearerToken()) { + // Bearer tokens collect. + // Turbo does not refresh the page, values are attached to the same header. + $bearerTokens = Collection::make(explode(',', $request->header('Authorization', ''))); + $newestToken = Str::substr(trim($bearerTokens->last()), 7); + + return $newestToken; + } + + return $request->get('token'); + } + + 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. + * + * @param Request $request The request object. + * + * @return bool + */ + protected function isApiRequest(Request $request): bool + { + return $request->ajax() || $request->expectsJson(); + } + + /** + * Check if there is a store record in the database. + * + * @param Request $request The request object. + * + * @return bool + */ + protected function checkPreviousInstallation(Request $request): bool + { + $shop = $this->shopQuery->getByDomain(ShopDomain::fromRequest($request), [], true); + + return $shop && ! $shop->trashed(); + } +} diff --git a/src/ShopifyApp/Http/Requests/StoreUsageCharge.php b/src/ShopifyApp/Http/Requests/StoreUsageCharge.php index 3b7ec090..8e8ea63c 100644 --- a/src/ShopifyApp/Http/Requests/StoreUsageCharge.php +++ b/src/ShopifyApp/Http/Requests/StoreUsageCharge.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; +use Osiset\ShopifyApp\Objects\Values\Hmac; use Osiset\ShopifyApp\Util; /** @@ -42,7 +43,7 @@ public function withValidator(Validator $validator): void $data['redirect'] = $this->request->get('redirect'); } - $signature = $data['signature']; + $signature = Hmac::fromNative($data['signature']); unset($data['signature']); // Confirm the charge hasn't been tampered with @@ -53,7 +54,7 @@ public function withValidator(Validator $validator): void ], Util::getShopifyConfig('api_secret') ); - if (! hash_equals($signature, $signatureLocal)) { + if (! $signature->isSame($signatureLocal)) { // Possible tampering $validator->errors()->add('signature', 'Signature does not match.'); } diff --git a/src/ShopifyApp/Macros/TokenRedirect.php b/src/ShopifyApp/Macros/TokenRedirect.php new file mode 100644 index 00000000..68220b9a --- /dev/null +++ b/src/ShopifyApp/Macros/TokenRedirect.php @@ -0,0 +1,31 @@ + 1]);` + * + * @return RedirectResponse + */ + public function __invoke(string $route, $params = [], bool $absolute = true): RedirectResponse + { + [$url, $params] = $this->generateParams($route, $params, $absolute); + + return Redirect::route($url, $params); + } +} diff --git a/src/ShopifyApp/Macros/TokenRoute.php b/src/ShopifyApp/Macros/TokenRoute.php new file mode 100644 index 00000000..18c4bd6a --- /dev/null +++ b/src/ShopifyApp/Macros/TokenRoute.php @@ -0,0 +1,31 @@ + 1]);` + * @example `Order #1` + * + * @return string + */ + public function __invoke(string $route, $params = [], bool $absolute = true): string + { + [$url, $params] = $this->generateParams($route, $params, $absolute); + + return URL::route($url, $params); + } +} diff --git a/src/ShopifyApp/Macros/TokenUrl.php b/src/ShopifyApp/Macros/TokenUrl.php new file mode 100644 index 00000000..2a8aeb10 --- /dev/null +++ b/src/ShopifyApp/Macros/TokenUrl.php @@ -0,0 +1,34 @@ + ShopDomain::fromRequest(Request::instance())->toNative(), + 'target' => URL::route($route, $params, $absolute), + ], + ]; + } +} diff --git a/src/ShopifyApp/Objects/Transfers/PlanDetails.php b/src/ShopifyApp/Objects/Transfers/PlanDetails.php index a0fb2bcc..eabba89f 100644 --- a/src/ShopifyApp/Objects/Transfers/PlanDetails.php +++ b/src/ShopifyApp/Objects/Transfers/PlanDetails.php @@ -54,12 +54,6 @@ final class PlanDetails extends AbstractTransfer * @var string|null */ public $terms; - /** - * cappedTerms. - * - * @var mixed - */ - public $cappedTerms; /** * Plan return URL. @@ -80,7 +74,6 @@ public function toArray(): array 'test' => $this->test, 'trial_days' => $this->trialDays, 'return_url' => $this->returnUrl, - 'cappedTerms' => $this->cappedTerms, 'terms' => $this->terms, 'capped_amount' => $this->cappedAmount, ]; diff --git a/src/ShopifyApp/Objects/Values/Hmac.php b/src/ShopifyApp/Objects/Values/Hmac.php new file mode 100644 index 00000000..f49b4040 --- /dev/null +++ b/src/ShopifyApp/Objects/Values/Hmac.php @@ -0,0 +1,22 @@ +toNative(), $object->toNative()); + } +} diff --git a/src/ShopifyApp/Objects/Values/NullSessionId.php b/src/ShopifyApp/Objects/Values/NullSessionId.php new file mode 100644 index 00000000..44872400 --- /dev/null +++ b/src/ShopifyApp/Objects/Values/NullSessionId.php @@ -0,0 +1,14 @@ +value->isEmpty(); + return empty($this->value->toNative()); } /** diff --git a/src/ShopifyApp/Objects/Values/NullableSessionId.php b/src/ShopifyApp/Objects/Values/NullableSessionId.php new file mode 100644 index 00000000..85e39aae --- /dev/null +++ b/src/ShopifyApp/Objects/Values/NullableSessionId.php @@ -0,0 +1,28 @@ +sessionToken = $sessionToken; + $this->sessionId = $sessionId; + $this->accessToken = $accessToken; + } + + /** + * Get the session token. + * + * @return SessionTokenValue + */ + public function getSessionToken(): SessionTokenValue + { + return $this->sessionToken; + } + + /** + * Get the access token. + * + * @return AccessTokenValue + */ + public function getAccessToken(): AccessTokenValue + { + return $this->accessToken; + } + + /** + * Get the session ID. + * + * @return SessionIdValue + */ + public function getSessionId(): SessionIdValue + { + return $this->sessionId; + } + + /** + * {@inheritDoc} + */ + public static function fromNative($native) + { + return new static( + NullableSessionToken::fromNative(Arr::get($native, 'session_token')), + NullableSessionId::fromNative(Arr::get($native, 'session_id')), + NullableAccessToken::fromNative(Arr::get($native, 'access_token')) + ); + } + + /** + * Confirm session is valid. + * TODO: Add per-user support. + * + * @param SessionContext|null $previousContext The last session context (if available). + * + * @return bool + */ + public function isValid(?SessionContext $previousContext = null): bool + { + // Confirm access token and session token are good + $tokenCheck = ! $this->getAccessToken()->isEmpty() && ! $this->getSessionToken()->isNull(); + + // Compare data + $sidCheck = true; + $domainCheck = true; + if ($previousContext !== null) { + /** @var $previousToken SessionToken */ + $previousToken = $previousContext->getSessionToken(); + /** @var $currentToken SessionToken */ + $currentToken = $this->getSessionToken(); + + // Compare the domains + $domainCheck = $previousToken->getShopDomain()->isSame($currentToken->getShopDomain()); + + // Compare the session IDs + if (! $previousContext->getSessionId()->isNull() && ! $this->getSessionId()->isNull()) { + $sidCheck = $previousContext->getSessionId()->isSame($this->getSessionId()); + } + } + + return $tokenCheck && $sidCheck && $domainCheck; + } +} diff --git a/src/ShopifyApp/Objects/Values/SessionId.php b/src/ShopifyApp/Objects/Values/SessionId.php new file mode 100644 index 00000000..90cbcb05 --- /dev/null +++ b/src/ShopifyApp/Objects/Values/SessionId.php @@ -0,0 +1,14 @@ +string = $token; + $this->decodeToken(); + + if ($verifyToken) { + // Confirm token signature, validity, and expiration + $this->verifySignature(); + $this->verifyValidity(); + $this->verifyExpiration(); + } + } + + /** + * Decode and validate the formatting of the token. + * + * @throws AssertionFailedException If token is malformed. + * + * @return void + */ + protected function decodeToken(): void + { + // Confirm token formatting + Assert::that($this->string)->regex(self::TOKEN_FORMAT, self::EXCEPTION_MALFORMED); + + // Decode the token + $this->parts = explode('.', $this->string); + $body = json_decode(Util::base64UrlDecode($this->parts[1]), true); + + // Confirm token is not malformed + Assert::thatAll([ + $body['iss'], + $body['dest'], + $body['aud'], + $body['sub'], + $body['exp'], + $body['nbf'], + $body['iat'], + $body['jti'], + $body['sid'], + ])->notNull(self::EXCEPTION_MALFORMED); + + // Format the values + $this->iss = $body['iss']; + $this->dest = $body['dest']; + $this->aud = $body['aud']; + $this->sub = $body['dest']; + $this->jti = $body['dest']; + $this->sid = SessionId::fromNative($body['sid']); + $this->exp = new Carbon($body['exp']); + $this->nbf = new Carbon($body['nbf']); + $this->iat = new Carbon($body['iat']); + + // Parse the shop domain from the destination + $host = parse_url($body['dest'], PHP_URL_HOST); + $this->shopDomain = NullableShopDomain::fromNative($host); + } + + /** + * Get the shop domain. + * + * @return ShopDomainValue + */ + public function getShopDomain(): ShopDomainValue + { + return $this->shopDomain; + } + + /** + * Get the session ID. + * + * @return SessionId + */ + public function getSessionId(): SessionId + { + return $this->sid; + } + + /** + * Get the expiration time of the token. + * + * @return Carbon + */ + public function getExpiration(): Carbon + { + return $this->exp; + } + + /** + * Checks the validity of the signature sent with the token. + * + * @throws AssertionFailedException If signature does not match. + * + * @return void + */ + protected function verifySignature(): void + { + // Get the token without the signature present + $partsCopy = $this->parts; + $signature = Hmac::fromNative(array_pop($partsCopy)); + $tokenWithoutSignature = implode('.', $partsCopy); + + // Create a local HMAC + $secret = Util::getShopifyConfig('api_secret', $this->shopDomain); + $hmac = Util::createHmac(['data' => $tokenWithoutSignature, 'raw' => true], $secret); + $encodedHmac = Hmac::fromNative(Util::base64UrlEncode($hmac->toNative())); + + Assert::that($signature->isSame($encodedHmac))->true(); + } + + /** + * Checks the token to ensure the issuer and audience matches. + * + * @throws AssertionFailedException If invalid token. + * + * @return void + */ + protected function verifyValidity(): void + { + Assert::that($this->iss)->contains($this->dest, self::EXCEPTION_INVALID); + Assert::that($this->aud)->eq(Util::getShopifyConfig('api_key'), self::EXCEPTION_INVALID); + } + + /** + * Checks the token to ensure its not expired. + * + * @throws AssertionFailedException If token is expired. + * + * @return void + */ + protected function verifyExpiration(): void + { + $now = Carbon::now(); + Assert::thatAll([ + $now->greaterThan($this->exp), + $now->lessThan($this->nbf), + $now->lessThan($this->iat), + ])->false(self::EXCEPTION_EXPIRED); + } +} diff --git a/src/ShopifyApp/Objects/Values/ShopDomain.php b/src/ShopifyApp/Objects/Values/ShopDomain.php index eb603d77..d46daa49 100644 --- a/src/ShopifyApp/Objects/Values/ShopDomain.php +++ b/src/ShopifyApp/Objects/Values/ShopDomain.php @@ -2,8 +2,12 @@ namespace Osiset\ShopifyApp\Objects\Values; +use Assert\AssertionFailedException; use Funeralzone\ValueObjects\Scalars\StringTrait; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Osiset\ShopifyApp\Contracts\Objects\Values\ShopDomain as ShopDomainValue; +use Osiset\ShopifyApp\Objects\Enums\DataSource; use Osiset\ShopifyApp\Util; /** @@ -25,6 +29,71 @@ public function __construct(string $domain) $this->string = $this->sanitizeShopDomain($domain); } + /** + * Grab the shop, if present, and how it was found. + * Order of precedence is:. + * + * - GET/POST Variable ("shop" or "shopDomain") + * - Headers ("X-Shop-Domain") + * - Referer ("shop" or "shopDomain" query param or decoded "token" query param) + * + * @param Request $request The request object. + * + * @return ShopDomainValue + */ + public static function fromRequest(Request $request): ShopDomainValue + { + // All possible methods + $options = [ + // GET/POST + DataSource::INPUT()->toNative() => $request->input('shop', $request->input('shopDomain')), + + // 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); + if (! $url) { + return null; + } + + $params = Util::parseQueryString($url); + $shop = Arr::get($params, 'shop', Arr::get($params, 'shopDomain')); + if ($shop) { + return $shop; + } + + $token = Arr::get($params, 'token'); + if ($token) { + try { + $token = new SessionToken($token, false); + if ($shopDomain = $token->getShopDomain()) { + return $shopDomain->toNative(); + } + } catch (AssertionFailedException $e) { + // Unable to decode the token + return null; + } + } + + return null; + }, + ]; + + // Loop through each until we find the shop + foreach ($options as $value) { + $result = is_callable($value) ? $value() : $value; + if ($result !== null) { + // Found a shop + return self::fromNative($result); + } + } + + // No shop domain found in any source + return NullShopDomain::fromNative(null); + } + /** * Ensures shop domain meets the specs. * diff --git a/src/ShopifyApp/Services/ChargeHelper.php b/src/ShopifyApp/Services/ChargeHelper.php index fd635714..afed00ec 100644 --- a/src/ShopifyApp/Services/ChargeHelper.php +++ b/src/ShopifyApp/Services/ChargeHelper.php @@ -267,7 +267,7 @@ public function details(Plan $plan, IShopModel $shop): PlanDetailsTransfer $transfer->returnUrl = URL::secure( Util::getShopifyConfig('billing_redirect'), ['plan' => $plan->getId()->toNative()] - ); + ).'?'.http_build_query(['shop' => $shop->getDomain()->toNative()]); return $transfer; } diff --git a/src/ShopifyApp/Services/ShopSession.php b/src/ShopifyApp/Services/ShopSession.php deleted file mode 100644 index e1659a31..00000000 --- a/src/ShopifyApp/Services/ShopSession.php +++ /dev/null @@ -1,389 +0,0 @@ -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()->getToken(), - ]; - - 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/ShopifyApp/ShopifyAppProvider.php b/src/ShopifyApp/ShopifyAppProvider.php index 4cd09669..4313486f 100644 --- a/src/ShopifyApp/ShopifyAppProvider.php +++ b/src/ShopifyApp/ShopifyAppProvider.php @@ -2,13 +2,14 @@ namespace Osiset\ShopifyApp; -use Illuminate\Auth\AuthManager; +use Illuminate\Routing\Redirector; +use Illuminate\Routing\UrlGenerator; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Osiset\ShopifyApp\Actions\ActivatePlan as ActivatePlanAction; use Osiset\ShopifyApp\Actions\ActivateUsageCharge as ActivateUsageChargeAction; use Osiset\ShopifyApp\Actions\AfterAuthorize as AfterAuthorizeAction; use Osiset\ShopifyApp\Actions\AuthenticateShop as AuthenticateShopAction; -use Osiset\ShopifyApp\Actions\AuthorizeShop as AuthorizeShopAction; use Osiset\ShopifyApp\Actions\CancelCharge as CancelChargeAction; use Osiset\ShopifyApp\Actions\CancelCurrentPlan as CancelCurrentPlanAction; use Osiset\ShopifyApp\Actions\CreateScripts as CreateScriptsAction; @@ -17,6 +18,7 @@ use Osiset\ShopifyApp\Actions\DispatchScripts as DispatchScriptsAction; use Osiset\ShopifyApp\Actions\DispatchWebhooks as DispatchWebhooksAction; use Osiset\ShopifyApp\Actions\GetPlanUrl as GetPlanUrlAction; +use Osiset\ShopifyApp\Actions\InstallShop as InstallShopAction; use Osiset\ShopifyApp\Console\WebhookJobMakeCommand; use Osiset\ShopifyApp\Contracts\ApiHelper as IApiHelper; use Osiset\ShopifyApp\Contracts\Commands\Charge as IChargeCommand; @@ -24,18 +26,17 @@ use Osiset\ShopifyApp\Contracts\Queries\Charge as IChargeQuery; use Osiset\ShopifyApp\Contracts\Queries\Plan as IPlanQuery; use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; +use Osiset\ShopifyApp\Directives\SessionToken; use Osiset\ShopifyApp\Http\Middleware\AuthProxy; -use Osiset\ShopifyApp\Http\Middleware\AuthShopify; -use Osiset\ShopifyApp\Http\Middleware\AuthToken; use Osiset\ShopifyApp\Http\Middleware\AuthWebhook; use Osiset\ShopifyApp\Http\Middleware\Billable; -use Osiset\ShopifyApp\Http\Middleware\ITP; +use Osiset\ShopifyApp\Http\Middleware\VerifyShopify; +use Osiset\ShopifyApp\Macros\TokenRedirect; +use Osiset\ShopifyApp\Macros\TokenRoute; use Osiset\ShopifyApp\Messaging\Jobs\ScripttagInstaller; use Osiset\ShopifyApp\Messaging\Jobs\WebhookInstaller; use Osiset\ShopifyApp\Services\ApiHelper; use Osiset\ShopifyApp\Services\ChargeHelper; -use Osiset\ShopifyApp\Services\CookieHelper; -use Osiset\ShopifyApp\Services\ShopSession; use Osiset\ShopifyApp\Storage\Commands\Charge as ChargeCommand; use Osiset\ShopifyApp\Storage\Commands\Shop as ShopCommand; use Osiset\ShopifyApp\Storage\Observers\Shop as ShopObserver; @@ -74,6 +75,8 @@ public function boot() $this->bootJobs(); $this->bootObservers(); $this->bootMiddlewares(); + $this->bootMacros(); + $this->bootDirectives(); } /** @@ -125,18 +128,16 @@ public function register() }], // Actions - AuthorizeShopAction::class => [self::CBIND, function ($app) { - return new AuthorizeShopAction( + InstallShopAction::class => [self::CBIND, function ($app) { + return new InstallShopAction( $app->make(IShopQuery::class), - $app->make(IShopCommand::class), - $app->make(ShopSession::class) + $app->make(IShopCommand::class) ); }], AuthenticateShopAction::class => [self::CBIND, function ($app) { return new AuthenticateShopAction( - $app->make(ShopSession::class), $app->make(IApiHelper::class), - $app->make(AuthorizeShopAction::class), + $app->make(InstallShopAction::class), $app->make(DispatchScriptsAction::class), $app->make(DispatchWebhooksAction::class), $app->make(AfterAuthorizeAction::class) @@ -220,23 +221,11 @@ public function register() }], // Services (end) - ShopSession::class => [self::CBIND, function ($app) { - return new ShopSession( - $app->make(AuthManager::class), - $app->make(IApiHelper::class), - $app->make(CookieHelper::class), - $app->make(IShopCommand::class), - $app->make(IShopQuery::class) - ); - }], ChargeHelper::class => [self::CBIND, function ($app) { return new ChargeHelper( $app->make(IChargeQuery::class) ); }], - CookieHelper::class => [self::CBIND, function () { - return new CookieHelper(); - }], ]; foreach ($binds as $key => $fn) { $this->app->{$fn[0]}($key, $fn[1]); @@ -347,11 +336,30 @@ private function bootObservers(): void private function bootMiddlewares(): void { // Middlewares - $this->app['router']->aliasMiddleware('auth.shopify', AuthShopify::class); - $this->app['router']->aliasMiddleware('auth.token', AuthToken::class); + $this->app['router']->aliasMiddleware('verify.shopify', VerifyShopify::class); $this->app['router']->aliasMiddleware('auth.webhook', AuthWebhook::class); $this->app['router']->aliasMiddleware('auth.proxy', AuthProxy::class); $this->app['router']->aliasMiddleware('billable', Billable::class); - $this->app['router']->aliasMiddleware('itp', ITP::class); + } + + /** + * Apply macros to Laravel framework. + * + * @return void + */ + private function bootMacros(): void + { + Redirector::macro('tokenRedirect', new TokenRedirect()); + UrlGenerator::macro('tokenRoute', new TokenRoute()); + } + + /** + * Init Blade directives. + * + * @return void + */ + private function bootDirectives(): void + { + Blade::directive('sessionToken', new SessionToken()); } } diff --git a/src/ShopifyApp/Storage/Commands/Charge.php b/src/ShopifyApp/Storage/Commands/Charge.php index ee5d5188..fc4f3b51 100644 --- a/src/ShopifyApp/Storage/Commands/Charge.php +++ b/src/ShopifyApp/Storage/Commands/Charge.php @@ -61,7 +61,7 @@ public function make(ChargeTransfer $chargeObj): ChargeId $charge->test = $chargeObj->planDetails->test; $charge->trial_days = $chargeObj->planDetails->trialDays; $charge->capped_amount = $chargeObj->planDetails->cappedAmount; - $charge->terms = $chargeObj->planDetails->cappedTerms; + $charge->terms = $chargeObj->planDetails->terms; $charge->activated_on = $isCarbon($chargeObj->activatedOn) ? $chargeObj->activatedOn->format('Y-m-d') : null; $charge->billing_on = $isCarbon($chargeObj->billingOn) ? $chargeObj->billingOn->format('Y-m-d') : null; $charge->trial_ends_on = $isCarbon($chargeObj->trialEndsOn) ? $chargeObj->trialEndsOn->format('Y-m-d') : null; diff --git a/src/ShopifyApp/Storage/Commands/Shop.php b/src/ShopifyApp/Storage/Commands/Shop.php index d667ead7..3b5d29d2 100644 --- a/src/ShopifyApp/Storage/Commands/Shop.php +++ b/src/ShopifyApp/Storage/Commands/Shop.php @@ -2,6 +2,7 @@ namespace Osiset\ShopifyApp\Storage\Commands; +use Illuminate\Support\Carbon; use Osiset\ShopifyApp\Contracts\Commands\Shop as ShopCommand; use Osiset\ShopifyApp\Contracts\Objects\Values\AccessToken as AccessTokenValue; use Osiset\ShopifyApp\Contracts\Objects\Values\PlanId as PlanIdValue; @@ -73,6 +74,7 @@ public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bo { $shop = $this->getShop($shopId); $shop->password = $token->toNative(); + $shop->password_updated_at = Carbon::now(); return $shop->save(); } diff --git a/src/ShopifyApp/Traits/AuthController.php b/src/ShopifyApp/Traits/AuthController.php index 2ccc0682..bdfe0869 100644 --- a/src/ShopifyApp/Traits/AuthController.php +++ b/src/ShopifyApp/Traits/AuthController.php @@ -9,7 +9,7 @@ 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; use Osiset\ShopifyApp\Util; @@ -20,75 +20,74 @@ trait AuthController { /** - * Authenticating a shop. - * - * @param AuthenticateShop $authenticateShop The action for authorizing and authenticating a shop. - * - * @throws SignatureVerificationException + * Installing/authenticating a shop. * * @return ViewView|RedirectResponse */ - public function authenticate(Request $request, AuthenticateShop $authenticateShop) + public function authenticate(Request $request, AuthenticateShop $authShop) { // Get the shop domain - $shopDomain = ShopDomain::fromNative($request->get('shop')); + if (Util::getShopifyConfig('turbo_enabled') && $request->user()) { + // If the user clicked on any link before load Turbo and receiving the token + $shopDomain = $request->user()->getDomain(); + $request['shop'] = $shopDomain->toNative(); + } else { + $shopDomain = ShopDomain::fromNative($request->get('shop')); + } - // Run the action, returns [result object, result status] - [$result, $status] = $authenticateShop($request); + // Run the action + [$result, $status] = $authShop($request); if ($status === null) { // Show exception, something is wrong throw new SignatureVerificationException('Invalid HMAC verification'); } elseif ($status === false) { - // No code, redirect to auth URL - return $this->oauthFailure($result->url, $shopDomain); - } else { - // Everything's good... determine if we need to redirect back somewhere - $return_to = Session::get('return_to'); - if ($return_to) { - Session::forget('return_to'); - - return Redirect::to($return_to); + if (! $result['url']) { + throw new MissingAuthUrlException('Missing auth url'); } - // No return_to, go to home route - return Redirect::route(Util::getShopifyConfig('route_names.home')); + return View::make( + 'shopify-app::auth.fullpage_redirect', + [ + 'authUrl' => $result['url'], + 'shopDomain' => $shopDomain->toNative(), + ] + ); + } else { + // Go to home route + return Redirect::route( + Util::getShopifyConfig('route_names.home'), + ['shop' => $shopDomain->toNative()] + ); } } /** - * Simply redirects to Shopify's Oauth screen. - * - * @param Request $request The request object. - * @param AuthorizeShop $authShop The action for authenticating a shop. + * Get session token for a shop. * * @return ViewView */ - public function oauth(Request $request, AuthorizeShop $authShop): ViewView + public function token(Request $request) { - // Setup - $shopDomain = ShopDomain::fromNative($request->get('shop')); - $result = $authShop($shopDomain, null); + $request->session()->reflash(); + $shopDomain = ShopDomain::fromRequest($request); + $target = $request->query('target'); + $query = parse_url($target, PHP_URL_QUERY); - // Redirect - return $this->oauthFailure($result->url, $shopDomain); - } + $cleanTarget = $target; + if ($query) { + // remove "token" from the target's query string + $params = Util::parseQueryString($query); + unset($params['token']); + + $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); + } - /** - * 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', + 'shopify-app::auth.token', [ - 'authUrl' => $authUrl, 'shopDomain' => $shopDomain->toNative(), + 'target' => $cleanTarget, ] ); } diff --git a/src/ShopifyApp/Traits/BillingController.php b/src/ShopifyApp/Traits/BillingController.php index 2c5eeed9..1d24742d 100644 --- a/src/ShopifyApp/Traits/BillingController.php +++ b/src/ShopifyApp/Traits/BillingController.php @@ -15,7 +15,8 @@ use Osiset\ShopifyApp\Objects\Values\ChargeReference; use Osiset\ShopifyApp\Objects\Values\NullablePlanId; use Osiset\ShopifyApp\Objects\Values\PlanId; -use Osiset\ShopifyApp\Services\ShopSession; +use Osiset\ShopifyApp\Objects\Values\ShopDomain; +use Osiset\ShopifyApp\Storage\Queries\Shop as ShopQuery; use Osiset\ShopifyApp\Util; /** @@ -27,16 +28,24 @@ trait BillingController * Redirects to billing screen for Shopify. * * @param int|null $plan The plan's ID, if provided in route. + * @param Request $request The request object. + * @param ShopQuery $shopQuery The shop querier. * @param GetPlanUrl $getPlanUrl The action for getting the plan URL. - * @param ShopSession $shopSession The shop session helper. * * @return ViewView */ - public function index(?int $plan = null, GetPlanUrl $getPlanUrl, ShopSession $shopSession): ViewView - { + public function index( + ?int $plan = null, + Request $request, + ShopQuery $shopQuery, + GetPlanUrl $getPlanUrl + ): ViewView { + // Get the shop + $shop = $shopQuery->getByDomain(ShopDomain::fromNative($request->get('shop'))); + // Get the plan URL for redirect $url = $getPlanUrl( - $shopSession->getShop()->getId(), + $shop->getId(), NullablePlanId::fromNative($plan) ); @@ -52,26 +61,31 @@ public function index(?int $plan = null, GetPlanUrl $getPlanUrl, ShopSession $sh * * @param int $plan The plan's ID. * @param Request $request The HTTP request object. + * @param ShopQuery $shopQuery The shop querier. * @param ActivatePlan $activatePlan The action for activating the plan for a shop. - * @param ShopSession $shopSession The shop session helper. * * @return RedirectResponse */ public function process( int $plan, Request $request, - ActivatePlan $activatePlan, - ShopSession $shopSession + ShopQuery $shopQuery, + ActivatePlan $activatePlan ): RedirectResponse { + // Get the shop + $shop = $shopQuery->getByDomain(ShopDomain::fromNative($request->query('shop'))); + // Activate the plan and save $result = $activatePlan( - $shopSession->getShop()->getId(), + $shop->getId(), PlanId::fromNative($plan), ChargeReference::fromNative((int) $request->query('charge_id')) ); // Go to homepage of app - return Redirect::route(Util::getShopifyConfig('route_names.home'))->with( + return Redirect::route(Util::getShopifyConfig('route_names.home'), [ + 'shop' => $shop->getDomain()->toNative(), + ])->with( $result ? 'success' : 'failure', 'billing' ); @@ -82,15 +96,11 @@ public function process( * * @param StoreUsageCharge $request The verified request. * @param ActivateUsageCharge $activateUsageCharge The action for activating a usage charge. - * @param ShopSession $shopSession The shop session helper. * * @return RedirectResponse */ - public function usageCharge( - StoreUsageCharge $request, - ActivateUsageCharge $activateUsageCharge, - ShopSession $shopSession - ): RedirectResponse { + public function usageCharge(StoreUsageCharge $request, ActivateUsageCharge $activateUsageCharge): RedirectResponse + { $validated = $request->validated(); // Create the transfer object @@ -99,14 +109,11 @@ public function usageCharge( $ucd->description = $validated['description']; // Activate and save the usage charge - $activateUsageCharge( - $shopSession->getShop()->getId(), - $ucd - ); + $activateUsageCharge($request->user()->getId(), $ucd); // All done, return with success - return isset($validated['redirect']) ? - Redirect::to($validated['redirect'])->with('success', 'usage_charge') : - Redirect::back()->with('success', 'usage_charge'); + return isset($validated['redirect']) + ? Redirect::to($validated['redirect'])->with('success', 'usage_charge') + : Redirect::back()->with('success', 'usage_charge'); } } diff --git a/src/ShopifyApp/Traits/HomeController.php b/src/ShopifyApp/Traits/HomeController.php index b8514831..85f196df 100644 --- a/src/ShopifyApp/Traits/HomeController.php +++ b/src/ShopifyApp/Traits/HomeController.php @@ -3,6 +3,7 @@ namespace Osiset\ShopifyApp\Traits; use Illuminate\Contracts\View\View as ViewView; +use Illuminate\Http\Request; use Illuminate\Support\Facades\View; /** @@ -13,10 +14,15 @@ trait HomeController /** * Index route which displays the home page of the app. * + * @param Request $request The request object. + * * @return ViewView */ - public function index(): ViewView + public function index(Request $request): ViewView { - return View::make('shopify-app::home.index'); + return View::make( + 'shopify-app::home.index', + ['shop' => $request->user()] + ); } } diff --git a/src/ShopifyApp/Traits/ItpController.php b/src/ShopifyApp/Traits/ItpController.php deleted file mode 100644 index 6249e769..00000000 --- a/src/ShopifyApp/Traits/ItpController.php +++ /dev/null @@ -1,50 +0,0 @@ - $request->query('shop'), - 'itp' => true, - ]); - } - - /** - * Second-pass of ITP mitigation. - * Ask the user for cookie/storage permissions. - * - * @return ViewView - */ - public function ask(): ViewView - { - return View::make('shopify-app::itp.ask', [ - 'redirect' => URL::route(Util::getShopifyConfig('route_names.home')), - ]); - } -} diff --git a/src/ShopifyApp/Traits/ShopModel.php b/src/ShopifyApp/Traits/ShopModel.php index 7d91c909..b02b3237 100644 --- a/src/ShopifyApp/Traits/ShopModel.php +++ b/src/ShopifyApp/Traits/ShopModel.php @@ -12,9 +12,9 @@ use Osiset\ShopifyApp\Contracts\Objects\Values\ShopDomain as ShopDomainValue; use Osiset\ShopifyApp\Contracts\Objects\Values\ShopId as ShopIdValue; use Osiset\ShopifyApp\Objects\Values\AccessToken; +use Osiset\ShopifyApp\Objects\Values\SessionContext; use Osiset\ShopifyApp\Objects\Values\ShopDomain; use Osiset\ShopifyApp\Objects\Values\ShopId; -use Osiset\ShopifyApp\Services\ShopSession; use Osiset\ShopifyApp\Storage\Models\Charge; use Osiset\ShopifyApp\Storage\Models\Plan; use Osiset\ShopifyApp\Storage\Scopes\Namespacing; @@ -33,6 +33,13 @@ trait ShopModel */ public $apiHelper; + /** + * Session context used between requests. + * + * @var SessionContext + */ + protected $sessionContext; + /** * Boot the trait. * @@ -64,7 +71,7 @@ public function getDomain(): ShopDomainValue /** * {@inheritdoc} */ - public function getToken(): AccessTokenValue + public function getAccessToken(): AccessTokenValue { return AccessToken::fromNative($this->password); } @@ -114,7 +121,23 @@ public function isFreemium(): bool */ public function hasOfflineAccess(): bool { - return ! $this->getToken()->isNull() && ! empty($this->password); + return ! $this->getAccessToken()->isNull() && ! empty($this->password); + } + + /** + * {@inheritDoc} + */ + public function setSessionContext(SessionContext $session): void + { + $this->sessionContext = $session; + } + + /** + * {@inheritDoc} + */ + public function getSessionContext(): ?SessionContext + { + return $this->sessionContext; } /** @@ -123,16 +146,10 @@ public function hasOfflineAccess(): bool public function apiHelper(): IApiHelper { if ($this->apiHelper === null) { - // Get the token - /** @var ShopSession $shopSession */ - $shopSession = resolve(ShopSession::class); - $token = $shopSession->guest() ? $this->getToken() : $shopSession->getToken(); - // Set the session $session = new Session( $this->getDomain()->toNative(), - $token->toNative(), - $shopSession->getUser() + $this->getAccessToken()->toNative() ); $this->apiHelper = resolve(IApiHelper::class)->make($session); } diff --git a/src/ShopifyApp/Traits/WebhookController.php b/src/ShopifyApp/Traits/WebhookController.php index 9e9a78c7..171307f5 100644 --- a/src/ShopifyApp/Traits/WebhookController.php +++ b/src/ShopifyApp/Traits/WebhookController.php @@ -31,6 +31,6 @@ public function handle(string $type, Request $request): ResponseResponse $jobData )->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); - return Response::make('', 201); + return Response::make('', ResponseResponse::HTTP_CREATED); } } diff --git a/src/ShopifyApp/Util.php b/src/ShopifyApp/Util.php index 78bc4edf..4abc6f0b 100644 --- a/src/ShopifyApp/Util.php +++ b/src/ShopifyApp/Util.php @@ -6,7 +6,11 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; use LogicException; +use Osiset\ShopifyApp\Objects\Values\Hmac; +/** + * Utilities and helpers used in various parts of the package. + */ class Util { /** @@ -15,9 +19,9 @@ class Util * @param array $opts The options for building the HMAC. * @param string $secret The app secret key. * - * @return string + * @return Hmac */ - public static function createHmac(array $opts, string $secret): string + public static function createHmac(array $opts, string $secret): Hmac { // Setup defaults $data = $opts['data']; @@ -43,7 +47,9 @@ public static function createHmac(array $opts, string $secret): string $hmac = hash_hmac('sha256', $data, $secret, $raw); // Return based on options - return $encode ? base64_encode($hmac) : $hmac; + $result = $encode ? base64_encode($hmac) : $hmac; + + return Hmac::fromNative($result); } /** diff --git a/src/ShopifyApp/resources/config/shopify-app.php b/src/ShopifyApp/resources/config/shopify-app.php index 2ed917da..3f781f32 100644 --- a/src/ShopifyApp/resources/config/shopify-app.php +++ b/src/ShopifyApp/resources/config/shopify-app.php @@ -6,9 +6,10 @@ | Debug Mode |-------------------------------------------------------------------------- | - | (Not yet complete) A verbose logged output of processes + | (Not yet complete) A verbose logged output of processes. | */ + 'debug' => (bool) env('SHOPIFY_DEBUG', false), /* @@ -21,6 +22,7 @@ | to your app's folder so you're free to modify before migrating. | */ + 'manual_migrations' => (bool) env('SHOPIFY_MANUAL_MIGRATIONS', false), /* @@ -35,10 +37,12 @@ | for a list of available route names. | Example: `home,billing` would ignore both "home" and "billing" routes. | - | Please note that if you override the route names (see "route_names" below), - | the route names that are used in this option DO NOT change! + | Please note that if you override the route names + | (see "route_names" below), the route names that are used in this + | option DO NOT change! | */ + 'manual_routes' => env('SHOPIFY_MANUAL_ROUTES', false), /* @@ -50,16 +54,15 @@ | This can help you avoid collisions with your existing route names. | */ + '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'), 'billing.usage_charge' => env('SHOPIFY_ROUTE_NAME_BILLING_USAGE_CHARGE', 'billing.usage_charge'), 'webhook' => env('SHOPIFY_ROUTE_NAME_WEBHOOK', 'webhook'), - 'itp' => env('SHOPIFY_ROUTE_NAME_ITP', 'itp'), - 'itp.ask' => env('SHOPIFY_ROUTE_NAME_ITP_ASK', 'itp.ask'), ], /* @@ -81,10 +84,12 @@ |-------------------------------------------------------------------------- | | This option allows you to change out the default job namespace - | which is \App\Jobs. This option is mainly used if any custom configuration - | is done in autoload and does not need to be changed unless required. + | which is \App\Jobs. This option is mainly used if any custom + | configuration is done in autoload and does not need to be changed + | unless required. | */ + 'job_namespace' => env('SHOPIFY_JOB_NAMESPACE', '\\App\\Jobs\\'), /* @@ -113,7 +118,7 @@ // Use semver range to link to a major or minor version number. // Leaving empty will use the latest verison - not recommended in production. - 'appbridge_version' => env('SHOPIFY_APPBRIDGE_VERSION', '1'), + 'appbridge_version' => env('SHOPIFY_APPBRIDGE_VERSION', 'latest'), /* |-------------------------------------------------------------------------- @@ -201,7 +206,8 @@ | Shopify API Time Store |-------------------------------------------------------------------------- | - | This option is for the class which will hold the timestamps for API calls. + | This option is for the class which will hold the timestamps for + | API calls. | */ @@ -212,7 +218,8 @@ | Shopify API Limit Store |-------------------------------------------------------------------------- | - | This option is for the class which will hold the call limits for REST and GraphQL. + | This option is for the class which will hold the call limits for REST + | and GraphQL. | */ @@ -223,7 +230,8 @@ | Shopify API Deferrer |-------------------------------------------------------------------------- | - | This option is for the class which will handle sleep deferrals for API calls. + | This option is for the class which will handle sleep deferrals for + | API calls. | */ @@ -234,10 +242,13 @@ | Shopify API Init Function |-------------------------------------------------------------------------- | - | This option is for initing the BasicShopifyAPI package optionally yourself. - | The first param injected in is the current options (\Osiset\BasicShopifyAPI\Options). - | The second param injected in is the session (if available) (\Osiset\BasicShopifyAPI\Session). - | The third param injected in is the current request input/query array (\Illuminate\Http\Request::all()). + | This option is for initing the BasicShopifyAPI package yourself. + | The first param injected in is the current options. + | (\Osiset\BasicShopifyAPI\Options) + | The second param injected in is the session (if available) . + | (\Osiset\BasicShopifyAPI\Session) + | The third param injected in is the current request input/query array. + (\Illuminate\Http\Request::all()) | With all this, you can customize the options, change params, and more. | | Value for this option must be a callable (callable, Closure, etc). @@ -375,8 +386,9 @@ | Config API Callback |-------------------------------------------------------------------------- | - | This option can be used to modify what returns when `getConfig('api_*')` is used. - | A use-case for this is modifying the return of `api_secret` or something similar. + | This option can be used to modify what returns when `getConfig('api_*')` + | is used. A use-case for this is modifying the return of `api_secret` + | or something similar. | | A closure/callable is required. | The first argument will be the key string. @@ -385,4 +397,16 @@ */ 'config_api_callback' => null, + + /* + |-------------------------------------------------------------------------- + | Enable Turbolinks or Hotwire Turbo + |-------------------------------------------------------------------------- + | + | If you use Turbolinks/Turbo and Livewire, turn on this setting to get + | the token assigned automatically. + | + */ + + 'turbo_enabled' => (bool) env('SHOPIFY_TURBO_ENABLED', false), ]; diff --git a/src/ShopifyApp/resources/database/migrations/2021_04_21_103633_add_password_updated_at_to_users_table.php b/src/ShopifyApp/resources/database/migrations/2021_04_21_103633_add_password_updated_at_to_users_table.php new file mode 100644 index 00000000..ab3ff59b --- /dev/null +++ b/src/ShopifyApp/resources/database/migrations/2021_04_21_103633_add_password_updated_at_to_users_table.php @@ -0,0 +1,32 @@ +date('password_updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('password_updated_at'); + }); + } +} diff --git a/src/ShopifyApp/resources/routes/api.php b/src/ShopifyApp/resources/routes/api.php index c36c5385..4893e46a 100644 --- a/src/ShopifyApp/resources/routes/api.php +++ b/src/ShopifyApp/resources/routes/api.php @@ -24,7 +24,7 @@ */ if (Util::registerPackageRoute('api', $manualRoutes)) { - Route::group(['prefix' => 'api', 'middleware' => ['auth.token']], function () { + Route::group(['prefix' => 'api', 'middleware' => ['verify.shopify']], function () { Route::get( '/', ApiController::class.'@index' diff --git a/src/ShopifyApp/resources/routes/shopify.php b/src/ShopifyApp/resources/routes/shopify.php index 08495db7..d300eb1a 100644 --- a/src/ShopifyApp/resources/routes/shopify.php +++ b/src/ShopifyApp/resources/routes/shopify.php @@ -13,7 +13,6 @@ use Osiset\ShopifyApp\Http\Controllers\AuthController; use Osiset\ShopifyApp\Http\Controllers\BillingController; use Osiset\ShopifyApp\Http\Controllers\HomeController; -use Osiset\ShopifyApp\Http\Controllers\ItpController; use Osiset\ShopifyApp\Util; // Check if manual routes override is to be use @@ -24,8 +23,7 @@ $manualRoutes = explode(',', $manualRoutes); } -// Route which require ITP checks -Route::group(['prefix' => Util::getShopifyConfig('prefix'), 'middleware' => ['itp', 'web']], function () use ($manualRoutes) { +Route::group(['prefix' => Util::getShopifyConfig('prefix'), 'middleware' => ['web']], function () use ($manualRoutes) { /* |-------------------------------------------------------------------------- | Home Route @@ -41,44 +39,22 @@ '/', HomeController::class.'@index' ) - ->middleware(['auth.shopify', 'billable']) + ->middleware(['verify.shopify', 'billable']) ->name(Util::getShopifyConfig('route_names.home')); } /* |-------------------------------------------------------------------------- - | ITP + | Authenticate: Install & Authorize |-------------------------------------------------------------------------- | - | Handles ITP and issues with it. - | - */ - - if (Util::registerPackageRoute('itp', $manualRoutes)) { - Route::get('/itp', ItpController::class.'@attempt') - ->name(Util::getShopifyConfig('route_names.itp')); - } - - if (Util::registerPackageRoute('itp.ask', $manualRoutes)) { - Route::get('/itp/ask', ItpController::class.'@ask') - ->name(Util::getShopifyConfig('route_names.itp.ask')); - } -}); - -// Routes without ITP checks -Route::group(['prefix' => Util::getShopifyConfig('prefix'), 'middleware' => ['web']], function () use ($manualRoutes) { - /* - |-------------------------------------------------------------------------- - | Authenticate Method - |-------------------------------------------------------------------------- - | - | Authenticates a shop. + | Install a shop and go through Shopify OAuth. | */ if (Util::registerPackageRoute('authenticate', $manualRoutes)) { Route::match( - ['get', 'post'], + ['GET', 'POST'], '/authenticate', AuthController::class.'@authenticate' ) @@ -87,19 +63,22 @@ /* |-------------------------------------------------------------------------- - | Authenticate OAuth + | Authenticate: Token |-------------------------------------------------------------------------- | - | Redirect to Shopify's OAuth screen. + | This route is hit when a shop comes to the app without a session token + | yet. A token will be grabbed from Shopify's AppBridge Javascript + | and then forwarded back to the home route. | */ - if (Util::registerPackageRoute('authenticate.oauth', $manualRoutes)) { + if (Util::registerPackageRoute('authenticate.token', $manualRoutes)) { Route::get( - '/authenticate/oauth', - AuthController::class.'@oauth' + '/authenticate/token', + AuthController::class.'@token' ) - ->name(Util::getShopifyConfig('route_names.authenticate.oauth')); + ->middleware(['verify.shopify']) + ->name(Util::getShopifyConfig('route_names.authenticate.token')); } /* @@ -116,7 +95,7 @@ '/billing/{plan?}', BillingController::class.'@index' ) - ->middleware(['auth.shopify']) + ->middleware(['verify.shopify']) ->where('plan', '^([0-9]+|)$') ->name(Util::getShopifyConfig('route_names.billing')); } @@ -127,6 +106,7 @@ |-------------------------------------------------------------------------- | | Processes the customer's response to the billing screen. + | The shop domain is encrypted. | */ @@ -135,7 +115,7 @@ '/billing/process/{plan?}', BillingController::class.'@process' ) - ->middleware(['auth.shopify']) + ->middleware(['verify.shopify']) ->where('plan', '^([0-9]+|)$') ->name(Util::getShopifyConfig('route_names.billing.process')); } @@ -155,7 +135,7 @@ '/billing/usage-charge', BillingController::class.'@usageCharge' ) - ->middleware(['auth.shopify']) + ->middleware(['verify.shopify']) ->name(Util::getShopifyConfig('route_names.billing.usage_charge')); } }); diff --git a/src/ShopifyApp/resources/views/auth/fullpage_redirect.blade.php b/src/ShopifyApp/resources/views/auth/fullpage_redirect.blade.php index c1b759ac..c6feecff 100644 --- a/src/ShopifyApp/resources/views/auth/fullpage_redirect.blade.php +++ b/src/ShopifyApp/resources/views/auth/fullpage_redirect.blade.php @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/src/ShopifyApp/resources/views/auth/token.blade.php b/src/ShopifyApp/resources/views/auth/token.blade.php new file mode 100644 index 00000000..10375023 --- /dev/null +++ b/src/ShopifyApp/resources/views/auth/token.blade.php @@ -0,0 +1,45 @@ +@extends('shopify-app::layouts.default') + +@section('styles') + @include('shopify-app::partials.polaris_skeleton_css') +@endsection + +@section('content') +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@endsection + +@section('scripts') + @parent + + @if(config('shopify-app.appbridge_enabled')) + + @endif +@endsection diff --git a/src/ShopifyApp/resources/views/billing/fullpage_redirect.blade.php b/src/ShopifyApp/resources/views/billing/fullpage_redirect.blade.php index 4a48aa36..5ddfb646 100644 --- a/src/ShopifyApp/resources/views/billing/fullpage_redirect.blade.php +++ b/src/ShopifyApp/resources/views/billing/fullpage_redirect.blade.php @@ -1,7 +1,7 @@ - + Redirecting... @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/src/ShopifyApp/resources/views/home/index.blade.php b/src/ShopifyApp/resources/views/home/index.blade.php index 35394af0..633a6b3d 100644 --- a/src/ShopifyApp/resources/views/home/index.blade.php +++ b/src/ShopifyApp/resources/views/home/index.blade.php @@ -1,52 +1,7 @@ @extends('shopify-app::layouts.default') @section('styles') - - - + @include('shopify-app::partials.laravel_skeleton_css') @endsection @section('content') @@ -57,7 +12,9 @@

Welcome to your Shopify App powered by Laravel.

-

{{ Auth::user()->name }}

+

 

+

{{ $shop->name }}

+