Completed
Branch master (09022f)
by Gareth
05:56 queued 03:06
created

EWSAutodiscover::responseToArray()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 8
ccs 0
cts 7
cp 0
rs 9.4285
cc 1
eloc 5
nc 1
nop 1
crap 2
1
<?php
2
namespace garethp\ews\API;
3
4
use garethp\ews\API;
5
use garethp\ews\API\Exception\AutoDiscoverFailed;
6
use garethp\ews\HttpPlayback\HttpPlayback;
7
use XMLWriter;
8
9
/**
10
 * Contains EWSAutodiscover.
11
 */
12
13
/**
14
 * Exchange Web Services Autodiscover implementation
15
 *
16
 * This class supports POX (Plain Old XML), which is deprecated but functional
17
 * in Exchange 2010. It may make sense for you to combine your Autodiscovery
18
 * efforts with a SOAP Autodiscover request as well.
19
 *
20
 * USAGE:
21
 *
22
 * (after any auto-loading class incantation)
23
 *
24
 * $ews = EWSAutodiscover::getEWS($email, $password);
25
 *
26
 * -- OR --
27
 *
28
 * If there are issues with your cURL installation that require you to specify
29
 * a path to a valid Certificate Authority, you can configure that manually.
30
 *
31
 * $auto = new EWSAutodiscover($email, $password);
32
 * $auto->setCAInfo('/path/to/your/cacert.pem');
33
 * $ews = $auto->newEWS();
34
 *
35
 * @link http://technet.microsoft.com/en-us/library/bb332063(EXCHG.80).aspx
36
 * @link https://www.testexchangeconnectivity.com/
37
 *
38
 * @package php-ews\AutoDiscovery
39
 */
