Skip to content

Commit a1d6371

Browse files
authored
JWT Support (gnikyt#601)
* 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
1 parent 6de9907 commit a1d6371

24 files changed

+1392
-56
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
run: composer validate --strict
4242

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

4747
- name: Install Laravel and Orchestra Testbench
@@ -62,7 +62,7 @@ jobs:
6262
run: vendor/bin/phpunit
6363

6464
- name: Upload coverage results
65-
if: matrix.php == '7.4' && matrix.laravel == '7.0'
65+
if: matrix.php == '7.4' && matrix.laravel == '8.0'
6666
env:
6767
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6868
run: |

phpunit.xml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,7 @@
4141
<logging/>
4242
<php>
4343
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
44+
<env name="SHOPIFY_API_KEY" value="00000000000000000000000000000000"/>
45+
<env name="SHOPIFY_API_SECRET" value="00000000000000000000000000000000"/>
4446
</php>
4547
</phpunit>

src/ShopifyApp/Actions/AuthorizeShop.php

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,34 +75,41 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): stdClass
7575
$this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null));
7676
$shop = $this->shopQuery->getByDomain($shopDomain);
7777
}
78-
$apiHelper = $shop->apiHelper();
7978

8079
// Return data
8180
$return = [
8281
'completed' => false,
8382
'url' => null,
8483
];
8584

86-
// Start the process
85+
$apiHelper = $shop->apiHelper();
86+
87+
// Access/grant mode
88+
$grantMode = $shop->hasOfflineAccess() ?
89+
AuthMode::fromNative($this->getConfig('api_grant_mode')) :
90+
AuthMode::OFFLINE();
91+
92+
$return['url'] = $apiHelper->buildAuthUrl($grantMode, $this->getConfig('api_scopes'));
93+
94+
// If there's no code
8795
if (empty($code)) {
88-
// Access/grant mode
89-
$grantMode = $shop->hasOfflineAccess() ?
90-
AuthMode::fromNative($this->getConfig('api_grant_mode')) :
91-
AuthMode::OFFLINE();
92-
93-
// Call the partial callback with the shop and auth URL as params
94-
$return['url'] = $apiHelper->buildAuthUrl($grantMode, $this->getConfig('api_scopes'));
95-
} else {
96-
// if the store has been deleted, restore the store to set the access token
97-
if ($shop->trashed()) {
98-
$shop->restore();
99-
}
100-
101-
// We have a good code, get the access details
102-
$this->shopSession->make($shop->getDomain());
103-
$this->shopSession->setAccess($apiHelper->getAccessData($code));
96+
return (object) $return;
97+
}
98+
99+
// if the store has been deleted, restore the store to set the access token
100+
if ($shop->trashed()) {
101+
$shop->restore();
102+
}
104103

104+
// We have a good code, get the access details
105+
$this->shopSession->make($shop->getDomain());
106+
107+
try {
108+
$this->shopSession->setAccess($apiHelper->getAccessData($code));
109+
$return['url'] = null;
105110
$return['completed'] = true;
111+
} catch (\Exception $e) {
112+
// Just return the default setting
106113
}
107114

