1package com.android.exchange.service;
2
3import android.content.Context;
4import android.net.Uri;
5import android.os.Bundle;
6import android.util.Xml;
7
8import com.android.emailcommon.mail.MessagingException;
9import com.android.emailcommon.provider.Account;
10import com.android.emailcommon.provider.HostAuth;
11import com.android.emailcommon.service.EmailServiceProxy;
12import com.android.exchange.Eas;
13import com.android.exchange.EasResponse;
14import com.android.mail.utils.LogUtils;
15
16import org.apache.http.HttpStatus;
17import org.apache.http.client.methods.HttpPost;
18import org.apache.http.entity.StringEntity;
19import org.xmlpull.v1.XmlPullParser;
20import org.xmlpull.v1.XmlPullParserException;
21import org.xmlpull.v1.XmlPullParserFactory;
22import org.xmlpull.v1.XmlSerializer;
23
24import java.io.ByteArrayOutputStream;
25import java.io.IOException;
26import java.net.URI;
27
28/**
29 * Performs Autodiscover for Exchange servers. This feature tries to find all the configuration
30 * options needed based on just a username and password.
31 */
32public class EasAutoDiscover extends EasServerConnection {
33    private static final String TAG = Eas.LOG_TAG;
34
35    private static final String AUTO_DISCOVER_SCHEMA_PREFIX =
36        "http://schemas.microsoft.com/exchange/autodiscover/mobilesync/";
37    private static final String AUTO_DISCOVER_PAGE = "/autodiscover/autodiscover.xml";
38
39    // Set of string constants for parsing the autodiscover response.
40    // TODO: Merge this into Tags.java? It's not quite the same but conceptually belongs there.
41    private static final String ELEMENT_NAME_SERVER = "Server";
42    private static final String ELEMENT_NAME_TYPE = "Type";
43    private static final String ELEMENT_NAME_MOBILE_SYNC = "MobileSync";
44    private static final String ELEMENT_NAME_URL = "Url";
45    private static final String ELEMENT_NAME_SETTINGS = "Settings";
46    private static final String ELEMENT_NAME_ACTION = "Action";
47    private static final String ELEMENT_NAME_ERROR = "Error";
48    private static final String ELEMENT_NAME_REDIRECT = "Redirect";
49    private static final String ELEMENT_NAME_USER = "User";
50    private static final String ELEMENT_NAME_EMAIL_ADDRESS = "EMailAddress";
51    private static final String ELEMENT_NAME_DISPLAY_NAME = "DisplayName";
52    private static final String ELEMENT_NAME_RESPONSE = "Response";
53    private static final String ELEMENT_NAME_AUTODISCOVER = "Autodiscover";
54
55    public EasAutoDiscover(final Context context, final String username, final String password) {
56        super(context, new Account(), new HostAuth());
57        mHostAuth.mLogin = username;
58        mHostAuth.mPassword = password;
59        mHostAuth.mFlags = HostAuth.FLAG_AUTHENTICATE | HostAuth.FLAG_SSL;
60        mHostAuth.mPort = 443;
61    }
62
63    /**
64     * Do all the work of autodiscovery.
65     * @return A {@link Bundle} with the host information if autodiscovery succeeded. If we failed
66     *     due to an authentication failure, we return a {@link Bundle} with no host info but with
67     *     an appropriate error code. Otherwise, we return null.
68     */
69    public Bundle doAutodiscover() {
70        final String domain = getDomain();
71        if (domain == null) {
72            return null;
73        }
74
75        final StringEntity entity = buildRequestEntity();
76        if (entity == null) {
77            return null;
78        }
79        try {
80            final HttpPost post = makePost("https://" + domain + AUTO_DISCOVER_PAGE, entity,
81                    "text/xml", false);
82            final EasResponse resp = getResponse(post, domain);
83            if (resp == null) {
84                return null;
85            }
86
87            try {
88                // resp is either an authentication error, or a good response.
89                final int code = resp.getStatus();
90                if (code == HttpStatus.SC_UNAUTHORIZED) {
91                    final Bundle bundle = new Bundle(1);
92                    bundle.putInt(EmailServiceProxy.AUTO_DISCOVER_BUNDLE_ERROR_CODE,
93                            MessagingException.AUTODISCOVER_AUTHENTICATION_FAILED);
94                    return bundle;
95                } else {
96                    final HostAuth hostAuth = parseAutodiscover(resp);
97                    if (hostAuth != null) {
98                        // Fill in the rest of the HostAuth
99                        // We use the user name and password that were successful during
100                        // the autodiscover process
101                        hostAuth.mLogin = mHostAuth.mLogin;
102                        hostAuth.mPassword = mHostAuth.mPassword;
103                        // Note: there is no way we can auto-discover the proper client
104                        // SSL certificate to use, if one is needed.
105                        hostAuth.mPort = 443;
106                        hostAuth.mProtocol = Eas.PROTOCOL;
107                        hostAuth.mFlags = HostAuth.FLAG_SSL | HostAuth.FLAG_AUTHENTICATE;
108                        final Bundle bundle = new Bundle(2);
109                        bundle.putParcelable(EmailServiceProxy.AUTO_DISCOVER_BUNDLE_HOST_AUTH,
110                                hostAuth);
111                        bundle.putInt(EmailServiceProxy.AUTO_DISCOVER_BUNDLE_ERROR_CODE,
112                                MessagingException.NO_ERROR);
113                        return bundle;
114                    }
115                }
116            } finally {
117                resp.close();
118            }
119        } catch (final IllegalArgumentException e) {
120            // This happens when the domain is malformatted.
121            // TODO: Fix sanitizing of the domain -- we try to in UI but apparently not correctly.
122            LogUtils.e(TAG, "ISE with domain: %s", domain);
123        }
124        return null;
125    }
126
127    /**
128     * Get the domain of our account.
129     * @return The domain of the email address.
130     */
131    private String getDomain() {
132        final int amp = mHostAuth.mLogin.indexOf('@');
133        if (amp < 0) {
134            return null;
135        }
136        return mHostAuth.mLogin.substring(amp + 1);
137    }
138
139    /**
140     * Create the payload of the request.
141     * @return A {@link StringEntity} for the request XML.
142     */
143    private StringEntity buildRequestEntity() {
144        try {
145            final XmlSerializer s = Xml.newSerializer();
146            final ByteArrayOutputStream os = new ByteArrayOutputStream(1024);
147            s.setOutput(os, "UTF-8");
148            s.startDocument("UTF-8", false);
149            s.startTag(null, "Autodiscover");
150            s.attribute(null, "xmlns", AUTO_DISCOVER_SCHEMA_PREFIX + "requestschema/2006");
151            s.startTag(null, "Request");
152            s.startTag(null, "EMailAddress").text(mHostAuth.mLogin).endTag(null, "EMailAddress");
153            s.startTag(null, "AcceptableResponseSchema");
154            s.text(AUTO_DISCOVER_SCHEMA_PREFIX + "responseschema/2006");
155            s.endTag(null, "AcceptableResponseSchema");
156            s.endTag(null, "Request");
157            s.endTag(null, "Autodiscover");
158            s.endDocument();
159            return new StringEntity(os.toString());
160        } catch (final IOException e) {
161            // For all exception types, we can simply punt on autodiscover.
162        } catch (final IllegalArgumentException e) {
163        } catch (final IllegalStateException e) {
164        }
165
166        return null;
167    }
168
169    /**
170     * Perform all requests necessary and get the server response. If the post fails or is
171     * redirected, we alter the post and retry.
172     * @param post The initial {@link HttpPost} for this request.
173     * @param domain The domain for our account.
174     * @return If this request succeeded or has an unrecoverable authentication error, an
175     *     {@link EasResponse} with the details. For other errors, we return null.
176     */
177    private EasResponse getResponse(final HttpPost post, final String domain) {
178        EasResponse resp = doPost(post, true);
179        if (resp == null) {
180            LogUtils.d(TAG, "Error in autodiscover, trying aternate address");
181            post.setURI(URI.create("https://autodiscover." + domain + AUTO_DISCOVER_PAGE));
182            resp = doPost(post, true);
183        }
184        return resp;
185    }
186
187    /**
188     * Perform one attempt to get autodiscover information. Redirection and some authentication
189     * errors are handled by recursively calls with modified host information.
190     * @param post The {@link HttpPost} for this request.
191     * @param canRetry Whether we can retry after an authentication failure.
192     * @return If this request succeeded or has an unrecoverable authentication error, an
193     *     {@link EasResponse} with the details. For other errors, we return null.
194     */
195    private EasResponse doPost(final HttpPost post, final boolean canRetry) {
196        final EasResponse resp;
197        try {
198            resp = executePost(post);
199        } catch (final IOException e) {
200            return null;
201        }
202
203        final int code = resp.getStatus();
204
205        if (resp.isRedirectError()) {
206            final String loc = resp.getRedirectAddress();
207            if (loc != null && loc.startsWith("http")) {
208                LogUtils.d(TAG, "Posting autodiscover to redirect: " + loc);
209                redirectHostAuth(loc);
210                post.setURI(URI.create(loc));
211                return doPost(post, canRetry);
212            }
213            return null;
214        }
215
216        if (code == HttpStatus.SC_UNAUTHORIZED) {
217            if (canRetry && mHostAuth.mLogin.contains("@")) {
218                // Try again using the bare user name
219                final int atSignIndex = mHostAuth.mLogin.indexOf('@');
220                mHostAuth.mLogin = mHostAuth.mLogin.substring(0, atSignIndex);
221                LogUtils.d(TAG, "401 received; trying username: %s", mHostAuth.mLogin);
222                resetAuthorization(post);
223                return doPost(post, false);
224            }
225        } else if (code != HttpStatus.SC_OK) {
226            // We'll try the next address if this doesn't work
227            LogUtils.d(TAG, "Bad response code when posting autodiscover: %d", code);
228            return null;
229        }
230
231        return resp;
232    }
233
234    /**
235     * Parse the Server element of the server response.
236     * @param parser The {@link XmlPullParser}.
237     * @param hostAuth The {@link HostAuth} to populate with the results of parsing.
238     * @throws XmlPullParserException
239     * @throws IOException
240     */
241    private static void parseServer(final XmlPullParser parser, final HostAuth hostAuth)
242            throws XmlPullParserException, IOException {
243        boolean mobileSync = false;
244        while (true) {
245            final int type = parser.next();
246            if (type == XmlPullParser.END_TAG && parser.getName().equals(ELEMENT_NAME_SERVER)) {
247                break;
248            } else if (type == XmlPullParser.START_TAG) {
249                final String name = parser.getName();
250                if (name.equals(ELEMENT_NAME_TYPE)) {
251                    if (parser.nextText().equals(ELEMENT_NAME_MOBILE_SYNC)) {
252                        mobileSync = true;
253                    }
254                } else if (mobileSync && name.equals(ELEMENT_NAME_URL)) {
255                    final String url = parser.nextText();
256                    if (url != null) {
257                        LogUtils.d(TAG, "Autodiscover URL: %s", url);
258                        hostAuth.mAddress = Uri.parse(url).getHost();
259                    }
260                }
261            }
262        }
263    }
264
265    /**
266     * Parse the Settings element of the server response.
267     * @param parser The {@link XmlPullParser}.
268     * @param hostAuth The {@link HostAuth} to populate with the results of parsing.
269     * @throws XmlPullParserException
270     * @throws IOException
271     */
272    private static void parseSettings(final XmlPullParser parser, final HostAuth hostAuth)
273            throws XmlPullParserException, IOException {
274        while (true) {
275            final int type = parser.next();
276            if (type == XmlPullParser.END_TAG && parser.getName().equals(ELEMENT_NAME_SETTINGS)) {
277                break;
278            } else if (type == XmlPullParser.START_TAG) {
279                final String name = parser.getName();
280                if (name.equals(ELEMENT_NAME_SERVER)) {
281                    parseServer(parser, hostAuth);
282                }
283            }
284        }
285    }
286
287    /**
288     * Parse the Action element of the server response.
289     * @param parser The {@link XmlPullParser}.
290     * @param hostAuth The {@link HostAuth} to populate with the results of parsing.
291     * @throws XmlPullParserException
292     * @throws IOException
293     */
294    private static void parseAction(final XmlPullParser parser, final HostAuth hostAuth)
295            throws XmlPullParserException, IOException {
296        while (true) {
297            final int type = parser.next();
298            if (type == XmlPullParser.END_TAG && parser.getName().equals(ELEMENT_NAME_ACTION)) {
299                break;
300            } else if (type == XmlPullParser.START_TAG) {
301                final String name = parser.getName();
302                if (name.equals(ELEMENT_NAME_ERROR)) {
303                    // Should parse the error
304                } else if (name.equals(ELEMENT_NAME_REDIRECT)) {
305                    LogUtils.d(TAG, "Redirect: " + parser.nextText());
306                } else if (name.equals(ELEMENT_NAME_SETTINGS)) {
307                    parseSettings(parser, hostAuth);
308                }
309            }
310        }
311    }
312
313    /**
314     * Parse the User element of the server response.
315     * @param parser The {@link XmlPullParser}.
316     * @param hostAuth The {@link HostAuth} to populate with the results of parsing.
317     * @throws XmlPullParserException
318     * @throws IOException
319     */
320    private static void parseUser(final XmlPullParser parser, final HostAuth hostAuth)
321            throws XmlPullParserException, IOException {
322        while (true) {
323            int type = parser.next();
324            if (type == XmlPullParser.END_TAG && parser.getName().equals(ELEMENT_NAME_USER)) {
325                break;
326            } else if (type == XmlPullParser.START_TAG) {
327                String name = parser.getName();
328                if (name.equals(ELEMENT_NAME_EMAIL_ADDRESS)) {
329                    final String addr = parser.nextText();
330                    LogUtils.d(TAG, "Autodiscover, email: %s", addr);
331                } else if (name.equals(ELEMENT_NAME_DISPLAY_NAME)) {
332                    final String dn = parser.nextText();
333                    LogUtils.d(TAG, "Autodiscover, user: %s", dn);
334                }
335            }
336        }
337    }
338
339    /**
340     * Parse the Response element of the server response.
341     * @param parser The {@link XmlPullParser}.
342     * @param hostAuth The {@link HostAuth} to populate with the results of parsing.
343     * @throws XmlPullParserException
344     * @throws IOException
345     */
346    private static void parseResponse(final XmlPullParser parser, final HostAuth hostAuth)
347            throws XmlPullParserException, IOException {
348        while (true) {
349            final int type = parser.next();
350            if (type == XmlPullParser.END_TAG && parser.getName().equals(ELEMENT_NAME_RESPONSE)) {
351                break;
352            } else if (type == XmlPullParser.START_TAG) {
353                final String name = parser.getName();
354                if (name.equals(ELEMENT_NAME_USER)) {
355                    parseUser(parser, hostAuth);
356                } else if (name.equals(ELEMENT_NAME_ACTION)) {
357                    parseAction(parser, hostAuth);
358                }
359            }
360        }
361    }
362
363    /**
364     * Parse the server response for the final {@link HostAuth}.
365     * @param resp The {@link EasResponse} from the server.
366     * @return The final {@link HostAuth} for this server.
367     */
368    private static HostAuth parseAutodiscover(final EasResponse resp) {
369        // The response to Autodiscover is regular XML (not WBXML)
370        try {
371            final XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
372            parser.setInput(resp.getInputStream(), "UTF-8");
373            if (parser.getEventType() != XmlPullParser.START_DOCUMENT) {
374                return null;
375            }
376            if (parser.next() != XmlPullParser.START_TAG) {
377                return null;
378            }
379            if (!parser.getName().equals(ELEMENT_NAME_AUTODISCOVER)) {
380                return null;
381            }
382
383            final HostAuth hostAuth = new HostAuth();
384            while (true) {
385                final int type = parser.nextTag();
386                if (type == XmlPullParser.END_TAG && parser.getName()
387                        .equals(ELEMENT_NAME_AUTODISCOVER)) {
388                    break;
389                } else if (type == XmlPullParser.START_TAG && parser.getName()
390                        .equals(ELEMENT_NAME_RESPONSE)) {
391                    parseResponse(parser, hostAuth);
392                    // Valid responses will set the address.
393                    if (hostAuth.mAddress != null) {
394                        return hostAuth;
395                    }
396                }
397            }
398        } catch (final XmlPullParserException e) {
399            // Parse error.
400        } catch (final IOException e) {
401            // Error reading parser.
402        }
403        return null;
404    }
405}
406