JWTAuthenticator::getKeyType()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 0
dl 0
loc 11
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 DateTimeImmutable;
7
use Exception;
8
use Firesphere\GraphQLJWT\Extensions\MemberExtension;
9
use Firesphere\GraphQLJWT\Helpers\MemberTokenGenerator;
10
use Firesphere\GraphQLJWT\Model\JWTRecord;
11
use Firesphere\GraphQLJWT\Types\TokenStatusEnum;
12
use Lcobucci\JWT\Builder;
13
use Lcobucci\JWT\Parser;
14
use Lcobucci\JWT\Signer;
15
use Lcobucci\JWT\Signer\Hmac;
16
use Lcobucci\JWT\Signer\Key;
17
use Lcobucci\JWT\Signer\Rsa;
18
use Lcobucci\JWT\Token;
19
use Lcobucci\JWT\ValidationData;
20
use LogicException;
21
use OutOfBoundsException;
22
use SilverStripe\Control\Director;
23
use SilverStripe\Control\HTTPRequest;
24
use SilverStripe\Core\Config\Configurable;
25
use SilverStripe\Core\Environment;
26
use SilverStripe\Core\Injector\Injectable;
27
use SilverStripe\ORM\FieldType\DBDatetime;
28
use SilverStripe\ORM\ValidationException;
29
use SilverStripe\ORM\ValidationResult;
30
use SilverStripe\Security\Authenticator;
31
use SilverStripe\Security\Member;
32
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
33
34
class JWTAuthenticator extends MemberAuthenticator
35
{
36
    use Injectable;
37
    use Configurable;
38
    use MemberTokenGenerator;
39
40
    const JWT_SIGNER_KEY = 'JWT_SIGNER_KEY';
41
42
    const JWT_KEY_PASSWORD = 'JWT_KEY_PASSWORD';
43
44
    const JWT_PUBLIC_KEY = 'JWT_PUBLIC_KEY';
45
46
    /**
47
     * Key is RSA public/private pair
48
     */
49
    const RSA = 'RSA';
50
51
    /**
52
     * Key is RSA public/private pair, with password enabled
53
     */
54
    const RSA_PASSWORD = 'RSA_PASSWORD';
55
56
    /**
57
     * Key is HMAC string
58
     */
59
    const HMAC = 'HMAC';
60
61
    /**
62
     * Set to true to allow anonymous JWT tokens (no member record / email / password)
63
     *
64
     * @config
65
     * @var bool
66
     */
67
    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...
68
69
    /**
70
     * @config
71
     * @var int
72
     */
73
    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...
74
75
    /**
76
     * Expires after 1 hour
77
     *
78
     * @config
79
     * @var int
80
     */
81
    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...
82
83
    /**
84
     * Token can be refreshed within 7 days
85
     *
86
     * @config
87
     * @var int
88
     */
89
    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...
90
91
    /**
92
     * Keys are one of:
93
     *   - public / private RSA pair files
94
     *   - public / private RSA pair files, password protected private key
95
     *   - private HMAC string
96
     *
97
     * @return string
98
     */
99
    protected function getKeyType(): string
100
    {
101
        $signerKey = $this->getEnv(self::JWT_SIGNER_KEY);
102
        $path = $this->resolvePath($signerKey);
103
        if (!$path) {
104
            return self::HMAC;
105
        }
106
        if ($this->getEnv(self::JWT_KEY_PASSWORD, null)) {
107
            return self::RSA_PASSWORD;
108
        }
109
        return self::RSA;
110
    }
111
112
    /**
113
     * @return Signer
114
     */
115
    protected function getSigner(): Signer
116
    {
117
        switch ($this->getKeyType()) {
118
            case self::HMAC:
119
                return new Hmac\Sha256();
120
            case self::RSA:
121
            case self::RSA_PASSWORD:
122
            default:
123
                return new Rsa\Sha256();
124
        }
125
    }
126
127
    /**
128
     * Get private key used to generate JWT tokens
129
     *
130
     * @return Key
131
     */
132
    protected function getPrivateKey(): Key
133
    {
134
        // Note: Only private key has password enabled
135
        $password = $this->getEnv(self::JWT_KEY_PASSWORD, null);
136
        return $this->makeKey(self::JWT_SIGNER_KEY, $password);
137
    }
138
139
    /**
140
     * Get public key used to validate JWT tokens
141
     *
142
     * @return Key
143
     * @throws LogicException
144
     */
145
    protected function getPublicKey(): Key
146
    {
147
        switch ($this->getKeyType()) {
148
            case self::HMAC:
149
                // If signer key is a HMAC string instead of a path, public key == private key
150
                return $this->getPrivateKey();
151
            default:
152
                // If signer key is a path to RSA token, then we require a separate public key path
153
                return $this->makeKey(self::JWT_PUBLIC_KEY);
154
        }
155
    }
156
157
    /**
158
     * Construct a new key from the named config variable
159
     *
160
     * @param string $name Key name
161
     * @param string|null $password Optional password
162
     * @return Key
163
     */
164
    private function makeKey(string $name, string $password = null): Key
165
    {
166
        $key = $this->getEnv($name);
167
        $path = $this->resolvePath($key);
168
169
        // String key
170
        if (empty($path)) {
171
            return new Key($path);
172
        }
173
174
        // Build key from path
175
        return new Key('file://' . $path, $password);
176
    }
177
178
    /**
179
     * JWT is stateless, therefore, we don't support anything but login
180
     *
181
     * @return int
182
     */
183
    public function supportedServices(): int
184
    {
185
        return Authenticator::LOGIN;
186
    }
187
188
    /**
189
     * @param array $data
190
     * @param HTTPRequest $request
191
     * @param ValidationResult|null $result
192
     * @return Member|null
193
     * @throws OutOfBoundsException
194
     * @throws BadMethodCallException
195
     * @throws Exception
196
     */
197
    public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null): ?Member
