Passed
Pull Request — master (#1412)
by Andrew
09:08
created

DeviceCodeGrant::validateDeviceCode()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 35
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7.457

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 7
nop 2
dl 0
loc 35
ccs 15
cts 19
cp 0.7895
crap 7.457
rs 8.8333
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * OAuth 2.0 Device Code grant.
5
 *
6
 * @author      Andrew Millington <[email protected]>
7
 * @copyright   Copyright (c) Alex Bilbie
8
 * @license     http://mit-license.org/
9
 *
10
 * @link        https://github.com/thephpleague/oauth2-server
11
 */
12
13
declare(strict_types=1);
14
15
namespace League\OAuth2\Server\Grant;
16
17
use DateInterval;
18
use DateTimeImmutable;
19
use Error;
20
use Exception;
21
use League\OAuth2\Server\Entities\ClientEntityInterface;
22
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
23
use League\OAuth2\Server\Entities\ScopeEntityInterface;
24
use League\OAuth2\Server\Exception\OAuthServerException;
25
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
26
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
27
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
28
use League\OAuth2\Server\RequestEvent;
29
use League\OAuth2\Server\ResponseTypes\DeviceCodeResponse;
30
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
31
use Psr\Http\Message\ServerRequestInterface;
32
use TypeError;
33
34
use function is_null;
35
use function random_int;
36
use function strlen;
37
use function time;
38
39
/**
40
 * Device Code grant class.
41
 */
42
class DeviceCodeGrant extends AbstractGrant
43
{
44
    protected DeviceCodeRepositoryInterface $deviceCodeRepository;
45
    private bool $includeVerificationUriComplete = false;
46
    private bool $intervalVisibility = false;
47
    private string $verificationUri;
48
49 17
    public function __construct(
50
        DeviceCodeRepositoryInterface $deviceCodeRepository,
51
        RefreshTokenRepositoryInterface $refreshTokenRepository,
52
        private DateInterval $deviceCodeTTL,
53
        string $verificationUri,
54
        private int $retryInterval = 5
55
    ) {
56 17
        $this->setDeviceCodeRepository($deviceCodeRepository);
57 17
        $this->setRefreshTokenRepository($refreshTokenRepository);
58
59 17
        $this->refreshTokenTTL = new DateInterval('P1M');
60
61 17
        $this->setVerificationUri($verificationUri);
62
    }
63
64
    /**
65
     * {@inheritdoc}
66
     */
67 2
    public function canRespondToDeviceAuthorizationRequest(ServerRequestInterface $request): bool
68
    {
69 2
        return true;
70
    }
71
72
    /**
73
     * {@inheritdoc}
74
     */
75 7
    public function respondToDeviceAuthorizationRequest(ServerRequestInterface $request): DeviceCodeResponse
76
    {
77 7
        $clientId = $this->getRequestParameter(
78 7
            'client_id',
79 7
            $request,
80 7
            $this->getServerParameter('PHP_AUTH_USER', $request)
81 7
        );
82
83 7
        if ($clientId === null) {
84 2
            throw OAuthServerException::invalidRequest('client_id');
85
        }
86
87 5
        $client = $this->getClientEntityOrFail($clientId, $request);
88
89 4
        $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope));
90
91 4
        $deviceCodeEntity = $this->issueDeviceCode(
92 4
            $this->deviceCodeTTL,
93 4
            $client,
94 4
            $this->verificationUri,
95 4
            $scopes
96 4
        );
97
98 4
        $response = new DeviceCodeResponse();
99
100 4
        if ($this->includeVerificationUriComplete === true) {
101 1
            $response->includeVerificationUriComplete();
102
        }
103
104 4
        $response->setDeviceCodeEntity($deviceCodeEntity);
105
106 4
        return $response;
107
    }
108
109
    /**
110
     * {@inheritdoc}
111
     */
112 3
    public function completeDeviceAuthorizationRequest(string $deviceCode, string $userId, bool $userApproved): void
