Completed
Push — master ( ba4277...ac6956 )
by Gareth
03:23
created

EWSAutodiscover::tryAutoDiscoverSubDomain()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 7
Ratio 100 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 7
loc 7
ccs 0
cts 4
cp 0
rs 9.4285
cc 1
eloc 4
nc 1
nop 3
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
8
/**
9
 * Contains EWSAutodiscover.
10
 */
11
12
/**
13
 * Exchange Web Services Autodiscover implementation
14
 *
15
 * This class supports POX (Plain Old XML), which is deprecated but functional
16
 * in Exchange 2010. It may make sense for you to combine your Autodiscovery
17
 * efforts with a SOAP Autodiscover request as well.
18
 *
19
 * USAGE:
20
 *
21
 * (after any auto-loading class incantation)
22
 *
23
 * $ews = EWSAutodiscover::getEWS($email, $password);
24
 *
25
 * -- OR --
26
 *
27
 * If there are issues with your cURL installation that require you to specify
28
 * a path to a valid Certificate Authority, you can configure that manually.
29
 *
30
 * $auto = new EWSAutodiscover($email, $password);
31
 * $auto->setCAInfo('/path/to/your/cacert.pem');
32
 * $ews = $auto->newEWS();
33
 *
34
 * @link http://technet.microsoft.com/en-us/library/bb332063(EXCHG.80).aspx
35
 * @link https://www.testexchangeconnectivity.com/
36
 *
37
 * @package php-ews\AutoDiscovery
38
 */