198
    {
199
        if (!$result) {
200
            $result = new ValidationResult();
201
        }
202
        $token = $data['token'];
203
204
        /** @var JWTRecord $record */
205
        list($record, $status) = $this->validateToken($token, $request);
206
207
        // Report success!
208
        if ($status === TokenStatusEnum::STATUS_OK) {
209
            return $record->Member();
210
        }
211
212
        // Add errors to result
213
        $result->addError(
214
            $this->getErrorMessage($status),
215
            ValidationResult::TYPE_ERROR,
216
            $status
217
        );
218
        return null;
219
    }
220
221
    /**
222
     * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user
223
     *
224
     * @param HTTPRequest $request
225
     * @param Member|MemberExtension $member
226
     * @return Token
227
     * @throws ValidationException
228
     */
229
    public function generateToken(HTTPRequest $request, Member $member): Token
230
    {
231
        $config = static::config();
232
        $uniqueID = uniqid($this->getEnv('JWT_PREFIX', ''), true);
233
234
        // Create new record
235
        $record = new JWTRecord();
236
        $record->UID = $uniqueID;
237
        $record->UserAgent = $request->getHeader('User-Agent');
238
        $member->AuthTokens()->add($record);
239
        if (!$record->isInDB()) {
240
            $record->write();
241
        }
242
243
        // Create builder for this record
244
        $builder = new Builder();
245
        $now = DBDatetime::now()->getTimestamp();
246
        $token = $builder
0 ignored issues
show
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setIssuedAt() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setSubject() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setAudience() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setId() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::sign() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::set() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setExpiration() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setIssuer() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
Deprecated Code introduced by
The function Lcobucci\JWT\Builder::setNotBefore() has been deprecated: This method will be removed on v4 ( Ignorable by Annotation )

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

246
        $token = /** @scrutinizer ignore-deprecated */ $builder

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...
247
            // Configures the issuer (iss claim)
248
            ->setIssuer($request->getHeader('Origin'))
249
            // Configures the audience (aud claim)
250
            ->setAudience(Director::absoluteBaseURL())
251
            // Configures the id (jti claim), replicating as a header item
252
            ->setId($uniqueID, true)
253
            // Configures the time that the token was issue (iat claim)
254
            ->setIssuedAt($now)
255
            // Configures the time that the token can be used (nbf claim)
256
            ->setNotBefore($now + $config->get('nbf_time'))
257
            // Configures the expiration time of the token (nbf claim)
258
            ->setExpiration($now + $config->get('nbf_expiration'))
259
            // Set renew expiration
260
            ->set('rexp', $now + $config->get('nbf_refresh_expiration'))
261
            // Configures a new claim, called "rid"
262
            ->set('rid', $record->ID)
263
            // Set the subject, which is the member
264
            ->setSubject($member->getJWTData())
265
            // Sign the key with the Signer's key
266
            ->sign($this->getSigner(), $this->getPrivateKey());
267
268
        // Return the token
269
        return $token->getToken();
270
    }
