ExchangeAutodiscover   B
last analyzed

Complexity

Total Complexity 46

Size/Duplication

Total Lines 388
Duplicated Lines 0 %

Test Coverage

Coverage 92.36%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 125
c 2
b 0
f 0
dl 0
loc 388
ccs 133
cts 144
cp 0.9236
rs 8.72
wmc 46

17 Methods

Rating   Name   Duplication   Size   Complexity  
A tryAutoDiscoverSubDomain() 0 6 1
A parseServerVersion() 0 16 2
A tryTopLevelDomain() 0 6 1
A getTopLevelDomainFromEmail() 0 8 2
A __construct() 0 2 1
A responseToArray() 0 5 1
A getAPI() 0 5 1
A parseAutodiscoverResponse() 0 19 5
A trySRVRecord() 0 13 2
A parseVersionAfter2013() 0 7 3
A getServerFromResponse() 0 9 4
A getServerVersionFromResponse() 0 12 5
A discover() 0 21 5
A parseVersionBefore2013() 0 23 4
A trySubdomainUnauthenticatedGet() 0 25 4
A doNTLMPost() 0 31 2
A newAPI() 0 21 3

How to fix   Complexity   

Complex Class

Complex classes like ExchangeAutodiscover 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 ExchangeAutodiscover, and based on these observations, apply Extract Interface, too.

1
<?php
2
namespace garethp\ews\API;
3
4
use garethp\ews\API;
5
use garethp\ews\API\Exception\AutodiscoverFailed;
6
use garethp\HttpPlayback\Client;
7
use garethp\HttpPlayback\Factory;
8
9
class ExchangeAutodiscover
10
{
11
    protected $autodiscoverPath = '/autodiscover/autodiscover.xml';
12
13
    /**
14
     * @var Client
15
     */
16
    protected $httpPlayback;
17
18 2
    protected function __construct()
19
    {
20 2
    }
21
22
    /**
23
     * Parse the hex ServerVersion value and return a valid
24
     * ExchangeWebServices::VERSION_* constant.
25
     *
26
     * @param $versionHex
27
     * @return string|boolean A known version constant, or FALSE if it could not
28
     * be determined.
29
     *
30
     * @link http://msdn.microsoft.com/en-us/library/bb204122(v=exchg.140).aspx
31
     * @link http://blogs.msdn.com/b/pcreehan/archive/2009/09/21/parsing-serverversion-when-an-int-is-really-5-ints.aspx
32
     */
33 3
    protected function parseServerVersion($versionHex)
34
    {
35
        //Convert from hex to binary
36 3
        $versionBinary = base_convert($versionHex, 16, 2);
37 3
        $versionBinary = str_pad($versionBinary, 32, "0", STR_PAD_LEFT);
38
39
        //Get the relevant parts of the binary and convert them to base 10
40 3
        $majorVersion = base_convert(substr($versionBinary, 4, 6), 2, 10);
41 3
        $minorVersion = base_convert(substr($versionBinary, 10, 6), 2, 10);
42 3
        $buildVersion = base_convert(substr($versionBinary, 17, 15), 2, 10);
43
44 3
        if ($majorVersion >= 15) {
45 2
            return $this->parseVersionAfter2013($majorVersion, $minorVersion, $buildVersion);
46
        }
47
48 1
        return $this->parseVersionBefore2013($majorVersion, $minorVersion);
49
    }
50
51
    /**
52
     * @param string $email
53
     * @param string $password
54
     * @param string $username
55
     * @param array $options
56
     *
57
     * @return API
58
     * @throws AutodiscoverFailed
59
     */
60 2
    protected function newAPI($email, $password, $username = null, $options = [])
61
    {
62 2
        $username = $username ?: $email;
63 2
        $options = array_replace_recursive([
64 2
            'httpPlayback' => [
65 2
                'mode' => null
66 2
            ]
67 2
        ], $options);
68
69 2
        $this->httpPlayback = Factory::getInstance($options['httpPlayback']);
70
71 2
        $settings = $this->discover($email, $password, $username);
72 1
        $server = $this->getServerFromResponse($settings);
73 1
        $version = $this->getServerVersionFromResponse($settings);
74
75 1
        $options = [];
76 1
        if ($version) {
77 1
            $options['version'] = $version;
78
        }
79
80 1
        return API::withUsernameAndPassword($server, $email, $password, $options);
81
    }
82
83 1
    protected function getServerVersionFromResponse($response)
84
    {
85
        // Pick out the host from the EXPR (Exchange RPC over HTTP).
86 1
        foreach ($response['Account']['Protocol'] as $protocol) {
87 1
            if (($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR')
88 1
                && isset($protocol['ServerVersion'])
89
            ) {
90 1
                return $this->parseServerVersion($protocol['ServerVersion']);
91
            }
92
        }
93
94
        return false;
95
    }
96
97 1
    protected function getServerFromResponse($response)
98
    {
99 1
        foreach ($response['Account']['Protocol'] as $protocol) {
100 1
            if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) {
101 1
                return $protocol['Server'];
102
            }
103
        }
104
105
        throw new AutodiscoverFailed();
106
    }
107
108
    /**
109
     * Static method may fail if there are issues surrounding SSL certificates.
110
     * In such cases, set up the object as needed, and then call newEWS().
111
     *
112
     * @param string $email
113
     * @param string $password
114
     * @param string $username If left blank, the email provided will be used.
115
     * @throws AutodiscoverFailed
116
     * @return API
117
     */
118 2
    public static function getAPI($email, $password, $username = null, $options = [])
119
    {
120 2
        $auto = new static();
121
122 2
        return $auto->newAPI($email, $password, $username, $options);
123
    }
124
125
    /**
126
     * Execute the full discovery chain of events in the correct sequence
127
     * until a valid response is received, or all methods have failed.
128
     *
129
     * @param string $email
130
     * @param string $password
131
     * @param string $username
132
     * @return string The discovered settings
133
     * @throws AutodiscoverFailed
134
     */
135 2
    protected function discover($email, $password, $username)
136
    {
137 2
        $result = $this->tryTopLevelDomain($email, $password, $username);
138
139 2
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
140 2
            $result = $this->tryAutoDiscoverSubDomain($email, $password, $username);
141
        }
142
143 2
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
144 2
            $result = $this->trySubdomainUnauthenticatedGet($email, $password, $username);
145
        }
