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/VerifyShopify.php index 3f12cf67..05d3eb9c 100644 --- a/src/Http/Middleware/VerifyShopify.php +++ b/src/Http/Middleware/VerifyShopify.php @@ -2,39 +2,17 @@ namespace Osiset\ShopifyApp\Http\Middleware; -use Assert\AssertionFailedException; use Closure; -use Illuminate\Auth\AuthManager; -use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Str; use Osiset\ShopifyApp\Contracts\ApiHelper as IApiHelper; -use Osiset\ShopifyApp\Contracts\Objects\Values\ShopDomain as ShopDomainValue; -use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery; -use Osiset\ShopifyApp\Contracts\ShopModel; -use Osiset\ShopifyApp\Exceptions\HttpException; use Osiset\ShopifyApp\Exceptions\SignatureVerificationException; use Osiset\ShopifyApp\Objects\Enums\DataSource; -use Osiset\ShopifyApp\Objects\Values\NullableSessionId; -use Osiset\ShopifyApp\Objects\Values\SessionContext; -use Osiset\ShopifyApp\Objects\Values\SessionToken; -use Osiset\ShopifyApp\Objects\Values\ShopDomain; -use Osiset\ShopifyApp\Util; /** * Responsible for validating the request. */ class VerifyShopify { - /** - * The auth manager. - * - * @var AuthManager - */ - protected $auth; /** * The API helper. @@ -43,50 +21,6 @@ class VerifyShopify */ protected $apiHelper; - /** - * The shop querier. - * - * @var IShopQuery - */ - protected $shopQuery; - - /** - * Previous request shop. - * - * @var ShopModel|null - */ - protected $previousShop; - - /** - * Constructor. - * - * @param AuthManager $auth The Laravel auth manager. - * @param IApiHelper $apiHelper The API helper. - * @param IShopQuery $shopQuery The shop querier. - * - * @return void - */ - public function __construct( - AuthManager $auth, - IApiHelper $apiHelper, - IShopQuery $shopQuery - ) { - $this->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) @@ -96,109 +30,9 @@ public function handle(Request $request, Closure $next) 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. * @@ -222,90 +56,6 @@ protected function verifyHmac(Request $request): ?bool 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:. @@ -349,31 +99,6 @@ protected function getHmacFromRequest(Request $request): array 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. * @@ -442,6 +167,7 @@ protected function getRequestData(Request $request, string $source): array return $options[$source](); } + /** * Parse the data source value. * Handle simple key/values, arrays, and nested arrays. @@ -472,29 +198,4 @@ protected function parseDataSourceValue($value): string 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/Http/Middleware/VerifyShopifyEmbedded.php b/src/Http/Middleware/VerifyShopifyEmbedded.php new file mode 100644 index 00000000..5bc6a344 --- /dev/null +++ b/src/Http/Middleware/VerifyShopifyEmbedded.php @@ -0,0 +1,325 @@ +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) + { + + // 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)); + } + + /** + * 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()] + ); + } + + /** + * 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'); + } + + /** + * 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/Http/Middleware/VerifyShopifyExternal.php b/src/Http/Middleware/VerifyShopifyExternal.php new file mode 100644 index 00000000..ed11729c --- /dev/null +++ b/src/Http/Middleware/VerifyShopifyExternal.php @@ -0,0 +1,228 @@ +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); + + $checks = []; + if ($this->shopSession->guest()) { + + // 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); + } + + /** + * 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 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); + } + + + /** + * 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()] + ); + } + +} 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..0b197e62 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; @@ -51,7 +51,7 @@ public function authenticate(Request $request, AuthenticateShop $authShop) 'shopify-app::auth.fullpage_redirect', [ 'authUrl' => $result['url'], - 'shopDomain' => $shopDomain->toNative(), + 'shopDomain' => $shopDomain->toNative() ] ); } else { @@ -95,4 +95,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