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

JWTAuthenticator::getPublicKey()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 9
nc 3
nop 0
dl 0
loc 16
rs 9.9666
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\PathResolver;
11
use Firesphere\GraphQLJWT\Helpers\RequiresConfig;
12
use Firesphere\GraphQLJWT\Model\JWTRecord;
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 RequiresConfig;
40
    use MemberTokenGenerator;
41
42
    /**
43
     * Set to true to allow anonymous JWT tokens (no member record / email / password)
44
     *
45
     * @config
46
     * @var bool
47
     */
48
    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...
49
50
    /**
51
     * @config
52
     * @var int
53
     */
54
    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...
55
56
    /**
57
     * Expires after 1 hour
58
     *
59
     * @config
60
     * @var int
61
     */
62
    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...
63
64
    /**
65
     * Token can be refreshed within 7 days
66
     *
67
     * @config
68
     * @var int
69
     */
70
    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...
71
72
    /**
73
     * @return Signer
74
     */
75
    protected function getSigner(): Signer
76
    {
77
        $signerKey = $this->getEnv('JWT_SIGNER_KEY');
78
        if (PathResolver::resolve($signerKey)) {
79
            return new Rsa\Sha256();
80
        } else {
81
            return new Hmac\Sha256();
82
        }
83
    }
84
85
    /**
86
     * Get private key
87
     *
88
     * @return Key
89
     */
90
    protected function getPrivateKey(): Key
91
    {
92
        $signerKey = $this->getEnv('JWT_SIGNER_KEY');
93
        $signerPath = PathResolver::resolve($signerKey);
94
        if ($signerPath) {
95
            $password = $this->getEnv('JWT_KEY_PASSWORD', null);
96
            return new Key('file://' . $signerPath, $password);
97
        }
98
        return new Key($signerKey);
99
    }
100
101
    /**
102
     * Get public key
103
     *
104
     * @return Key
105
     * @throws LogicException
106
     */
107
    private function getPublicKey(): Key
108
    {
109
        $signerKey = Environment::getEnv('JWT_SIGNER_KEY');
110
        $signerPath = PathResolver::resolve($signerKey);
111
        // If it's a private key, we also need a public key for validation!
112
        if (empty($signerPath)) {
113
            return new Key($signerKey);
114
        }
115
116
        // Ensure public key exists
117
        $publicKey = Environment::getEnv('JWT_PUBLIC_KEY');
118
        $publicPath = PathResolver::resolve($publicKey);
119
        if (empty($publicPath)) {
120
            throw new LogicException('JWT_PUBLIC_KEY path does not exist');
121
        }
122
        return new Key('file://' . $publicPath);
123
    }
124
125
    /**
126
     * JWT is stateless, therefore, we don't support anything but login
127
     *
128
     * @return int
129
     */
130
    public function supportedServices(): int
131
    {
132
        return Authenticator::LOGIN;
133
    }
134
135
    /**
136
     * @param array $data
137
     * @param HTTPRequest $request
138
     * @param ValidationResult|null $result
139
     * @return Member|null
140
     * @throws OutOfBoundsException
141
     * @throws BadMethodCallException
142
     * @throws Exception
143
     */
144
    public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null): ?Member
145
    {
146
        if (!$result) {
147
            $result = new ValidationResult();
148
        }
149
        $token = $data['token'];
150
151
        /** @var JWTRecord $record */
152
        list($record, $status) = $this->validateToken($token, $request);
153
154
        // Report success!
155
        if ($status === TokenStatusEnum::STATUS_OK) {
156
            return $record->Member();
157
        }
158
159
        // Add errors to result
160
        $result->addError(
161
            $this->getErrorMessage($status),
162
            ValidationResult::TYPE_ERROR,
163
            $status
164
        );
165
        return null;
166
    }
167
168
    /**
169
     * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user
170
     *
171
     * @param HTTPRequest $request
172
     * @param Member|MemberExtension $member
173
     * @return Token
174
     * @throws ValidationException
175
     */
176
    public function generateToken(HTTPRequest $request, Member $member): Token
