1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* Copyright (c) 2016, 2017 François Kooman <[email protected]>. |
5
|
|
|
* |
6
|
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy |
7
|
|
|
* of this software and associated documentation files (the "Software"), to deal |
8
|
|
|
* in the Software without restriction, including without limitation the rights |
9
|
|
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
10
|
|
|
* copies of the Software, and to permit persons to whom the Software is |
11
|
|
|
* furnished to do so, subject to the following conditions: |
12
|
|
|
* |
13
|
|
|
* The above copyright notice and this permission notice shall be included in all |
14
|
|
|
* copies or substantial portions of the Software. |
15
|
|
|
* |
16
|
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
17
|
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
18
|
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
19
|
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
20
|
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
21
|
|
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
22
|
|
|
* SOFTWARE. |
23
|
|
|
*/ |
24
|
|
|
|
25
|
|
|
namespace fkooman\OAuth\Client; |
26
|
|
|
|
27
|
|
|
use DateTime; |
28
|
|
|
use fkooman\OAuth\Client\Exception\OAuthException; |
29
|
|
|
use fkooman\OAuth\Client\Exception\OAuthServerException; |
30
|
|
|
use fkooman\OAuth\Client\Http\HttpClientInterface; |
31
|
|
|
use fkooman\OAuth\Client\Http\Request; |
32
|
|
|
use fkooman\OAuth\Client\Http\Response; |
33
|
|
|
use ParagonIE\ConstantTime\Base64; |
34
|
|
|
use Psr\Log\LoggerInterface; |
35
|
|
|
use Psr\Log\NullLogger; |
36
|
|
|
|
37
|
|
|
class OAuthClient |
38
|
|
|
{ |
39
|
|
|
/** @var TokenStorageInterface */ |
40
|
|
|
private $tokenStorage; |
41
|
|
|
|
42
|
|
|
/** @var \fkooman\OAuth\Client\Http\HttpClientInterface */ |
43
|
|
|
private $httpClient; |
44
|
|
|
|
45
|
|
|
/** @var SessionInterface */ |
46
|
|
|
private $session; |
47
|
|
|
|
48
|
|
|
/** @var RandomInterface */ |
49
|
|
|
private $random; |
50
|
|
|
|
51
|
|
|
/** @var \Psr\Log\LoggerInterface */ |
52
|
|
|
private $logger; |
53
|
|
|
|
54
|
|
|
/** @var \DateTime */ |
55
|
|
|
private $dateTime; |
56
|
|
|
|
57
|
|
|
/** @var array */ |
58
|
|
|
private $providerList = []; |
59
|
|
|
|
60
|
|
|
/** @var string */ |
61
|
|
|
private $providerId = null; |
62
|
|
|
|
63
|
|
|
/** @var string|null */ |
64
|
|
|
private $userId = null; |
65
|
|
|
|
66
|
|
|
/** |
67
|
|
|
* @param TokenStorageInterface $tokenStorage |
68
|
|
|
* @param Http\HttpClientInterface $httpClient |
69
|
|
|
*/ |
70
|
|
|
public function __construct(TokenStorageInterface $tokenStorage, HttpClientInterface $httpClient) |
71
|
|
|
{ |
72
|
|
|
$this->tokenStorage = $tokenStorage; |
73
|
|
|
$this->httpClient = $httpClient; |
74
|
|
|
|
75
|
|
|
$this->session = new Session(); |
76
|
|
|
$this->random = new Random(); |
77
|
|
|
$this->logger = new NullLogger(); |
78
|
|
|
$this->dateTime = new DateTime(); |
79
|
|
|
} |
80
|
|
|
|
81
|
|
|
/** |
82
|
|
|
* @param string $providerId |
83
|
|
|
* @param Provider $provider |
84
|
|
|
*/ |
85
|
|
|
public function addProvider($providerId, Provider $provider) |
86
|
|
|
{ |
87
|
|
|
$this->providerList[$providerId] = $provider; |
88
|
|
|
// the first provider we add becomes the active provider, can be |
89
|
|
|
// overridden by the "setProviderId" method |
90
|
|
|
if (1 === count($this->providerList)) { |
91
|
|
|
$this->providerId = $providerId; |
92
|
|
|
} |
93
|
|
|
} |
94
|
|
|
|
95
|
|
|
/** |
96
|
|
|
* @param string $providerId |
97
|
|
|
*/ |
98
|
|
|
public function setProviderId($providerId) |
99
|
|
|
{ |
100
|
|
|
if (!array_key_exists($providerId, $this->providerList)) { |
101
|
|
|
throw new OAuthException(sprintf('provider with providerId "%s" does not exist', $this->providerId)); |
102
|
|
|
} |
103
|
|
|
} |
104
|
|
|
|
105
|
|
|
/** |
106
|
|
|
* @param SessionInterface $session |
107
|
|
|
*/ |
108
|
|
|
public function setSession(SessionInterface $session) |
109
|
|
|
{ |
110
|
|
|
$this->session = $session; |
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
/** |
114
|
|
|
* @param RandomInterface $random |
115
|
|
|
*/ |
116
|
|
|
public function setRandom(RandomInterface $random) |
117
|
|
|
{ |
118
|
|
|
$this->random = $random; |
119
|
|
|
} |
120
|
|
|
|
121
|
|
|
/** |
122
|
|
|
* @param LoggerInterface $logger |
123
|
|
|
*/ |
124
|
|
|
public function setLogger(LoggerInterface $logger) |
125
|
|
|
{ |
126
|
|
|
$this->logger = $logger; |
127
|
|
|
} |
128
|
|
|
|
129
|
|
|
/** |
130
|
|
|
* @param DateTime $dateTime |
131
|
|
|
*/ |
132
|
|
|
public function setDateTime(DateTime $dateTime) |
133
|
|
|
{ |
134
|
|
|
$this->dateTime = $dateTime; |
135
|
|
|
} |
136
|
|
|
|
137
|
|
|
/** |
138
|
|
|
* @param string $userId |
139
|
|
|
*/ |
140
|
|
|
public function setUserId($userId) |
141
|
|
|
{ |
142
|
|
|
$this->userId = $userId; |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
/** |
146
|
|
|
* Perform a GET request, convenience wrapper for ::send(). |
147
|
|
|
* |
148
|
|
|
* @param string $requestScope |
149
|
|
|
* @param string $requestUri |
150
|
|
|
* @param array $requestHeaders |
151
|
|
|
* |
152
|
|
|
* @return Http\Response|false |
153
|
|
|
*/ |
154
|
|
|
public function get($requestScope, $requestUri, array $requestHeaders = []) |
155
|
|
|
{ |
156
|
|
|
return $this->send($requestScope, Request::get($requestUri, $requestHeaders)); |
157
|
|
|
} |
158
|
|
|
|
159
|
|
|
/** |
160
|
|
|
* Perform a POST request, convenience wrapper for ::send(). |
161
|
|
|
* |
162
|
|
|
* @param string $requestScope |
163
|
|
|
* @param string $requestUri |
164
|
|
|
* @param array $postBody |
165
|
|
|
* @param array $requestHeaders |
166
|
|
|
* |
167
|
|
|
* @return Http\Response|false |
168
|
|
|
*/ |
169
|
|
|
public function post($requestScope, $requestUri, array $postBody, array $requestHeaders = []) |
170
|
|
|
{ |
171
|
|
|
return $this->send($requestScope, Request::post($requestUri, $postBody, $requestHeaders)); |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
/** |
175
|
|
|
* Perform a HTTP request. |
176
|
|
|
* |
177
|
|
|
* @param string $requestScope |
178
|
|
|
* @param Http\Request $request |
179
|
|
|
* |
180
|
|
|
* @return Response|false |
181
|
|
|
*/ |
182
|
|
|
public function send($requestScope, Request $request) |
183
|
|
|
{ |
184
|
|
|
if (is_null($this->userId)) { |
185
|
|
|
throw new OAuthException('userId not set'); |
186
|
|
|
} |
187
|
|
|
|
188
|
|
|
// make sure we have an access token |
189
|
|
View Code Duplication |
if (false === $accessToken = $this->tokenStorage->getAccessToken($this->userId, $this->providerId, $requestScope)) { |
|
|
|
|
190
|
|
|
$this->logger->info(sprintf('no access_token available for user "%s" with scope "%s"', $this->userId, $requestScope)); |
191
|
|
|
|
192
|
|
|
return false; |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
if ($requestScope !== $accessToken->getScope()) { |
196
|
|
|
throw new OAuthException('access_token does not have the required scope'); |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
$refreshedToken = false; |
200
|
|
|
if ($accessToken->isExpired($this->dateTime)) { |
201
|
|
|
$this->logger->info(sprintf('access_token for user "%s" with scope "%s" expired', $this->userId, $requestScope)); |
202
|
|
|
// access_token is expired, try to refresh it |
203
|
|
View Code Duplication |
if (is_null($accessToken->getRefreshToken())) { |
|
|
|
|
204
|
|
|
$this->logger->info(sprintf('no refresh_token available in this access_token for user "%s" with scope "%s", deleting it', $this->userId, $requestScope)); |
205
|
|
|
// we do not have a refresh_token, delete this access token, it |
206
|
|
|
// is useless now... |
207
|
|
|
$this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken); |
208
|
|
|
|
209
|
|
|
return false; |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
$this->logger->info(sprintf('using refresh_token to obtain new access_token for user "%s" with scope "%s"', $this->userId, $requestScope)); |
213
|
|
|
|
214
|
|
|
try { |
215
|
|
|
// delete the old one, and use it to try to get a new one |
216
|
|
|
$this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken); |
217
|
|
|
$accessToken = $this->refreshAccessToken($accessToken); |
218
|
|
|
} catch (OAuthServerException $e) { |
219
|
|
|
$this->logger->info(sprintf('deleting access_token as refresh_token for user "%s" with scope "%s" was not accepted by the authorization server: "%s"', $this->userId, $requestScope, $e->getMessage())); |
220
|
|
|
|
221
|
|
|
return false; |
222
|
|
|
} |
223
|
|
|
$this->logger->info(sprintf('got a new access_token using the refresh_token for user "%s" with scope "%s"', $this->userId, $requestScope)); |
224
|
|
|
$refreshedToken = true; |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
// add Authorization header to the request headers |
228
|
|
|
$request->setHeader('Authorization', sprintf('Bearer %s', $accessToken->getToken())); |
229
|
|
|
|
230
|
|
|
$response = $this->httpClient->send($request); |
231
|
|
View Code Duplication |
if (401 === $response->getStatusCode()) { |
|
|
|
|
232
|
|
|
$this->logger->info(sprintf('deleting access_token for user "%s" with scope "%s" that was supposed to work, but did not, possibly revoked by user', $this->userId, $requestScope)); |
233
|
|
|
// this indicates an invalid access_token |
234
|
|
|
$this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken); |
235
|
|
|
|
236
|
|
|
return false; |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
$this->logger->info(sprintf('access_token for use "%s" with scope "%s" successfully used', $this->userId, $requestScope)); |
240
|
|
|
|
241
|
|
|
if ($refreshedToken) { |
242
|
|
|
$this->logger->info(sprintf('storing refreshed access_token for user "%s" with scope "%s" as it was successfully used', $this->userId, $requestScope)); |
243
|
|
|
// if we refreshed the token, and it was successful, i.e. not a 401, |
244
|
|
|
// update the stored AccessToken |
245
|
|
|
$this->tokenStorage->setAccessToken($this->userId, $this->providerId, $accessToken); |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
return $response; |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
/** |
252
|
|
|
* Obtain an authorization request URL to start the authorization process |
253
|
|
|
* at the OAuth provider. |
254
|
|
|
* |
255
|
|
|
* @param string $scope the space separated scope tokens |
256
|
|
|
* @param string $redirectUri the URL registered at the OAuth provider, to |
257
|
|
|
* be redirected back to |
258
|
|
|
* |
259
|
|
|
* @return string the authorization request URL |
260
|
|
|
* |
261
|
|
|
* @see https://tools.ietf.org/html/rfc6749#section-3.3 |
262
|
|
|
* @see https://tools.ietf.org/html/rfc6749#section-3.1.2 |
263
|
|
|
*/ |
264
|
|
|
public function getAuthorizeUri($scope, $redirectUri) |
265
|
|
|
{ |
266
|
|
|
$queryParameters = [ |
267
|
|
|
'client_id' => $this->getActiveProvider()->getId(), |
268
|
|
|
'redirect_uri' => $redirectUri, |
269
|
|
|
'scope' => $scope, |
270
|
|
|
'state' => $this->random->get(16), |
271
|
|
|
'response_type' => 'code', |
272
|
|
|
]; |
273
|
|
|
|
274
|
|
|
$authorizeUri = sprintf( |
275
|
|
|
'%s%s%s', |
276
|
|
|
$this->getActiveProvider()->getAuthorizationEndpoint(), |
277
|
|
|
false === strpos($this->getActiveProvider()->getAuthorizationEndpoint(), '?') ? '?' : '&', |
278
|
|
|
http_build_query($queryParameters, '&') |
279
|
|
|
); |
280
|
|
|
$this->session->set('_oauth2_session', array_merge($queryParameters, ['provider_id' => $this->providerId])); |
281
|
|
|
|
282
|
|
|
return $authorizeUri; |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
/** |
286
|
|
|
* @param string $responseCode the code passed to the "code" |
287
|
|
|
* query parameter on the callback URL |
288
|
|
|
* @param string $responseState the state passed to the "state" |
289
|
|
|
* query parameter on the callback URL |
290
|
|
|
*/ |
291
|
|
|
public function handleCallback($responseCode, $responseState) |
292
|
|
|
{ |
293
|
|
|
if (is_null($this->userId)) { |
294
|
|
|
throw new OAuthException('userId not set'); |
295
|
|
|
} |
296
|
|
|
|
297
|
|
|
$sessionData = $this->session->get('_oauth2_session'); |
298
|
|
|
$this->setProviderId($sessionData['provider_id']); |
299
|
|
|
|
300
|
|
|
// delete the session, we don't want it to be used multiple times... |
301
|
|
|
$this->session->del('_oauth2_session'); |
302
|
|
|
|
303
|
|
|
if ($responseState !== $sessionData['state']) { |
304
|
|
|
// the OAuth state from the initial request MUST be the same as the |
305
|
|
|
// state used by the response |
306
|
|
|
throw new OAuthException('invalid OAuth state'); |
307
|
|
|
} |
308
|
|
|
|
309
|
|
|
if ($sessionData['client_id'] !== $this->getActiveProvider()->getId()) { |
310
|
|
|
// the client_id used for the initial request differs from the |
311
|
|
|
// currently configured Provider, the client_id MUST be identical |
312
|
|
|
throw new OAuthException('unexpected client identifier'); |
313
|
|
|
} |
314
|
|
|
|
315
|
|
|
// prepare access_token request |
316
|
|
|
$tokenRequestData = [ |
317
|
|
|
'client_id' => $this->getActiveProvider()->getId(), |
318
|
|
|
'grant_type' => 'authorization_code', |
319
|
|
|
'code' => $responseCode, |
320
|
|
|
'redirect_uri' => $sessionData['redirect_uri'], |
321
|
|
|
]; |
322
|
|
|
|
323
|
|
|
$response = $this->httpClient->send( |
324
|
|
|
Request::post( |
325
|
|
|
$this->getActiveProvider()->getTokenEndpoint(), |
326
|
|
|
$tokenRequestData, |
327
|
|
|
[ |
328
|
|
|
'Authorization' => sprintf( |
329
|
|
|
'Basic %s', |
330
|
|
|
Base64::encode( |
331
|
|
|
sprintf('%s:%s', $this->getActiveProvider()->getId(), $this->getActiveProvider()->getSecret()) |
332
|
|
|
) |
333
|
|
|
), |
334
|
|
|
] |
335
|
|
|
) |
336
|
|
|
); |
337
|
|
|
|
338
|
|
|
$this->tokenStorage->setAccessToken( |
339
|
|
|
$this->userId, |
340
|
|
|
$this->providerId, |
341
|
|
|
AccessToken::fromCodeResponse( |
342
|
|
|
$this->dateTime, |
343
|
|
|
$response, |
344
|
|
|
// in case server does not return a scope, we know it granted our requested scope |
345
|
|
|
$sessionData['scope'] |
346
|
|
|
) |
347
|
|
|
); |
348
|
|
|
} |
349
|
|
|
|
350
|
|
|
/** |
351
|
|
|
* @param AccessToken $accessToken the current AccessToken |
352
|
|
|
* |
353
|
|
|
* @return AccessToken the refreshed AccessToken |
354
|
|
|
*/ |
355
|
|
|
private function refreshAccessToken(AccessToken $accessToken) |
356
|
|
|
{ |
357
|
|
|
// prepare access_token request |
358
|
|
|
$tokenRequestData = [ |
359
|
|
|
'grant_type' => 'refresh_token', |
360
|
|
|
'refresh_token' => $accessToken->getRefreshToken(), |
361
|
|
|
'scope' => $accessToken->getScope(), |
362
|
|
|
]; |
363
|
|
|
|
364
|
|
|
$response = $this->httpClient->send( |
365
|
|
|
Request::post( |
366
|
|
|
$this->getActiveProvider()->getTokenEndpoint(), |
367
|
|
|
$tokenRequestData, |
368
|
|
|
[ |
369
|
|
|
'Authorization' => sprintf( |
370
|
|
|
'Basic %s', |
371
|
|
|
Base64::encode( |
372
|
|
|
sprintf('%s:%s', $this->getActiveProvider()->getId(), $this->getActiveProvider()->getSecret()) |
373
|
|
|
) |
374
|
|
|
), |
375
|
|
|
] |
376
|
|
|
) |
377
|
|
|
); |
378
|
|
|
|
379
|
|
|
return AccessToken::fromRefreshResponse( |
380
|
|
|
$this->dateTime, |
381
|
|
|
$response, |
382
|
|
|
// provide the old AccessToken to borrow some fields if the server |
383
|
|
|
// does not provide them on "refresh" |
384
|
|
|
$accessToken |
385
|
|
|
); |
386
|
|
|
} |
387
|
|
|
|
388
|
|
|
/** |
389
|
|
|
* Get the active OAuth provider. |
390
|
|
|
* |
391
|
|
|
* @return Provider |
392
|
|
|
*/ |
393
|
|
|
private function getActiveProvider() |
394
|
|
|
{ |
395
|
|
|
return $this->providerList[$this->providerId]; |
396
|
|
|
} |
397
|
|
|
} |
398
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.