Completed
Push — master ( 132976...bb2167 )
by Gareth
02:42
created

ExchangeAutodiscover::getServerFromResponse()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4.074

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 10
ccs 5
cts 6
cp 0.8333
rs 9.2
cc 4
eloc 5
nc 3
nop 1
crap 4.074
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 5
    protected function parseServerVersion($versionHex)
34
    {
35
        //Convert from hex to binary
36 5
        $versionBinary = base_convert($versionHex, 16, 2);
37 5
        $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 5
        $majorVersion = base_convert(substr($versionBinary, 4, 6), 2, 10);
41 5
        $minorVersion = base_convert(substr($versionBinary, 10, 6), 2, 10);
42
43
        $versions = [
44
            8 => [
45 5
                'name' => 'VERSION_2007',
46
                'spCount' => 3
47 5
            ],
48
            14 => [
49 5
                'name' => 'VERSION_2010',
50
                'spCount' => 3
51 5
            ],
52
            15 => [
53 5
                'name' => 'VERSION_2013',
54
                'spCount' => 1
55 5
            ]
56 5
        ];
57
58 5
        if (!isset($versions[$majorVersion])) {
59 1
            return false;
60
        }
61
62 4
        $constant = $versions[$majorVersion]['name'];
63 4
        if ($minorVersion > 0 && $minorVersion <= $versions[$majorVersion]['spCount']) {
64 2
            $constant .= "_SP$minorVersion";
65 2
        }
66
67 4
        return constant(ExchangeWebServices::class."::$constant");
68
    }
69
70
    /**
71
     * @param string $email
72
     * @param string $password
73
     * @param string $username
74
     * @param array $options
75
     *
76
     * @return API
77
     * @throws AutodiscoverFailed
78
     */
79 2
    protected function newAPI($email, $password, $username = null, $options = [])
80
    {
81 2
        $username = $username ?: $email;
82 2
        $options = array_replace_recursive([
83
            'httpPlayback' => [
84
                'mode' => null
85 2
            ]
86 2
        ], $options);
87
88 2
        $this->httpPlayback = Factory::getInstance($options['httpPlayback']);
89
90 2
        $settings = $this->discover($email, $password, $username);
91 1
        $server = $this->getServerFromResponse($settings);
92 1
        $version = $this->getServerVersionFromResponse($settings);
93
94 1
        $options = [];
95 1
        if ($version) {
96 1
            $options['version'] = $version;
97 1
        }
98
99 1
        return API::withUsernameAndPassword($server, $email, $password, $options);
100
    }
101
102 1
    protected function getServerVersionFromResponse($response)
103
    {
104
        // Pick out the host from the EXPR (Exchange RPC over HTTP).
105 1
        foreach ($response['Account']['Protocol'] as $protocol) {
106 1
            if (($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR')
107 1
                && isset($protocol['ServerVersion'])
108 1
            ) {
109 1
                return $this->parseServerVersion($protocol['ServerVersion']);
110
            }
111
        }
112
113
        return false;
114
    }
115
116 1
    protected function getServerFromResponse($response)
117
    {
118 1
        foreach ($response['Account']['Protocol'] as $protocol) {
119 1
            if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) {
120 1
                return $protocol['Server'];
121
            }
122 1
        }
123
124
        throw new AutodiscoverFailed();
125
    }
126
127
    /**
128
     * Static method may fail if there are issues surrounding SSL certificates.
129
     * In such cases, set up the object as needed, and then call newEWS().
130
     *
131
     * @param string $email
132
     * @param string $password
133
     * @param string $username If left blank, the email provided will be used.
134
     * @throws AutodiscoverFailed
135
     * @return API
136
     */
137 2
    public static function getAPI($email, $password, $username = null, $options = [])
138
    {
139 2
        $auto = new static();
140
141 2
        return $auto->newAPI($email, $password, $username, $options);
142
    }
143
144
    /**
145
     * Execute the full discovery chain of events in the correct sequence
146
     * until a valid response is received, or all methods have failed.
147
     *
148
     * @param string $email
149
     * @param string $password
150
     * @param string $username
151
     * @return string The discovered settings
152
     * @throws AutodiscoverFailed
153
     */
154 2
    protected function discover($email, $password, $username)
155
    {
156 2
        $result = $this->tryTopLevelDomain($email, $password, $username);
157
158 2
        if ($result === false) {
159 2
            $result = $this->tryAutoDiscoverSubDomain($email, $password, $username);
160 2
        }
161
162 2
        if ($result === false) {
163 2
            $result = $this->trySubdomainUnauthenticatedGet($email, $password, $username);
164 2
        }
165
166 2
        if ($result === false) {
167 1
            $result = $this->trySRVRecord($email, $password, $username);
168 1
        }
169
170 2
        if ($result === false) {
171 1
            throw new AutodiscoverFailed();
172
        }
173
174 1
        return $result;
175
    }
176
177
    /**
178
     * Perform an NTLM authenticated HTTPS POST to the top-level
179
     * domain of the email address.
180
     *
181
     * @param string $email
182
     * @param string $password
183
     * @param string $username
184
     *
185
     * @return string The discovered settings
186
     */
187 2 View Code Duplication
    protected function tryTopLevelDomain($email, $password, $username)
188
    {
189 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
190 2
        $url = 'https://www.'.$topLevelDomain.$this->autodiscoverPath;
191
192 2
        return $this->doNTLMPost($url, $email, $password, $username);
193
    }
194
195
    /**
196
     * Perform an NTLM authenticated HTTPS POST to the 'autodiscover'
197
     * subdomain of the email address' TLD.
198
     *
199
     * @param string $email
200
     * @param string $password
201
     * @param string $username
202
     *
203
     * @return string The discovered settings
204
     */
