Passed
Branch master (fc5382)
by Fabian
03:13
created

Order   C

Complexity

Total Complexity 57

Size/Duplication

Total Lines 433
Duplicated Lines 0 %

Test Coverage

Coverage 20.81%

Importance

Changes 14
Bugs 0 Features 0
Metric Value
wmc 57
eloc 185
dl 0
loc 433
ccs 41
cts 197
cp 0.2081
rs 5.04
c 14
b 0
f 0

22 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 82 12
A __construct() 0 20 2
A authorize() 0 13 2
A _clearAfterExpiredAuthorization() 0 8 1
A _create() 0 15 2
B enableAutoRenewal() 0 33 7
A _getExpireTimeFromCertificateDirectoryPath() 0 16 5
A create() 0 9 1
A get() 0 13 2
A _getLatestCertificateDirectory() 0 12 4
A _getAuthorizer() 0 13 4
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 _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 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 3
        }, $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 3
            Utilities\Logger::LEVEL_DEBUG,
56 3
            get_class() . '::' . __FUNCTION__ .
57 3
            ' subject: "' . implode(':', $this->getSubjects()) . '" ' .
58 3
            ' path: ' . $this->getKeyDirectoryPath()
59
        );
60 3
    }
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 2
            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 3
    public static function exists(Account $account, array $subjects) : bool {
114
115 3
        $order = new self($account, $subjects);
116 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

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

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

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