146
147 2
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
148 1
            $result = $this->trySRVRecord($email, $password, $username);
149
        }
150
151 2
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
152 1
            throw new AutodiscoverFailed();
153
        }
154
155 1
        return $result;
156
    }
157
158
    /**
159
     * Perform an NTLM authenticated HTTPS POST to the top-level
160
     * domain of the email address.
161
     *
162
     * @param string $email
163
     * @param string $password
164
     * @param string $username
165
     *
166
     * @return string The discovered settings
167
     */
168 2
    protected function tryTopLevelDomain($email, $password, $username)
169
    {
170 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
171 2
        $url = 'https://www.'.$topLevelDomain.$this->autodiscoverPath;
0 ignored issues
show
Bug introduced by
Are you sure $topLevelDomain of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

171
        $url = 'https://www.'./** @scrutinizer ignore-type */ $topLevelDomain.$this->autodiscoverPath;
Loading history...
172
173 2
        return $this->doNTLMPost($url, $email, $password, $username);
174
    }
175
176
    /**
177
     * Perform an NTLM authenticated HTTPS POST to the 'autodiscover'
178
     * subdomain of the email address' TLD.
179
     *
180
     * @param string $email
181
     * @param string $password
182
     * @param string $username
183
     *
184
     * @return string The discovered settings
185
     */
186 2
    protected function tryAutoDiscoverSubDomain($email, $password, $username)