205 2 View Code Duplication
    protected function tryAutoDiscoverSubDomain($email, $password, $username)
206
    {
207 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
208 2
        $url = 'https://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
209
210 2
        return $this->doNTLMPost($url, $email, $password, $username);
211
    }
212
213
    /**
214
     * Perform an unauthenticated HTTP GET in an attempt to get redirected
215
     * via 302 to the correct location to perform the HTTPS POST.
216
     *
217
     * @param string $email
218
     * @param string $password
219
     * @param string $username
220
     *
221
     * @return string The discovered settings
222
     */
223 2
    protected function trySubdomainUnauthenticatedGet($email, $password, $username)
224
    {
225 2
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
226
227 2
        $url = 'http://autodiscover.'.$topLevelDomain.$this->autodiscoverPath;
228
229
        $postOptions = [
230 2
            'timeout' => 2,
231 2
            'allow_redirects' => false,
232
            'headers' => [
233
                'Content-Type' => 'text/xml; charset=utf-8'
234 2
            ],
235 2
            'curl' => []
236 2
        ];
237
238
        try {
239 2
            $response = $this->httpPlayback->get($url, $postOptions);
240
241 2
            if ($response->getStatusCode() == 301 || $response->getStatusCode() == 302) {
242 1
                return $this->doNTLMPost($response->getHeaderLine('Location'), $email, $password, $username);
243
            }
244 1
        } catch (\Exception $e) {
245
        }
246
247 1
        return false;
248
    }
249
250
    /**
251
     * Attempt to retrieve the autodiscover host from an SRV DNS record.
252
     *
253
     * @link http://support.microsoft.com/kb/940881
254
     *
255
     * @param string $email
256
     * @param string $password
257
     * @param string $username
258
     *
259
     * @return string The discovered settings
260
     */
261 1
    protected function trySRVRecord($email, $password, $username)
262
    {
263 1
        $topLevelDomain = $this->getTopLevelDomainFromEmail($email);
264 1
        $srvHost = '_autodiscover._tcp.'.$topLevelDomain;
265 1
        $lookup = dns_get_record($srvHost, DNS_SRV);
266 1
        if (sizeof($lookup) > 0) {
267
            $host = $lookup[0]['target'];
268
            $url = 'https://'.$host.$this->autodiscoverPath;
269
270
            return $this->doNTLMPost($url, $email, $password, $username);
271
        }
272
273 1
        return false;
274
    }
275
276
    /**
277
     * Perform the NTLM authenticated post against one of the chosen
278
     * endpoints.
279
     *
280
     * @param string $url URL to try posting to
281
     * @param string $email
282
     * @param string $password
283
     * @param string $username
284
     *
285
     * @return string The discovered settings
286
     */
287 2
    protected function doNTLMPost($url, $email, $password, $username)
288
    {
289
        $autodiscoverXml = <<<XML
290
<?xml version="1.0" encoding="UTF-8"?>
291
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
292
 <Request>
293
  <EMailAddress>$email</EMailAddress>
294
  <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
295
 </Request>
296 2
</Autodiscover>
297 2
XML;
298
        $postOptions = [
299 2
            'body' => $autodiscoverXml,
300 2
            'timeout' => 2,
301 2
            'allow_redirects' => true,
302
            'headers' => [
303
                'Content-Type' => 'text/xml; charset=utf-8'
304 2
            ],
305 2
            'curl' => [],
306
            'verify' => false
307 2
        ];
308 2
        $auth = ExchangeWebServicesAuth::fromUsernameAndPassword($username, $password);
309 2
        $postOptions = array_replace_recursive($postOptions, $auth);
310
311
        try {
312 2
            $response = $this->httpPlayback->post($url, $postOptions);
313 2
        } catch (\Exception $e) {
314 2
            return false;
315
        }
316
317 1
        return $this->parseAutodiscoverResponse($response->getBody()->__toString());
318
    }
319
320
    /**
321
     * Parse the Autoresponse Payload, particularly to determine if an
322
     * additional request is necessary.
323
     *
324
     * @param $response
325
     * @return array|bool
326
     * @throws AutodiscoverFailed
327
     */
328 1
    protected function parseAutodiscoverResponse($response)
329
    {
330
        // Content-type isn't trustworthy, unfortunately. Shame on Microsoft.
331 1
        if (substr($response, 0, 5) !== '<?xml') {
332
            throw new AutodiscoverFailed();
333
        }
334
335 1
        $response = $this->responseToArray($response);
336
337 1
        if (isset($response['Error'])) {
338
            return false;
339
        }
340
341 1
        $action = $response['Account']['Action'];
342 1
        if ($action == 'redirectUrl' || $action == 'redirectAddr') {
343
            return false;
344
        }
345
346 1
        return $response;
347
    }
348
349
    /**
350
     * Get a top level domain based on an email address
351
     *
352
     * @param string $email
353
     * @return string|false
354
     */
355 2
    protected function getTopLevelDomainFromEmail($email)
356
    {
357 2
        $pos = strpos($email, '@');
358 2
        if ($pos !== false) {
359 1
            return trim(substr($email, $pos + 1));
360
        }
361
362 1
        return false;
363
    }
364
365
    /**
366
     * Utility function to parse XML payloads from the response into easier
367
     * to manage associative arrays.
368
     *
369
     * @param string $xml XML to parse
370
     * @return array
371
     */
372 1
    protected function responseToArray($xml)
373
    {
374 1
        $xml = simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOCDATA);
375
376 1
        return json_decode(json_encode($xml), true)['Response'];
377
    }
378
}
379