Passed
Push — master ( 558a62...4d34e4 )
by Fabian
02:20
created

Order   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 450
Duplicated Lines 0 %

Test Coverage

Coverage 24.44%

Importance

Changes 15
Bugs 0 Features 0
Metric Value
wmc 59
eloc 189
c 15
b 0
f 0
dl 0
loc 450
ccs 44
cts 180
cp 0.2444
rs 4.08

23 Methods

Rating   Name   Duplication   Size   Complexity  
A getAccount() 0 2 1
A _create() 0 15 2
A setPreferredChain() 0 2 1
A getSubjects() 0 3 1
A setHTTPAuthorizationDirectoryPath() 0 3 1
A __construct() 0 20 2
A create() 0 9 1
A exists() 0 4 1
C finalize() 0 82 12
A authorize() 0 13 2
A _clearAfterExpiredAuthorization() 0 8 1
A existsCertificateBundle() 0 4 1
A _getAuthorizer() 0 13 4
A shouldStartAuthorization() 0 9 2
A clear() 0 3 1
A _saveCertificate() 0 13 1
A isCertificateBundleAvailable() 0 3 1
A revokeCertificate() 0 19 3
B enableAutoRenewal() 0 33 7
A _getExpireTimeFromCertificateDirectoryPath() 0 16 5
A get() 0 13 2
A _getLatestCertificateDirectory() 0 16 5
A getCertificateBundle() 0 14 2

How to fix   Complexity   

Complex Class