187
    {
188 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
189 2
        $url = 'https://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
0 ignored issues
show
Bug introduced by
Are you sure $topLevelDomain of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
        $url = 'https://autodiscover.'./** @scrutinizer ignore-type */ $topLevelDomain.$this->autodiscoverPath;
Loading history...
190
191 2
        return $this->doNTLMPost($url, $email, $password, $username);
192
    }
193
194
    /**
195
     * Perform an unauthenticated HTTP GET in an attempt to get redirected
196
     * via 302 to the correct location to perform the HTTPS POST.
197
     *
198
     * @param string $email
199
     * @param string $password
200
     * @param string $username
201
     *
202
     * @return string The discovered settings
203
     */
204 2
    protected function trySubdomainUnauthenticatedGet($email, $password, $username)
205
    {
206 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
207
208 2
        $url = 'http://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
0 ignored issues
show
Bug introduced by
Are you sure $topLevelDomain of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

208
        $url = 'http://autodiscover.'./** @scrutinizer ignore-type */ $topLevelDomain.$this->autodiscoverPath;
Loading history...
209
210 2
        $postOptions = [
211 2
            'timeout' => 2,
212 2
            'allow_redirects' => false,
213 2
            'headers' => [
214 2
                'Content-Type' => 'text/xml; charset=utf-8'
215 2
            ],
216 2
            'curl' => []
217 2
        ];
218
219
        try {
220 2
            $response = $this->httpPlayback->get($url, $postOptions);
221
222 2
            if ($response->getStatusCode() == 301 || $response->getStatusCode() == 302) {
223 2
                return $this->doNTLMPost($response->getHeaderLine('Location'), $email, $password, $username);
224
            }
225
        } catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
226
        }
227
228 1
        return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
229
    }
230
231
    /**
232
     * Attempt to retrieve the autodiscover host from an SRV DNS record.
233
     *
234
     * @link http://support.microsoft.com/kb/940881
235
     *
236
     * @param string $email
237
     * @param string $password
238
     * @param string $username
239
     *
240
     * @return string The discovered settings
241
     */
242 1
    protected function trySRVRecord($email, $password, $username)
243
    {
244 1
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
245 1
        $srvHost = '_autodiscover._tcp.'.$topLevelDomain;
0 ignored issues
show
Bug introduced by
Are you sure $topLevelDomain of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

245
        $srvHost = '_autodiscover._tcp.'./** @scrutinizer ignore-type */ $topLevelDomain;
Loading history...
246 1
        $lookup = dns_get_record($srvHost, DNS_SRV);
247 1
        if (sizeof($lookup) > 0) {
248
            $host = $lookup[0]['target'];
249
            $url = 'https://'.$host.$this->autodiscoverPath;
250
251
            return $this->doNTLMPost($url, $email, $password, $username);
252
        }
253
254 1
        return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
255
    }
256
257
    /**
258
     * Perform the NTLM authenticated post against one of the chosen
259
     * endpoints.
260
     *
261
     * @param string $url URL to try posting to
262
     * @param string $email
263
     * @param string $password
264
     * @param string $username
265
     *
266
     * @return string The discovered settings
267
     */
268 2
    protected function doNTLMPost($url, $email, $password, $username)
269
    {
270 2
        $autodiscoverXml = <<<XML
271 2
<?xml version="1.0" encoding="UTF-8"?>
272
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
273
 <Request>
274 2
  <EMailAddress>$email</EMailAddress>
275
  <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
276
 </Request>
277
</Autodiscover>
278 2
XML;
279 2
        $postOptions = [
280 2
            'body' => $autodiscoverXml,
281 2
            'timeout' => 2,
282 2
            'allow_redirects' => true,
283 2
            'headers' => [
284 2
                'Content-Type' => 'text/xml; charset=utf-8'
285 2
            ],
286 2
            'curl' => [],
287 2
            'verify' => false
288 2
        ];
289 2
        $auth = ExchangeWebServicesAuth::fromUsernameAndPassword($username, $password);
290 2
        $postOptions = array_replace_recursive($postOptions, $auth);
291
292
        try {
293 2
            $response = $this->httpPlayback->post($url, $postOptions);
294 2
        } catch (\Exception $e) {
295 2
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
296
        }
297
298 1
        return $this->parseAutodiscoverResponse($response->getBody()->__toString());
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseAutod...etBody()->__toString()) returns the type array|boolean which is incompatible with the documented return type string.
Loading history...
299
    }