113
    {
114 3
        $deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByDeviceCode($deviceCode);
115
116 3
        if ($deviceCode instanceof DeviceCodeEntityInterface === false) {
117
            throw OAuthServerException::invalidRequest('device_code', 'Device code does not exist');
118
        }
119
120 3
        if ($userId === '') {
121
            throw OAuthServerException::invalidRequest('user_id', 'User ID is required');
122
        }
123
124 3
        $deviceCode->setUserIdentifier($userId);
125 3
        $deviceCode->setUserApproved($userApproved);
126
127 3
        $this->deviceCodeRepository->persistDeviceCode($deviceCode);
128
    }
129
130
    /**
131
     * {@inheritdoc}
132
     */
133 7
    public function respondToAccessTokenRequest(
134
        ServerRequestInterface $request,
135
        ResponseTypeInterface $responseType,
136
        DateInterval $accessTokenTTL
137
    ): ResponseTypeInterface {
138
        // Validate request
139 7
        $client = $this->validateClient($request);
140 6
        $deviceCodeEntity = $this->validateDeviceCode($request, $client);
141
142 3
        $deviceCodeEntity->setLastPolledAt(new DateTimeImmutable());
143 3
        $this->deviceCodeRepository->persistDeviceCode($deviceCodeEntity);
144
145
        // If device code has no user associated, respond with pending
146 3
        if (is_null($deviceCodeEntity->getUserIdentifier())) {
147 1
            throw OAuthServerException::authorizationPending();
148
        }
149
150 2
        if ($deviceCodeEntity->getUserApproved() === false) {
151 1
            throw OAuthServerException::accessDenied();
152
        }
153
154
        // Finalize the requested scopes
155 1
        $finalizedScopes = $this->scopeRepository->finalizeScopes($deviceCodeEntity->getScopes(), $this->getIdentifier(), $client, $deviceCodeEntity->getUserIdentifier());
156
157
        // Issue and persist new access token
158 1
        $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $deviceCodeEntity->getUserIdentifier(), $finalizedScopes);
159 1
        $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request));
160 1
        $responseType->setAccessToken($accessToken);
161
162
        // Issue and persist new refresh token if given
163 1
        $refreshToken = $this->issueRefreshToken($accessToken);
164
165 1
        if ($refreshToken !== null) {
166 1
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request));
167 1
            $responseType->setRefreshToken($refreshToken);
168
        }
169
170 1
        $this->deviceCodeRepository->revokeDeviceCode($deviceCodeEntity->getIdentifier());
171
172 1
        return $responseType;
173
    }
174
175
    /**
176
     * @throws OAuthServerException
177
     */
178 6
    protected function validateDeviceCode(ServerRequestInterface $request, ClientEntityInterface $client): DeviceCodeEntityInterface
179
    {
180 6
        $deviceCode = $this->getRequestParameter('device_code', $request);
181
182 6
        if (is_null($deviceCode)) {
183 1
            throw OAuthServerException::invalidRequest('device_code');
184
        }
185
186 5
        $deviceCodeEntity = $this->deviceCodeRepository->getDeviceCodeEntityByDeviceCode(
187 5
            $deviceCode
188 5
        );
189
190 5
        if ($deviceCodeEntity instanceof DeviceCodeEntityInterface === false) {
191
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
192
193
            throw OAuthServerException::invalidGrant();
194
        }
195
196 5
        if (time() > $deviceCodeEntity->getExpiryDateTime()->getTimestamp()) {
197 1
            throw OAuthServerException::expiredToken('device_code');
198
        }
199
200 4
        if ($this->deviceCodeRepository->isDeviceCodeRevoked($deviceCode) === true) {
201
            throw OAuthServerException::invalidRequest('device_code', 'Device code has been revoked');
202
        }
203
204 4
        if ($deviceCodeEntity->getClient()->getIdentifier() !== $client->getIdentifier()) {
205
            throw OAuthServerException::invalidRequest('device_code', 'Device code was not issued to this client');
206
        }
207
208 4
        if ($this->deviceCodePolledTooSoon($deviceCodeEntity->getLastPolledAt()) === true) {
209 1
            throw OAuthServerException::slowDown();
210
        }
211
212 3
        return $deviceCodeEntity;
213
    }
