Completed
Push — master ( a7d441...9e9528 )
by Gareth
03:30
created

ExchangeAutodiscover::newAPI()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 33
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5.0843

Importance

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