Completed
Push — master ( 9e6269...55e422 )
by Gareth
04:08
created

ExchangeAutodiscover::discover()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 18
ccs 9
cts 9
cp 1
rs 9.2
cc 4
eloc 9
nc 8
nop 3
crap 4
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\Factory;
7
8
class ExchangeAutodiscover
9
{
10
    protected $autodiscoverPath = '/autodiscover/autodiscover.xml';
11
12
    /**
13
     * @var Factory
14
     */
15
    protected $httpPlayback;
16
17
    protected function __construct()
18
    {
19
    }
20
21
    /**
22
     * Parse the hex ServerVersion value and return a valid
23
     * ExchangeWebServices::VERSION_* constant.
24
     *
25
     * @param $versionHex
26
     * @return string|boolean A known version constant, or FALSE if it could not
27
     * be determined.
28
     *
29
     * @link http://msdn.microsoft.com/en-us/library/bb204122(v=exchg.140).aspx
30
     * @link http://blogs.msdn.com/b/pcreehan/archive/2009/09/21/parsing-serverversion-when-an-int-is-really-5-ints.aspx
31
     */
32 5
    protected function parseServerVersion($versionHex)
33
    {
34
        //Convert from hex to binary
35 5
        $versionBinary = base_convert($versionHex, 16, 2);
36 5
        $versionBinary = str_pad($versionBinary, 32, "0", STR_PAD_LEFT);
37
38
        //Get the relevant parts of the binary and convert them to base 10
39 5
        $majorVersion = base_convert(substr($versionBinary, 4, 6), 2, 10);
40 5
        $minorVersion = base_convert(substr($versionBinary, 10, 6), 2, 10);
41
42
        $versions = [
43
            8 => [
44
                'name' => 'VERSION_2007',
45
                'spCount' => 3
46 5
            ],
47
            14 => [
48
                'name' => 'VERSION_2010',
49
                'spCount' => 3
50
            ],
51
            15 => [
52
                'name' => 'VERSION_2013',
53
                'spCount' => 1
54
            ]
55
        ];
56
57 5
        if (!isset($versions[$majorVersion])) {
58 1
            return false;
59
        }
60
61 4
        $constant = $versions[$majorVersion]['name'];
62 4
        if ($minorVersion > 0 && $minorVersion <= $versions[$majorVersion]['spCount']) {
63 2
            $constant .= "_SP$minorVersion";
64
        }
65
66 4
        return constant(ExchangeWebServices::class."::$constant");
67
    }
68
69
    /**
70
     * @param string $email
71
     * @param string $password
72
     * @param string $username
73
     */
74 2
    protected function newAPI($email, $password, $username = null, $options = [])
75
    {
76 2
        $options = array_replace_recursive([
77
            'httpPlayback' => [
78
                'mode' => null
79
            ]
80 2
        ], $options);
81
82 2
        $this->httpPlayback = Factory::getInstance($options['httpPlayback']);
83
84 2
        if (!$username) {
85
            $username = $email;
86
        }
87
88 2
        $settings = $this->discover($email, $password, $username);
89 2
        if (!$settings) {
90 1
            throw new AutodiscoverFailed();
91
        }
92
93 1
        $server = $this->getServerFromResponse($settings);
94 1
        $version = $this->getServerVersionFromResponse($settings);
95
96 1
        if (!$server) {
97
            throw new AutodiscoverFailed();
98
        }
99
100 1
        $options = [];
101 1
        if ($version) {
102 1
            $options['version'] = $version;
103
        }
104
105 1
        return API::withUsernameAndPassword($server, $email, $password, $options);
106
    }
107
108 1
    protected function getServerVersionFromResponse($response)
109
    {
110
        // Pick out the host from the EXPR (Exchange RPC over HTTP).
111 1
        foreach ($response['Account']['Protocol'] as $protocol) {
112 1
            if (($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR')
113 1
                && isset($protocol['ServerVersion'])
114
            ) {
115 1
                return $this->parseServerVersion($protocol['ServerVersion']);
116
            }
117
        }
118
119
        return false;
120
    }
121
122 1
    protected function getServerFromResponse($response)
123
    {
124 1
        foreach ($response['Account']['Protocol'] as $protocol) {
125 1
            if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) {
126 1
                return $protocol['Server'];
127
            }
128
        }
129
130
        return false;
131
    }
132
133
    /**
134
     * Static method may fail if there are issues surrounding SSL certificates.
135
     * In such cases, set up the object as needed, and then call newEWS().
136
     *
137
     * @param string $email
138
     * @param string $password
139
     * @param string $username If left blank, the email provided will be used.
140
     * @throws AutodiscoverFailed
141
     * @return API
142
     */
143 2
    public static function getAPI($email, $password, $username = null, $options = [])
144
    {
145 2
        $auto = new static();
146
147 2
        return $auto->newAPI($email, $password, $username, $options);
148
    }
149
150
    /**
151
     * Execute the full discovery chain of events in the correct sequence
152
     * until a valid response is received, or all methods have failed.
153
     *
154
     * @param string $email
155
     * @param string $password
156
     * @param string $username
157
     *
158
     * @return string The discovered settings
159
     */
160 2
    protected function discover($email, $password, $username)
161
    {
162 2
        $result = $this->tryTopLevelDomain($email, $password, $username);
163
164 2
        if ($result === false) {
165 2
            $result = $this->tryAutoDiscoverSubDomain($email, $password, $username);
166
        }
167
168 2
        if ($result === false) {
169 2
            $result = $this->trySubdomainUnauthenticatedGet($email, $password, $username);
170
        }
171
172 2
        if ($result === false) {
173 1
            $result = $this->trySRVRecord($email, $password, $username);
174
        }
175
176 2
        return $result;
177
    }
178
179
    /**
180
     * Perform an NTLM authenticated HTTPS POST to the top-level
181
     * domain of the email address.
182
     *
183
     * @param string $email
184
     * @param string $password
185
     * @param string $username
186
     *
187
     * @return string The discovered settings
188
     */
189 2 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...
190
    {
191 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
192 2
        $url = 'https://www.'.$topLevelDomain.$this->autodiscoverPath;
193
194 2
        return $this->doNTLMPost($url, $email, $password, $username);
195
    }
196
197
    /**
198
     * Perform an NTLM authenticated HTTPS POST to the 'autodiscover'
199
     * subdomain of the email address' TLD.
200
     *
201
     * @param string $email
202
     * @param string $password
203
     * @param string $username
204
     *
205
     * @return string The discovered settings
206
     */
207 2 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...
208
    {
209 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
210 2
        $url = 'https://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
211
212 2
        return $this->doNTLMPost($url, $email, $password, $username);
213
    }
214
215
    /**
216
     * Perform an unauthenticated HTTP GET in an attempt to get redirected
217
     * via 302 to the correct location to perform the HTTPS POST.
218
     *
219
     * @param string $email
220
     * @param string $password
221
     * @param string $username
222
     *
223
     * @return string The discovered settings
224
     */
225 2
    protected function trySubdomainUnauthenticatedGet($email, $password, $username)
226
    {
227 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
228
229 2
        $url = 'http://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
230
231
        $postOptions = [
232 2
            'timeout' => 2,
233
            'allow_redirects' => false,
234
            'headers' => [
235
                'Content-Type' => 'text/xml; charset=utf-8'
236
            ],
237
            'curl' => []
238
        ];
239
240
        try {
241 2
            $response = $this->httpPlayback->get($url, $postOptions);
0 ignored issues
show
Bug introduced by
The method get() does not seem to exist on object<garethp\ews\HttpPlayback\Factory>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
242
243 2
            if ($response->getStatusCode() == 301 || $response->getStatusCode() == 302) {
244 2
                return $this->doNTLMPost($response->getHeaderLine('Location'), $email, $password, $username);
245
            }
246
        } catch (\Exception $e) {
247
        }
248
249 1
        return false;
250
    }
251
252
    /**
253
     * Attempt to retrieve the autodiscover host from an SRV DNS record.
254
     *
255
     * @link http://support.microsoft.com/kb/940881
256
     *
257
     * @param string $email
258
     * @param string $password
259
     * @param string $username
260
     *
261
     * @return string The discovered settings
262
     */
263 1
    protected function trySRVRecord($email, $password, $username)
264
    {
265 1
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
266 1
        $srvHost = '_autodiscover._tcp.'.$topLevelDomain;
267 1
        $lookup = dns_get_record($srvHost, DNS_SRV);
268 1
        if (sizeof($lookup) > 0) {
269
            $host = $lookup[0]['target'];
270
            $url = 'https://'.$host.$this->autodiscoverPath;
271
272
            return $this->doNTLMPost($url, $email, $password, $username);
273
        }
274
275 1
        return false;
276
    }
277
278
    /**
279
     * Perform the NTLM authenticated post against one of the chosen
280
     * endpoints.
281
     *
282
     * @param string $url URL to try posting to
283
     * @param string $email
284
     * @param string $password
285
     * @param string $username
286
     *
287
     * @return string The discovered settings
288
     */
289 2
    protected function doNTLMPost($url, $email, $password, $username)
290
    {
291
        $autodiscoverXml = <<<XML
292
<?xml version="1.0" encoding="UTF-8"?>
293
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
294
 <Request>
295 2
  <EMailAddress>$email</EMailAddress>
296
  <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
297
 </Request>
298 2
</Autodiscover>
299
XML;
300
        $postOptions = [
301 2
            'body' => $autodiscoverXml,
302 2
            'timeout' => 2,
303
            'allow_redirects' => true,
304
            'headers' => [
305
                'Content-Type' => 'text/xml; charset=utf-8'
306
            ],
307
            'curl' => []
308
        ];
309 2
        $auth = ExchangeWebServicesAuth::fromUsernameAndPassword($username, $password);
310 2
        $postOptions = array_replace_recursive($postOptions, $auth);
311
312
        try {
313 2
            $response = $this->httpPlayback->post($url, $postOptions);
0 ignored issues
show
Bug introduced by
The method post() does not seem to exist on object<garethp\ews\HttpPlayback\Factory>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
314 2
        } catch (\Exception $e) {
315 2
            return false;
316
        }
317
318 1
        return $this->parseAutodiscoverResponse($response->getBody()->__toString());
319
    }
320
321
    /**
322
     * Parse the Autoresponse Payload, particularly to determine if an
323
     * additional request is necessary.
324
     *
325
     * @param $response
326
     * @return array|bool
327
     * @throws AutodiscoverFailed
328
     */
329 1
    protected function parseAutodiscoverResponse($response)
330
    {
331
        // Content-type isn't trustworthy, unfortunately. Shame on Microsoft.
332 1
        if (substr($response, 0, 5) !== '<?xml') {
333
            throw new AutodiscoverFailed();
334
        }
335
336 1
        $response = $this->responseToArray($response);
337
338 1
        if (isset($response['Error'])) {
339
            return false;
340
        }
341
342 1
        $action = $response['Account']['Action'];
343 1
        if ($action == 'redirectUrl' || $action == 'redirectAddr') {
344
            return false;
345
        }
346
347 1
        return $response;
348
    }
349
350
    /**
351
     * Get a top level domain based on an email address
352
     *
353
     * @param string $email
354
     * @return string|false
355
     */
356 2
    protected function getTopLevelDomainFromEmail($email)
357
    {
358 2
        $pos = strpos($email, '@');
359 2
        if ($pos !== false) {
360 1
            return trim(substr($email, $pos + 1));
361
        }
362
363 1
        return false;
364
    }
365
366
    /**
367
     * Utility function to parse XML payloads from the response into easier
368
     * to manage associative arrays.
369
     *
370
     * @param string $xml XML to parse
371
     * @return array
372
     */
373 1
    protected function responseToArray($xml)
374
    {
375 1
        $xml = simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOCDATA);
376
377 1
        return json_decode(json_encode($xml), true)['Response'];
378
    }
379
}
380