Complex classes like Order often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Order, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LE_ACME2;
4
5
use LE_ACME2\Request;
6
use LE_ACME2\Response;
7
8
use LE_ACME2\Cache;
9
use LE_ACME2\Authorizer;
10
use LE_ACME2\Exception;
11
use LE_ACME2\Utilities;
12
13
class Order extends AbstractKeyValuable {
14
15
    const CHALLENGE_TYPE_HTTP = 'http-01';
16
    const CHALLENGE_TYPE_DNS = 'dns-01';
17
18
    /**
19
     * @deprecated
20
     * @param $directoryPath
21
     */
22
    public static function setHTTPAuthorizationDirectoryPath(string $directoryPath) {
23
24
        Authorizer\HTTP::setDirectoryPath($directoryPath);
25
    }
26
27
    CONST IDENTRUST_ISSUER_CN = 'DST Root CA X3';
28
29
    /** @var string|null $_preferredChain */
30
    private static $_preferredChain = null;
31
32
    public static function setPreferredChain(string $issuerCN = null) {
33
        self::$_preferredChain = $issuerCN;
34
    }
35
36
    protected $_account;
37
    protected $_subjects;
38
39 3
    public function __construct(Account $account, array $subjects) {
40
41 3
        array_map(function($subject) {
42
43 3
            if(preg_match_all('~(\*\.)~', $subject) > 1)
44
                throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.');
45
46
        }, $subjects);
47
48 3
        $this->_account = $account;
49 3
        $this->_subjects = $subjects;
50
51 3
        $this->_identifier = $this->_getAccountIdentifier($account) . DIRECTORY_SEPARATOR .
52 3
            'order_' . md5(implode('|', $subjects));
53
54 3
        Utilities\Logger::getInstance()->add(
0 ignored issues
show
Bug introduced by
It seems like add() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

54
        Utilities\Logger::getInstance()->/** @scrutinizer ignore-call */ add(
Loading history...
55
            Utilities\Logger::LEVEL_DEBUG,
56 3
            get_class() . '::' . __FUNCTION__ .
57 3
            ' subject: "' . implode(':', $this->getSubjects()) . '" ' .
58 3
            ' path: ' . $this->getKeyDirectoryPath()
59
        );
60
    }
61
62 2
    public function getAccount() : Account {
63 2
        return $this->_account;
64
    }
65
66 3
    public function getSubjects() : array {
67
68 3
        return $this->_subjects;
69
    }
70
71
    /**
72
     * @param Account $account
73
     * @param array $subjects
74
     * @param string $keyType
75
     * @return Order
76
     * @throws Exception\AbstractException
77
     */
78 2
    public static function create(Account $account, array $subjects, string $keyType = self::KEY_TYPE_RSA) : Order {
79
80 2
        Utilities\Logger::getInstance()->add(
81
            Utilities\Logger::LEVEL_INFO,
82 2
            get_class() . '::' . __FUNCTION__ .  ' "' . implode(':', $subjects) . '"'
83
        );
84
85 2
        $order = new self($account, $subjects);
86 2
        return $order->_create($keyType, false);
87
    }
88
89
    /**
90
     * @param $keyType
91
     * @param bool $ignoreIfKeysExist
92
     * @return Order
93
     * @throws Exception\AbstractException
94
     */
95 2
    protected function _create(string $keyType, bool $ignoreIfKeysExist = false) : Order {
96
97 2
        $this->_initKeyDirectory($keyType, $ignoreIfKeysExist);
98
99 2
        $request = new Request\Order\Create($this);
100
101
        try {
102 2
            $response = $request->getResponse();
103
104 2
            Cache\OrderResponse::getInstance()->set($this, $response);
0 ignored issues
show
Bug introduced by
It seems like set() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

104
            Cache\OrderResponse::getInstance()->/** @scrutinizer ignore-call */ set($this, $response);
Loading history...
105 2
            return $this;
106
107
        } catch(Exception\AbstractException $e) {
108
            $this->_clearKeyDirectory();
109
            throw $e;
110
        }
111
    }
112
113
    /**
114
     * Returns true, when a let's encrypt order exists
115
     * Returns false, when no order exists, because it was never created or cleared
116
     */
117 3
    public static function exists(Account $account, array $subjects) : bool {
118
119 3
        $order = new self($account, $subjects);
120 3
        return Cache\OrderResponse::getInstance()->exists($order);
0 ignored issues
show
Bug introduced by
It seems like exists() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

120
        return Cache\OrderResponse::getInstance()->/** @scrutinizer ignore-call */ exists($order);
Loading history...
121
    }
122
123
    /**
124
     * Returns true, when a certificate bundle exists, irrespective from the existence of a let's encrypt order
125
     */
126 1
    public static function existsCertificateBundle(Account $account, array $subjects) : bool {
127
128 1
        $order = new self($account, $subjects);
129 1
        return $order->isCertificateBundleAvailable();
130
    }
131
132 3
    public static function get(Account $account, array $subjects) : Order {
133
134 3
        Utilities\Logger::getInstance()->add(
135
            Utilities\Logger::LEVEL_INFO,
136 3
            get_class() . '::' . __FUNCTION__ .  ' "' . implode(':', $subjects) . '"'
137
        );
138
139 3
        $order = new self($account, $subjects);
140
141 3
        if(!self::exists($account, $subjects))
142 1
            throw new \RuntimeException('Order does not exist');
143
144 2
        return $order;
145
    }
146
147
    /** @var Authorizer\AbstractAuthorizer|Authorizer\HTTP|null $_authorizer  */
148
    protected $_authorizer = null;
149
150
    /**
151
     * @param $type
152
     * @return Authorizer\AbstractAuthorizer|Authorizer\HTTP|null
153
     * @throws Exception\InvalidResponse
154
     * @throws Exception\RateLimitReached
155
     * @throws Exception\ExpiredAuthorization
156
     */
157
    protected function _getAuthorizer(string $type) : Authorizer\AbstractAuthorizer {
158
159
        if($this->_authorizer === null) {
160
161
            if($type == self::CHALLENGE_TYPE_HTTP) {
162
                $this->_authorizer = new Authorizer\HTTP($this->_account, $this);
163
            } else if($type == self::CHALLENGE_TYPE_DNS) {
164
                $this->_authorizer = new Authorizer\DNS($this->_account, $this);
165
            } else {
166
                throw new \RuntimeException('Challenge type not implemented');
167
            }
168
        }
169
        return $this->_authorizer;
170
    }
171
172
    /**
173
     * The Authorization has expired, so we clean the complete order to restart again on the next call
174
     */
175
    protected function _clearAfterExpiredAuthorization() {
176
177
        Utilities\Logger::getInstance()->add(
178
            Utilities\Logger::LEVEL_INFO,
179
            get_class() . '::' . __FUNCTION__ . ' "Will clear after expired authorization'
180
        );
181
182
        $this->clear();
183
    }
184
185
    public function clear() {
186
        Cache\OrderResponse::getInstance()->set($this, null);
187
        $this->_clearKeyDirectory();
188
    }
189
190
    /**
191
     * @return bool
192
     * @param $type
193
     * @throws Exception\InvalidResponse
194
     * @throws Exception\RateLimitReached
195
     */
196
    public function shouldStartAuthorization(string $type) : bool {
197
198
        try {
199
            return $this->_getAuthorizer($type)->shouldStartAuthorization();
200
        } catch(Exception\ExpiredAuthorization $e) {
201
202
            $this->_clearAfterExpiredAuthorization();
203
204
            return false;
205
        }
206
    }
207
208
    /**
209
     * @param $type
210
     * @return bool
211
     * @throws Exception\InvalidResponse
212
     * @throws Exception\RateLimitReached
213
     * @throws Exception\AuthorizationInvalid
214
     */
215
    public function authorize(string $type) : bool {
216
217
        try {
218
            $authorizer = $this->_getAuthorizer($type);
219
            $authorizer->progress();
220
221
            return $authorizer->hasFinished();
222
223
        } catch(Exception\ExpiredAuthorization $e) {
224
225
            $this->_clearAfterExpiredAuthorization();
226
227
            return false;
228
        }
229
    }
230
231
    /**
232
     * @throws Exception\InvalidResponse
233
     * @throws Exception\RateLimitReached
234
     * @throws Exception\OpenSSLException
235
     */
236
    public function finalize() {
237
238
        if(!is_object($this->_authorizer) || !$this->_authorizer->hasFinished()) {
239
240
            throw new \RuntimeException('Not all challenges are valid. Please check result of authorize() first!');
241
        }
242
243
        Utilities\Logger::getInstance()->add(
244
            Utilities\Logger::LEVEL_INFO,
245
            get_class() . '::' . __FUNCTION__ . ' "Will finalize'
246
        );
247
248
        $orderResponse = Cache\OrderResponse::getInstance()->get($this);
0 ignored issues
show
Bug introduced by
It seems like get() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

248
        $orderResponse = Cache\OrderResponse::getInstance()->/** @scrutinizer ignore-call */ get($this);
Loading history...
249
250
        if(
251
            $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_PENDING /* DEPRECATED AFTER JULI 5TH 2018 */ ||
252
            $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_READY   // ACME draft-12 Section 7.1.6
253
        ) {
254
            $request = new Request\Order\Finalize($this, $orderResponse);
255
            $orderResponse = $request->getResponse();
256
257
            $this->_authorizer = null; // Reset Authorizer to prevent that the certificate is written multiple times, when this is called multiple times
258
            Cache\OrderResponse::getInstance()->set($this, $orderResponse);
259
        }
260
261
        if($orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_VALID) {
262
263
            $request = new Request\Order\GetCertificate($this, $orderResponse);
264
            $response = $request->getResponse();
265
266
            $certificate = $response->getCertificate();
267
            $intermediate = $response->getIntermediate();
268
269
            //$certificateInfo = openssl_x509_parse($certificate);
270
            //$certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t'];
271
            $intermediateInfo = openssl_x509_parse($intermediate);
272
273
            if(self::$_preferredChain !== null) {
274
                Utilities\Logger::getInstance()->add(
275
                    Utilities\Logger::LEVEL_INFO,
276
                    'Preferred chain is set: ' . self::$_preferredChain,
277
                );
278
            }
279
280
            $found = false;
281
            if(self::$_preferredChain !== null && $intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
282
283
                Utilities\Logger::getInstance()->add(
284
                    Utilities\Logger::LEVEL_INFO,
285
                    'Default certificate does not satisfy preferred chain, trying to fetch alternative'
286
                );
287
288
                foreach($response->getAlternativeLinks() as $link) {
289
290
                    $request = new Request\Order\GetCertificate($this, $orderResponse, $link);
291
                    $response = $request->getResponse();
292
293
                    $alternativeCertificate = $response->getCertificate();
294
                    $alternativeIntermediate = $response->getIntermediate();
295
296
                    $intermediateInfo = openssl_x509_parse($intermediate);
297
                    if($intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
298
                        continue;
299
                    }
300
301
                    $found = true;
302
303
                    $certificate = $alternativeCertificate;
304
                    $intermediate = $alternativeIntermediate;
305
306
                    break;
307
                }
308
309
                if(!$found) {
310
                    Utilities\Logger::getInstance()->add(
311
                        Utilities\Logger::LEVEL_INFO,
312
                        'Preferred chain could not be satisfied, returning default chain'
313
                    );
314
                }
315
            }
316
            Cache\OrderAuthorizationResponse::getInstance()->clear($this);
0 ignored issues
show
Bug introduced by
It seems like clear() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

316
            Cache\OrderAuthorizationResponse::getInstance()->/** @scrutinizer ignore-call */ clear($this);
Loading history...
317
            $this->_saveCertificate($certificate, $intermediate);
318
        }
319
    }
320
321
    private function _saveCertificate(string $certificate, string $intermediate) : void {
322
323
        $certificateInfo = openssl_x509_parse($certificate);
324
        $certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t'];
325
326
        $path = $this->getKeyDirectoryPath() . self::BUNDLE_DIRECTORY_PREFIX . $certificateValidToTimeTimestamp . DIRECTORY_SEPARATOR;
327
328
        mkdir($path);
329
        rename($this->getKeyDirectoryPath() . 'private.pem', $path . 'private.pem');
330
        file_put_contents($path . 'certificate.crt', $certificate);
331
        file_put_contents($path . 'intermediate.pem', $intermediate);
332
333
        Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Certificate received');
334
    }
335
336
    const BUNDLE_DIRECTORY_PREFIX = 'bundle_';
337
338 1
    protected function _getLatestCertificateDirectory() : ?string {
339
340 1
        if(!file_exists($this->getKeyDirectoryPath())) {
341 1
            return null;
342
        }
343
344
        $files = scandir($this->getKeyDirectoryPath(), SORT_NUMERIC | SORT_DESC);
345
        foreach($files as $file) {
346
            if(
347
                substr($file, 0, strlen(self::BUNDLE_DIRECTORY_PREFIX)) == self::BUNDLE_DIRECTORY_PREFIX &&
348
                is_dir($this->getKeyDirectoryPath() . $file)
349
            ) {
350
                return $file;
351
            }
352
        }
353
        return null;
354
    }
355
356 1
    public function isCertificateBundleAvailable() : bool {
357
358 1
        return $this->_getLatestCertificateDirectory() !== NULL;
359
    }
360
361
    public function getCertificateBundle() : Struct\CertificateBundle {
362
363
        if(!$this->isCertificateBundleAvailable()) {
364
            throw new \RuntimeException('There is no certificate available');
365
        }
366
367
        $certificatePath = $this->getKeyDirectoryPath() . $this->_getLatestCertificateDirectory();
368
369
        return new Struct\CertificateBundle(
370
            $certificatePath . DIRECTORY_SEPARATOR,
371
            'private.pem',
372
            'certificate.crt',
373
            'intermediate.pem',
374
            self::_getExpireTimeFromCertificateDirectoryPath($certificatePath)
375
        );
376
    }
377
378
    /**
379
     * @param string|null $keyType default KEY_TYPE_RSA
380
     * @param int|null $renewBefore Unix timestamp
381
     * @throws Exception\AbstractException
382
     */
383
    public function enableAutoRenewal(string $keyType = null, int $renewBefore = null) {
384
385
        if($keyType === null) {
386
            $keyType = self::KEY_TYPE_RSA;
387
        }
388
389
        if(!$this->isCertificateBundleAvailable()) {
390
            throw new \RuntimeException('There is no certificate available');
391
        }
392
393
        $orderResponse = Cache\OrderResponse::getInstance()->get($this);
394
        if(
395
            $orderResponse === null ||
396
            $orderResponse->getStatus() != Response\Order\AbstractOrder::STATUS_VALID
397
        ) {
398
            return;
399
        }
400
401
        Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_DEBUG,'Auto renewal triggered');
402
403
        $directory = $this->_getLatestCertificateDirectory();
404
405
        $expireTime = self::_getExpireTimeFromCertificateDirectoryPath($directory);
406
407
        if($renewBefore === null) {
408
            $renewBefore = strtotime('-30 days', $expireTime);
409
        }
410
411
        if($renewBefore < time()) {
412
413
            Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO,'Auto renewal: Will recreate order');
414
415
            $this->_create($keyType, true);
416
        }
417
    }