214
215 4
    private function deviceCodePolledTooSoon(?DateTimeImmutable $lastPoll): bool
216
    {
217 4
        return $lastPoll !== null && $lastPoll->getTimestamp() + $this->retryInterval > time();
218
    }
219
220
    /**
221
     * Set the verification uri
222
     */
223 17
    public function setVerificationUri(string $verificationUri): void
224
    {
225 17
        $this->verificationUri = $verificationUri;
226
    }
227
228
    /**
229
     * {@inheritdoc}
230
     */
231 8
    public function getIdentifier(): string
232
    {
233 8
        return 'urn:ietf:params:oauth:grant-type:device_code';
234
    }
235
236 17
    private function setDeviceCodeRepository(DeviceCodeRepositoryInterface $deviceCodeRepository): void
237
    {
238 17
        $this->deviceCodeRepository = $deviceCodeRepository;
239
    }
240
241
    /**
242
     * Issue a device code.
243
     *
244
     * @param ScopeEntityInterface[] $scopes
245
     *
246
     * @throws OAuthServerException
247
     * @throws UniqueTokenIdentifierConstraintViolationException
248
     */
249 4
    protected function issueDeviceCode(
250
        DateInterval $deviceCodeTTL,
251
        ClientEntityInterface $client,
252
        string $verificationUri,
253
        array $scopes = [],
254
    ): DeviceCodeEntityInterface {
255 4
        $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;
256
257 4
        $deviceCode = $this->deviceCodeRepository->getNewDeviceCode();
258 4
        $deviceCode->setExpiryDateTime((new DateTimeImmutable())->add($deviceCodeTTL));
259 4
        $deviceCode->setClient($client);
260 4
        $deviceCode->setVerificationUri($verificationUri);
261
262 4
        if ($this->getIntervalVisibility() === true) {
263 1
            $deviceCode->setInterval($this->retryInterval);
264
        }
265
266 4
        foreach ($scopes as $scope) {
267 4
            $deviceCode->addScope($scope);
268
        }
269
270 4
        while ($maxGenerationAttempts-- > 0) {
271 4
            $deviceCode->setIdentifier($this->generateUniqueIdentifier());
272 4
            $deviceCode->setUserCode($this->generateUserCode());
273
274
            try {
275 4
                $this->deviceCodeRepository->persistDeviceCode($deviceCode);
276
277 4
                return $deviceCode;
278
            } catch (UniqueTokenIdentifierConstraintViolationException $e) {
279
                if ($maxGenerationAttempts === 0) {
280
                    throw $e;
281
                }
282
            }
283
        }
284
285
        // This should never be hit. It is here to work around a PHPStan false error
286
        return $deviceCode;
287
    }
288
289
    /**
290
     * Generate a new user code.
291
     *
292
     * @throws OAuthServerException
293
     */
294 4
    protected function generateUserCode(int $length = 8): string
295
    {
296
        try {
297 4
            $userCode = '';
298 4
            $userCodeCharacters = 'BCDFGHJKLMNPQRSTVWXZ';
299
300 4
            while (strlen($userCode) < $length) {
301 4
                $userCode .= $userCodeCharacters[random_int(0, 19)];
302
            }
303
304 4
            return $userCode;
305
            // @codeCoverageIgnoreStart
306
        } catch (TypeError | Error $e) {
307
            throw OAuthServerException::serverError('An unexpected error has occurred', $e);
308
        } catch (Exception $e) {
309
            // If you get this message, the CSPRNG failed hard.
310
            throw OAuthServerException::serverError('Could not generate a random string', $e);
311
        }
312
        // @codeCoverageIgnoreEnd
313
    }
314
315 1
    public function setIntervalVisibility(bool $intervalVisibility): void
316
    {
317 1
        $this->intervalVisibility = $intervalVisibility;
318
    }
319
320 4
    public function getIntervalVisibility(): bool
321
    {
322 4
        return $this->intervalVisibility;
323
    }
324
325 1
    public function setIncludeVerificationUriComplete(bool $includeVerificationUriComplete): void
326
    {
327 1
        $this->includeVerificationUriComplete = $includeVerificationUriComplete;
328
    }
329
}
330