Test Failed
Push — master ( 5093c2...558a62 )
by Fabian
02:30
created

Order   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 446
Duplicated Lines 0 %

Test Coverage

Coverage 12.92%

Importance

Changes 14
Bugs 0 Features 0
Metric Value
wmc 58
eloc 187
c 14
b 0
f 0
dl 0
loc 446
rs 4.5599
ccs 23
cts 178
cp 0.1292

23 Methods

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

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 1
    public function __construct(Account $account, array $subjects) {
40
41 1
        array_map(function($subject) {
42
43 1
            if(preg_match_all('~(\*\.)~', $subject) > 1)
44
                throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.');
45
46
        }, $subjects);
47
48 1
        $this->_account = $account;
49 1
        $this->_subjects = $subjects;
50
51 1
        $this->_identifier = $this->_getAccountIdentifier($account) . DIRECTORY_SEPARATOR .
52 1
            'order_' . md5(implode('|', $subjects));
53
54 1
        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 1
            get_class() . '::' . __FUNCTION__ .
57 1
            ' subject: "' . implode(':', $this->getSubjects()) . '" ' .
58 1
            ' path: ' . $this->getKeyDirectoryPath()
59
        );
60
    }
61
62
    public function getAccount() : Account {
63
        return $this->_account;
64
    }
65
66 1
    public function getSubjects() : array {
67
68 1
        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
    public static function create(Account $account, array $subjects, string $keyType = self::KEY_TYPE_RSA) : Order {
79
80
        Utilities\Logger::getInstance()->add(
81
            Utilities\Logger::LEVEL_INFO,
82
            get_class() . '::' . __FUNCTION__ .  ' "' . implode(':', $subjects) . '"'
83
        );
84
85
        $order = new self($account, $subjects);
86
        return $order->_create($keyType, false);
87
    }
88
89
    /**
90
     * @param $keyType
91
     * @param bool $ignoreIfKeysExist
92
     * @return Order
93
     * @throws Exception\AbstractException
94
     */
95
    protected function _create(string $keyType, bool $ignoreIfKeysExist = false) : Order {
96
97
        $this->_initKeyDirectory($keyType, $ignoreIfKeysExist);
98
99
        $request = new Request\Order\Create($this);
100
101
        try {
102
            $response = $request->getResponse();
103
104
            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
            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 1
    public static function exists(Account $account, array $subjects) : bool {
118
119 1
        $order = new self($account, $subjects);
120 1
        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
    public static function get(Account $account, array $subjects) : Order {
133
134
        Utilities\Logger::getInstance()->add(
135
            Utilities\Logger::LEVEL_INFO,
136
            get_class() . '::' . __FUNCTION__ .  ' "' . implode(':', $subjects) . '"'
137
        );
138
139
        $order = new self($account, $subjects);
140
141
        if(!self::exists($account, $subjects))
142
            throw new \RuntimeException('Order does not exist');
143
144
        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
        $files = scandir($this->getKeyDirectoryPath(), SORT_NUMERIC | SORT_DESC);
341
        foreach($files as $file) {
342
            if(
343
                substr($file, 0, strlen(self::BUNDLE_DIRECTORY_PREFIX)) == self::BUNDLE_DIRECTORY_PREFIX &&
344
                is_dir($this->getKeyDirectoryPath() . $file)
345
            ) {
346
                return $file;
347
            }
348
        }
349
        return null;
350
    }
351
352 1
    public function isCertificateBundleAvailable() : bool {
353
354 1
        return $this->_getLatestCertificateDirectory() !== NULL;
355
    }
356
357
    public function getCertificateBundle() : Struct\CertificateBundle {
358
359
        if(!$this->isCertificateBundleAvailable()) {
360
            throw new \RuntimeException('There is no certificate available');
361
        }
362
363
        $certificatePath = $this->getKeyDirectoryPath() . $this->_getLatestCertificateDirectory();
364
365
        return new Struct\CertificateBundle(
366
            $certificatePath . DIRECTORY_SEPARATOR,
367
            'private.pem',
368
            'certificate.crt',
369
            'intermediate.pem',
370
            self::_getExpireTimeFromCertificateDirectoryPath($certificatePath)
371
        );
372
    }
373
374
    /**
375
     * @param string|null $keyType default KEY_TYPE_RSA
376
     * @param int|null $renewBefore Unix timestamp
377
     * @throws Exception\AbstractException
378
     */
379
    public function enableAutoRenewal(string $keyType = null, int $renewBefore = null) {
380
381
        if($keyType === null) {
382
            $keyType = self::KEY_TYPE_RSA;
383
        }
384
385
        if(!$this->isCertificateBundleAvailable()) {
386
            throw new \RuntimeException('There is no certificate available');
387
        }
388
389
        $orderResponse = Cache\OrderResponse::getInstance()->get($this);
390
        if(
391
            $orderResponse === null ||
392
            $orderResponse->getStatus() != Response\Order\AbstractOrder::STATUS_VALID
393
        ) {
394
            return;
395
        }
396
397
        Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_DEBUG,'Auto renewal triggered');
398
399
        $directory = $this->_getLatestCertificateDirectory();
400
401
        $expireTime = self::_getExpireTimeFromCertificateDirectoryPath($directory);
402
403
        if($renewBefore === null) {
404
            $renewBefore = strtotime('-30 days', $expireTime);
405
        }
406
407
        if($renewBefore < time()) {
408
409
            Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO,'Auto renewal: Will recreate order');
410
411
            $this->_create($keyType, true);
412
        }
413
    }
414
415
    /**
416
     * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate.
417
     *                    Possible reasons can be found in section 5.3.1 of RFC5280.
418
     * @return bool
419
     * @throws Exception\RateLimitReached
420
     */
421
    public function revokeCertificate(int $reason = 0) : bool {
422
423
        if(!$this->isCertificateBundleAvailable()) {
424
            throw new \RuntimeException('There is no certificate available to revoke');
425
        }
426
427
        $bundle = $this->getCertificateBundle();
428
429
        $request = new Request\Order\RevokeCertificate($bundle, $reason);
430
431
        try {
432
            /* $response = */ $request->getResponse();
433
            rename(
434
                $this->getKeyDirectoryPath(),
435
                $this->_getKeyDirectoryPath('-revoked-' . microtime(true))
436
            );
437
            return true;
438
        } catch(Exception\InvalidResponse $e) {
439
            return false;
440
        }
441
    }
442
443
    protected static function _getExpireTimeFromCertificateDirectoryPath(string $path) : int {
444
445
        $stringPosition = strrpos($path, self::BUNDLE_DIRECTORY_PREFIX);
446
        if($stringPosition === false) {
447
            throw new \RuntimeException('ExpireTime not found in' . $path);
448
        }
449
450
        $expireTime = substr($path, $stringPosition + strlen(self::BUNDLE_DIRECTORY_PREFIX));
451
        if(
452
            !is_numeric($expireTime) ||
453
            $expireTime < strtotime('-10 years') ||
454
            $expireTime > strtotime('+10 years')
455
        ) {
456
            throw new \RuntimeException('Unexpected expireTime: ' . $expireTime);
457
        }
458
        return (int)$expireTime;
459
    }
460
}