39
class EWSAutodiscover
40
{
41
    /**
42
     * The path appended to the various schemes and hostnames used during
43
     * autodiscovery.
44
     *
45
     * @var string
46
     */
47
    const AUTODISCOVER_PATH = '/autodiscover/autodiscover.xml';
48
49
    /**
50
     * The Certificate Authority path. Should point to a directory containing
51
     * one or more certificates to use in SSL verification.
52
     *
53
     * @var string
54
     */
55
    protected $certificateAuthorityPath;
56
57
    /**
58
     * The path to a specific Certificate Authority file. Get one and use it
59
     * for full Autodiscovery compliance.
60
     *
61
     * @var string
62
     *
63
     * @link http://curl.haxx.se/ca/cacert.pem
64
     * @link http://curl.haxx.se/ca/
65
     */
66
    protected $certificateAuthorityInfo;
67
68
    /**
69
     * @var HttpPlayback
70
     */
71
    protected $httpPlayback;
72
73
    protected function __construct()
74
    {
75
    }
76
77
    /**
78
     * Parse the hex ServerVersion value and return a valid
79
     * ExchangeWebServices::VERSION_* constant.
80
     *
81
     * @param $versionHex
82
     * @return string|boolean A known version constant, or FALSE if it could not
83
     * be determined.
84
     *
85
     * @link http://msdn.microsoft.com/en-us/library/bb204122(v=exchg.140).aspx
86
     * @link http://blogs.msdn.com/b/pcreehan/archive/2009/09/21/parsing-serverversion-when-an-int-is-really-5-ints.aspx
87
     */
88 4
    public function parseServerVersion($versionHex)
89
    {
90
        //Convert from hex to binary
91 4
        $versionBinary = base_convert($versionHex, 16, 2);
92 4
        $versionBinary = str_pad($versionBinary, 32, "0", STR_PAD_LEFT);
93
94
        //Get the relevant parts of the binary and convert them to base 10
95 4
        $majorVersion = base_convert(substr($versionBinary, 4, 6), 2, 10);
96 4
        $minorVersion = base_convert(substr($versionBinary, 10, 6), 2, 10);
97
98
        $versions = [
99
            8 => [
100 4
                'name' => 'VERSION_2007',
101
                'spCount' => 3
102 4
            ],
103
            14 => [
104 4
                'name' => 'VERSION_2010',
105
                'spCount' => 3
106 4
            ],
107
            15 => [
108 4
                'name' => 'VERSION_2013',
109
                'spCount' => 1
110 4
            ]
111 4
        ];
112
113 4
        if (!isset($versions[$majorVersion])) {
114 1
            return false;
115
        }
116
117 3
        $constant = $versions[$majorVersion]['name'];
118 3
        if ($minorVersion > 0 && $minorVersion <= $versions[$majorVersion]['spCount']) {
119 1
            $constant .= "_SP$minorVersion";
120 1
        }
121
122 3
        return constant(ExchangeWebServices::class . "::$constant");
123
    }
124
125
    protected function newAPI($email, $password, $username = null, $options = [])
126
    {
127
        $options = array_replace_recursive([
128
            'httpPlayback' => [
129
                'mode' => null
130
            ]
131
        ], $options);
132
133
        $this->httpPlayback = HttpPlayback::getInstance($options['httpPlayback']);
134
135
        if (!$username) {
136
            $username = $email;
137
        }
138
139
        $settings = $this->discover($email, $password, $username);
140
        if ($settings === false) {
141
            throw new AutoDiscoverFailed();
142
        }
143
144
        $server = false;
145
        $version = null;
146
147
        // Pick out the host from the EXPR (Exchange RPC over HTTP).
148
        foreach ($settings['Account']['Protocol'] as $protocol) {
149
            if (($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR')
150
                && isset($protocol['ServerVersion'])
151
            ) {
152
                $serverVersion = $this->parseServerVersion($protocol['ServerVersion']);
153
                if ($serverVersion) {
154
                    $version = $serverVersion;
155
                }
156
            }
157
158
            if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) {
159
                $server = $protocol['Server'];
160
            }
161
        }
162
163
        if (!$server) {
164
            throw new AutoDiscoverFailed();
165
        }
166
167
        $options = [];
168
        if ($version !== null) {
169
            $options['version'] = $version;
170
        }
171
172
        return API::withUsernameAndPassword($server, $email, $password, $options);
173
    }
174
175
    /**
176
     * Static method may fail if there are issues surrounding SSL certificates.
177
     * In such cases, set up the object as needed, and then call newEWS().
178
     *
179
     * @param string $email
180
     * @param string $password
181
     * @param string $username If left blank, the email provided will be used.
182
     * @return mixed
183
     */
184
    public static function getAPI($email, $password, $username = null, $options = [])
185
    {
186
        $auto = new static();
187
188
        return $auto->newAPI($email, $password, $username, $options);
189
    }
190
191
    /**
192
     * Execute the full discovery chain of events in the correct sequence
193
     * until a valid response is received, or all methods have failed.
194
     *
195
     * @param string $email
196
     * @param string $password
197
     * @param string $username
198
     *
199
     * @return string The discovered settings
200
     */
201
    protected function discover($email, $password, $username)
202
    {
203
        $result = $this->tryTopLevelDomain($email, $password, $username);
204
205
        if ($result === false) {
206
            $result = $this->tryAutoDiscoverSubDomain($email, $password, $username);
207
        }
208
209
        if ($result === false) {
210
            $result = $this->trySubdomainUnauthenticatedGet($email, $password, $username);
211
        }
212
213
        if ($result === false) {
214
            $result = $this->trySRVRecord($email, $password, $username);
215
        }
216
217
        return $result;
218
    }
219
220
    /**
221
     * Perform an NTLM authenticated HTTPS POST to the top-level
222
     * domain of the email address.
223
     *
224
     * @param string $email
225
     * @param string $password
226
     * @param string $username
227
     *
228
     * @return string The discovered settings
229
     */
230 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...
231
    {
232
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
233
        $url = 'https://www.' . $topLevelDomain . self::AUTODISCOVER_PATH;
234
235
        return $this->doNTLMPost($url, $email, $password, $username);
236
    }
237
238
    /**
239
     * Perform an NTLM authenticated HTTPS POST to the 'autodiscover'
240
     * subdomain of the email address' TLD.
241
     *
242
     * @param string $email
243
     * @param string $password
244
     * @param string $username
245
     *
246
     * @return string The discovered settings
247
     */
248 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...
249
    {
250
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
251
        $url = 'https://autodiscover.' . $topLevelDomain . self::AUTODISCOVER_PATH;
252
253
        return $this->doNTLMPost($url, $email, $password, $username);
254
    }
255
256
    /**
257
     * Perform an unauthenticated HTTP GET in an attempt to get redirected
258
     * via 302 to the correct location to perform the HTTPS POST.
259
     *
260
     * @param string $email
261
     * @param string $password
262
     * @param string $username
263
     *
264
     * @return string The discovered settings
265
     */
266
    protected function trySubdomainUnauthenticatedGet($email, $password, $username)
267
    {
268
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
269
270
        $url = 'http://autodiscover.' . $topLevelDomain . self::AUTODISCOVER_PATH;
271
272
        $client = $this->httpPlayback->getHttpClient();
273
        $postOptions = [
274
            'timeout' => 2,
275
            'allow_redirects' => false,
276
            'headers' => [
277
                'Content-Type' => 'text/xml; charset=utf-8'
278
            ],
279
            'curl' => []
280
        ];
281
282
        try {
283
            $response = $client->get($url, $postOptions);
284
285
            if ($response->getStatusCode() == 301 || $response->getStatusCode() == 302) {
286
                return $this->doNTLMPost($response->getHeaderLine('Location'), $email, $password, $username);
287
            }
288
        } catch (\Exception $e) {
289
            return false;
290
        }
291
    }
292
293
    /**
294
     * Attempt to retrieve the autodiscover host from an SRV DNS record.
295
     *
296
     * @link http://support.microsoft.com/kb/940881
297
     *
298
     * @param string $email
299
     * @param string $password
300
     * @param string $username
301
     *
302
     * @return string The discovered settings
303
     */
304
    protected function trySRVRecord($email, $password, $username)
305
    {
306
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
307
        $srvHost = '_autodiscover._tcp.' . $topLevelDomain;
308
        $lookup = dns_get_record($srvHost, DNS_SRV);
309
        if (sizeof($lookup) > 0) {
310
            $host = $lookup[0]['target'];
311
            $url = 'https://' . $host . self::AUTODISCOVER_PATH;
312
313
            return $this->doNTLMPost($url, $email, $password, $username);
314
        }
315
316
        return false;
317
    }
318
319
    /**
320
     * Set the path to the file to be used by CURLOPT_CAINFO.
321
     *
322
     * @param string $path Path to a certificate file such as cacert.pem
323
     * @return self
324
     */
325
    public function setCAInfo($path)
326
    {
327
        if (file_exists($path) && is_file($path)) {
328
            $this->certificateAuthorityInfo = $path;
329
        }
330
331
        return $this;
332
    }
333
334
    /**
335
     * Set the path to the file to be used by CURLOPT_CAPATH.
336
     *
337
     * @param string $path Path to a directory containing one or more CA
338
     * certificates.
339
     * @return self
340
     */
341
    public function setCertificateAuthorityPath($path)
342
    {
343
        if (is_dir($path)) {
344
            $this->certificateAuthorityPath = $path;
345
        }
346
347
        return $this;
348
    }
349
350
    /**
351
     * Perform the NTLM authenticated post against one of the chosen
352
     * endpoints.
353
     *
354
     * @param string $url URL to try posting to
355
     * @param string $email
356
     * @param string $password
357
     * @param string $username
358
     *
359
     * @return string The discovered settings
360
     */
361
    protected function doNTLMPost($url, $email, $password, $username)
362
    {
363
        $client = $this->httpPlayback->getHttpClient();
364
        $postOptions = [
365
            'body' => $this->getAutoDiscoverXML($email),
366
            'timeout' => 2,
367
            'allow_redirects' => true,
368
            'headers' => [
369
                'Content-Type' => 'text/xml; charset=utf-8'
370
            ],
371
            'curl' => []
372
        ];
373
        $auth = ExchangeWebServicesAuth::fromUsernameAndPassword($username, $password);
374
        $postOptions = array_replace_recursive($postOptions, $auth);
375
376
        if (!empty($this->certificateAuthorityInfo)) {
377
            $postOptions['cur'][CURLOPT_CAINFO] = $this->certificateAuthorityInfo;
378
        }
379
380
        if (!empty($this->certificateAuthorityPath)) {
381
            $postOptions['cur'][CURLOPT_CAPATH] = $this->certificateAuthorityPath;
382
        }
383
384
        try {
385
            $response = $client->post($url, $postOptions);
386
        } catch (\Exception $e) {
387
            return false;
388
        }
389
390
        return $this->parseAutodiscoverResponse($response->getBody()->__toString());
391
    }
392
393
    /**
394
     * Parse the Autoresponse Payload, particularly to determine if an
395
     * additional request is necessary.
396
     *
397
     * @param $response
398
     * @return array|bool
399
     * @throws AutoDiscoverFailed
400
     */
401
    protected function parseAutodiscoverResponse($response)
402
    {
403
        // Content-type isn't trustworthy, unfortunately. Shame on Microsoft.
404
        if (substr($response, 0, 5) !== '<?xml') {
405
            throw new AutoDiscoverFailed();
406
        }
407
408
        $response = $this->responseToArray($response);
409
410
        if (isset($response['Error'])) {
411
            return false;
412
        }
413
414
        $action = $response['Account']['Action'];
415
        if ($action == 'redirectUrl' || $action == 'redirectAddr') {
416
            return false;
417
        }
418
419
        return $response;
420
    }
421
422
    /**
423
     * Get a top level domain based on an email address
424
     *
425
     * @param $email
426
     * @return bool|string
427
     */
428
    protected function getTopLevelDomainFromEmail($email)
429
    {
430
        $pos = strpos($email, '@');
431
        if ($pos !== false) {
432
            return trim(substr($email, $pos + 1));
433
        }
434
435
        return false;
436
    }
437
438
    /**
439
     * Return the generated Autodiscover XML request body.
440
     *
441
     * @param string $email
442
     * @return string
443
     */
444
    protected function getAutoDiscoverXML($email)
445
    {
446
        return <<<XML
447
<?xml version="1.0" encoding="UTF-8"?>
448
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
449
 <Request>
450
  <EMailAddress>$email</EMailAddress>
451
  <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
452
 </Request>
453
</Autodiscover>
454
455
XML;
456
457
    }
458
459
    /**
460
     * Utility function to parse XML payloads from the response into easier
461
     * to manage associative arrays.
462
     *
463
     * @param string $xml XML to parse
464
     * @return array
465
     */
466
    protected function responseToArray($xml)
467
    {
468
        $xml = simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOCDATA);
469
        $json = json_encode($xml);
470
471
        return json_decode($json, true)['Response'];
472
    }
473
}
474