40
class EWSAutodiscover
41
{
42
    /**
43
     * The path appended to the various schemes and hostnames used during
44
     * autodiscovery.
45
     *
46
     * @var string
47
     */
48
    const AUTODISCOVER_PATH = '/autodiscover/autodiscover.xml';
49
50
    /**
51
     * The Autodiscover XML request. Since it's used repeatedly, it's cached
52
     * in this property to avoid redundant re-generation.
53
     *
54
     * @var string
55
     */
56
    protected $requestXML;
57
58
    /**
59
     * The Certificate Authority path. Should point to a directory containing
60
     * one or more certificates to use in SSL verification.
61
     *
62
     * @var string
63
     */
64
    protected $certificateAuthorityPath;
65
66
    /**
67
     * The path to a specific Certificate Authority file. Get one and use it
68
     * for full Autodiscovery compliance.
69
     *
70
     * @var string
71
     *
72
     * @link http://curl.haxx.se/ca/cacert.pem
73
     * @link http://curl.haxx.se/ca/
74
     */
75
    protected $certificateAuthorityInfo;
76
77
    /**
78
     * @var HttpPlayback
79
     */
80
    protected $httpPlayback;
81
82
    /**
83
     * An associative array of response headers that resulted from the
84
     * last request. Keys are lowercased for easy checking.
85
     *
86
     * @var array
87
     */
88
    public $last_response_headers;
89
90
    /**
91
     * The result of the most recent curl_exec.
92
     *
93
     * @var mixed
94
     */
95
    public $last_response;
96
97
    /**
98
     * The output of curl_info() relating to the most recent cURL request.
99
     *
100
     * @var mixed
101
     */
102
    public $last_info;
103
104
    /**
105
     * The cURL error code associated with the most recent cURL request.
106
     *
107
     * @var integer
108
     */
109
    public $last_curl_errno;
110
111
    /**
112
     * Human-readable description of the most recent cURL error.
113
     *
114
     * @var string
115
     */
116
    public $last_curl_error;
117
118
    /**
119
     * Information about an Autodiscover Response containing an error will
120
     * be stored here.
121
     *
122
     * @var mixed
123
     */
124
    public $error = false;
125
126
    /**
127
     * Information about an Autodiscover Response with a redirect will be
128
     * retained here.
129
     *
130
     * @var mixed
131
     */
132
    public $redirect = false;
133
134
    /**
135
     * A successful, non-error and non-redirect parsed Autodiscover response
136
     * will be stored here.
137
     *
138
     * @var mixed
139
     */
140
    public $discovered = null;
141
142
    protected function __construct()
143
    {
144
    }
145
146
    /**
147
     * Parse the hex ServerVersion value and return a valid
148
     * ExchangeWebServices::VERSION_* constant.
149
     *
150
     * @param $version_hex
151
     * @return string|boolean A known version constant, or FALSE if it could not
152
     * be determined.
153
     *
154
     * @link http://msdn.microsoft.com/en-us/library/bb204122(v=exchg.140).aspx
155
     * @link http://blogs.msdn.com/b/pcreehan/archive/2009/09/21/parsing-serverversion-when-an-int-is-really-5-ints.aspx
156
     */
157
    protected function parseServerVersion($version_hex)
158
    {
159
        $svbinary = base_convert($version_hex, 16, 2);
160
        if (strlen($svbinary) == 31) {
161
            $svbinary = '0' . $svbinary;
162
        }
163
164
        $majorversion = base_convert(substr($svbinary, 4, 6), 2, 10);
165
        $minorversion = base_convert(substr($svbinary, 10, 6), 2, 10);
166
167
        if ($majorversion == 8) {
168 View Code Duplication
            switch ($minorversion) {
169
                case 0:
170
                    return ExchangeWebServices::VERSION_2007;
171
                case 1:
172
                    return ExchangeWebServices::VERSION_2007_SP1;
173
                case 2:
174
                    return ExchangeWebServices::VERSION_2007_SP2;
175
                case 3:
176
                    return ExchangeWebServices::VERSION_2007_SP3;
177
                default:
178
                    return ExchangeWebServices::VERSION_2007;
179
            }
180
        } elseif ($majorversion == 14) {
181 View Code Duplication
            switch ($minorversion) {
182
                case 0:
183
                    return ExchangeWebServices::VERSION_2010;
184
                case 1:
185
                    return ExchangeWebServices::VERSION_2010_SP1;
186
                case 2:
187
                    return ExchangeWebServices::VERSION_2010_SP2;
188
                default:
189
                    return ExchangeWebServices::VERSION_2010;
190
            }
191
        }
192
193
        // Guess we didn't find a known version.
194
        return false;
195
    }
196
197
    protected function newAPI($email, $password, $username = null, $options = [])
198
    {
199
        $options = array_replace_recursive([
200
            'httpPlayback' => [
201
                'mode' => null
202
            ]
203
        ], $options);
204
205
        $this->httpPlayback = HttpPlayback::getInstance($options['httpPlayback']);
206
207
        if (!$username) {
208
            $username = $email;
209
        }
210
211
        $settings = $this->discover($email, $password, $username);
212
        if ($settings === false) {
213
            throw new AutoDiscoverFailed();
214
        }
215
216
        $server = false;
217
        $version = null;
218
219
        // Pick out the host from the EXPR (Exchange RPC over HTTP).
220
        foreach ($settings['Account']['Protocol'] as $protocol) {
221
            if (($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR')
222
                && isset($protocol['ServerVersion'])
223
            ) {
224
                if ($version == null) {
225
                    $sv = $this->parseServerVersion($protocol['ServerVersion']);
226
                    if ($sv !== false) {
227
                        $version = $sv;
228
                    }
229
                }
230
            }
231
232
            if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) {
233
                $server = $protocol['Server'];
234
            }
235
        }
236
237
        if ($server) {
238
            $options = [];
239
            if ($version !== null) {
240
                $options['version'] = $version;
241
            }
242
243
            return API::withUsernameAndPassword($server, $email, $password, $options);
244
        }
245
246
        return false;
247
    }
248
249
    /**
250
     * Static method may fail if there are issues surrounding SSL certificates.
251
     * In such cases, set up the object as needed, and then call newEWS().
252
     *
253
     * @param string $email
254
     * @param string $password
255
     * @param string $username If left blank, the email provided will be used.
256
     * @return mixed
257
     */
258
    public static function getAPI($email, $password, $username = null, $options = [])
259
    {
260
        $auto = new static();
261
262
        return $auto->newAPI($email, $password, $username, $options);
263
    }
264
265
    /**
266
     * Execute the full discovery chain of events in the correct sequence
267
     * until a valid response is received, or all methods have failed.
268
     *
269
     * @param string $email
270
     * @param string $password
271
     * @param string $username
272
     *
273
     * @return string The discovered settings
274
     */
275
    protected function discover($email, $password, $username)
276
    {
277
        $result = $this->tryTopLevelDomain($email, $password, $username);
278
279
        if ($result === false) {
280
            $result = $this->tryAutoDiscoverSubDomain($email, $password, $username);
281
        }
282
283
        if ($result === false) {
284
            $result = $this->trySubdomainUnauthenticatedGet($email, $password, $username);
285
        }
286
287
        if ($result === false) {
288
            $result = $this->trySRVRecord($email, $password, $username);
289
        }
290
291
        return $result;
292
    }
293
294
    /**
295
     * Perform an NTLM authenticated HTTPS POST to the top-level
296
     * domain of the email address.
297
     *
298
     * @param string $email
299
     * @param string $password
300
     * @param string $username
301
     *
302
     * @return string The discovered settings
303
     */
304 View Code Duplication
    protected function tryTopLevelDomain($email, $password, $username)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
305
    {
306
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
307
        $url = 'https://www.' . $topLevelDomain . self::AUTODISCOVER_PATH;
308
309
        return $this->doNTLMPost($url, $email, $password, $username);
310
    }
311
312
    /**
313
     * Perform an NTLM authenticated HTTPS POST to the 'autodiscover'
314
     * subdomain of the email address' TLD.
315
     *
316
     * @param string $email
317
     * @param string $password
318
     * @param string $username
319
     *
320
     * @return string The discovered settings
321
     */
322 View Code Duplication
    protected function tryAutoDiscoverSubDomain($email, $password, $username)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
323
    {
324
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
325
        $url = 'https://autodiscover.' . $topLevelDomain . self::AUTODISCOVER_PATH;
326
327
        return $this->doNTLMPost($url, $email, $password, $username);
328
    }
329
330
    /**
331
     * Perform an unauthenticated HTTP GET in an attempt to get redirected
332
     * via 302 to the correct location to perform the HTTPS POST.
333
     *
334
     * @param string $email
335
     * @param string $password
336
     * @param string $username
337
     *
338
     * @return string The discovered settings
339
     */
340
    protected function trySubdomainUnauthenticatedGet($email, $password, $username)
341
    {
342
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
343
344
        $url = 'http://autodiscover.' . $topLevelDomain . self::AUTODISCOVER_PATH;
345
346
        $client = $this->httpPlayback->getHttpClient();
347
        $postOptions = [
348
            'timeout' => 2,
349
            'allow_redirects' => false,
350
            'headers' => [
351
                'Content-Type' => 'text/xml; charset=utf-8'
352
            ],
353
            'curl' => []
354
        ];
355
356
        try {
357
            $response = $client->get($url, $postOptions);
358
359
            if ($response->getStatusCode() == 301 || $response->getStatusCode() == 302) {
360
                return $this->doNTLMPost($response->getHeaderLine('Location'), $email, $password, $username);
361
            }
362
        } catch (\Exception $e) {
363
            return false;
364
        }
365
    }
366
367
    /**
368
     * Attempt to retrieve the autodiscover host from an SRV DNS record.
369
     *
370
     * @link http://support.microsoft.com/kb/940881
371
     *
372
     * @param string $email
373
     * @param string $password
374
     * @param string $username
375
     *
376
     * @return string The discovered settings
377
     */
378
    protected function trySRVRecord($email, $password, $username)
379
    {
380
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
381
        $srvHost = '_autodiscover._tcp.' . $topLevelDomain;
382
        $lookup = dns_get_record($srvHost, DNS_SRV);
383
        if (sizeof($lookup) > 0) {
384
            $host = $lookup[0]['target'];
385
            $url = 'https://' . $host . self::AUTODISCOVER_PATH;
386
387
            return $this->doNTLMPost($url, $email, $password, $username);
388
        }
389
390
        return false;
391
    }
392
393
    /**
394
     * Set the path to the file to be used by CURLOPT_CAINFO.
395
     *
396
     * @param string $path Path to a certificate file such as cacert.pem
397
     * @return self
398
     */
399
    public function setCAInfo($path)
400
    {
401
        if (file_exists($path) && is_file($path)) {
402
            $this->certificateAuthorityInfo = $path;
403
        }
404
405
        return $this;
406
    }
407
408
    /**
409
     * Set the path to the file to be used by CURLOPT_CAPATH.
410
     *
411
     * @param string $path Path to a directory containing one or more CA
412
     * certificates.
413
     * @return self
414
     */
415
    public function setCertificateAuthorityPath($path)
416
    {
417
        if (is_dir($path)) {
418
            $this->certificateAuthorityPath = $path;
419
        }
420
421
        return $this;
422
    }
423
424
    /**
425
     * Perform the NTLM authenticated post against one of the chosen
426
     * endpoints.
427
     *
428
     * @param string $url URL to try posting to
429
     * @param string $email
430
     * @param string $password
431
     * @param string $username
432
     *
433
     * @return string The discovered settings
434
     */
435
    protected function doNTLMPost($url, $email, $password, $username)
436
    {
437
        $client = $this->httpPlayback->getHttpClient();
438
        $postOptions = [
439
            'body' => $this->getAutoDiscoverXML($email),
440
            'timeout' => 2,
441
            'allow_redirects' => true,
442
            'headers' => [
443
                'Content-Type' => 'text/xml; charset=utf-8'
444
            ],
445
            'curl' => []
446
        ];
447
        $auth = ExchangeWebServicesAuth::fromUsernameAndPassword($username, $password);
448
        $postOptions = array_replace_recursive($postOptions, $auth);
449
450
        if (!empty($this->certificateAuthorityInfo)) {
451
            $postOptions['cur'][CURLOPT_CAINFO] = $this->certificateAuthorityInfo;
452
        }
453
454
        if (!empty($this->certificateAuthorityPath)) {
455
            $postOptions['cur'][CURLOPT_CAPATH] = $this->certificateAuthorityPath;
456
        }
457
458
        try {
459
            $response = $client->post($url, $postOptions);
460
        } catch (\Exception $e) {
461
            return false;
462
        }
463
464
        return $this->parseAutodiscoverResponse($response->getBody()->__toString());
465
    }
466
467
    /**
468
     * Parse the Autoresponse Payload, particularly to determine if an
469
     * additional request is necessary.
470
     *
471
     * @param $response
472
     * @return array|bool
473
     * @throws AutoDiscoverFailed
474
     */
475
    protected function parseAutodiscoverResponse($response)
476
    {
477
        // Content-type isn't trustworthy, unfortunately. Shame on Microsoft.
478
        if (substr($response, 0, 5) !== '<?xml') {
479
            throw new AutoDiscoverFailed();
480
        }
481
482
        $response = $this->responseToArray($response);
483
484
        if (isset($response['Error'])) {
485
            $this->error = $response['Error'];
486
487
            return false;
488
        }
489
490
        // Check the account action for redirect.
491
        switch ($response['Account']['Action']) {
492
            case 'redirectUrl':
493
                $this->redirect = array(
494
                    'redirectUrl' => $response['Account']['redirectUrl']
495
                );
496
497
                return false;
498
            case 'redirectAddr':
499
                $this->redirect = array(
500
                    'redirectAddr' => $response['Account']['redirectAddr']
501
                );
502
503
                return false;
504
            case 'settings':
505
            default:
506
                return $response;
507
        }
508
    }
509
510
    /**
511
     * Get a top level domain based on an email address
512
     *
513
     * @param $email
514
     * @return bool|string
515
     */
516
    protected function getTopLevelDomainFromEmail($email)
517
    {
518
        $pos = strpos($email, '@');
519
        if ($pos !== false) {
520
            return trim(substr($email, $pos + 1));
521
        }
522
523
        return false;
524
    }
525
526
    /**
527
     * Return the generated Autodiscover XML request body.
528
     *
529
     * @param string $email
530
     * @return string
531
     */
532
    protected function getAutoDiscoverXML($email)
533
    {
534
        if (!empty($this->requestXML)) {
535
            return $this->requestXML;
536
        }
537
538
        $xml = new XMLWriter;
539
        $xml->openMemory();
540
        $xml->setIndent(true);
541
        $xml->startDocument('1.0', 'UTF-8');
542
        $xml->startElementNS(
543
            null,
544
            'Autodiscover',
545
            'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006'
546
        );
547
548
        $xml->startElement('Request');
549
        $xml->writeElement('EMailAddress', $email);
550
        $xml->writeElement(
551
            'AcceptableResponseSchema',
552
            'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
553
        );
554
        $xml->endElement();
555
        $xml->endElement();
556
557
        $this->requestXML = $xml->outputMemory();
558
559
        return $this->requestXML;
560
    }
561
562
    /**
563
     * Utility function to parse XML payloads from the response into easier
564
     * to manage associative arrays.
565
     *
566
     * @param string $xml XML to parse
567
     * @return array
568
     */
569
    protected function responseToArray($xml)
570
    {
571
        $doc = new \DOMDocument();
572
        $doc->loadXML($xml);
573
        $out = $this->nodeToArray($doc->documentElement);
574
575
        return $out['Response'];
576
    }
577
578
    /**
579
     * Recursive method for parsing DOM nodes.
580
     *
581
     * @link https://github.com/gaarf/XML-string-to-PHP-array
582
     * @param object $node DOMNode object
583
     * @return mixed
584
     */
585
    protected function nodeToArray($node)
586
    {
587
        $output = array();
588
        switch ($node->nodeType) {
589
            case XML_CDATA_SECTION_NODE:
590
            case XML_TEXT_NODE:
591
                $output = trim($node->textContent);
592
                break;
593
            case XML_ELEMENT_NODE:
594
                for ($i = 0, $m = $node->childNodes->length; $i < $m; $i++) {
595
                    $child = $node->childNodes->item($i);
596
                    $v = $this->nodeToArray($child);
597
                    if (isset($child->tagName)) {
598
                        $t = $child->tagName;
599
                        if (!isset($output[$t])) {
600
                            $output[$t] = array();
601
                        }
602
                        $output[$t][] = $v;
603
                    } elseif ($v || $v === '0') {
604
                        $output = (string)$v;
605
                    }
606
                }
607
608
                // Edge case of a node containing a text node, which also has
609
                // attributes. this way we'll retain text and attributes for
610
                // this node.
611
                if (is_string($output) && $node->attributes->length) {
612
                    $output = array('@text' => $output);
613
                }
614
615
                if (is_array($output)) {
616
                    if ($node->attributes->length) {
617
                        $a = array();
618
                        foreach ($node->attributes as $attrName => $attrNode) {
619
                            $a[$attrName] = (string)$attrNode->value;
620
                        }
621
                        $output['@attributes'] = $a;
622
                    }
623
                    foreach ($output as $t => $v) {
624
                        if (is_array($v) && count($v) == 1 && $t != '@attributes') {
625
                            $output[$t] = $v[0];
626
                        }
627
                    }
628
                }
629
                break;
630
        }
631
632
        return $output;
633
    }
634
}
635