Passed
Pull Request — master (#31)
by Damian
03:59 queued 01:28
created

JWTAuthenticator::getPublicKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 9
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Firesphere\GraphQLJWT\Authentication;
4
5
use BadMethodCallException;
6
use DateInterval;
7
use DateTimeImmutable;
8
use Exception;
9
use Firesphere\GraphQLJWT\Extensions\MemberExtension;
10
use Firesphere\GraphQLJWT\Helpers\MemberTokenGenerator;
11
use Firesphere\GraphQLJWT\Model\JWTRecord;
12
use Firesphere\GraphQLJWT\Types\TokenStatusEnum;
13
use Lcobucci\JWT\Builder;
14
use Lcobucci\JWT\Parser;
15
use Lcobucci\JWT\Signer;
16
use Lcobucci\JWT\Signer\Hmac;
17
use Lcobucci\JWT\Signer\Key;
18
use Lcobucci\JWT\Signer\Rsa;
19
use Lcobucci\JWT\Token;
20
use Lcobucci\JWT\ValidationData;
21
use LogicException;
22
use OutOfBoundsException;
23
use SilverStripe\Control\Director;
24
use SilverStripe\Control\HTTPRequest;
25
use SilverStripe\Core\Config\Configurable;
26
use SilverStripe\Core\Environment;
27
use SilverStripe\Core\Injector\Injectable;
28
use SilverStripe\ORM\FieldType\DBDatetime;
29
use SilverStripe\ORM\ValidationException;
30
use SilverStripe\ORM\ValidationResult;
31
use SilverStripe\Security\Authenticator;
32
use SilverStripe\Security\Member;
33
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
34
35
class JWTAuthenticator extends MemberAuthenticator
36
{
37
    use Injectable;
38
    use Configurable;
39
    use MemberTokenGenerator;
40
41
    const JWT_SIGNER_KEY = 'JWT_SIGNER_KEY';
42
43
    const JWT_KEY_PASSWORD = 'JWT_KEY_PASSWORD';
44
45
    const JWT_PUBLIC_KEY = 'JWT_PUBLIC_KEY';
46
47
    /**
48
     * Key is RSA public/private pair
49
     */
50
    const RSA = 'RSA';
51
52
    /**
53
     * Key is RSA public/private pair, with password enabled
54
     */
55
    const RSA_PASSWORD = 'RSA_PASSWORD';
56
57
    /**
58
     * Key is HMAC string
59
     */
60
    const HMAC = 'HMAC';
61
62
    /**
63
     * Set to true to allow anonymous JWT tokens (no member record / email / password)
64
     *
65
     * @config
66
     * @var bool
67
     */
68
    private static $anonymous_allowed = false;
0 ignored issues
show
introduced by
The private property $anonymous_allowed is not used, and could be removed.
Loading history...
69
70
    /**
71
     * @config
72
     * @var int
73
     */
74
    private static $nbf_time = 0;
0 ignored issues
show
introduced by
The private property $nbf_time is not used, and could be removed.
Loading history...
75
76
    /**
77
     * Expires after 1 hour
78
     *
79
     * @config
80
     * @var int
81
     */
82
    private static $nbf_expiration = 3600;
0 ignored issues
show
introduced by
The private property $nbf_expiration is not used, and could be removed.
Loading history...
83
84
    /**
85
     * Token can be refreshed within 7 days
86
     *
87
     * @config
88
     * @var int
89
     */
90
    private static $nbf_refresh_expiration = 604800;
0 ignored issues
show
introduced by
The private property $nbf_refresh_expiration is not used, and could be removed.
Loading history...
91
92
    /**
93
     * Keys are one of:
94
     *   - public / private RSA pair files
95
     *   - public / private RSA pair files, password protected private key
96
     *   - private HMAC string
97
     *
98
     * @return string
99
     */
100
    protected function getKeyType(): string
101
    {
102
        $signerKey = $this->getEnv(self::JWT_SIGNER_KEY);
103
        $path = $this->resolvePath($signerKey);
104
        if (!$path) {
105
            return self::HMAC;
106
        }
107
        if ($this->getEnv(self::JWT_KEY_PASSWORD, null)) {
108
            return self::RSA_PASSWORD;
109
        }
110
        return self::RSA;
111
    }
112
113
    /**
114
     * @return Signer
115
     */
116
    protected function getSigner(): Signer
117
    {
118
        switch ($this->getKeyType()) {
119
            case self::HMAC:
120
                return new Hmac\Sha256();
121
            case self::RSA:
122
            case self::RSA_PASSWORD:
123
            default:
124
                return new Rsa\Sha256();
125
        }
126
    }
127
128
    /**
129
     * Get private key used to generate JWT tokens
130
     *
131
     * @return Key
132
     */
133
    protected function getPrivateKey(): Key
134
    {
135
        // Note: Only private key has password enabled
136
        $password = $this->getEnv(self::JWT_KEY_PASSWORD, null);
137
        return $this->makeKey(self::JWT_SIGNER_KEY, $password);
138
    }
139
140
    /**
141
     * Get public key used to validate JWT tokens
142
     *
143
     * @return Key
144
     * @throws LogicException
145
     */
146
    protected function getPublicKey(): Key
147
    {
148
        switch ($this->getKeyType()) {
149
            case self::HMAC:
150
                // If signer key is a HMAC string instead of a path, public key == private key
151
                return $this->getPrivateKey();
152
            default:
153
                // If signer key is a path to RSA token, then we require a separate public key path
154
                return $this->makeKey(self::JWT_PUBLIC_KEY);
155
        }
156
    }
157
158
    /**
159
     * Construct a new key from the named config variable
160
     *
161
     * @param string $name Key name
162
     * @param string|null $password Optional password
163
     * @return Key
164
     */
165
    private function makeKey(string $name, string $password = null): Key
166
    {
167
        $key = $this->getEnv($name);
168
        $path = $this->resolvePath($key);
169
170
        // String key
171
        if (empty($path)) {
172
            return new Key($path);
173
        }
174
175
        // Build key from path
176
        return new Key('file://' . $path, $password);
177
    }
178
179
    /**
180
     * JWT is stateless, therefore, we don't support anything but login
181
     *
182
     * @return int
183
     */
184
    public function supportedServices(): int
185
    {
186
        return Authenticator::LOGIN;
187
    }
188
189
    /**
190
     * @param array $data
191
     * @param HTTPRequest $request
192
     * @param ValidationResult|null $result
193
     * @return Member|null
194
     * @throws OutOfBoundsException
195
     * @throws BadMethodCallException
196
     * @throws Exception
197
     */
198
    public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null): ?Member
199
    {
200
        if (!$result) {
201
            $result = new ValidationResult();
202
        }
203
        $token = $data['token'];
204
205
        /** @var JWTRecord $record */
206
        list($record, $status) = $this->validateToken($token, $request);
207
208
        // Report success!
209
        if ($status === TokenStatusEnum::STATUS_OK) {
210
            return $record->Member();
211
        }
212
213
        // Add errors to result
214
        $result->addError(
215
            $this->getErrorMessage($status),
216
            ValidationResult::TYPE_ERROR,
217
            $status
218
        );
219
        return null;
220
    }
221
222
    /**
223
     * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user
224
     *
225
     * @param HTTPRequest $request
226
     * @param Member|MemberExtension $member
227
     * @return Token
228
     * @throws ValidationException
229
     * @throws Exception
230
     */
231
    public function generateToken(HTTPRequest $request, Member $member): Token
232
    {
233
        $config = static::config();
234
        $uniqueID = uniqid($this->getEnv('JWT_PREFIX', ''), true);
235
236
        // Create new record
237
        $record = new JWTRecord();
238
        $record->UID = $uniqueID;
239
        $record->UserAgent = $request->getHeader('User-Agent');
240
        $member->AuthTokens()->add($record);
241
        if (!$record->isInDB()) {
242
            $record->write();
243
        }
244
245
        // Create builder for this record
246
        $builder = new Builder();
247
        $token = $builder
248
            // Configures the issuer (iss claim)
249
            ->issuedBy($request->getHeader('Origin'))
250
            // Configures the audience (aud claim)
251
            ->permittedFor(Director::absoluteBaseURL())
252
            // Configures the id (jti claim), replicating as a header item
253
            ->identifiedBy($uniqueID)->withHeader('jti', $uniqueID)
254
            // Configures the time that the token was issue (iat claim)
255
            ->issuedAt($this->getNow())
256
            // Configures the time that the token can be used (nbf claim)
257
            ->canOnlyBeUsedAfter($this->getNowPlus($config->get('nbf_time')))
258
            // Configures the expiration time of the token (nbf claim)
259
            ->expiresAt($this->getNowPlus($config->get('nbf_expiration')))
260
            // Set renew expiration (unix timestamp)
261
            ->withClaim('rexp', $this->getNowPlus($config->get('nbf_refresh_expiration')->getTimestamp()))
262
            // Configures a new claim, called "rid"
263
            ->withClaim('rid', $record->ID)
264
            // Set the subject, which is the member
265
            ->relatedTo($member->getJWTData())
266
            // Sign the key with the Signer's key
267
            ->getToken($this->getSigner(), $this->getPrivateKey());
268
269
        // Return the token
270
        return $token;
271
    }
272
273
    /**
274
     * @param string $token
275
     * @param HTTPRequest $request
276
     * @return array|null Array with JWTRecord and int status (STATUS_*)
277
     * @throws BadMethodCallException|Exception
278
     */
279
    public function validateToken(?string $token, HTTPrequest $request): array
280
    {
281
        // Parse token
282
        $parsedToken = $this->parseToken($token);
283
        if (!$parsedToken) {
284
            return [null, TokenStatusEnum::STATUS_INVALID];
285
        }
286
287
        // Find local record for this token
288
        /** @var JWTRecord $record */
289
        $record = JWTRecord::get()->byID($parsedToken->claims()->get('rid'));
290
        if (!$record) {
0 ignored issues
show
introduced by
$record is of type Firesphere\GraphQLJWT\Model\JWTRecord, thus it always evaluated to true.
Loading history...
291
            return [null, TokenStatusEnum::STATUS_INVALID];
292
        }
293
294
        // Verified and valid = ok!
295
        $valid = $this->validateParsedToken($parsedToken, $request, $record);
296
        if ($valid) {
297
            return [$record, TokenStatusEnum::STATUS_OK];
298
        }
299
300
        // If the token is invalid, but not because it has expired, fail
301
        if (!$parsedToken->isExpired($this->getNow())) {
302
            return [$record, TokenStatusEnum::STATUS_INVALID];
303
        }
304
305
        // If expired, check if it can be renewed
306
        $canReniew = $this->canTokenBeRenewed($parsedToken);
307
        if ($canReniew) {
308
            return [$record, TokenStatusEnum::STATUS_EXPIRED];
309
        }
310
311
        // If expired and cannot be renewed, it's dead
312
        return [$record, TokenStatusEnum::STATUS_DEAD];
313
    }
314
315
    /**
316
     * Parse a string into a token
317
     *
318
     * @param string|null $token
319
     * @return Token|null
320
     */
321
    protected function parseToken(?string $token): ?Token
322
    {
323
        // Ensure token given at all
324
        if (!$token) {
325
            return null;
326
        }
327
328
        try {
329
            // Verify parsed token matches signer
330
            $parser = new Parser();
331
            $parsedToken = $parser->parse($token);
332
        } catch (Exception $ex) {
333
            // Un-parsable tokens are invalid
334
            return null;
335
        }
336
337
        // Verify this token with configured keys
338
        $verified = $parsedToken->verify($this->getSigner(), $this->getPublicKey());
0 ignored issues
show
Deprecated Code introduced by
The function Lcobucci\JWT\Token::verify() has been deprecated: This method has been removed from the interface in v4.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

338
        $verified = /** @scrutinizer ignore-deprecated */ $parsedToken->verify($this->getSigner(), $this->getPublicKey());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
339
        return $verified ? $parsedToken : null;
340
    }
341
342
    /**
343
     * Determine if the given token is current, given the context of the current request
344
     *
345
     * @param Token $parsedToken
346
     * @param HTTPRequest $request
347
     * @param JWTRecord $record
348
     * @return bool
349
     * @throws Exception
350
     */
351
    protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, JWTRecord $record): bool
