Order   F
last analyzed

Complexity

Total Complexity 61

Size/Duplication

Total Lines 453
Duplicated Lines 0 %

Test Coverage

Coverage 25.14%

Importance

Changes 20
Bugs 1 Features 0
Metric Value
wmc 61
eloc 195
dl 0
loc 453
rs 3.52
c 20
b 1
f 0
ccs 46
cts 183
cp 0.2514

24 Methods

Rating   Name   Duplication   Size   Complexity  
A getAccount() 0 2 1
A isCertificateBundleAvailable() 0 3 1
A exists() 0 4 1
A revokeCertificate() 0 19 3
C finalize() 0 83 12
A __construct() 0 20 2
A requestCreate() 0 14 2
A authorize() 0 13 2
A existsCertificateBundle() 0 4 1
B enableAutoRenewal() 0 38 7
A _clearAfterExpiredAuthorization() 0 8 1
A create() 0 11 1
A _getExpireTimeFromCertificateDirectoryPath() 0 16 5
A get() 0 13 2
A _getAuthorizer() 0 13 4
A _getLatestCertificateDirectory() 0 16 5
A setPreferredChain() 0 2 1
A getSubjects() 0 3 1
A shouldStartAuthorization() 0 9 2
A setHTTPAuthorizationDirectoryPath() 0 3 1
A clear() 0 3 1
A getCertificateBundle() 0 14 2
A hasResponse() 0 2 1
A _saveCertificate() 0 17 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
     */
21
    public static function setHTTPAuthorizationDirectoryPath(string $directoryPath) {
22
23
        Authorizer\HTTP::setDirectoryPath($directoryPath);
24
    }
25
26
    CONST IDENTRUST_ISSUER_CN = 'DST Root CA X3';
27
28
    /** @var string|null $_preferredChain */
29
    private static $_preferredChain = null;
30
31
    public static function setPreferredChain(string $issuerCN = null) {
32
        self::$_preferredChain = $issuerCN;
33
    }
34
35
    protected $_account;
36
    protected $_subjects;
37
38
    public function __construct(Account $account, array $subjects) {
39 3
40
        array_map(function($subject) {
41 3
42
            if(preg_match_all('~(\*\.)~', $subject) > 1)
43 3
                throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.');
44
45
        }, $subjects);
46
47
        $this->_account = $account;
48 3
        $this->_subjects = $subjects;
49 3
50
        $this->_identifier = $this->_getAccountIdentifier($account) . DIRECTORY_SEPARATOR .
51 3
            'order_' . md5(implode('|', $subjects));
52 3
53
        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

53
        Utilities\Logger::getInstance()->/** @scrutinizer ignore-call */ add(
Loading history...
54 3
            Utilities\Logger::LEVEL_DEBUG,
55
            static::class . '::' . __FUNCTION__ .
56 3
            ' subject: "' . implode(':', $this->getSubjects()) . '" ' .
57 3
            ' path: ' . $this->getKeyDirectoryPath()
58 3
        );
59
    }
60
61
    public function getAccount() : Account {
62 2
        return $this->_account;
63 2
    }
64
65
    public function getSubjects() : array {
66 3
67
        return $this->_subjects;
68 3
    }
69
70
    /**
71
     * @throws Exception\AbstractException
72
     */
73
    public static function create(Account $account, array $subjects, string $keyType = self::KEY_TYPE_RSA) : Order {
74 2
75
        Utilities\Logger::getInstance()->add(
76 2
            Utilities\Logger::LEVEL_INFO,
77
            static::class . '::' . __FUNCTION__ .  ' "' . implode(':', $subjects) . '"'
78 2
        );
79
80
        $order = new self($account, $subjects);
81 2
        $order->requestCreate($keyType, false);
82 2
83
        return $order;
84 2
    }
85
86
    /**
87
     * Request to create a new order
88
     *
89
     * @throws Exception\AbstractException
90
     */
91
    public function requestCreate(string $keyType = self::KEY_TYPE_RSA, bool $ignoreIfKeysExist = false) : void {
92 2
93
        $this->_initKeyDirectory($keyType, $ignoreIfKeysExist);
94 2
95
        $request = new Request\Order\Create($this);
96 2
97
        try {
98
            $response = $request->getResponse();
99 2
100
            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

100
            Cache\OrderResponse::getInstance()->/** @scrutinizer ignore-call */ set($this, $response);
Loading history...
101 2
102
        } catch(Exception\AbstractException $e) {
103
            $this->_clearKeyDirectory();
104
            throw $e;
105
        }
106
    }
107
108
    /**
109
     * Returns true, when a let's encrypt order exists
110
     * Returns false, when no order exists, because it was never created or cleared
111
     */
112
    public static function exists(Account $account, array $subjects) : bool {
113 3
114
        $order = new self($account, $subjects);
115 3
        return $order->hasResponse();
116 3
    }
117
    
118
    public function hasResponse() : bool {
119 3
        return Cache\OrderResponse::getInstance()->exists($this);
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

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

241
        $orderResponse = Cache\OrderResponse::getInstance()->/** @scrutinizer ignore-call */ get($this);
Loading history...
242
243
        if(
244
            $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_PENDING /* DEPRECATED AFTER JULI 5TH 2018 */ ||
245
            $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_READY   // ACME draft-12 Section 7.1.6
246
        ) {
247
            $request = new Request\Order\Finalize($this, $orderResponse);
248
            $orderResponse = $request->getResponse();
249
250
            $this->_authorizer = null; // Reset Authorizer to prevent that the certificate is written multiple times, when this is called multiple times
251
            Cache\OrderResponse::getInstance()->set($this, $orderResponse);
252
        }
253
254
        if($orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_VALID) {
255
256
            $request = new Request\Order\GetCertificate($this, $orderResponse);
257
            $response = $request->getResponse();
258
259
            $certificate = $response->getCertificate();
260
            $intermediate = $response->getIntermediate();
261
262
            //$certificateInfo = openssl_x509_parse($certificate);
263
            //$certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t'];
264
            $intermediateInfo = openssl_x509_parse($intermediate);
265
266
            if(self::$_preferredChain !== null) {
267
                Utilities\Logger::getInstance()->add(
268
                    Utilities\Logger::LEVEL_INFO,
269
                    'Preferred chain is set: ' . self::$_preferredChain,
270
                );
271
            }
272
273
            $found = false;
274
            if(self::$_preferredChain !== null && $intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
275
276
                Utilities\Logger::getInstance()->add(
277
                    Utilities\Logger::LEVEL_INFO,
278
                    'Default certificate does not satisfy preferred chain, trying to fetch alternative'
279
                );
280
281
                foreach($response->getAlternativeLinks() as $link) {
282
283
                    $request = new Request\Order\GetCertificate($this, $orderResponse, $link);
284
                    $response = $request->getResponse();
285
286
                    $alternativeCertificate = $response->getCertificate();
287
                    $alternativeIntermediate = $response->getIntermediate();
288
289
                    $intermediateInfo = openssl_x509_parse($intermediate);
290
                    if($intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
291
                        continue;
292
                    }
293
294
                    $found = true;
295
296
                    $certificate = $alternativeCertificate;
297
                    $intermediate = $alternativeIntermediate;
298
299
                    break;
300
                }
301
302
                if(!$found) {
303
                    Utilities\Logger::getInstance()->add(
304
                        Utilities\Logger::LEVEL_INFO,
305
                        'Preferred chain could not be satisfied, returning default chain'
306
                    );
307
                }
308
            }
309
            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

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