Skip to content

Commit 4bb425a

Browse files
committed
LYNX-319: 401 and 403 HTTP response codes for GraphQL API
1 parent 881ca23 commit 4bb425a

File tree

4 files changed

+348
-3
lines changed

4 files changed

+348
-3
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
/************************************************************************
3+
*
4+
* Copyright 2023 Adobe
5+
* All Rights Reserved.
6+
*
7+
* NOTICE: All information contained herein is, and remains
8+
* the property of Adobe and its suppliers, if any. The intellectual
9+
* and technical concepts contained herein are proprietary to Adobe
10+
* and its suppliers and are protected by all applicable intellectual
11+
* property laws, including trade secret and copyright laws.
12+
* Dissemination of this information or reproduction of this material
13+
* is strictly forbidden unless prior written permission is obtained
14+
* from Adobe.
15+
* ***********************************************************************
16+
*/
17+
declare(strict_types=1);
18+
19+
namespace Magento\CustomerGraphQl\Controller\HttpRequestValidator;
20+
21+
use Magento\Framework\App\HttpRequestInterface;
22+
use Magento\Framework\Exception\AuthorizationException;
23+
use Magento\Framework\GraphQl\Exception\GraphQlAuthenticationException;
24+
use Magento\GraphQl\Controller\HttpRequestValidatorInterface;
25+
use Magento\Integration\Api\Exception\UserTokenException;
26+
use Magento\Integration\Api\UserTokenReaderInterface;
27+
use Magento\Integration\Api\UserTokenValidatorInterface;
28+
29+
/**
30+
* Validate the token if it is present in headers
31+
*/
32+
class AuthorizationRequestValidator implements HttpRequestValidatorInterface
33+
{
34+
/**
35+
* @var UserTokenReaderInterface
36+
*
37+
* phpcs:disable Magento2.Commenting.ClassPropertyPHPDocFormatting
38+
*/
39+
private readonly UserTokenReaderInterface $userTokenReader;
40+
41+
/**
42+
* @var UserTokenValidatorInterface
43+
*
44+
* phpcs:disable Magento2.Commenting.ClassPropertyPHPDocFormatting
45+
*/
46+
private readonly UserTokenValidatorInterface $userTokenValidator;
47+
48+
/**
49+
* @param UserTokenReaderInterface $tokenReader
50+
* @param UserTokenValidatorInterface $tokenValidator
51+
*/
52+
public function __construct(
53+
UserTokenReaderInterface $tokenReader,
54+
UserTokenValidatorInterface $tokenValidator
55+
) {
56+
$this->userTokenReader = $tokenReader;
57+
$this->userTokenValidator = $tokenValidator;
58+
}
59+
60+
/**
61+
* Validate the authorization header bearer token if it is set
62+
*
63+
* @param HttpRequestInterface $request
64+
* @return void
65+
* @throws GraphQlAuthenticationException
66+
*/
67+
public function validate(HttpRequestInterface $request): void
68+
{
69+
$authorizationHeaderValue = $request->getHeader('Authorization');
70+
if (!$authorizationHeaderValue) {
71+
return;
72+
}
73+
74+
$headerPieces = explode(' ', $authorizationHeaderValue);
75+
if (count($headerPieces) !== 2) {
76+
return;
77+
}
78+
79+
$tokenType = strtolower(reset($headerPieces));
80+
if ($tokenType !== 'bearer') {
81+
return;
82+
}
83+
84+
$bearerToken = end($headerPieces);
85+
try {
86+
$token = $this->userTokenReader->read($bearerToken);
87+
} catch (UserTokenException $exception) {
88+
throw new GraphQlAuthenticationException(__($exception->getMessage()));
89+
}
90+
91+
try {
92+
$this->userTokenValidator->validate($token);
93+
} catch (AuthorizationException $exception) {
94+
throw new GraphQlAuthenticationException(__($exception->getMessage()));
95+
}
96+
}
97+
}

app/code/Magento/CustomerGraphQl/etc/graphql/di.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,11 @@
214214
</argument>
215215
</arguments>
216216
</type>
217+
<type name="Magento\GraphQl\Controller\HttpRequestProcessor">
218+
<arguments>
219+
<argument name="requestValidators" xsi:type="array">
220+
<item name="authorizationValidator" xsi:type="object">Magento\CustomerGraphQl\Controller\HttpRequestValidator\AuthorizationRequestValidator</item>
221+
</argument>
222+
</arguments>
223+
</type>
217224
</config>

