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

JWTAuthenticator::generateToken()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 41
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 22
nc 2
nop 2
dl 0
loc 41
rs 9.568
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\Helpers\RequiresConfig;
11
use Firesphere\GraphQLJWT\Model\JWTRecord;
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\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 RequiresConfig;
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
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 Array with JWTRecord and int status (STATUS_*)
276
     * @throws BadMethodCallException
277
     */
278
    public function validateToken(string $token, HTTPrequest $request): array
279
    {
280
        // Ensure token given at all
281
        if (!$token) {
282
            return [null, TokenStatusEnum::STATUS_INVALID];
283
        }
284
285
        // Parse token
286
        $parser = new Parser();
287
        try {
288
            $parsedToken = $parser->parse($token);
289
        } catch (Exception $ex) {
290
            // Un-parsable tokens are invalid
291
            return [null, TokenStatusEnum::STATUS_INVALID];
292
        }
293
294
        // Validate token against Id and user-agent
295
        $userAgent = $request->getHeader('User-Agent');
296
        /** @var JWTRecord $record */
297
        $record = JWTRecord::get()
298
            ->filter(['UserAgent' => $userAgent])
299
            ->byID($parsedToken->getClaim('rid'));
300
        if (!$record) {
0 ignored issues
show
introduced by
$record is of type Firesphere\GraphQLJWT\Model\JWTRecord, thus it always evaluated to true.
Loading history...
301
            return [null, TokenStatusEnum::STATUS_INVALID];
302
        }
303
304
        // Get validator for this token
305
        $now = DBDatetime::now()->getTimestamp();
306
        $validator = new ValidationData();
307
        $validator->setIssuer($request->getHeader('Origin'));
308
        $validator->setAudience(Director::absoluteBaseURL());
309
        $validator->setId($record->UID);
310
        $validator->setCurrentTime($now);
311
        $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

311
        $verified = $parsedToken->verify($this->getSigner(), /** @scrutinizer ignore-type */ $this->getPublicKey());
Loading history...
312
        $valid = $parsedToken->validate($validator);
313
314
        // If unverified, break
315
        if (!$verified) {
316
            return [$record, TokenStatusEnum::STATUS_INVALID];
317
        }
318
319
        // Verified and valid = ok!
320
        if ($valid) {
321
            return [$record, TokenStatusEnum::STATUS_OK];
322
        }
323
324
        // If the token is invalid, but not because it has expired, fail
325
        if (!$parsedToken->isExpired()) {
326
            return [$record, TokenStatusEnum::STATUS_INVALID];
327
        }
328
329
        // If expired, check if it can be renewed
330
        $renewBefore = $parsedToken->getClaim('rexp');
331
        if ($renewBefore > $now) {
332
            return [$record, TokenStatusEnum::STATUS_EXPIRED];
333
        }
334
335
        // If expired and cannot be renewed, it's dead
336
        return [$record, TokenStatusEnum::STATUS_DEAD];
337
    }
338
339
    /**
340
     * Return an absolute path from a relative one
341
     * If the path doesn't exist, returns null
342
     *
343
     * @param string $path
344
     * @param string $base
345
     * @return string|null
346
     */
347
    protected function resolvePath(string $path, string $base = BASE_PATH): ?string
348
    {
349
        if (strstr($path, '/') !== 0) {
350
            $path = $base . '/' . $path;
351
        }
352
        return realpath($path) ?: null;
353
    }
354
}
355