418
419
    /**
420
     * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate.
421
     *                    Possible reasons can be found in section 5.3.1 of RFC5280.
422
     * @return bool
423
     * @throws Exception\RateLimitReached
424
     */
425
    public function revokeCertificate(int $reason = 0) : bool {
426
427
        if(!$this->isCertificateBundleAvailable()) {
428
            throw new \RuntimeException('There is no certificate available to revoke');
429
        }
430
431
        $bundle = $this->getCertificateBundle();
432
433
        $request = new Request\Order\RevokeCertificate($bundle, $reason);
434
435
        try {
436
            /* $response = */ $request->getResponse();
437
            rename(
438
                $this->getKeyDirectoryPath(),
439
                $this->_getKeyDirectoryPath('-revoked-' . microtime(true))
440
            );
441
            return true;
442
        } catch(Exception\InvalidResponse $e) {
443
            return false;
444
        }
445
    }
446
447
    protected static function _getExpireTimeFromCertificateDirectoryPath(string $path) : int {
448
449
        $stringPosition = strrpos($path, self::BUNDLE_DIRECTORY_PREFIX);
450
        if($stringPosition === false) {
451
            throw new \RuntimeException('ExpireTime not found in' . $path);
452
        }
453
454
        $expireTime = substr($path, $stringPosition + strlen(self::BUNDLE_DIRECTORY_PREFIX));
455
        if(
456
            !is_numeric($expireTime) ||
457
            $expireTime < strtotime('-10 years') ||
458
            $expireTime > strtotime('+10 years')
459
        ) {
460
            throw new \RuntimeException('Unexpected expireTime: ' . $expireTime);
461
        }
462
        return (int)$expireTime;
463
    }
464
}