app/code/Magento/GraphQl/Controller/GraphQl.php

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use Magento\Framework\App\ResponseInterface;
2020
use Magento\Framework\Controller\Result\JsonFactory;
2121
use Magento\Framework\GraphQl\Exception\ExceptionFormatter;
22+
use Magento\Framework\GraphQl\Exception\GraphQlAuthenticationException;
23+
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
2224
use Magento\Framework\GraphQl\Query\Fields as QueryFields;
2325
use Magento\Framework\GraphQl\Query\QueryParser;
2426
use Magento\Framework\GraphQl\Query\QueryProcessor;
@@ -181,10 +183,9 @@ public function dispatch(RequestInterface $request): ResponseInterface
181183
{
182184
$this->areaList->getArea(Area::AREA_GRAPHQL)->load(Area::PART_TRANSLATE);
183185

184-
$statusCode = 200;
185186
$jsonResult = $this->jsonFactory->create();
186187
$data = $this->getDataFromRequest($request);
187-
$result = [];
188+
$result = ['errors' => []];
188189

189190
$schema = null;
190191
try {
@@ -205,8 +206,14 @@ public function dispatch(RequestInterface $request): ResponseInterface
205206
$this->contextFactory->create(),
206207
$data['variables'] ?? []
207208
);
209+
$statusCode = $this->getHttpResponseCode($result);
210+
} catch (GraphQlAuthenticationException $error) {
211+
$result['errors'][] = $this->graphQlError->create($error);
212+
$statusCode = 401;
213+
} catch (GraphQlAuthorizationException $error) {
214+
$result['errors'][] = $this->graphQlError->create($error);
215+
$statusCode = 403;
208216
} catch (\Exception $error) {
209-
$result['errors'] = isset($result['errors']) ? $result['errors'] : [];
210217
$result['errors'][] = $this->graphQlError->create($error);
211218
$statusCode = ExceptionFormatter::HTTP_GRAPH_QL_SCHEMA_ERROR_STATUS;
212219
}
@@ -224,6 +231,32 @@ public function dispatch(RequestInterface $request): ResponseInterface
224231
return $this->httpResponse;
225232
}
226233