271
272
    /**
273
     * @param string $token
274
     * @param HTTPRequest $request
275
     * @return array|null Array with JWTRecord and int status (STATUS_*)
276
     * @throws BadMethodCallException|Exception
277
     */
278
    public function validateToken(?string $token, HTTPrequest $request): array
279
    {
280
        // Parse token
281
        $parsedToken = $this->parseToken($token);
282
        if (!$parsedToken) {
283
            return [null, TokenStatusEnum::STATUS_INVALID];
284
        }
285
286
        // Find local record for this token
287
        /** @var JWTRecord $record */
288
        $record = JWTRecord::get()->byID($parsedToken->getClaim('rid'));
0 ignored issues
show
Deprecated Code introduced by
The function Lcobucci\JWT\Token::getClaim() 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

288
        $record = JWTRecord::get()->byID(/** @scrutinizer ignore-deprecated */ $parsedToken->getClaim('rid'));

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...
289
        if (!$record) {
0 ignored issues
show
introduced by
$record is of type Firesphere\GraphQLJWT\Model\JWTRecord, thus it always evaluated to true.
Loading history...
290
            return [null, TokenStatusEnum::STATUS_INVALID];
291
        }
292
293
        // Verified and valid = ok!
294
        $valid = $this->validateParsedToken($parsedToken, $request, $record);
295
        if ($valid) {
296
            return [$record, TokenStatusEnum::STATUS_OK];
297
        }
298
299
        // If the token is invalid, but not because it has expired, fail
300
        $now = new DateTimeImmutable(DBDatetime::now()->getValue());
301
        if (!$parsedToken->isExpired($now)) {
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
     */
350
    protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, JWTRecord $record): bool
351
    {
352
        $now = DBDatetime::now()->getTimestamp();
353
        $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

353
        $validator = /** @scrutinizer ignore-deprecated */ new ValidationData();
Loading history...
354
        $validator->setIssuer($request->getHeader('Origin'));
355
        $validator->setAudience(Director::absoluteBaseURL());
356
        $validator->setId($record->UID);
357
        $validator->setCurrentTime($now);
358
        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

358
        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...
359
    }
360
361
    /**
362
     * Check if the given token can be renewed
363
     *
364
     * @param Token $parsedToken
365
     * @return bool
366
     */
367
    protected function canTokenBeRenewed(Token $parsedToken): bool
368
    {
369
        $renewBefore = $parsedToken->getClaim('rexp');
0 ignored issues
show
Deprecated Code introduced by
The function Lcobucci\JWT\Token::getClaim() 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

369
        $renewBefore = /** @scrutinizer ignore-deprecated */ $parsedToken->getClaim('rexp');

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...
370
        $now = DBDatetime::now()->getTimestamp();
371
        return $renewBefore > $now;
372
    }
373
374
    /**
375
     * Return an absolute path from a relative one
376
     * If the path doesn't exist, returns null
377
     *
378
     * @param string $path
379
     * @param string $base
380
     * @return string|null
381
     */
382
    protected function resolvePath(string $path, string $base = BASE_PATH): ?string
383
    {
384
        if (strstr($path, '/') !== 0) {
385
            $path = $base . '/' . $path;
386
        }
387
        return realpath($path) ?: null;
388
    }
389
390
391
    /**
392
     * Get an environment value. If $default is not set and the environment isn't set either this will error.
393
     *
394
     * @param string $key
395
     * @param string|null $default
396
     * @return string|null
397
     * @throws LogicException Error if environment variable is required, but not configured
398
     */
399
    protected function getEnv(string $key, $default = null): ?string
400
    {
401
        $value = Environment::getEnv($key);
402
        if ($value) {
403
            return $value;
404
        }
405
        if (func_num_args() === 1) {
406
            throw new LogicException("Required environment variable {$key} not set");
407
        }
408
        return $default;
409
    }
410
}
411