177
    {
178
        $config = static::config();
179
        $uniqueID = uniqid($this->getEnv('JWT_PREFIX', ''), true);
180
181
        // Create new record
182
        $record = new JWTRecord();
183
        $record->UID = $uniqueID;
184
        $record->UserAgent = $request->getHeader('User-Agent');
185
        $member->AuthTokens()->add($record);
186
        if (!$record->isInDB()) {
187
            $record->write();
188
        }
189
190
        // Create builder for this record
191
        $builder = new Builder();
192
        $now = DBDatetime::now()->getTimestamp();
193
        $token = $builder
194
            // Configures the issuer (iss claim)
195
            ->setIssuer($request->getHeader('Origin'))
196
            // Configures the audience (aud claim)
197
            ->setAudience(Director::absoluteBaseURL())
198
            // Configures the id (jti claim), replicating as a header item
199
            ->setId($uniqueID, true)
200
            // Configures the time that the token was issue (iat claim)
201
            ->setIssuedAt($now)
202
            // Configures the time that the token can be used (nbf claim)
203
            ->setNotBefore($now + $config->get('nbf_time'))
204
            // Configures the expiration time of the token (nbf claim)
205
            ->setExpiration($now + $config->get('nbf_expiration'))
206
            // Set renew expiration
207
            ->set('rexp', $now + $config->get('nbf_refresh_expiration'))
208
            // Configures a new claim, called "rid"
209
            ->set('rid', $record->ID)
210
            // Set the subject, which is the member
211
            ->setSubject($member->getJWTData())
212
            // Sign the key with the Signer's key
213
            ->sign($this->getSigner(), $this->getPrivateKey());
214
215
        // Return the token
216
        return $token->getToken();
217
    }
218
219
    /**
220
     * @param string $token
221
     * @param HTTPRequest $request
222
     * @return array Array with JWTRecord and int status (STATUS_*)
223
     * @throws BadMethodCallException
224
     */
225
    public function validateToken(string $token, HTTPrequest $request): array
226
    {
227
        // Ensure token given at all
228
        if (!$token) {
229
            return [null, TokenStatusEnum::STATUS_INVALID];
230
        }
231
232
        // Parse token
233
        $parser = new Parser();
234
        try {
235
            $parsedToken = $parser->parse($token);
236
        } catch (Exception $ex) {
237
            // Un-parsable tokens are invalid
238
            return [null, TokenStatusEnum::STATUS_INVALID];
239
        }
240
241
        // Validate token against Id and user-agent
242
        $userAgent = $request->getHeader('User-Agent');
243
        /** @var JWTRecord $record */
244
        $record = JWTRecord::get()
245
            ->filter(['UserAgent' => $userAgent])
246
            ->byID($parsedToken->getClaim('rid'));
247
        if (!$record) {
0 ignored issues
show
introduced by
$record is of type Firesphere\GraphQLJWT\Model\JWTRecord, thus it always evaluated to true.
Loading history...
248
            return [null, TokenStatusEnum::STATUS_INVALID];
249
        }
250
251
        // Get validator for this token
252
        $now = DBDatetime::now()->getTimestamp();
253
        $validator = new ValidationData();
254
        $validator->setIssuer($request->getHeader('Origin'));
255
        $validator->setAudience(Director::absoluteBaseURL());
256
        $validator->setId($record->UID);
257
        $validator->setCurrentTime($now);
258
        $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

258
        $verified = $parsedToken->verify($this->getSigner(), /** @scrutinizer ignore-type */ $this->getPublicKey());
Loading history...
259
        $valid = $parsedToken->validate($validator);
260
261
        // If unverified, break
262
        if (!$verified) {
263
            return [$record, TokenStatusEnum::STATUS_INVALID];
264
        }
265
266
        // Verified and valid = ok!
267
        if ($valid) {
268
            return [$record, TokenStatusEnum::STATUS_OK];
269
        }
270
271
        // If the token is invalid, but not because it has expired, fail
272
        if (!$parsedToken->isExpired()) {
273
            return [$record, TokenStatusEnum::STATUS_INVALID];
274
        }
275
276
        // If expired, check if it can be renewed
277
        $renewBefore = $parsedToken->getClaim('rexp');
278
        if ($renewBefore > $now) {
279
            return [$record, TokenStatusEnum::STATUS_EXPIRED];
280
        }
281
282
        // If expired and cannot be renewed, it's dead
283
        return [$record, TokenStatusEnum::STATUS_DEAD];
284
    }
285
}
286