View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  
20  package davmail.exchange.auth;
21  
22  import davmail.BundleMessage;
23  import davmail.Settings;
24  import davmail.exception.DavMailAuthenticationException;
25  import davmail.http.HttpClientAdapter;
26  import davmail.http.request.GetRequest;
27  import davmail.http.request.PostRequest;
28  import davmail.http.request.ResponseWrapper;
29  import davmail.http.request.RestRequest;
30  import davmail.ui.NumberMatchingFrame;
31  import davmail.ui.PasswordPromptDialog;
32  import org.apache.http.HttpStatus;
33  import org.apache.http.client.utils.URIBuilder;
34  import org.apache.log4j.Logger;
35  import org.codehaus.jettison.json.JSONException;
36  import org.codehaus.jettison.json.JSONObject;
37  
38  import javax.swing.*;
39  import java.awt.*;
40  import java.io.BufferedReader;
41  import java.io.IOException;
42  import java.io.InputStreamReader;
43  import java.net.URI;
44  import java.net.URISyntaxException;
45  import java.util.regex.Matcher;
46  import java.util.regex.Pattern;
47  
48  public class O365Authenticator implements ExchangeAuthenticator {
49      protected static final Logger LOGGER = Logger.getLogger(O365Authenticator.class);
50  
51      private String tenantId;
52      // Office 365 username
53      private String username;
54      // authentication userid, can be different from username
55      private String userid;
56      private String password;
57      private O365Token token;
58  
59      public static String buildAuthorizeUrl(String tenantId, String clientId, String redirectUri, String username) throws IOException {
60          URI uri;
61          try {
62              URIBuilder uriBuilder = new URIBuilder(Settings.getO365LoginUrl())
63                      .addParameter("client_id", clientId)
64                      .addParameter("response_type", "code")
65                      .addParameter("redirect_uri", redirectUri)
66                      .addParameter("response_mode", "query")
67                      .addParameter("login_hint", username);
68  
69              // force consent
70              //uriBuilder.addParameter("prompt", "consent");
71  
72              if ("https://outlook.live.com".equals(Settings.getOutlookUrl())) {
73                  // live.com endpoint, able to obtain a token but EWS endpoint does not work
74                  String liveAuthorizeUrl = "https://login.live.com/oauth20_authorize.srf";
75                  uriBuilder = new URIBuilder(liveAuthorizeUrl)
76                          .addParameter("client_id", clientId)
77                          .addParameter("response_type", "code")
78                          .addParameter("redirect_uri", redirectUri)
79                          .addParameter("response_mode", "query")
80                          .addParameter("login_hint", username)
81                          .addParameter("scope", "openid offline_access https://outlook.live.com/EWS.AccessAsUser.All Mail.ReadWrite MailboxSettings.Read")
82                          .addParameter("resource", "https://outlook.live.com")
83                  //.addParameter("prompt", "consent")
84                  ;
85  
86              } else if (Settings.getBooleanProperty("davmail.enableGraph", false)) {
87                  if (Settings.getBooleanProperty("davmail.enableOidc", false)) {
88                      // OIDC compliant
89                      uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
90                              .addParameter("scope", Settings.getProperty("davmail.oauth.scope", "openid profile offline_access Mail.ReadWrite Calendars.ReadWrite MailboxSettings.Read Mail.ReadWrite.Shared Contacts.ReadWrite Tasks.ReadWrite Mail.Send"));
91                      // return scopes with 00000003-0000-0000-c000-000000000000 scopes
92                      //.addParameter("scope", "openid profile offline_access .default");
93                      //.addParameter("scope", "openid profile offline_access "+Settings.getGraphUrl()+"/.default");
94                      //.addParameter("scope", "openid " + Settings.getOutlookUrl() + "/EWS.AccessAsUser.All AuditLog.Read.All Calendar.ReadWrite Calendars.Read.Shared Calendars.ReadWrite Contacts.ReadWrite DataLossPreventionPolicy.Evaluate Directory.AccessAsUser.All Directory.Read.All Files.Read Files.Read.All Files.ReadWrite.All Group.Read.All Group.ReadWrite.All InformationProtectionPolicy.Read Mail.ReadWrite Mail.Send Notes.Create Organization.Read.All People.Read People.Read.All Printer.Read.All PrintJob.ReadWriteBasic SensitiveInfoType.Detect SensitiveInfoType.Read.All SensitivityLabel.Evaluate Tasks.ReadWrite TeamMember.ReadWrite.All TeamsTab.ReadWriteForChat User.Read.All User.ReadBasic.All User.ReadWrite Users.Read");
95                  } else {
96                      // Outlook desktop relies on classic authorize endpoint
97                      // on graph endpoint default scopes are scope -> AuditLog.Create Calendar.ReadWrite Calendars.Read.Shared Calendars.ReadWrite Contacts.ReadWrite DataLossPreventionPolicy.Evaluate Directory.AccessAsUser.All Directory.Read.All Files.Read Files.Read.All Files.ReadWrite.All FileStorageContainer.Selected Group.Read.All Group.ReadWrite.All InformationProtectionPolicy.Read Mail.ReadWrite Mail.Send Notes.Create Organization.Read.All People.Read People.Read.All Printer.Read.All PrinterShare.ReadBasic.All PrintJob.Create PrintJob.ReadWriteBasic Reports.Read.All SensitiveInfoType.Detect SensitiveInfoType.Read.All SensitivityLabel.Evaluate Tasks.ReadWrite TeamMember.ReadWrite.All TeamsTab.ReadWriteForChat User.Read.All User.ReadBasic.All User.ReadWrite Users.Read
98                      uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
99                              .addParameter("resource", Settings.getGraphUrl());
100                 }
101                 // Probably irrelevant except for graph api, see above, switch to new v2.0 OIDC compliant endpoint https://docs.microsoft.com/en-us/azure/active-directory/develop/azure-ad-endpoint-comparison
102             } else if (Settings.getBooleanProperty("davmail.enableOidc", false)) {
103                 uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
104                         .addParameter("scope", Settings.getProperty("davmail.oauth.scope", "openid profile offline_access " + Settings.getOutlookUrl() + "/EWS.AccessAsUser.All"));
105             } else {
106                 uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
107                         .addParameter("resource", Settings.getOutlookUrl());
108             }
109 
110             uri = uriBuilder.build();
111         } catch (URISyntaxException e) {
112             throw new IOException(e);
113         }
114         return uri.toString();
115     }
116 
117     public void setUsername(String username) {
118         if (username.contains("|")) {
119             this.userid = username.substring(0, username.indexOf("|"));
120             this.username = username.substring(username.indexOf("|") + 1);
121         } else {
122             this.username = username;
123             this.userid = username;
124         }
125     }
126 
127     public void setPassword(String password) {
128         this.password = password;
129     }
130 
131     public O365Token getToken() {
132         return token;
133     }
134 
135     public URI getExchangeUri() {
136         return URI.create(Settings.getO365Url());
137     }
138 
139     /**
140      * Return a pool enabled HttpClientAdapter instance to access O365
141      *
142      * @return HttpClientAdapter instance
143      */
144     @Override
145     public HttpClientAdapter getHttpClientAdapter() {
146         return new HttpClientAdapter(getExchangeUri(), username, password, true);
147     }
148 
149     public void authenticate() throws IOException {
150         // common DavMail client id
151         String clientId = Settings.getProperty("davmail.oauth.clientId", "facd6cff-a294-4415-b59f-c5b01937d7bd");
152         // standard native app redirectUri
153         String redirectUri = Settings.getProperty("davmail.oauth.redirectUri", Settings.getO365LoginUrl() + "/common/oauth2/nativeclient");
154         // company tenantId or common
155         tenantId = Settings.getProperty("davmail.oauth.tenantId", "common");
156 
157         // first try to load stored token
158         token = O365Token.load(tenantId, clientId, redirectUri, username, password);
159         if (token != null) {
160             return;
161         }
162 
163         String url = O365Authenticator.buildAuthorizeUrl(tenantId, clientId, redirectUri, username);
164 
165         try (
166                 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(url, userid, password)
167         ) {
168 
169             GetRequest getRequest = new GetRequest(url);
170             String responseBodyAsString = executeFollowRedirect(httpClientAdapter, getRequest);
171             String code;
172             if (!responseBodyAsString.contains("Config=") && responseBodyAsString.contains("ServerData =")) {
173                 // live.com form
174                 JSONObject config = extractServerData(responseBodyAsString);
175 
176                 String referer = getRequest.getURI().toString();
177                 code = authenticateLive(httpClientAdapter, config, referer);
178             } else if (!responseBodyAsString.contains("Config=")) {
179                 // we are no longer on Microsoft, try ADFS
180                 code = authenticateADFS(httpClientAdapter, responseBodyAsString, url);
181             } else {
182                 JSONObject config = extractConfig(responseBodyAsString);
183 
184                 checkConfigErrors(config);
185 
186                 String context = config.getString("sCtx"); // csts request
187                 String apiCanary = config.getString("apiCanary"); // canary for API calls
188                 String clientRequestId = config.getString("correlationId");
189                 String hpgact = config.getString("hpgact");
190                 String hpgid = config.getString("hpgid");
191                 String flowToken = config.getString("sFT");
192                 String canary = config.getString("canary");
193                 String sessionId = config.getString("sessionId");
194 
195                 String referer = getRequest.getURI().toString();
196 
197                 RestRequest getCredentialMethod = new RestRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/GetCredentialType");
198                 getCredentialMethod.setRequestHeader("Accept", "application/json");
199                 getCredentialMethod.setRequestHeader("canary", apiCanary);
200                 getCredentialMethod.setRequestHeader("client-request-id", clientRequestId);
201                 getCredentialMethod.setRequestHeader("hpgact", hpgact);
202                 getCredentialMethod.setRequestHeader("hpgid", hpgid);
203                 getCredentialMethod.setRequestHeader("hpgrequestid", sessionId);
204                 getCredentialMethod.setRequestHeader("Referer", referer);
205 
206                 final JSONObject jsonObject = new JSONObject();
207                 jsonObject.put("username", username);
208                 jsonObject.put("isOtherIdpSupported", true);
209                 jsonObject.put("checkPhones", false);
210                 jsonObject.put("isRemoteNGCSupported", false);
211                 jsonObject.put("isCookieBannerShown", false);
212                 jsonObject.put("isFidoSupported", false);
213                 jsonObject.put("flowToken", flowToken);
214                 jsonObject.put("originalRequest", context);
215 
216                 getCredentialMethod.setJsonBody(jsonObject);
217 
218                 JSONObject credentialType = httpClientAdapter.executeRestRequest(getCredentialMethod);
219 
220                 LOGGER.debug("CredentialType=" + credentialType);
221 
222                 JSONObject credentials = credentialType.getJSONObject("Credentials");
223                 String federationRedirectUrl = credentials.optString("FederationRedirectUrl");
224 
225                 if (federationRedirectUrl != null && !federationRedirectUrl.isEmpty()) {
226                     LOGGER.debug("Detected ADFS, redirecting to " + federationRedirectUrl);
227                     code = authenticateRedirectADFS(httpClientAdapter, federationRedirectUrl, url);
228                 } else {
229                     PostRequest logonMethod = new PostRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/login");
230                     logonMethod.setRequestHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
231                     logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
232 
233                     logonMethod.setRequestHeader("Referer", referer);
234 
235                     logonMethod.setParameter("canary", canary);
236                     logonMethod.setParameter("ctx", context);
237                     logonMethod.setParameter("flowToken", flowToken);
238                     logonMethod.setParameter("hpgrequestid", sessionId);
239                     logonMethod.setParameter("login", username);
240                     logonMethod.setParameter("loginfmt", username);
241                     logonMethod.setParameter("passwd", password);
242 
243                     responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
244                     URI location = logonMethod.getRedirectLocation();
245 
246                     if (responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
247                         logonMethod = handleMfa(httpClientAdapter, logonMethod, username, clientRequestId);
248                         location = logonMethod.getRedirectLocation();
249                     }
250 
251                     if (location == null || !location.toString().startsWith(redirectUri)) {
252                         // extract response
253                         config = extractConfig(logonMethod.getResponseBodyAsString());
254                         if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
255                             LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
256                             LOGGER.warn(url);
257                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
258                         } else if ("ConvergedChangePassword".equals(config.optString("pgid"))) {
259                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_PASSWORD_EXPIRED");
260                         } else if ("50126".equals(config.optString("sErrorCode"))) {
261                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
262                         } else if ("50125".equals(config.optString("sErrorCode"))) {
263                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Your organization needs more information to keep your account secure, authenticate once in a web browser and try again");
264                         } else if ("50128".equals(config.optString("sErrorCode"))) {
265                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Invalid domain name - No tenant-identifying information found in either the request or implied by any provided credentials.");
266                         } else if (config.optString("strServiceExceptionMessage", null) != null) {
267                             LOGGER.debug("O365 returned error: " + config.optString("strServiceExceptionMessage"));
268                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
269                         } else {
270                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + config);
271                         }
272                     }
273                     String query = location.toString();
274                     if (query.contains("code=")) {
275                         code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
276                     } else {
277                         throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + query);
278                     }
279                 }
280             }
281             LOGGER.debug("Authentication Code: " + code);
282 
283             token = O365Token.build(tenantId, clientId, redirectUri, code, password);
284 
285             LOGGER.debug("Authenticated username: " + token.getUsername());
286             if (!username.equalsIgnoreCase(token.getUsername())) {
287                 throw new IOException("Authenticated username " + token.getUsername() + " does not match " + username);
288             }
289 
290         } catch (JSONException e) {
291             throw new IOException(e + " " + e.getMessage());
292         }
293 
294     }
295 
296     private void checkConfigErrors(JSONObject config) throws DavMailAuthenticationException {
297         if (config.optString("strServiceExceptionMessage", null) != null) {
298             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", config.optString("strServiceExceptionMessage"));
299         }
300     }
301 
302     private String authenticateLive(HttpClientAdapter httpClientAdapter, JSONObject config, String referer) throws JSONException, IOException {
303         String urlPost = config.getString("urlPost");
304         PostRequest logonMethod = new PostRequest(urlPost);
305         logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
306         logonMethod.setRequestHeader("Referer", referer);
307         String sFTTag = config.optString("sFTTag");
308         String ppft = "";
309         if (sFTTag.contains("value=")) {
310             ppft = sFTTag.substring(sFTTag.indexOf("value=\"")+7, sFTTag.indexOf("\"/>"));
311         }
312 
313         logonMethod.setParameter("PPFT", ppft);
314 
315         logonMethod.setParameter("login", username);
316         logonMethod.setParameter("loginfmt", username);
317 
318         logonMethod.setParameter("passwd", password);
319 
320         String responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
321         URI location = logonMethod.getRedirectLocation();
322         if (location == null) {
323             if (responseBodyAsString.contains("ServerData =")) {
324                 String errorMessage = extractServerData(responseBodyAsString).optString("sErrTxt");
325                 throw new IOException("Live.com authentication failure: "+errorMessage);
326             }
327         } else {
328             String query = location.getQuery();
329             if (query.contains("code=")) {
330                 String code = query.substring(query.indexOf("code=") + 5);
331                 LOGGER.debug("Authentication Code: " + code);
332                 return code;
333             }
334         }
335         throw new IOException("Unknown Live.com authentication failure");
336     }
337 
338     private String authenticateRedirectADFS(HttpClientAdapter httpClientAdapter, String federationRedirectUrl, String authorizeUrl) throws IOException, JSONException {
339         // get ADFS login form
340         GetRequest logonFormMethod = new GetRequest(federationRedirectUrl);
341         logonFormMethod = httpClientAdapter.executeFollowRedirect(logonFormMethod);
342         String responseBodyAsString = logonFormMethod.getResponseBodyAsString();
343         return authenticateADFS(httpClientAdapter, responseBodyAsString, authorizeUrl);
344     }
345 
346     private String authenticateADFS(HttpClientAdapter httpClientAdapter, String responseBodyAsString, String authorizeUrl) throws IOException, JSONException {
347         URI location;
348 
349         if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
350             LOGGER.info("Already authenticated through Basic or NTLM");
351         } else {
352             // parse form to get target url, authenticate as userid
353             PostRequest logonMethod = new PostRequest(extract("method=\"post\" action=\"([^\"]+)\"", responseBodyAsString));
354             logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
355 
356             logonMethod.setParameter("UserName", userid);
357             logonMethod.setParameter("Password", password);
358             logonMethod.setParameter("AuthMethod", "FormsAuthentication");
359 
360             httpClientAdapter.executePostRequest(logonMethod);
361             location = logonMethod.getRedirectLocation();
362             if (location == null) {
363                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
364             }
365 
366             GetRequest redirectMethod = new GetRequest(location);
367             responseBodyAsString = httpClientAdapter.executeGetRequest(redirectMethod);
368         }
369 
370         if (!responseBodyAsString.contains(Settings.getO365LoginUrl())) {
371             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
372         }
373         String targetUrl = extract("action=\"([^\"]+)\"", responseBodyAsString);
374         String wa = extract("name=\"wa\" value=\"([^\"]+)\"", responseBodyAsString);
375         String wresult = extract("name=\"wresult\" value=\"([^\"]+)\"", responseBodyAsString);
376         // decode wresult
377         wresult = wresult.replaceAll(""", "\"");
378         wresult = wresult.replaceAll("&lt;", "<");
379         wresult = wresult.replaceAll("&gt;", ">");
380         String wctx = extract("name=\"wctx\" value=\"([^\"]+)\"", responseBodyAsString);
381         wctx = wctx.replaceAll("&amp;", "&");
382 
383         PostRequest targetMethod = new PostRequest(targetUrl);
384         targetMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
385         targetMethod.setParameter("wa", wa);
386         targetMethod.setParameter("wresult", wresult);
387         targetMethod.setParameter("wctx", wctx);
388 
389         responseBodyAsString = httpClientAdapter.executePostRequest(targetMethod);
390         location = targetMethod.getRedirectLocation();
391 
392         LOGGER.debug(targetMethod.getURI().toString());
393         LOGGER.debug(targetMethod.getReasonPhrase());
394         LOGGER.debug(responseBodyAsString);
395 
396         if (targetMethod.getStatusCode() == HttpStatus.SC_OK) {
397             JSONObject config = extractConfig(responseBodyAsString);
398             if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
399                 LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
400                 LOGGER.warn(authorizeUrl);
401                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
402             }
403         } else if (targetMethod.getStatusCode() != HttpStatus.SC_MOVED_TEMPORARILY || location == null) {
404             throw new IOException("Unknown ADFS authentication failure");
405         }
406 
407         if (location.getHost().startsWith("device")) {
408             location = processDeviceLogin(httpClientAdapter, location);
409         }
410         String query = location.getQuery();
411         if (query == null) {
412             // failover for null query with non https URI like urn:ietf:wg:oauth:2.0:oob?code=...
413             query = location.getSchemeSpecificPart();
414         }
415 
416         if (query.contains("code=") && query.contains("&session_state=")) {
417             String code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
418             LOGGER.debug("Authentication Code: " + code);
419             return code;
420         }
421         throw new IOException("Unknown ADFS authentication failure");
422     }
423 
424     private URI processDeviceLogin(HttpClientAdapter httpClient, URI location) throws IOException, JSONException {
425         URI result = location;
426         LOGGER.debug("Proceed to device authentication, must have access to a client certificate signed by MS-Organization-Access");
427         if (Settings.isWindows() &&
428                 (System.getProperty("java.version").compareTo("13") < 0
429                         || !"MSCAPI".equals(Settings.getProperty("davmail.ssl.clientKeystoreType")))
430         ) {
431             LOGGER.warn("MSCAPI and Java version 13 or higher required to access TPM protected client certificate on Windows");
432         }
433         GetRequest deviceLoginMethod = new GetRequest(location);
434 
435         String responseBodyAsString = httpClient.executeGetRequest(deviceLoginMethod);
436 
437         if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
438             String ctx = extract("name=\"ctx\" value=\"([^\"]+)\"", responseBodyAsString);
439             String flowtoken = extract("name=\"flowtoken\" value=\"([^\"]+)\"", responseBodyAsString);
440 
441             PostRequest processMethod = new PostRequest(extract("action=\"([^\"]+)\"", responseBodyAsString));
442             processMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
443 
444             processMethod.setParameter("ctx", ctx);
445             processMethod.setParameter("flowtoken", flowtoken);
446 
447             responseBodyAsString = httpClient.executePostRequest(processMethod);
448             result = processMethod.getRedirectLocation();
449 
450             // MFA triggered after device authentication
451             if (result == null && responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
452                 processMethod = handleMfa(httpClient, processMethod, username, null);
453                 result = processMethod.getRedirectLocation();
454             }
455 
456             if (result == null) {
457                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
458             }
459 
460         }
461         return result;
462     }
463 
464     private PostRequest handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMethod, String username, String clientRequestId) throws IOException, JSONException {
465         JSONObject config = extractConfig(logonMethod.getResponseBodyAsString());
466         LOGGER.debug("Config=" + config);
467 
468         String urlBeginAuth = config.getString("urlBeginAuth");
469         String urlEndAuth = config.getString("urlEndAuth");
470         // Get processAuth url from config
471         String urlProcessAuth = config.optString("urlPost", Settings.getO365LoginUrl() + "/" + tenantId + "/SAS/ProcessAuth");
472 
473         boolean isMFAMethodSupported = false;
474         String chosenAuthMethodId = null;
475         String chosenAuthMethodPrompt = null;
476 
477         for (int i = 0; i < config.getJSONArray("arrUserProofs").length(); i++) {
478             JSONObject authMethod = (JSONObject) config.getJSONArray("arrUserProofs").get(i);
479             String authMethodId = authMethod.getString("authMethodId");
480             LOGGER.debug("Authentication method: " + authMethodId);
481             if ("PhoneAppNotification".equals(authMethodId)) {
482                 LOGGER.debug("Found phone app auth method " + authMethod.getString("display"));
483                 isMFAMethodSupported = true;
484                 chosenAuthMethodId = authMethodId;
485                 chosenAuthMethodPrompt = authMethod.getString("display");
486                 // prefer phone app
487                 break;
488             }
489             if ("OneWaySMS".equals(authMethodId)) {
490                 LOGGER.debug("Found OneWaySMS auth method " + authMethod.getString("display"));
491                 chosenAuthMethodId = authMethodId;
492                 chosenAuthMethodPrompt = authMethod.getString("display");
493                 isMFAMethodSupported = true;
494             }
495         }
496 
497         if (!isMFAMethodSupported) {
498             throw new IOException("MFA authentication methods not supported");
499         }
500 
501         String context = config.getString("sCtx");
502         String flowToken = config.getString("sFT");
503 
504         String canary = config.getString("canary");
505         String apiCanary = config.getString("apiCanary");
506 
507         String hpgrequestid = logonMethod.getResponseHeader("x-ms-request-id").getValue();
508         String hpgact = config.getString("hpgact");
509         String hpgid = config.getString("hpgid");
510 
511         // clientRequestId is null coming from device login
512         String correlationId = clientRequestId;
513         if (correlationId == null) {
514             correlationId = config.getString("correlationId");
515         }
516 
517         RestRequest beginAuthMethod = new RestRequest(urlBeginAuth);
518         beginAuthMethod.setRequestHeader("Accept", "application/json");
519         beginAuthMethod.setRequestHeader("canary", apiCanary);
520         beginAuthMethod.setRequestHeader("client-request-id", correlationId);
521         beginAuthMethod.setRequestHeader("hpgact", hpgact);
522         beginAuthMethod.setRequestHeader("hpgid", hpgid);
523         beginAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid);
524 
525         // only support PhoneAppNotification
526         JSONObject beginAuthJson = new JSONObject();
527         beginAuthJson.put("AuthMethodId", chosenAuthMethodId);
528         beginAuthJson.put("Ctx", context);
529         beginAuthJson.put("FlowToken", flowToken);
530         beginAuthJson.put("Method", "BeginAuth");
531         beginAuthMethod.setJsonBody(beginAuthJson);
532 
533         config = httpClientAdapter.executeRestRequest(beginAuthMethod);
534         LOGGER.debug(config);
535 
536         if (!config.getBoolean("Success")) {
537             throw new IOException("Authentication failed: " + config);
538         }
539 
540         // look for number matching value
541         String entropy = config.optString("Entropy", null);
542 
543         // display number matching value to user
544         NumberMatchingFrame numberMatchingFrame = null;
545         if (entropy != null && !"0".equals(entropy)) {
546             LOGGER.info("Number matching value for " + username + ": " + entropy);
547             if (!Settings.getBooleanProperty("davmail.server") && !GraphicsEnvironment.isHeadless()) {
548                 numberMatchingFrame = new NumberMatchingFrame(entropy);
549             }
550         }
551 
552         String smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
553 
554         context = config.getString("Ctx");
555         flowToken = config.getString("FlowToken");
556         String sessionId = config.getString("SessionId");
557 
558         int i = 0;
559         boolean success = false;
560         try {
561             while (!success && i++ < 12) {
562 
563                 try {
564                     Thread.sleep(5000);
565                 } catch (InterruptedException e) {
566                     LOGGER.debug("Interrupted");
567                     Thread.currentThread().interrupt();
568                 }
569 
570                 RestRequest endAuthMethod = new RestRequest(urlEndAuth);
571                 endAuthMethod.setRequestHeader("Accept", "application/json");
572                 endAuthMethod.setRequestHeader("canary", apiCanary);
573                 endAuthMethod.setRequestHeader("client-request-id", clientRequestId);
574                 endAuthMethod.setRequestHeader("hpgact", hpgact);
575                 endAuthMethod.setRequestHeader("hpgid", hpgid);
576                 endAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid);
577 
578                 JSONObject endAuthJson = new JSONObject();
579                 endAuthJson.put("AuthMethodId", chosenAuthMethodId);
580                 endAuthJson.put("Ctx", context);
581                 endAuthJson.put("FlowToken", flowToken);
582                 endAuthJson.put("Method", "EndAuth");
583                 endAuthJson.put("PollCount", "1");
584                 endAuthJson.put("SessionId", sessionId);
585 
586                 // When in beginAuthMethod is used 'AuthMethodId': 'OneWaySMS', then in endAuthMethod is send SMS code
587                 // via attribute 'AdditionalAuthData'
588                 endAuthJson.put("AdditionalAuthData", smsCode);
589 
590                 endAuthMethod.setJsonBody(endAuthJson);
591 
592                 config = httpClientAdapter.executeRestRequest(endAuthMethod);
593                 LOGGER.debug(config);
594                 String resultValue = config.getString("ResultValue");
595                 if ("PhoneAppDenied".equals(resultValue) || "PhoneAppNoResponse".equals(resultValue)) {
596                     throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", resultValue);
597                 }
598                 if ("SMSAuthFailedWrongCodeEntered".equals(resultValue)) {
599                     smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
600                 }
601                 if (config.getBoolean("Success")) {
602                     success = true;
603                 }
604             }
605         } finally {
606             // close number matching frame if exists
607             if (numberMatchingFrame != null && numberMatchingFrame.isVisible()) {
608                 final JFrame finalNumberMatchingFrame = numberMatchingFrame;
609                 SwingUtilities.invokeLater(() -> {
610                     finalNumberMatchingFrame.setVisible(false);
611                     finalNumberMatchingFrame.dispose();
612                 });
613             }
614 
615         }
616         if (!success) {
617             throw new IOException("Authentication failed: " + config);
618         }
619 
620         String authMethod = chosenAuthMethodId;
621         String type = "22";
622 
623         context = config.getString("Ctx");
624         flowToken = config.getString("FlowToken");
625 
626         // process auth
627         PostRequest processAuthMethod = new PostRequest(urlProcessAuth);
628         processAuthMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
629         processAuthMethod.setParameter("type", type);
630         processAuthMethod.setParameter("request", context);
631         processAuthMethod.setParameter("mfaAuthMethod", authMethod);
632         processAuthMethod.setParameter("canary", canary);
633         processAuthMethod.setParameter("login", username);
634         processAuthMethod.setParameter("flowToken", flowToken);
635         processAuthMethod.setParameter("hpgrequestid", hpgrequestid);
636 
637         httpClientAdapter.executePostRequest(processAuthMethod);
638         return processAuthMethod;
639 
640     }
641 
642     private String retrieveSmsCode(String chosenAuthMethodId, String chosenAuthMethodPrompt) throws IOException {
643         String smsCode = null;
644         if ("OneWaySMS".equals(chosenAuthMethodId)) {
645             LOGGER.info("Need to retrieve SMS verification code for " + username);
646             if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
647                 // headless or server mode
648                 System.out.print(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
649                 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
650                 smsCode = inReader.readLine();
651             } else {
652                 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
653                 smsCode = String.valueOf(passwordPromptDialog.getPassword());
654             }
655         }
656         return smsCode;
657     }
658 
659     private String executeFollowRedirect(HttpClientAdapter httpClientAdapter, GetRequest getRequest) throws IOException {
660         LOGGER.debug(getRequest.getURI());
661         ResponseWrapper responseWrapper = httpClientAdapter.executeFollowRedirect(getRequest);
662         String responseHost = responseWrapper.getURI().getHost();
663         if (responseHost.endsWith("okta.com")) {
664             throw new DavMailAuthenticationException("LOG_MESSAGE", "Okta authentication not supported, please try O365Interactive");
665         }
666         return responseWrapper.getResponseBodyAsString();
667     }
668 
669     public JSONObject extractConfig(String content) throws IOException {
670         try {
671             return new JSONObject(extract("Config=([^\n]+);", content));
672         } catch (JSONException e1) {
673             LOGGER.debug(content);
674             throw new IOException("Unable to extract config from response body");
675         }
676     }
677 
678     /**
679      * Live.com logon form information
680      * @param content response form
681      * @return parsed configuration json
682      * @throws IOException on error
683      */
684     public JSONObject extractServerData(String content) throws IOException {
685         try {
686             return new JSONObject(extract("ServerData =([^\n]+);", content));
687         } catch (JSONException e1) {
688             LOGGER.debug(content);
689             throw new IOException("Unable to extract config from response body");
690         }
691     }
692 
693     public String extract(String pattern, String content) throws IOException {
694         String value;
695         Matcher matcher = Pattern.compile(pattern).matcher(content);
696         if (matcher.find()) {
697             value = matcher.group(1);
698         } else {
699             throw new IOException("pattern not found");
700         }
701         return value;
702     }
703 
704 }