234+
/**
235+
* Retrieve http response code based on the error categories
236+
*
237+
* @param array $result
238+
* @return int
239+
*/
240+
private function getHttpResponseCode(array $result): int
241+
{
242+
if (empty($result['errors'])) {
243+
return 200;
244+
}
245+
foreach ($result['errors'] as $error) {
246+
if (!isset($error['extensions']['category'])) {
247+
continue;
248+
}
249+
switch ($error['extensions']['category']) {
250+
case GraphQlAuthenticationException::EXCEPTION_CATEGORY:
251+
return 401;
252+
case GraphQlAuthorizationException::EXCEPTION_CATEGORY:
253+
return 403;
254+
}
255+
}
256+
257+
return 200;
258+
}
259+
227260
/**
228261
* Get data from request body or query string
229262
*
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
<?php
2+
/************************************************************************
3+
*
4+
* Copyright 2023 Adobe
5+
* All Rights Reserved.
6+
*
7+
* NOTICE: All information contained herein is, and remains
8+
* the property of Adobe and its suppliers, if any. The intellectual
9+
* and technical concepts contained herein are proprietary to Adobe
10+
* and its suppliers and are protected by all applicable intellectual
11+
* property laws, including trade secret and copyright laws.
12+
* Dissemination of this information or reproduction of this material
13+
* is strictly forbidden unless prior written permission is obtained
14+
* from Adobe.
15+
* ***********************************************************************
16+
*/
17+
declare(strict_types=1);
18+
19+
namespace Magento\GraphQl\Customer;
20+
21+
use Magento\Customer\Api\CustomerRepositoryInterface;
22+
use Magento\Customer\Api\Data\CustomerInterface;
23+
use Magento\Customer\Test\Fixture\Customer;
24+
use Magento\Integration\Api\CustomerTokenServiceInterface;
25+
use Magento\TestFramework\Fixture\DataFixture;
26+
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
27+
use Magento\TestFramework\Helper\Bootstrap;
28+
use Magento\TestFramework\TestCase\GraphQlAbstract;
29+
use Magento\TestFramework\TestCase\HttpClient\CurlClient;
30+
31+
/**
32+
* Test customer authentication responses
33+
*/
34+
class AuthenticationTest extends GraphQlAbstract
35+
{
36+
private const QUERY_ACCESSIBLE_BY_GUEST = <<<QUERY
37+
{
38+
isEmailAvailable(email: "customer@example.com") {
39+
is_email_available
40+
}
41+
}
42+
QUERY;
43+
44+
private const QUERY_REQUIRE_AUTHENTICATION = <<<QUERY
45+
{
46+
customer {
47+
email
48+
}
49+
}
50+
QUERY;
51+
52+
private $tokenService;
53+
54+
protected function setUp(): void
55+
{
56+
$this->tokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class);
57+
}
58+
59+
public function testNoToken()
60+
{
61+
$response = $this->graphQlQuery(self::QUERY_ACCESSIBLE_BY_GUEST);
62+
63+
self::assertArrayHasKey('isEmailAvailable', $response);
64+
self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']);
65+
}
66+
67+
public function testInvalidToken()
68+
{
69+
$this->expectExceptionCode(401);
70+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
71+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
72+
[
73+
'query' => self::QUERY_ACCESSIBLE_BY_GUEST
74+
],
75+
[
76+
'Authorization: Bearer invalid_token'
77+
]
78+
);
79+
}
80+
81+
#[
82+
DataFixture(Customer::class, as: 'customer'),
83+
]
84+
public function testRevokedTokenPublicQuery()
85+
{
86+
/** @var CustomerInterface $customer */
87+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
88+
$token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password');
89+
90+
$response = $this->graphQlQuery(
91+
self::QUERY_ACCESSIBLE_BY_GUEST,
92+
[],
93+
'',
94+
[
95+
'Authorization' => 'Bearer ' . $token
96+
]
97+
);
98+
99+
self::assertArrayHasKey('isEmailAvailable', $response);
100+
self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']);
101+
102+
$this->tokenService->revokeCustomerAccessToken($customer->getId());
103+
104+
$this->expectExceptionCode(401);
105+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
106+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
107+
[
108+
'query' => self::QUERY_ACCESSIBLE_BY_GUEST
109+
],
110+
[
111+
'Authorization: Bearer ' . $token
112+
]
113+
);
114+
}
115+
116+
#[
117+
DataFixture(Customer::class, as: 'customer'),
118+
]
119+
public function testRevokedTokenProtectedQuery()
120+
{
121+
/** @var CustomerInterface $customer */
122+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
123+
$token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password');
124+
125+
$response = $this->graphQlQuery(
126+
self::QUERY_REQUIRE_AUTHENTICATION,
127+
[],
128+
'',
129+
[
130+
'Authorization' => 'Bearer ' . $token
131+
]
132+
);
133+
134+
self::assertEquals(
135+
[
136+
'customer' => [
137+
'email' => $customer->getEmail()
138+
]
139+
],
140+
$response
141+
);
142+
143+
$this->tokenService->revokeCustomerAccessToken($customer->getId());
144+
145+
$this->expectExceptionCode(401);
146+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
147+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
148+
[
149+
'query' => self::QUERY_REQUIRE_AUTHENTICATION
150+
],
151+
[
152+
'Authorization: Bearer ' . $token
153+
]
154+
);
155+
}
156+
157+
#[
158+
DataFixture(Customer::class, as: 'customer'),
159+
DataFixture(
160+
Customer::class,
161+
[
162+
'addresses' => [
163+
[
164+
'country_id' => 'US',
165+
'region_id' => 32,
166+
'city' => 'Boston',
167+
'street' => ['10 Milk Street'],
168+
'postcode' => '02108',
169+
'telephone' => '1234567890',
170+
'default_billing' => true,
171+
'default_shipping' => true
172+
]
173+
]
174+
],
175+
as: 'customer2'
176+
),
177+
]
178+
public function testForbidden()
179+
{
180+
/** @var CustomerInterface $customer2 */
181+
$customer2Data = DataFixtureStorageManager::getStorage()->get('customer2');
182+
$customer2 = Bootstrap::getObjectManager()
183+
->get(CustomerRepositoryInterface::class)
184+
->get($customer2Data->getEmail());
185+
$addressId = $customer2->getDefaultBilling();
186+
$mutation
187+
= <<<MUTATION
188+
mutation {
189+
deleteCustomerAddress(id: {$addressId})
190+
}
191+
MUTATION;
192+
193+
/** @var CustomerInterface $customer */
194+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
195+
$token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password');
196+
197+
$this->expectExceptionCode(403);
198+
Bootstrap::getObjectManager()->get(CurlClient::class)->post(
199+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
200+
json_encode(['query' => $mutation]),
201+
[
202+
'Authorization: Bearer ' . $token,
203+
'Accept: application/json',
204+
'Content-Type: application/json'
205+
]
206+
);
207+
}
208+
}

0 commit comments

Comments
 (0)