Skip to content
This repository has been archived by the owner on Nov 30, 2022. It is now read-only.

Commit

Permalink
JWT Support (#601)
Browse files Browse the repository at this point in the history
* Work in Progress

* Add new config option

* Work in progress

* Shopify token middleware

* Remove redundant command

* Removed debugging code

* Revert some changes back to master versions

* No longer need the JWT env option

* Reverted newlines

* Removed custom package

* Expiration bugfix

* Add root api route

* Split routes

* Authentication token tests

* Nove logic

* Update unit tests

* Update unit tests

* Reverted composer

* Removed

* Lint fix

* Drop php 7.2

* Only use legacy factories when Laravel 8 is in use

* Remove legacy package

* Add docblock for helpers

* Missing docblock

* Style CI fixes

* Style CI fixes

* Force the middleware

* Updated to throw exceptions instead of a plain response on token errors

* Exception handler for bad tokens

* Style CI fixes

* trigger GitHub actions

* Make sure the expiration in the test tokens is in the future
  • Loading branch information
darrynten authored Oct 15, 2020
1 parent 6de9907 commit a1d6371
Show file tree
Hide file tree
Showing 24 changed files with 1,392 additions and 56 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: composer validate --strict

- name: Install Laravel legacy factories support
if: matrix.php > '7.2' && matrix.laravel == '8.0'
if: matrix.laravel == '8.0'
run: composer require "laravel/legacy-factories:^1.0" --no-interaction --no-update

- name: Install Laravel and Orchestra Testbench
Expand All @@ -62,7 +62,7 @@ jobs:
run: vendor/bin/phpunit

- name: Upload coverage results
if: matrix.php == '7.4' && matrix.laravel == '7.0'
if: matrix.php == '7.4' && matrix.laravel == '8.0'
env:
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
2 changes: 2 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,7 @@
<logging/>
<php>
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
<env name="SHOPIFY_API_KEY" value="00000000000000000000000000000000"/>
<env name="SHOPIFY_API_SECRET" value="00000000000000000000000000000000"/>
</php>
</phpunit>
43 changes: 25 additions & 18 deletions src/ShopifyApp/Actions/AuthorizeShop.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,34 +75,41 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): stdClass
$this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null));
$shop = $this->shopQuery->getByDomain($shopDomain);
}
$apiHelper = $shop->apiHelper();

// Return data
$return = [
'completed' => false,
'url' => null,
];

// Start the process
$apiHelper = $shop->apiHelper();

// Access/grant mode
$grantMode = $shop->hasOfflineAccess() ?
AuthMode::fromNative($this->getConfig('api_grant_mode')) :
AuthMode::OFFLINE();

$return['url'] = $apiHelper->buildAuthUrl($grantMode, $this->getConfig('api_scopes'));