352
    {
353
        // @todo - upgrade
354
        // @see https://lcobucci-jwt.readthedocs.io/en/latest/upgrading/#replace-tokenverify-and-tokenvalidate-with-validation-api
355
        $validator = new ValidationData();
0 ignored issues
show
Deprecated Code introduced by
The class Lcobucci\JWT\ValidationData has been deprecated: This component has been removed from the interface in v4.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

355
        $validator = /** @scrutinizer ignore-deprecated */ new ValidationData();
Loading history...
356
        $validator->setIssuer($request->getHeader('Origin'));
357
        $validator->setAudience(Director::absoluteBaseURL());
358
        $validator->setId($record->UID);
359
        $validator->setCurrentTime($this->getNow()->getTimestamp());
360
        return $parsedToken->validate($validator);
0 ignored issues
show
Deprecated Code introduced by
The function Lcobucci\JWT\Token::validate() has been deprecated: This method has been removed from the interface in v4.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

360
        return /** @scrutinizer ignore-deprecated */ $parsedToken->validate($validator);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
361
    }
362
363
    /**
364
     * Check if the given token can be renewed
365
     *
366
     * @param Token $parsedToken
367
     * @return bool
368
     * @throws Exception
369
     */
370
    protected function canTokenBeRenewed(Token $parsedToken): bool