108115
return (object) $return;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Osiset\ShopifyApp\Exceptions;
4+
5+
/**
6+
* Exception for use in requests that need http responses.
7+
*/
8+
class HttpException extends BaseException
9+
{
10+
public function render($request)
11+
{
12+
if ($request->expectsJson()) {
13+
return response()->json([
14+
'error' => $this->getMessage(),
15+
], $this->getCode());
16+
}
17+
18+
return response($this->getMessage(), $this->getCode());
19+
}
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Osiset\ShopifyApp\Http\Controllers;
4+
5+
use Illuminate\Routing\Controller;
6+
use Osiset\ShopifyApp\Traits\ApiController as ApiControllerTrait;
7+
8+
/**
9+
* Authenticates with a JWT through auth.token Middleware.
10+
*/
11+
class ApiController extends Controller
12+
{
13+
use ApiControllerTrait;
14+
15+
/**
16+
* Create a new controller instance.
17+
*
18+
* @return void
19+
*/
20+
public function __construct()
21+
{
22+
$this->middleware('auth.token');
23+
}
24+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Osiset\ShopifyApp\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use function Osiset\ShopifyApp\base64url_decode;
8+
use function Osiset\ShopifyApp\base64url_encode;
9+
use Osiset\ShopifyApp\Exceptions\HttpException;
10+
use Osiset\ShopifyApp\Objects\Values\ShopDomain;
11+
use Osiset\ShopifyApp\Services\ShopSession;
12+
use Osiset\ShopifyApp\Traits\ConfigAccessible;
13+
14+
class AuthToken
15+
{
16+
use ConfigAccessible;
17+
18+
/**
19+
* The shop session helper.
20+
*
21+
* @var ShopSession
22+
*/
23+
protected $shopSession;
24+
25+
/**
26+
* Constructor.
27+
*
28+
* @param ShopSession $shopSession The shop session helper.
29+
*
30+
* @return void
31+
*/
32+
public function __construct(ShopSession $shopSession)
33+
{
34+
$this->shopSession = $shopSession;
35+
}
36+
37+
/**
38+
* Handle an incoming request.
39+
*
40+
* Get the bearer token, validate and verify, and create a
41+
* session based on the contents.
42+
*
43+
* The token is "url safe" (`+` is `-` and `/` is `_`) base64.
44+
*
45+
* @param Request $request The request object.
46+
* @param \Closure $next The next action.
47+
*
48+
* @return mixed
49+
*/
50+
public function handle(Request $request, Closure $next)
51+
{
52+
$now = time();
53+
54+
$token = $request->bearerToken();
55+
56+
if (! $token) {
57+
throw new HttpException('Missing authentication token', 401);
58+
}
59+
60+
// The header is fixed so include it here
61+
if (! preg_match('/^eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9\-\_=]+\.[A-Za-z0-9\-\_\=]*$/', $token)) {
62+
throw new HttpException('Malformed token', 400);
63+
}
64+
65+
if (! $this->checkSignature($token)) {
66+
throw new HttpException('Unable to verify signature', 400);
67+
}
68+
69+
$parts = explode('.', $token);
70+
71+
$body = base64url_decode($parts[1]);
72+
$signature = $parts[2];
73+
74+
$body = json_decode($body);
75+
76+
if (! $body ||
77+
! isset($body->iss) ||
78+
! isset($body->dest) ||
79+
! isset($body->aud) ||
80+
! isset($body->sub) ||
81+
! isset($body->exp) ||
82+
! isset($body->nbf) ||
83+
! isset($body->iat) ||
84+
! isset($body->jti) ||
85+
! isset($body->sid)) {
86+
throw new HttpException('Malformed token', 400);
87+
}
88+
89+
if (($now > $body->exp) || ($now < $body->nbf) || ($now < $body->iat)) {
90+
throw new HttpException('Expired token', 403);
91+
}
92+
93+
if (! stristr($body->iss, $body->dest)) {
94+
throw new HttpException('Invalid token', 400);
95+
}
96+
97+
if ($body->aud !== $this->getConfig('api_key')) {
98+
throw new HttpException('Invalid token', 400);
99+
}
100+
101+
// All is well, login
102+
$url = parse_url($body->dest);
103+
104+
$this->shopSession->make(ShopDomain::fromNative($url['host']));
105+
$this->shopSession->setSessionToken($body->sid);
106+
107+
return $next($request);
108+
}
109+
110+
/**
111+
* Checks the validity of the signature sent with the token.
112+
*
113+
* @param string $token The token to check.
114+
*
115+
* @return bool
116+
*/
117+
private function checkSignature($token)
118+
{
119+
$parts = explode('.', $token);
120+
$signature = array_pop($parts);
121+
$check = implode('.', $parts);
122+
123+
$secret = $this->getConfig('api_secret');
124+
$hmac = hash_hmac('sha256', $check, $secret, true);
125+
$encoded = base64url_encode($hmac);
126+
127+
return $encoded === $signature;
128+
}
129+
}

src/ShopifyApp/Http/Middleware/Billable.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function handle(Request $request, Closure $next)
4848
$shop = $this->shopSession->getShop();
4949
if (! $shop->isFreemium() && ! $shop->isGrandfathered() && ! $shop->plan) {
5050
// They're not grandfathered in, and there is no charge or charge was declined... redirect to billing
51-
return Redirect::route('billing');
51+
return Redirect::route('billing', $request->input());
5252
}
5353
}
5454

src/ShopifyApp/Services/ApiHelper.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public function make(Session $session = null): self
4141
{
4242
// Create the options
4343
$opts = new Options();
44+
4445
$opts->setApiKey($this->getConfig('api_key'));
4546
$opts->setApiSecret($this->getConfig('api_secret'));
4647
$opts->setVersion($this->getConfig('api_version'));

src/ShopifyApp/ShopifyAppProvider.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery;
2727
use Osiset\ShopifyApp\Http\Middleware\AuthProxy;
2828
use Osiset\ShopifyApp\Http\Middleware\AuthShopify;
29+
use Osiset\ShopifyApp\Http\Middleware\AuthToken;
2930
use Osiset\ShopifyApp\Http\Middleware\AuthWebhook;
3031
use Osiset\ShopifyApp\Http\Middleware\Billable;
3132
use Osiset\ShopifyApp\Messaging\Jobs\ScripttagInstaller;
@@ -255,7 +256,8 @@ public function register()
255256
*/
256257
private function bootRoutes(): void
257258
{
258-
$this->loadRoutesFrom(__DIR__.'/resources/routes.php');
259+
$this->loadRoutesFrom(__DIR__.'/resources/routes/shopify.php');
260+
$this->loadRoutesFrom(__DIR__.'/resources/routes/api.php');
259261
}
260262

261263
/**
@@ -352,6 +354,7 @@ private function bootMiddlewares(): void
352354
{
353355
// Middlewares
354356
$this->app['router']->aliasMiddleware('auth.shopify', AuthShopify::class);
357+
$this->app['router']->aliasMiddleware('auth.token', AuthToken::class);
355358
$this->app['router']->aliasMiddleware('auth.webhook', AuthWebhook::class);
356359
$this->app['router']->aliasMiddleware('auth.proxy', AuthProxy::class);
357360
$this->app['router']->aliasMiddleware('billable', Billable::class);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Osiset\ShopifyApp\Traits;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Support\Facades\Auth;
7+
use Osiset\ShopifyApp\Storage\Models\Plan;
8+
9+
/**
10+
* Responsible for showing the main homescreen for the app.
11+
*/
12+
trait ApiController
13+
{
14+
/**
15+
* 200 Response.
16+
*
17+
* @return JsonResponse
18+
*/
19+
public function index(): JsonResponse
20+
{
21+
return response()->json();
22+
}
23+
24+
/**
25+
* Returns authenticated users details.
26+
*
27+
* @return JsonResponse
28+
*/
29+
public function getSelf(): JsonResponse
30+
{
31+
return response()->json(Auth::user()->only([
32+
'name',
33+
'shopify_grandfathered',
34+
'shopify_freemium',
35+
'plan',
36+
]));
37+
}
38+
39+
/**
40+
* Returns currently available plans.
41+
*
42+
* @return JsonResponse
43+
*/
44+
public function getPlans(): JsonResponse
45+
{
46+
return response()->json(Plan::all());
47+
}
48+
}

0 commit comments

Comments
 (0)