// If there's no code
if (empty($code)) {
// Access/grant mode
$grantMode = $shop->hasOfflineAccess() ?
AuthMode::fromNative($this->getConfig('api_grant_mode')) :
AuthMode::OFFLINE();

// Call the partial callback with the shop and auth URL as params
$return['url'] = $apiHelper->buildAuthUrl($grantMode, $this->getConfig('api_scopes'));
} else {
// 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());
$this->shopSession->setAccess($apiHelper->getAccessData($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;
Expand Down
20 changes: 20 additions & 0 deletions src/ShopifyApp/Exceptions/HttpException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Osiset\ShopifyApp\Exceptions;

/**
* Exception for use in requests that need http responses.
*/
class HttpException extends BaseException
{
public function render($request)
{
if ($request->expectsJson()) {
return response()->json([
'error' => $this->getMessage(),
], $this->getCode());
}

return response($this->getMessage(), $this->getCode());
}
}
24 changes: 24 additions & 0 deletions src/ShopifyApp/Http/Controllers/ApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Osiset\ShopifyApp\Http\Controllers;

use Illuminate\Routing\Controller;
use Osiset\ShopifyApp\Traits\ApiController as ApiControllerTrait;

/**
* Authenticates with a JWT through auth.token Middleware.
*/
class ApiController extends Controller
{
use ApiControllerTrait;

/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth.token');
}
}
129 changes: 129 additions & 0 deletions src/ShopifyApp/Http/Middleware/AuthToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace Osiset\ShopifyApp\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use function Osiset\ShopifyApp\base64url_decode;
use function Osiset\ShopifyApp\base64url_encode;
use Osiset\ShopifyApp\Exceptions\HttpException;
use Osiset\ShopifyApp\Objects\Values\ShopDomain;
use Osiset\ShopifyApp\Services\ShopSession;
use Osiset\ShopifyApp\Traits\ConfigAccessible;

class AuthToken
{
use ConfigAccessible;

/**
* The shop session helper.
*
* @var ShopSession
*/
protected $shopSession;

/**
* Constructor.
*
* @param ShopSession $shopSession The shop session helper.
*
* @return void
*/
public function __construct(ShopSession $shopSession)
{
$this->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 = base64url_decode($parts[1]);
$signature = $parts[2];

$body = json_decode($body);

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 !== $this->getConfig('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)
{
$parts = explode('.', $token);
$signature = array_pop($parts);
$check = implode('.', $parts);

$secret = $this->getConfig('api_secret');
$hmac = hash_hmac('sha256', $check, $secret, true);
$encoded = base64url_encode($hmac);

return $encoded === $signature;
}
}
2 changes: 1 addition & 1 deletion src/ShopifyApp/Http/Middleware/Billable.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function handle(Request $request, Closure $next)
$shop = $this->shopSession->getShop();
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('billing');
return Redirect::route('billing', $request->input());
}
}

Expand Down
1 change: 1 addition & 0 deletions src/ShopifyApp/Services/ApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public function make(Session $session = null): self
{
// Create the options
$opts = new Options();

$opts->setApiKey($this->getConfig('api_key'));
$opts->setApiSecret($this->getConfig('api_secret'));
$opts->setVersion($this->getConfig('api_version'));
Expand Down
5 changes: 4 additions & 1 deletion src/ShopifyApp/ShopifyAppProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery;
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\Messaging\Jobs\ScripttagInstaller;
Expand Down Expand Up @@ -255,7 +256,8 @@ public function register()
*/
private function bootRoutes(): void
{
$this->loadRoutesFrom(__DIR__.'/resources/routes.php');
$this->loadRoutesFrom(__DIR__.'/resources/routes/shopify.php');
$this->loadRoutesFrom(__DIR__.'/resources/routes/api.php');
}

/**
Expand Down Expand Up @@ -352,6 +354,7 @@ 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('auth.webhook', AuthWebhook::class);
$this->app['router']->aliasMiddleware('auth.proxy', AuthProxy::class);
$this->app['router']->aliasMiddleware('billable', Billable::class);
Expand Down
48 changes: 48 additions & 0 deletions src/ShopifyApp/Traits/ApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Osiset\ShopifyApp\Traits;

use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Osiset\ShopifyApp\Storage\Models\Plan;

/**
* Responsible for showing the main homescreen for the app.
*/
trait ApiController
{
/**
* 200 Response.
*
* @return JsonResponse
*/
public function index(): JsonResponse
{
return response()->json();
}

/**
* Returns authenticated users details.
*
* @return JsonResponse
*/
public function getSelf(): JsonResponse
{
return response()->json(Auth::user()->only([
'name',
'shopify_grandfathered',
'shopify_freemium',
'plan',
]));
}

/**
* Returns currently available plans.
*
* @return JsonResponse
*/
public function getPlans(): JsonResponse
{
return response()->json(Plan::all());
}
}
30 changes: 30 additions & 0 deletions src/ShopifyApp/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,36 @@ function parseQueryString(string $qs, string $d = null): array
return $params;
}

/**
* URL-safe Base64 encoding.
*
* Replaces `+` with `-` and `/` with `_` and trims padding `=`.
*
* @param string $data The data to be encoded.
*
* @return string
*/
function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
* URL-safe Base64 decoding.
*
* Replaces `-` with `+` and `_` with `/`.
*
* Adds padding `=` if needed.
*
* @param string $data The data to be decoded.
*
* @return string
*/
function base64url_decode($data)
{
return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
}

/**
* Checks if the route should be registered or not.
*
Expand Down
Loading

0 comments on commit a1d6371

Please sign in to comment.