300
301
    /**
302
     * Parse the Autoresponse Payload, particularly to determine if an
303
     * additional request is necessary.
304
     *
305
     * @param $response
306
     * @return array|bool
307
     * @throws AutodiscoverFailed
308
     */
309 1
    protected function parseAutodiscoverResponse($response)
310
    {
311
        // Content-type isn't trustworthy, unfortunately. Shame on Microsoft.
312 1
        if (substr($response, 0, 5) !== '<?xml') {
313
            throw new AutodiscoverFailed();
314
        }
315
316 1
        $response = $this->responseToArray($response);
317
318 1
        if (isset($response['Error'])) {
319
            return false;
320
        }
321
322 1
        $action = $response['Account']['Action'];
323 1
        if ($action == 'redirectUrl' || $action == 'redirectAddr') {
324
            return false;
325
        }
326
327 1
        return $response;
328
    }
329
330
    /**
331
     * Get a top level domain based on an email address
332
     *
333
     * @param string $email
334
     * @return string|false
335
     */
336 2
    protected function getTopLevelDomainFromEmail($email)
337
    {
338 2
        $pos = strpos($email, '@');
339 2
        if ($pos !== false) {
340 1
            return trim(substr($email, $pos + 1));
341
        }
342
343 1
        return false;
344
    }
345
346
    /**
347
     * Utility function to parse XML payloads from the response into easier
348
     * to manage associative arrays.
349
     *
350
     * @param string $xml XML to parse
351
     * @return array
352
     */
353 1
    protected function responseToArray($xml)
354
    {
355 1
        $xml = simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOCDATA);
356
357 1
        return json_decode(json_encode($xml), true)['Response'];
358
    }
359
360
    /**
361
     * @param $majorVersion
362
     * @param $minorVersion
363
     * @return bool|mixed
364
     */
365 1
    protected function parseVersionBefore2013($majorVersion, $minorVersion)
366
    {
367 1
        $versions = [
368 1
            8 => [
369 1
                'name' => 'VERSION_2007',
370 1
                'spCount' => 3
371 1
            ],
372 1
            14 => [
373 1
                'name' => 'VERSION_2010',
374 1
                'spCount' => 3
375 1
            ]
376 1
        ];
377
378 1
        if (!isset($versions[$majorVersion])) {
379
            return false;
380
        }
381
382 1
        $constant = $versions[$majorVersion]['name'];
383 1
        if ($minorVersion > 0 && $minorVersion <= $versions[$majorVersion]['spCount']) {
384
            $constant .= "_SP$minorVersion";
385
        }
386
387 1
        return constant(ExchangeWebServices::class . "::$constant");
388
    }
389
390 2
    protected function parseVersionAfter2013($majorVersion, $minorVersion, $buildVersion)
0 ignored issues
show
Unused Code introduced by
The parameter $majorVersion is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

390
    protected function parseVersionAfter2013(/** @scrutinizer ignore-unused */ $majorVersion, $minorVersion, $buildVersion)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
391
    {
392 2
        if ($minorVersion >= 1) {
393 1
            return ExchangeWebServices::VERSION_2016;
394
        }
395
396 1
        return $buildVersion >= 847 ? ExchangeWebServices::VERSION_2013_SP1 : ExchangeWebServices::VERSION_2013;
397
    }
398
}
399