Completed
Push — master ( 2a45ae...76e80a )
by Simon
17s queued 11s
created

JWTAuthenticator::__construct()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 3
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 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
        /** @var JWTRecord $record */
287
        $record = JWTRecord::get()->byID($parsedToken->getClaim('rid'));
288
        if (!$record) {
0 ignored issues
show
introduced by
$record is of type Firesphere\GraphQLJWT\Model\JWTRecord, thus it always evaluated to true.
Loading history...
289
            return [null, TokenStatusEnum::STATUS_INVALID];
290
        }
291
292
        // Verified and valid = ok!
293
        $valid = $this->validateParsedToken($parsedToken, $request, $record);
294
        if ($valid) {
295
            return [$record, TokenStatusEnum::STATUS_OK];
296
        }
297
298
        // If the token is invalid, but not because it has expired, fail
299
        if (!$parsedToken->isExpired()) {
300
            return [$record, TokenStatusEnum::STATUS_INVALID];
301
        }
302
303
        // If expired, check if it can be renewed
304
        $canReniew = $this->canTokenBeRenewed($parsedToken);
305
        if ($canReniew) {
306
            return [$record, TokenStatusEnum::STATUS_EXPIRED];
307
        }
308
309
        // If expired and cannot be renewed, it's dead
310
        return [$record, TokenStatusEnum::STATUS_DEAD];
311
    }
312
313
    /**
314
     * Parse a string into a token
315
     *
316
     * @param string $token
317
     * @return Token|null
318
     */
319
    protected function parseToken(string $token): ?Token
320
    {
321
        // Ensure token given at all
322
        if (!$token) {
323
            return null;
324
        }
325
326
        try {
327
            // Verify parsed token matches signer
328
            $parser = new Parser();
329
            $parsedToken = $parser->parse($token);
330
        } catch (Exception $ex) {
331
            // Un-parsable tokens are invalid
332
            return null;
333
        }
334
335
        // Verify this token with configured keys
336
        $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

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