Passed
Pull Request — master (#24)
by Damian
01:42
created

JWTAuthenticator   A

Complexity

Total Complexity 37

Size/Duplication

Total Lines 390
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 122
dl 0
loc 390
rs 9.44
c 0
b 0
f 0
wmc 37

15 Methods

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

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

335
        $verified = $parsedToken->verify($this->getSigner(), /** @scrutinizer ignore-type */ $this->getPublicKey());
Loading history...
336
        return $verified ? $parsedToken : null;
337
    }
338
339
    /**
340
     * Given a parsed Token, find the matching JWTRecord dataobject
341
     *
342
     * @param Token $parsedToken
343
     * @param HTTPRequest $request
344
     * @return JWTRecord|null
345
     */
346
    protected function findTokenRecord(Token $parsedToken, HTTPrequest $request): ?JWTRecord
347
    {
348
        $userAgent = $request->getHeader('User-Agent');
349
        /** @var JWTRecord $record */
350
        $record = JWTRecord::get()
351
            ->filter(['UserAgent' => $userAgent])
352
            ->byID($parsedToken->getClaim('rid'));
353
        return $record;
354
    }
355
356
    /**
357
     * Determine if the given token is current, given the context of the current request
358
     *
359
     * @param Token $parsedToken
360
     * @param HTTPRequest $request
361
     * @param JWTRecord $record
362
     * @return bool
363
     */
364
    protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, JWTRecord $record): bool
365
    {
366
        $now = DBDatetime::now()->getTimestamp();
367
        $validator = new ValidationData();
368
        $validator->setIssuer($request->getHeader('Origin'));
369
        $validator->setAudience(Director::absoluteBaseURL());
370
        $validator->setId($record->UID);
371
        $validator->setCurrentTime($now);
372
        return $parsedToken->validate($validator);
373
    }
374
375
    /**
376
     * Check if the given token can be renewed
377
     *
378
     * @param Token $parsedToken
379
     * @return bool
380
     */
381
    protected function canTokenBeRenewed(Token $parsedToken): bool
382
    {
383
        $renewBefore = $parsedToken->getClaim('rexp');
384
        $now = DBDatetime::now()->getTimestamp();
385
        return $renewBefore > $now;
386
    }
387
388
    /**
389
     * Return an absolute path from a relative one
390
     * If the path doesn't exist, returns null
391
     *
392
     * @param string $path
393
     * @param string $base
394
     * @return string|null
395
     */
396
    protected function resolvePath(string $path, string $base = BASE_PATH): ?string
397
    {
398
        if (strstr($path, '/') !== 0) {
399
            $path = $base . '/' . $path;
400
        }
401
        return realpath($path) ?: null;
402
    }
403
404
405
    /**
406
     * Get an environment value. If $default is not set and the environment isn't set either this will error.
407
     *
408
     * @param string $key
409
     * @param string|null $default
410
     * @throws LogicException Error if environment variable is required, but not configured
411
     * @return string|null
412
     */
413
    protected function getEnv(string $key, $default = null): ?string
414
    {
415
        $value = Environment::getEnv($key);
416
        if ($value) {
417
            return $value;
418
        }
419
        if (func_num_args() === 1) {
420
            throw new LogicException("Required environment variable {$key} not set");
421
        }
422
        return $default;
423
    }
424
}
425