371
    {
372
        $renewBefore = $parsedToken->claims()->get('rexp');
373
        return $renewBefore > $this->getNow()->getTimestamp();
374
    }
375
376
    /**
377
     * Return an absolute path from a relative one
378
     * If the path doesn't exist, returns null
379
     *
380
     * @param string $path
381
     * @param string $base
382
     * @return string|null
383
     */
384
    protected function resolvePath(string $path, string $base = BASE_PATH): ?string
385
    {
386
        if (strstr($path, '/') !== 0) {
387
            $path = $base . '/' . $path;
388
        }
389
        return realpath($path) ?: null;
390
    }
391
392
393
    /**
394
     * Get an environment value. If $default is not set and the environment isn't set either this will error.
395
     *
396
     * @param string $key
397
     * @param string|null $default
398
     * @return string|null
399
     * @throws LogicException Error if environment variable is required, but not configured
400
     */
401
    protected function getEnv(string $key, $default = null): ?string
402
    {
403
        $value = Environment::getEnv($key);
404
        if ($value) {
405
            return $value;
406
        }
407
        if (func_num_args() === 1) {
408
            throw new LogicException("Required environment variable {$key} not set");
409
        }
410
        return $default;
411
    }
412
413
    /**
414
     * @return DateTimeImmutable
415
     * @throws Exception
416
     */
417
    protected function getNow(): DateTimeImmutable
418
    {
419
        return new DateTimeImmutable(DBDatetime::now()->getValue());
420
    }
421
422
    /**
423
     * @param int $seconds
424
     * @return DateTimeImmutable
425
     * @throws Exception
426
     */
427
    protected function getNowPlus($seconds)
428
    {
429
        return $this->getNow()->add(new DateInterval(sprintf("PT%dS", $seconds)));
430
    }
431
}
432